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