| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128 |
- //! 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 `<global config dir>/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<PathBuf> {
- 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<ExtraConfig> {
- 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<ExtraConfig> {
- 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::<toml::Value>(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());
- }
- }
|