//! The private config block, editable from the dashboard. //! //! Home Assistant's add-on option form has no multi-line control (the //! Supervisor schema only knows `str`/`password`/`int`/`bool`/`list(...)`, //! so a whole TOML block cannot live in an option), which is why the //! add-on's `sm121_config_extra` option is a one-line field nobody can use. //! //! Instead the block lives in a **file** the dashboard edits with a real //! textarea: `SM121_CONFIG_EXTRA_FILE` (the add-on points it at //! `/data/config-extra.toml`, so it survives restarts and rides the add-on //! backup) or `/config-extra.toml` for a CLI install. The //! entrypoint appends it to the generated config; a plain `-c/--config` run //! can `include` it. Validation happens on save (a TOML parse), so a typo //! cannot brick the next boot. use anyhow::{Context, Result}; use std::path::PathBuf; /// Where the private block lives. pub fn extra_config_path() -> Option { if let Ok(p) = std::env::var("SM121_CONFIG_EXTRA_FILE") && !p.trim().is_empty() { return Some(PathBuf::from(p.trim())); } crate::config::default_config_dir().map(|d| d.join("config-extra.toml")) } /// What the dashboard shows in the editor. #[derive(Clone, Debug, serde::Serialize)] pub struct ExtraConfig { pub path: String, pub exists: bool, pub text: String, /// True when the file is what the running add-on actually reads (the /// entrypoint sets the env var); otherwise the panel is a plain editor. pub managed: bool, } /// Read the block (a missing file is an empty block, not an error). pub fn read_extra() -> Result { let path = extra_config_path() .context("no config dir (set SM121_CONFIG_EXTRA_FILE to pick a path)")?; let managed = std::env::var("SM121_CONFIG_EXTRA_FILE") .map(|v| !v.trim().is_empty()) .unwrap_or(false); let text = match std::fs::read_to_string(&path) { Ok(t) => t, Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(), Err(e) => return Err(e).with_context(|| format!("cannot read {}", path.display())), }; Ok(ExtraConfig { path: path.display().to_string(), exists: path.exists(), text, managed, }) } /// Validate + save. An empty body deletes the file (the block goes away); /// a parse error is reported before anything is written. pub fn write_extra(text: &str) -> Result { let path = extra_config_path() .context("no config dir (set SM121_CONFIG_EXTRA_FILE to pick a path)")?; let trimmed = text.trim(); if trimmed.is_empty() { match std::fs::remove_file(&path) { Ok(()) => {} Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} Err(e) => return Err(e).with_context(|| format!("cannot remove {}", path.display())), } return read_extra(); } toml::from_str::(trimmed).map_err(|e| anyhow::anyhow!("TOML parse error: {e}"))?; if let Some(dir) = path.parent() { std::fs::create_dir_all(dir).with_context(|| format!("cannot create {}", dir.display()))?; } // Atomic: a crash mid-write must not leave a half block for the boot. let tmp = path.with_extension("toml.tmp"); std::fs::write(&tmp, format!("{}\n", trimmed.trim_end())) .with_context(|| format!("cannot write {}", tmp.display()))?; std::fs::rename(&tmp, &path).with_context(|| format!("cannot replace {}", path.display()))?; read_extra() } #[cfg(test)] mod tests { use super::*; fn temp_path(name: &str) -> PathBuf { let p = std::env::temp_dir().join(format!("sm121-settings-{name}-{}", std::process::id())); let _ = std::fs::remove_file(&p); p } #[test] fn save_and_read_roundtrip() { let p = temp_path("roundtrip"); // SAFETY: single-threaded test binary section; no other thread reads it. unsafe { std::env::set_var("SM121_CONFIG_EXTRA_FILE", &p) }; let saved = write_extra("[cache]\nnode = \"dgx5.lan\"\n").expect("save"); assert!(saved.exists && saved.managed); assert!(saved.text.contains("dgx5.lan")); let read = read_extra().expect("read"); assert_eq!(read.text.trim(), saved.text.trim()); let _ = std::fs::remove_file(&p); } #[test] fn invalid_toml_is_refused_and_nothing_is_written() { let p = temp_path("invalid"); unsafe { std::env::set_var("SM121_CONFIG_EXTRA_FILE", &p) }; let err = write_extra("[cache\nnode = 1").expect_err("must fail"); assert!(err.to_string().contains("TOML")); assert!(!p.exists(), "a rejected block must not reach the disk"); } #[test] fn empty_body_deletes_the_file() { let p = temp_path("delete"); unsafe { std::env::set_var("SM121_CONFIG_EXTRA_FILE", &p) }; write_extra("a = 1\n").expect("save"); assert!(p.exists()); let after = write_extra(" \n").expect("clear"); assert!(!after.exists); assert!(after.text.is_empty()); } }