1use std::path::{Path, PathBuf};
8
9use serde_json::Value;
10use speccoder_core::{
11 CommandData, CommandResult, CommandStatus, ConfigChange, ConfigKey, ConfigReport,
12 ConfigSection, ConfigTable,
13};
14
15use crate::git::Git;
16use crate::verify::config::FILE;
17
18mod catalogue;
19mod edit;
20#[cfg(test)]
21mod tests;
22
23pub use catalogue::{CATALOGUE, Key, Kind, Section};
24pub use edit::{edit, value_of};
25
26pub fn validate(text: &str) -> Result<(), String> {
32 crate::substrate::model_config::parse(text)?;
33 validate_but_model(text)
34}
35
36pub(super) fn validate_but_model(text: &str) -> Result<(), String> {
38 crate::verify::config::parse(text)?;
39 crate::substrate::run_config::parse(text)?;
40 crate::sandbox::parse(text)?;
41 crate::prepare::parse(text)?;
42 crate::mcp::parse(text)?;
43 crate::mutation::parse(text)?;
44 crate::external::parse(text)?;
45 crate::format::parse_config(text)?;
46 Ok(())
47}
48
49pub(super) fn home(root: &Path) -> PathBuf {
52 crate::verify::find_config(root).unwrap_or_else(|| crate::init::root_of(root))
55}
56
57pub(super) fn lookup<'a>(table: &'a toml::Table, path: &[&str]) -> Option<&'a toml::Value> {
58 let (last, parents) = path.split_last()?;
59 let mut t = table;
60 for p in parents {
61 t = t.get(*p)?.as_table()?;
62 }
63 t.get(*last)
64}
65
66pub(super) fn key_view(k: &Key, value: Option<Value>) -> ConfigKey {
67 let (min, max) = match k.kind {
68 #[allow(clippy::cast_precision_loss)]
69 Kind::Int(a, b) => (Some(a as f64), Some(b as f64)),
70 Kind::Num(a, b) => (Some(a), Some(b)),
71 _ => (None, None),
72 };
73 ConfigKey {
74 key: k.key.to_owned(),
75 kind: k.kind.name().to_owned(),
76 about: k.about.to_owned(),
77 default: k.default.and_then(|d| serde_json::from_str(d).ok()),
78 value,
79 choices: match k.kind {
80 Kind::Choice(c) => c.iter().map(|s| (*s).to_owned()).collect(),
81 _ => Vec::new(),
82 },
83 min,
84 max,
85 required: k.required,
86 }
87}
88
89pub(super) fn json_of(v: &toml::Value) -> Value {
90 serde_json::to_value(v).unwrap_or(Value::Null)
91}
92
93#[must_use]
95pub fn read(root: &Path) -> ConfigReport {
96 let dir = home(root);
97 let path = dir.join(FILE);
98 let text = std::fs::read_to_string(&path).ok();
99 let table: toml::Table = text
100 .as_deref()
101 .and_then(|t| toml::from_str(t).ok())
102 .unwrap_or_default();
103 let sections = CATALOGUE
104 .iter()
105 .map(|s| {
106 if s.named {
107 let tables = table
108 .get(s.name)
109 .and_then(toml::Value::as_table)
110 .map(|named| {
111 named
112 .iter()
113 .map(|(name, t)| ConfigTable {
114 name: name.clone(),
115 keys: s
116 .keys
117 .iter()
118 .map(|k| {
119 let v =
120 t.as_table().and_then(|t| t.get(k.key)).map(json_of);
121 key_view(k, v)
122 })
123 .collect(),
124 })
125 .collect()
126 })
127 .unwrap_or_default();
128 ConfigSection {
129 name: s.name.to_owned(),
130 title: s.title.to_owned(),
131 about: s.about.to_owned(),
132 named: true,
133 keys: s.keys.iter().map(|k| key_view(k, None)).collect(),
134 tables,
135 }
136 } else {
137 ConfigSection {
138 name: s.name.to_owned(),
139 title: s.title.to_owned(),
140 about: s.about.to_owned(),
141 named: false,
142 keys: s
143 .keys
144 .iter()
145 .map(|k| {
146 let mut path = vec![s.name];
147 path.extend(k.key.split('.'));
148 let mut view = key_view(k, lookup(&table, &path).map(json_of));
149 view.required |= s.name == "model"
151 && k.key == "region"
152 && lookup(&table, &["model", "provider"])
153 .and_then(toml::Value::as_str)
154 == Some("bedrock");
155 view
156 })
157 .collect(),
158 tables: Vec::new(),
159 }
160 }
161 })
162 .collect();
163 let error = text.as_deref().and_then(|t| validate(t).err());
164 let (committed, diff) = state(&dir, text.is_some());
165 ConfigReport {
166 file: FILE.to_owned(),
167 exists: text.is_some(),
168 committed,
169 valid: text.is_some() && error.is_none(),
170 error,
171 sections,
172 diff,
173 }
174}
175
176pub(super) fn state(dir: &Path, exists: bool) -> (bool, Option<String>) {
178 let g = Git::at(dir);
179 let tracked = g
180 .succeeds(&["ls-files", "--error-unmatch", "--", FILE])
181 .unwrap_or(false);
182 if !tracked {
183 let diff = exists.then(|| {
184 std::fs::read_to_string(dir.join(FILE))
185 .unwrap_or_default()
186 .lines()
187 .map(|l| format!("+{l}"))
188 .collect::<Vec<_>>()
189 .join("\n")
190 });
191 return (
192 false,
193 diff.map(|d| {
194 format!("diff --git a/{FILE} b/{FILE}\nnew file mode 100644\n@@ -0,0 +1 @@\n{d}\n")
195 }),
196 );
197 }
198 let clean = g
199 .succeeds(&["diff", "--quiet", "HEAD", "--", FILE])
200 .unwrap_or(false);
201 if clean {
202 return (true, None);
203 }
204 let diff = g
205 .run(&["diff", "--no-color", "HEAD", "--", FILE])
206 .map(|b| String::from_utf8_lossy(&b).into_owned())
207 .ok();
208 (false, diff)
209}
210
211fn model_notes(
215 text: &str,
216 changes: &[ConfigChange],
217) -> (Vec<String>, Vec<speccoder_core::next::NextStep>) {
218 use crate::substrate::model_config;
219 let mut notes = Vec::new();
220 let mut next = Vec::new();
221 if model_config::parse(text).is_err_and(|e| e.starts_with(model_config::NEEDS_REGION)) {
222 notes.push("provider bedrock needs model.region, set it next".to_owned());
223 next.push(speccoder_core::next::NextStep {
224 command: "speccoder config set model.region <region>".to_owned(),
225 why: "provider bedrock needs the AWS region its calls go to".to_owned(),
226 });
227 }
228 let touched = changes
229 .iter()
230 .any(|c| c.key == "model.provider" || c.key == "model.model");
231 let model: toml::Table = toml::from_str(text).unwrap_or_default();
232 let model = model.get("model").and_then(toml::Value::as_table);
233 let field = |k: &str| model.and_then(|m| m.get(k)).and_then(toml::Value::as_str);
234 if touched
235 && field("provider") == Some("bedrock")
236 && let Some(w) = field("model").and_then(model_config::bedrock_model_warning)
237 {
238 notes.push(format!("warning: {w}"));
239 }
240 (notes, next)
241}
242
243#[must_use]
245pub fn show(root: &Path) -> CommandResult {
246 let report = read(root);
247 let message = match (&report.exists, &report.error, report.committed) {
248 (false, _, _) => "no speccoder.toml yet: every key has its default".to_owned(),
249 (true, Some(e), _) => format!("speccoder.toml has an error: {e}"),
250 (true, None, false) => {
251 "speccoder.toml has changes that are not committed (not in force yet)".to_owned()
252 }
253 (true, None, true) => "speccoder.toml".to_owned(),
254 };
255 let result = CommandResult::new("config", CommandStatus::Ok, &message);
256 let result = if report.committed || !report.exists {
257 result
258 } else {
259 result.with_next(vec![speccoder_core::next::NextStep {
260 command: "speccoder config commit".to_owned(),
261 why: "speccoder reads committed configuration only".to_owned(),
262 }])
263 };
264 result.with_data(CommandData::Config(Box::new(report)))
265}
266
267pub const PREFIX_KEYS: [&str; 2] = ["commands.allow_prefix", "sandbox.network_allow_prefix"];
269
270#[must_use]
275pub fn apply(root: &Path, changes: &[ConfigChange]) -> CommandResult {
276 if let Some(c) = changes.iter().find(|c| {
277 PREFIX_KEYS.contains(&c.key.as_str()) && (c.value.is_some() || c.append.is_some())
278 }) {
279 let narrows = c.value.as_ref().is_some_and(|v| {
281 let now = read(root);
282 let current = now
283 .sections
284 .iter()
285 .flat_map(|s| {
286 s.keys
287 .iter()
288 .map(move |k| (format!("{}.{}", s.name, k.key), k))
289 })
290 .find(|(name, _)| *name == c.key)
291 .and_then(|(_, k)| k.value.clone())
292 .and_then(|v| v.as_array().cloned())
293 .unwrap_or_default();
294 v.as_array()
295 .is_some_and(|new| new.iter().all(|e| current.contains(e)))
296 });
297 if !narrows {
298 return CommandResult::new(
299 "config",
300 CommandStatus::UsageError,
301 &format!(
302 "`{}` is saved only from its coverage preview: `speccoder config coverage {} <argv…>`, then confirm",
303 c.key, c.key
304 ),
305 )
306 .with_code("CONFIG-INVALID");
307 }
308 }
309 apply_confirmed(root, changes)
310}
311
312#[must_use]
315pub(crate) fn apply_confirmed(root: &Path, changes: &[ConfigChange]) -> CommandResult {
316 let dir = home(root);
317 let path = dir.join(FILE);
318 let text = std::fs::read_to_string(&path).unwrap_or_default();
319 match edit(&text, changes) {
320 Err(e) => CommandResult::new("config", CommandStatus::ConfigError, &e)
321 .with_code("CONFIG-INVALID")
322 .with_fix(vec![
323 crate::substrate::model_config::config_fix(&e).unwrap_or_else(|| {
324 format!(
325 "speccoder config get {}",
326 changes.first().map_or("", |c| c.key.as_str())
327 )
328 }),
329 ]),
330 Ok(out) if out == text => show(root),
331 Ok(out) => match std::fs::write(&path, &out) {
332 Ok(()) => {
333 let keys: Vec<&str> = changes.iter().map(|c| c.key.as_str()).collect();
334 let r = show(root);
335 let (notes, mut next) = model_notes(&out, changes);
336 next.push(speccoder_core::next::NextStep {
337 command: "speccoder config commit".to_owned(),
338 why: "speccoder reads committed configuration only".to_owned(),
339 });
340 let mut message =
341 format!("changed {} — commit it to put it in force", keys.join(", "));
342 for n in notes {
343 message.push('\n');
344 message.push_str(&n);
345 }
346 CommandResult::new("config", CommandStatus::Ok, &message)
347 .with_next(next)
348 .with_data(
349 r.data()
350 .cloned()
351 .unwrap_or(CommandData::Config(Box::new(read(root)))),
352 )
353 }
354 Err(e) => CommandResult::new(
355 "config",
356 CommandStatus::Error,
357 &format!("{}: {e}", path.display()),
358 ),
359 },
360 }
361}
362
363#[must_use]
366pub fn commit(root: &Path) -> CommandResult {
367 let dir = home(root);
368 let text = match std::fs::read_to_string(dir.join(FILE)) {
369 Ok(t) => t,
370 Err(_) => {
371 return CommandResult::new(
372 "config",
373 CommandStatus::ConfigError,
374 "there is no speccoder.toml to commit",
375 )
376 .with_code("CONFIG-MISSING");
377 }
378 };
379 if let Err(e) = validate(&text) {
380 return CommandResult::new("config", CommandStatus::ConfigError, &e)
381 .with_code("CONFIG-INVALID");
382 }
383 let (committed, _) = state(&dir, true);
384 if committed {
385 return CommandResult::new(
386 "config",
387 CommandStatus::Ok,
388 "speccoder.toml is committed; nothing to do",
389 );
390 }
391 let g = Git::at(&dir);
393 let before: toml::Table = g
394 .run(&["show", &format!("HEAD:./{FILE}")])
395 .ok()
396 .and_then(|b| toml::from_str(&String::from_utf8_lossy(&b)).ok())
397 .unwrap_or_default();
398 let after: toml::Table = toml::from_str(&text).unwrap_or_default();
399 let mut changed: Vec<&str> = CATALOGUE
400 .iter()
401 .map(|s| s.name)
402 .filter(|s| before.get(*s) != after.get(*s))
403 .collect();
404 changed.dedup();
405 let message = if changed.is_empty() {
406 "speccoder: configuration".to_owned()
407 } else {
408 format!("speccoder: configuration ({})", changed.join(", "))
409 };
410 match g
411 .run(&["add", "--", FILE])
412 .and_then(|_| g.run(&["commit", "-q", "-m", &message, "--", FILE]))
413 {
414 Ok(_) => {
415 let id = g
416 .line(&["rev-parse", "--short", "HEAD"])
417 .unwrap_or_default();
418 CommandResult::new(
419 "config",
420 CommandStatus::Ok,
421 &format!("committed speccoder.toml as {id} \"{message}\""),
422 )
423 .with_data(CommandData::Config(Box::new(read(root))))
424 }
425 Err(e) => CommandResult::new(
426 "config",
427 CommandStatus::GitPrecondition,
428 &format!("commit failed: {}", e.0),
429 ),
430 }
431}