Skip to main content

mesh_llm_config/
store.rs

1use crate::{ConfigEditor, MeshConfig, validate_config};
2use anyhow::{Context, Result, bail};
3use std::path::{Path, PathBuf};
4use toml_edit::{ArrayOfTables, DocumentMut, Item, Table, value};
5
6pub fn config_path(override_path: Option<&Path>) -> Result<PathBuf> {
7    if let Some(path) = override_path {
8        return Ok(path.to_path_buf());
9    }
10    if let Ok(path) = std::env::var("MESH_LLM_CONFIG") {
11        return Ok(PathBuf::from(path));
12    }
13    let home = dirs::home_dir().context("Cannot determine home directory")?;
14    Ok(home.join(".mesh-llm").join("config.toml"))
15}
16
17pub fn load_config(override_path: Option<&Path>) -> Result<MeshConfig> {
18    let path = config_path(override_path)?;
19    if !path.exists() {
20        return Ok(MeshConfig::default());
21    }
22    let raw = std::fs::read_to_string(&path)
23        .with_context(|| format!("Failed to read config {}", path.display()))?;
24    parse_config_toml(&raw).with_context(|| format!("Invalid config {}", path.display()))
25}
26
27pub fn parse_config_toml(raw: &str) -> Result<MeshConfig> {
28    let config: MeshConfig = toml::from_str(raw).context("failed to parse config TOML")?;
29    validate_config(&config)?;
30    Ok(config)
31}
32
33pub fn config_to_toml(config: &MeshConfig) -> Result<String> {
34    validate_config(config)?;
35    toml::to_string(config).context("toml serialization failed")
36}
37
38#[derive(Clone, Debug)]
39pub struct ConfigStore {
40    path: PathBuf,
41}
42
43impl ConfigStore {
44    pub fn open(path: impl Into<PathBuf>) -> Self {
45        Self { path: path.into() }
46    }
47
48    pub fn default_path() -> Result<Self> {
49        Ok(Self {
50            path: config_path(None)?,
51        })
52    }
53
54    pub fn path(&self) -> &Path {
55        &self.path
56    }
57
58    pub fn load(&self) -> Result<MeshConfig> {
59        load_config(Some(&self.path))
60    }
61
62    pub fn save(&self, config: &MeshConfig) -> Result<()> {
63        let toml_str = config_to_toml(config)?;
64        atomic_write(&self.path, toml_str.as_bytes())
65            .with_context(|| format!("failed to write config {}", self.path.display()))
66    }
67
68    pub fn update<F>(&self, edit: F) -> Result<MeshConfig>
69    where
70        F: FnOnce(&mut ConfigEditor) -> Result<()>,
71    {
72        let mut editor = ConfigEditor::new(self.load()?);
73        edit(&mut editor)?;
74        let config = editor.into_config();
75        self.save(&config)?;
76        Ok(config)
77    }
78
79    pub fn edit_preserving<F>(&self, edit: F) -> Result<MeshConfig>
80    where
81        F: FnOnce(&mut DocumentMut) -> Result<()>,
82    {
83        let mut doc = self.read_document()?;
84        edit(&mut doc)?;
85        let config = parse_config_toml(&doc.to_string())
86            .with_context(|| format!("invalid edited config {}", self.path.display()))?;
87        self.write_document(&doc)?;
88        Ok(config)
89    }
90
91    pub fn model_refs(&self) -> Result<Vec<String>> {
92        let doc = self.read_document()?;
93        let Some(models) = doc.get("models").and_then(Item::as_array_of_tables) else {
94            return Ok(Vec::new());
95        };
96        Ok(models.iter().filter_map(model_ref_from_table).collect())
97    }
98
99    pub fn add_model_ref(&self, model_ref: &str) -> Result<Vec<String>> {
100        let model_ref = normalize_model_ref(model_ref)?;
101        self.edit_preserving(|doc| {
102            let models = ensure_models_array(doc)?;
103            if !models
104                .iter()
105                .filter_map(model_ref_from_table)
106                .any(|configured| configured == model_ref)
107            {
108                let mut table = Table::new();
109                table["model"] = value(model_ref);
110                models.push(table);
111            }
112            Ok(())
113        })?;
114        self.model_refs()
115    }
116
117    pub fn remove_model_ref(&self, model_ref: &str) -> Result<Vec<String>> {
118        let model_ref = normalize_model_ref(model_ref)?;
119        self.edit_preserving(|doc| {
120            let Some(models) = doc.get("models").and_then(Item::as_array_of_tables) else {
121                return Ok(());
122            };
123            let mut next = ArrayOfTables::new();
124            for table in models.iter() {
125                let keep = model_ref_from_table(table)
126                    .map(|configured| configured != model_ref)
127                    .unwrap_or(true);
128                if keep {
129                    next.push(table.clone());
130                }
131            }
132            doc["models"] = Item::ArrayOfTables(next);
133            Ok(())
134        })?;
135        self.model_refs()
136    }
137
138    fn read_document(&self) -> Result<DocumentMut> {
139        if !self.path.exists() {
140            return Ok(DocumentMut::new());
141        }
142        let raw = std::fs::read_to_string(&self.path)
143            .with_context(|| format!("failed to read config {}", self.path.display()))?;
144        raw.parse::<DocumentMut>()
145            .with_context(|| format!("failed to parse config {}", self.path.display()))
146    }
147
148    fn write_document(&self, doc: &DocumentMut) -> Result<()> {
149        atomic_write(&self.path, doc.to_string().as_bytes())
150            .with_context(|| format!("failed to write config {}", self.path.display()))
151    }
152}
153
154fn ensure_models_array(doc: &mut DocumentMut) -> Result<&mut ArrayOfTables> {
155    if !doc.as_table().contains_key("models") {
156        doc["models"] = Item::ArrayOfTables(ArrayOfTables::new());
157    }
158    doc["models"]
159        .as_array_of_tables_mut()
160        .ok_or_else(|| anyhow::anyhow!("config key `models` is not a TOML array of tables"))
161}
162
163fn model_ref_from_table(table: &Table) -> Option<String> {
164    table
165        .get("model")
166        .and_then(Item::as_str)
167        .map(str::trim)
168        .filter(|value| !value.is_empty())
169        .map(ToOwned::to_owned)
170}
171
172fn normalize_model_ref(model_ref: &str) -> Result<&str> {
173    let model_ref = model_ref.trim();
174    if model_ref.is_empty() {
175        bail!("model ref cannot be empty");
176    }
177    Ok(model_ref)
178}
179
180fn atomic_write(target: &Path, contents: &[u8]) -> std::io::Result<()> {
181    use std::io::Write;
182    if let Some(parent) = target.parent() {
183        std::fs::create_dir_all(parent)?;
184    }
185    let file_name = target
186        .file_name()
187        .unwrap_or(target.as_os_str())
188        .to_string_lossy();
189    let parent = target.parent().unwrap_or(Path::new("."));
190    let pid = std::process::id();
191    let nanos = std::time::SystemTime::now()
192        .duration_since(std::time::UNIX_EPOCH)
193        .unwrap_or_default()
194        .subsec_nanos();
195    let tmp = parent.join(format!(".{}.{}.{}.tmp", file_name, pid, nanos));
196    let mut file = std::fs::OpenOptions::new()
197        .write(true)
198        .create(true)
199        .truncate(true)
200        .open(&tmp)?;
201    file.write_all(contents)?;
202    file.sync_all()?;
203    drop(file);
204    #[cfg(windows)]
205    if target.exists() {
206        std::fs::remove_file(target)?;
207    }
208    if let Err(e) = std::fs::rename(&tmp, target) {
209        let _ = std::fs::remove_file(&tmp);
210        return Err(e);
211    }
212    Ok(())
213}