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}