bos/bos-settings/src/config/mod.rs
Breadway 1f53377914
All checks were successful
Mirror to GitHub / mirror (push) Successful in 6s
Build and publish package / package (push) Successful in 1m35s
bos-settings: full, non-destructive control of every bread* config
The bread/breadpad/breadcrumbs/breadbox views wrote invented schemas
(e.g. top-level log_level, [[profile]] name/ssids) that did not match the
apps' real TOML, so they showed empty and — worse — clobbered the real
config on Save, since the old config::save serialized only the keys it
modelled.

Rework the config layer onto toml_edit: parse each file into a
DocumentMut, mutate only the specific keys a view exposes, and write it
back preserving comments and any unmodelled keys (calendar password,
saved-network passwords, model paths). Unit-tested.

Add ui/widgets.rs (switch/entry/password/dropdown/spin/float/csv rows +
view scaffold + save button) bound to the shared document, then rewrite
the four views against the real schemas with far more coverage:

- bread: [daemon], [lua], [modules], all five [adapters.*] with their
  sub-options, [events], [notifications]
- breadpad: [settings], [model] + [model.ollama], [reminders], [calendar]
- breadcrumbs: [settings] (7 keys), [[networks]] editor, [profiles.*] editor
- breadbox: fixed to real [[contexts]] name/priority array editor

Goal: configure everything from the GUI rather than hand-editing TOML.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 14:26:49 +08:00

195 lines
6.3 KiB
Rust

