settings.rs 5.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128
  1. //! The private config block, editable from the dashboard.
  2. //!
  3. //! Home Assistant's add-on option form has no multi-line control (the
  4. //! Supervisor schema only knows `str`/`password`/`int`/`bool`/`list(...)`,
  5. //! so a whole TOML block cannot live in an option), which is why the
  6. //! add-on's `sm121_config_extra` option is a one-line field nobody can use.
  7. //!
  8. //! Instead the block lives in a **file** the dashboard edits with a real
  9. //! textarea: `SM121_CONFIG_EXTRA_FILE` (the add-on points it at
  10. //! `/data/config-extra.toml`, so it survives restarts and rides the add-on
  11. //! backup) or `<global config dir>/config-extra.toml` for a CLI install. The
  12. //! entrypoint appends it to the generated config; a plain `-c/--config` run
  13. //! can `include` it. Validation happens on save (a TOML parse), so a typo
  14. //! cannot brick the next boot.
  15. use anyhow::{Context, Result};
  16. use std::path::PathBuf;
  17. /// Where the private block lives.
  18. pub fn extra_config_path() -> Option<PathBuf> {
  19. if let Ok(p) = std::env::var("SM121_CONFIG_EXTRA_FILE")
  20. && !p.trim().is_empty()
  21. {
  22. return Some(PathBuf::from(p.trim()));
  23. }
  24. crate::config::default_config_dir().map(|d| d.join("config-extra.toml"))
  25. }
  26. /// What the dashboard shows in the editor.
  27. #[derive(Clone, Debug, serde::Serialize)]
  28. pub struct ExtraConfig {
  29. pub path: String,
  30. pub exists: bool,
  31. pub text: String,
  32. /// True when the file is what the running add-on actually reads (the
  33. /// entrypoint sets the env var); otherwise the panel is a plain editor.
  34. pub managed: bool,
  35. }
  36. /// Read the block (a missing file is an empty block, not an error).
  37. pub fn read_extra() -> Result<ExtraConfig> {
  38. let path = extra_config_path()
  39. .context("no config dir (set SM121_CONFIG_EXTRA_FILE to pick a path)")?;
  40. let managed = std::env::var("SM121_CONFIG_EXTRA_FILE")
  41. .map(|v| !v.trim().is_empty())
  42. .unwrap_or(false);
  43. let text = match std::fs::read_to_string(&path) {
  44. Ok(t) => t,
  45. Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
  46. Err(e) => return Err(e).with_context(|| format!("cannot read {}", path.display())),
  47. };
  48. Ok(ExtraConfig {
  49. path: path.display().to_string(),
  50. exists: path.exists(),
  51. text,
  52. managed,
  53. })
  54. }
  55. /// Validate + save. An empty body deletes the file (the block goes away);
  56. /// a parse error is reported before anything is written.
  57. pub fn write_extra(text: &str) -> Result<ExtraConfig> {
  58. let path = extra_config_path()
  59. .context("no config dir (set SM121_CONFIG_EXTRA_FILE to pick a path)")?;
  60. let trimmed = text.trim();
  61. if trimmed.is_empty() {
  62. match std::fs::remove_file(&path) {
  63. Ok(()) => {}
  64. Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
  65. Err(e) => return Err(e).with_context(|| format!("cannot remove {}", path.display())),
  66. }
  67. return read_extra();
  68. }
  69. toml::from_str::<toml::Value>(trimmed).map_err(|e| anyhow::anyhow!("TOML parse error: {e}"))?;
  70. if let Some(dir) = path.parent() {
  71. std::fs::create_dir_all(dir).with_context(|| format!("cannot create {}", dir.display()))?;
  72. }
  73. // Atomic: a crash mid-write must not leave a half block for the boot.
  74. let tmp = path.with_extension("toml.tmp");
  75. std::fs::write(&tmp, format!("{}\n", trimmed.trim_end()))
  76. .with_context(|| format!("cannot write {}", tmp.display()))?;
  77. std::fs::rename(&tmp, &path).with_context(|| format!("cannot replace {}", path.display()))?;
  78. read_extra()
  79. }
  80. #[cfg(test)]
  81. mod tests {
  82. use super::*;
  83. fn temp_path(name: &str) -> PathBuf {
  84. let p = std::env::temp_dir().join(format!("sm121-settings-{name}-{}", std::process::id()));
  85. let _ = std::fs::remove_file(&p);
  86. p
  87. }
  88. #[test]
  89. fn save_and_read_roundtrip() {
  90. let p = temp_path("roundtrip");
  91. // SAFETY: single-threaded test binary section; no other thread reads it.
  92. unsafe { std::env::set_var("SM121_CONFIG_EXTRA_FILE", &p) };
  93. let saved = write_extra("[cache]\nnode = \"dgx5.lan\"\n").expect("save");
  94. assert!(saved.exists && saved.managed);
  95. assert!(saved.text.contains("dgx5.lan"));
  96. let read = read_extra().expect("read");
  97. assert_eq!(read.text.trim(), saved.text.trim());
  98. let _ = std::fs::remove_file(&p);
  99. }
  100. #[test]
  101. fn invalid_toml_is_refused_and_nothing_is_written() {
  102. let p = temp_path("invalid");
  103. unsafe { std::env::set_var("SM121_CONFIG_EXTRA_FILE", &p) };
  104. let err = write_extra("[cache\nnode = 1").expect_err("must fail");
  105. assert!(err.to_string().contains("TOML"));
  106. assert!(!p.exists(), "a rejected block must not reach the disk");
  107. }
  108. #[test]
  109. fn empty_body_deletes_the_file() {
  110. let p = temp_path("delete");
  111. unsafe { std::env::set_var("SM121_CONFIG_EXTRA_FILE", &p) };
  112. write_extra("a = 1\n").expect("save");
  113. assert!(p.exists());
  114. let after = write_extra(" \n").expect("clear");
  115. assert!(!after.exists);
  116. assert!(after.text.is_empty());
  117. }
  118. }