//! Non-destructive config editing.
//!
//! Every bread* app owns a TOML config that may contain keys, sections, and
//! comments this settings app does not model (e.g. breadpad's calendar
//! credentials, breadcrumbs' saved-network passwords). To edit safely we parse
//! the file into a `toml_edit::DocumentMut`, mutate only the specific keys the
//! UI exposes, and write the document back — preserving everything else,
//! formatting and comments included.
use std::error::Error;
use std::path::{Path, PathBuf};
use toml_edit::{value, Array, DocumentMut, Item, Table, Value};
/// Load a TOML file into an editable document. A missing or unparseable file
/// yields an empty document so the UI still renders (with defaults).
pub fn load_doc(path: &Path) -> DocumentMut {
std::fs::read_to_string(path)
.ok()
.and_then(|s| s.parse::<DocumentMut>().ok())
.unwrap_or_default()
}
/// Write the document back to disk, creating parent dirs as needed.
pub fn save_doc(path: &Path, doc: &DocumentMut) -> Result<(), Box<dyn Error>> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(path, doc.to_string())?;
Ok(())
}
pub fn config_dir() -> PathBuf {
// Honour XDG_CONFIG_HOME if set; otherwise fall back to $HOME/.config.
if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME") {
let p = PathBuf::from(xdg);
if p.is_absolute() {
return p;
}
}
let home = std::env::var("HOME").unwrap_or_else(|_| "/root".to_string());
PathBuf::from(home).join(".config")
}
// --- typed readers (walk a dotted path, return None if absent/wrong type) ---
fn get<'a>(doc: &'a DocumentMut, path: &[&str]) -> Option<&'a Item> {
let mut tbl = doc.as_table();
let (last, parents) = path.split_last()?;
for key in parents {
tbl = tbl.get(key)?.as_table()?;
}
tbl.get(last)
}
pub fn get_bool(doc: &DocumentMut, path: &[&str]) -> Option<bool> {
get(doc, path)?.as_bool()
}
pub fn get_str(doc: &DocumentMut, path: &[&str]) -> Option<String> {
get(doc, path)?.as_str().map(str::to_string)
}
pub fn get_i64(doc: &DocumentMut, path: &[&str]) -> Option<i64> {
get(doc, path)?.as_integer()
}
pub fn get_f64(doc: &DocumentMut, path: &[&str]) -> Option<f64> {
let item = get(doc, path)?;
item.as_float().or_else(|| item.as_integer().map(|i| i as f64))
}
/// Read an array of strings (e.g. modules.disable, contexts[].priority).
pub fn get_str_list(doc: &DocumentMut, path: &[&str]) -> Vec<String> {
match get(doc, path).and_then(Item::as_array) {
Some(arr) => arr
.iter()
.filter_map(|v| v.as_str().map(str::to_string))
.collect(),
None => Vec::new(),
}
}
// --- setters (auto-create intermediate tables, replace only the leaf) ---
fn table_at_mut<'a>(doc: &'a mut DocumentMut, parents: &[&str]) -> &'a mut Table {
let mut tbl = doc.as_table_mut();
for key in parents {
let entry = tbl.entry(key).or_insert_with(|| Item::Table(Table::new()));
if !entry.is_table() {
*entry = Item::Table(Table::new());
}
tbl = entry.as_table_mut().expect("just ensured table");
}
tbl
}
fn set_item(doc: &mut DocumentMut, path: &[&str], item: Item) {
let Some((last, parents)) = path.split_last() else {
return;
};
table_at_mut(doc, parents).insert(last, item);
}
pub fn set_bool(doc: &mut DocumentMut, path: &[&str], v: bool) {
set_item(doc, path, value(v));
}
pub fn set_str(doc: &mut DocumentMut, path: &[&str], v: &str) {
set_item(doc, path, value(v));
}
pub fn set_i64(doc: &mut DocumentMut, path: &[&str], v: i64) {
set_item(doc, path, value(v));
}
pub fn set_f64(doc: &mut DocumentMut, path: &[&str], v: f64) {
set_item(doc, path, value(v));
}
pub fn set_str_list(doc: &mut DocumentMut, path: &[&str], items: &[String]) {
let mut arr = Array::new();
for s in items {
arr.push(s.as_str());
}
set_item(doc, path, Item::Value(Value::Array(arr)));
}
/// Set a string key, or remove it entirely when the value is empty — keeps
/// optional fields out of the file rather than persisting `key = ""`.
pub fn set_str_or_remove(doc: &mut DocumentMut, path: &[&str], v: &str) {
if v.is_empty() {
remove(doc, path);
} else {
set_str(doc, path, v);
}
}
pub fn remove(doc: &mut DocumentMut, path: &[&str]) {
if let Some((last, parents)) = path.split_last() {
let mut tbl = doc.as_table_mut();
for key in parents {
match tbl.get_mut(key).and_then(Item::as_table_mut) {
Some(t) => tbl = t,
None => return,
}
}
tbl.remove(last);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn edits_preserve_unmodelled_keys_and_comments() {
let src = "\
# a leading comment
[daemon]
log_level = \"info\"
[calendar]
password = \"secret\" # keep me
";
let mut doc: DocumentMut = src.parse().unwrap();
// Modify a single modelled key.
set_str(&mut doc, &["daemon", "log_level"], "debug");
// A key/section the UI never touches must survive untouched.
let out = doc.to_string();
assert!(out.contains("log_level = \"debug\""));
assert!(out.contains("password = \"secret\""));
assert!(out.contains("# keep me"));
assert!(out.contains("# a leading comment"));
}
#[test]
fn setters_create_missing_tables() {
let mut doc = DocumentMut::new();
set_bool(&mut doc, &["adapters", "power", "enabled"], false);
set_i64(&mut doc, &["adapters", "power", "poll_interval_secs"], 45);
assert_eq!(get_bool(&doc, &["adapters", "power", "enabled"]), Some(false));
assert_eq!(
get_i64(&doc, &["adapters", "power", "poll_interval_secs"]),
Some(45)
);
}
#[test]
fn empty_string_removes_key() {
let mut doc: DocumentMut = "[calendar]\nurl = \"x\"\n".parse().unwrap();
set_str_or_remove(&mut doc, &["calendar", "url"], "");
assert_eq!(get_str(&doc, &["calendar", "url"]), None);
}
#[test]
fn str_list_roundtrips() {
let mut doc = DocumentMut::new();
let items = vec!["a".to_string(), "b".to_string()];
set_str_list(&mut doc, &["modules", "disable"], &items);
assert_eq!(get_str_list(&doc, &["modules", "disable"]), items);
}
}