1pub mod openspec;
11pub mod speckit;
12mod stubs;
13
14use std::path::{Path, PathBuf};
15use std::sync::OnceLock;
16
17use speccoder_core::snapshot::{Diagnostic, ParsedTasks, ProjectSnapshot, Severity};
18use speccoder_core::spec::{FormatKind, ScenarioRef};
19use speccoder_core::{ChecklistStatus, CommandStatus};
20
21use crate::context::{Bundle, BundleError};
22
23pub use speckit::Workspace;
24
25#[derive(Debug, Clone, PartialEq, Eq)]
27pub struct Recorded {
28 pub feature: String,
30 pub dir: String,
32 pub task: String,
34 pub requirement: String,
36 pub title: String,
38}
39
40#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
42pub struct Extension {
43 pub id: String,
45 pub version: String,
47 pub enabled: bool,
49 pub source: String,
51}
52
53#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
55#[serde(rename_all = "snake_case")]
56pub enum InventorySource {
57 Cli,
59 Files,
61}
62
63#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
65pub struct Inventory {
66 pub source: InventorySource,
68 pub entries: Vec<Extension>,
70}
71
72impl Inventory {
73 #[must_use]
75 pub fn enabled(&self, id: &str) -> Option<&Extension> {
76 self.entries.iter().find(|e| e.id == id && e.enabled)
77 }
78}
79
80#[derive(Debug, Clone, PartialEq, Eq)]
82pub struct BugRefusal {
83 pub code: &'static str,
85 pub message: String,
87}
88
89impl BugRefusal {
90 #[must_use]
92 pub fn new(code: &'static str, message: impl Into<String>) -> Self {
93 Self {
94 code,
95 message: message.into(),
96 }
97 }
98}
99
100#[derive(Debug, Clone, PartialEq, Eq)]
102pub struct RecordedBug {
103 pub unit: String,
105 pub dir: String,
107 pub requirement: String,
109 pub task: String,
111 pub title: String,
113 pub files: Vec<String>,
115}
116
117#[derive(Debug, Clone, Copy)]
120pub struct BugStep {
121 pub skill: &'static str,
123 pub arguments: fn(&speccoder_core::bug::Triad, &str) -> String,
125 pub rules: &'static Rules,
127}
128
129#[derive(Debug, Clone, PartialEq, Eq)]
131pub struct ReadBug {
132 pub current: Vec<String>,
134 pub expected: Vec<String>,
136 pub unchanged: Vec<String>,
138 pub title: Option<String>,
140 pub file: String,
142}
143
144#[must_use]
147pub fn bug_refusal_unsupported(start: &Path) -> Option<BugRefusal> {
148 Detect::root(&stubs::KIRO, start).map(|_| BugRefusal::new("BUG-NO-UNIT", stubs::KIRO_BUGS))
149}
150
151#[derive(Debug, Clone, PartialEq, Eq)]
154pub struct OracleRequirement {
155 pub key: String,
157 pub title: String,
159 pub text: String,
161 pub scenarios: Vec<ScenarioRef>,
163}
164
165impl OracleRequirement {
166 #[must_use]
169 pub fn digest(&self) -> String {
170 format!(
171 "sha256:{}",
172 crate::integrity::sha256_hex(format!("{}\n{}", self.title, self.text).as_bytes())
173 )
174 }
175}
176
177#[derive(Debug, Clone, PartialEq, Eq)]
179pub struct FormatInit {
180 pub extensions: Vec<speccoder_core::new_project::ExtensionChoice>,
182 pub preset: bool,
185 pub state: PathBuf,
187 pub cache: PathBuf,
190}
191
192#[derive(Debug, Clone, PartialEq, Eq)]
194pub struct InitStep {
195 pub argv: Vec<String>,
197 pub hosts: Vec<String>,
199 pub writes: String,
201 pub env: Vec<(String, String)>,
203 pub marker: &'static str,
205 pub note: Option<String>,
207 pub preset: bool,
209}
210
211#[derive(Debug, Clone, Copy, PartialEq, Eq)]
213pub enum PhaseKind {
214 Setup,
216 Foundational,
218 Story,
220 Polish,
222 Other,
224}
225
226#[derive(Debug, Clone, PartialEq, Eq)]
228pub struct PlannedTask {
229 pub id: String,
231 pub title: String,
233 pub phase: PhaseKind,
235 pub story: Option<String>,
237 pub spans: Vec<String>,
239 pub done: bool,
241}
242
243#[derive(Debug, Clone, PartialEq, Eq, Default)]
246pub struct PlanStructure {
247 pub tasks: Vec<PlannedTask>,
249 pub stories: std::collections::BTreeMap<String, String>,
251}
252
253#[must_use]
255pub fn code_spans(text: &str) -> Vec<String> {
256 text.split('`')
257 .enumerate()
258 .filter(|(i, _)| i % 2 == 1)
259 .map(|(_, s)| s.to_owned())
260 .filter(|s| !s.trim().is_empty())
261 .collect()
262}
263
264pub trait Adapter: Send + Sync + std::fmt::Debug {
266 fn kind(&self) -> FormatKind;
268
269 fn detect(&self, start: &Path) -> Option<PathBuf>;
271
272 fn inspect(&self, start: &Path, feature: Option<&str>) -> Option<ProjectSnapshot>;
275
276 fn open(&self, start: &Path, feature: Option<&str>) -> Option<ProjectSnapshot> {
279 self.inspect(start, feature)
280 }
281
282 fn parse_tasks(&self, path: &str, text: &str) -> (ParsedTasks, Vec<Diagnostic>);
284
285 fn rules(&self) -> &'static Rules;
287
288 fn wording(&self) -> &'static Wording;
290
291 fn messages(&self) -> &'static Messages;
293
294 fn planning(&self) -> &'static Planning;
296
297 fn bundle(&self, snapshot: &ProjectSnapshot, task: &str) -> Result<Bundle, BundleError>;
299
300 fn scenarios(&self, snapshot: &ProjectSnapshot, task: &str) -> Vec<ScenarioRef>;
303
304 fn acceptance_testable(&self, bundle: &Bundle) -> bool;
306
307 fn is_convergence(&self, phase: &str) -> bool;
309
310 fn convergence_texts(&self, root: &Path, unit_dir: &Path) -> [String; 4];
313
314 fn blocking<'s>(&self, _snapshot: &'s ProjectSnapshot) -> Vec<&'s Diagnostic> {
317 Vec::new()
318 }
319
320 fn crosscheck(
323 &self,
324 _snapshot: &ProjectSnapshot,
325 _runner: &dyn crate::verify::process::CommandRunner,
326 ) -> Vec<Diagnostic> {
327 Vec::new()
328 }
329
330 fn checklists(&self, root: &Path, unit_dir: &str) -> Vec<ChecklistStatus>;
332
333 fn plan_structure(&self, _snapshot: &ProjectSnapshot) -> Option<PlanStructure> {
336 None
337 }
338
339 fn doctor_checks(
342 &self,
343 _root: &Path,
344 _env: &dyn Fn(&str) -> Option<String>,
345 ) -> Vec<speccoder_core::DoctorCheck> {
346 Vec::new()
347 }
348
349 fn tool(&self) -> Option<(&'static str, &'static str)> {
352 None
353 }
354
355 fn init_steps(&self, _choices: &FormatInit) -> Vec<InitStep> {
359 Vec::new()
360 }
361
362 fn prepare_init(&self, _choices: &FormatInit) -> Result<(), String> {
369 Ok(())
370 }
371
372 fn requirements_file(&self) -> Option<&'static str> {
376 None
377 }
378
379 fn requirements_in(&self, _text: &str) -> Vec<(String, String)> {
382 Vec::new()
383 }
384
385 fn record_change(
389 &self,
390 _root: &Path,
391 _request: &str,
392 _checks: &[String],
393 ) -> Result<Recorded, String> {
394 Err("this spec format has no place for quick changes: write a spec for it".to_owned())
395 }
396
397 fn extensions(&self, _root: &Path) -> Option<Inventory> {
401 None
402 }
403
404 fn bug_unit(&self, _root: &Path) -> Result<speccoder_core::bug::BugHome, BugRefusal> {
412 Err(BugRefusal::new(
413 "BUG-NO-UNIT",
414 "this spec format has no place for a bug: write a spec for the fix, or `speccoder do` for a change without a reproduction",
415 ))
416 }
417
418 fn record_bug(
426 &self,
427 _root: &Path,
428 _home: &speccoder_core::bug::BugHome,
429 _triad: &speccoder_core::bug::Triad,
430 ) -> Result<RecordedBug, BugRefusal> {
431 Err(BugRefusal::new(
432 "BUG-NO-UNIT",
433 "this spec format has no standing unit for bugs",
434 ))
435 }
436
437 fn bug_step(&self) -> Option<BugStep> {
439 None
440 }
441
442 fn bug_dir(
445 &self,
446 _home: &speccoder_core::bug::BugHome,
447 _slug: &str,
448 ) -> Option<(String, String)> {
449 None
450 }
451
452 fn bug_slug(&self, _root: &Path, _home: &speccoder_core::bug::BugHome, _title: &str) -> String {
454 String::new()
455 }
456
457 fn read_bug(
464 &self,
465 _root: &Path,
466 _home: &speccoder_core::bug::BugHome,
467 _key: &str,
468 ) -> Result<ReadBug, BugRefusal> {
469 Err(BugRefusal::new("BUG-NO-UNIT", "no bug home"))
470 }
471
472 fn oracle_requirements(&self, _snapshot: &ProjectSnapshot) -> Vec<OracleRequirement> {
476 Vec::new()
477 }
478
479 fn standing_units(&self, _root: &Path) -> Vec<(String, speccoder_core::ladder::Rung)> {
482 Vec::new()
483 }
484
485 fn requirement_ids(&self, root: &Path, unit_dir: &str) -> Vec<(String, String)> {
489 let Some(file) = self.requirements_file() else {
490 return Vec::new();
491 };
492 let text = std::fs::read_to_string(root.join(unit_dir).join(file)).unwrap_or_default();
493 self.requirements_in(&text)
494 .into_iter()
495 .map(|(id, line)| (id, crate::integrity::sha256_hex(line.as_bytes())))
496 .collect()
497 }
498
499 fn current_pointer(&self) -> Option<&'static str> {
502 None
503 }
504
505 fn set_current(&self, _root: &Path, _unit_dir: Option<&str>) -> Result<String, String> {
509 Err(self.messages().no_current.to_owned())
510 }
511
512 fn tick(&self, tasks_file: &Path, task: &str, line_hash: &str) -> Result<(), String>;
514
515 fn append(
517 &self,
518 tasks_file: &Path,
519 file_hash: &str,
520 items: &[String],
521 ) -> Result<Vec<String>, String>;
522
523 fn commit_precondition(
526 &self,
527 root: &Path,
528 join: &dyn Fn(&str) -> String,
529 unit_dir: &str,
530 ) -> CommitPrecondition;
531}
532
533pub type FoundEnv = fn(&dyn Fn(&str) -> Option<String>) -> Vec<(String, String)>;
536
537#[must_use]
539pub fn no_found_env(_: &dyn Fn(&str) -> Option<String>) -> Vec<(String, String)> {
540 Vec::new()
541}
542
543pub type CommandRule = fn(&[String], bool) -> Option<Result<(), String>>;
546
547#[derive(Debug)]
550pub struct Rules {
551 pub intent: &'static [&'static str],
553 pub intent_reason: &'static str,
555 pub authoring_path: fn(&str) -> bool,
557 pub command: CommandRule,
561 pub env: &'static [(&'static str, &'static str)],
563 pub found_env: FoundEnv,
567 pub authoring_refusal: fn(&str) -> String,
569}
570
571#[derive(Debug)]
574pub struct Wording {
575 pub product: &'static str,
577 pub name: &'static str,
579 pub unit: &'static str,
581 pub intent_paths: &'static str,
583 pub intent_code: &'static str,
585 pub documents: &'static str,
587 pub authoring: &'static str,
590 pub authoring_tool: &'static str,
592 pub story: &'static str,
594 pub constitution: &'static str,
596 pub plan: &'static str,
598 pub plan_short: &'static str,
600 pub examples: &'static str,
602 pub convergence_task: &'static str,
604 pub files: [&'static str; 3],
606 pub assess: Assess,
608}
609
610#[derive(Debug)]
612pub struct Assess {
613 pub rules: &'static str,
615 pub targets: &'static str,
617 pub must: &'static str,
619 pub sources: &'static str,
621 pub critical: &'static str,
623 pub refs: &'static str,
625}
626
627#[derive(Debug)]
629pub struct Messages {
630 pub tasks_file: &'static str,
632 pub make_tasks: &'static str,
634 pub not_a_project: fn(&Path) -> String,
636 pub no_project: &'static str,
638 pub no_project_in_worktree: &'static str,
640 pub not_in_git: &'static str,
642 pub no_skills: &'static str,
644 pub checklists: fn(&str) -> String,
646 pub unit: &'static str,
648 pub no_current: &'static str,
650 pub current_shared: &'static str,
652 pub make_project: (&'static str, &'static str),
654}
655
656#[derive(Debug)]
658pub struct Planning {
659 pub prefix: &'static str,
661 pub core: &'static [&'static str],
663 pub refused: fn(&str) -> Option<String>,
665 pub creates: &'static [&'static str],
668 pub current_env: Option<&'static str>,
671 pub toward_tasks: fn(bool) -> &'static [&'static str],
674 pub revises: Option<&'static str>,
677 pub checks: Option<&'static str>,
680}
681
682impl Planning {
683 #[must_use]
687 pub fn role_of(&self, short: &str) -> speccoder_core::actions::SkillRole {
688 use speccoder_core::actions::SkillRole;
689 let toward = |plan| (self.toward_tasks)(plan).contains(&short);
690 if self.creates.contains(&short) {
691 SkillRole::Create
692 } else if toward(false) || toward(true) {
693 SkillRole::TowardTasks
694 } else if self.revises == Some(short) {
695 SkillRole::Revise
696 } else if self.checks == Some(short) {
697 SkillRole::Check
698 } else {
699 SkillRole::Other
700 }
701 }
702}
703
704#[derive(Debug, Clone)]
706pub struct CommitPrecondition {
707 pub strict: Vec<String>,
709 pub tracked: Vec<String>,
712 pub runtime: fn(&str) -> bool,
714 pub essentials: Vec<String>,
716 pub message: &'static str,
718 pub hint: &'static str,
720 pub no_commits_hint: &'static str,
722}
723
724impl CommitPrecondition {
725 #[must_use]
727 pub fn paths(&self) -> Vec<String> {
728 self.tracked.iter().chain(&self.strict).cloned().collect()
729 }
730}
731
732fn all() -> [&'static dyn Detect; 3] {
734 [&speckit::SPECKIT, &openspec::OPENSPEC, &stubs::KIRO]
735}
736
737pub trait Detect: Send + Sync {
739 fn kind(&self) -> FormatKind;
741 fn root(&self, start: &Path) -> Option<PathBuf>;
743 fn adapter(&self) -> Option<&'static dyn Adapter>;
745}
746
747#[must_use]
749pub fn adapters() -> Vec<&'static dyn Adapter> {
750 all().into_iter().filter_map(|d| d.adapter()).collect()
751}
752
753#[must_use]
755pub fn adapter(kind: FormatKind) -> Option<&'static dyn Adapter> {
756 all()
757 .into_iter()
758 .find(|d| d.kind() == kind)
759 .and_then(|d| d.adapter())
760}
761
762#[must_use]
764pub fn reference() -> &'static dyn Adapter {
765 &speckit::SPECKIT
766}
767
768static REQUEST: OnceLock<FormatKind> = OnceLock::new();
769
770pub fn request(kind: FormatKind) {
773 let _ = REQUEST.set(kind);
774}
775
776pub struct Opened {
778 pub adapter: &'static dyn Adapter,
780 pub snapshot: ProjectSnapshot,
782}
783
784#[derive(Debug, Clone, PartialEq, Eq)]
786pub struct Refused {
787 pub status: CommandStatus,
789 pub message: String,
791 pub unmarked: bool,
793}
794
795pub fn open(start: &Path, feature: Option<&str>) -> Result<Opened, Refused> {
798 open_with(start, feature, REQUEST.get().copied())
799}
800
801pub fn open_with(
803 start: &Path,
804 feature: Option<&str>,
805 explicit: Option<FormatKind>,
806) -> Result<Opened, Refused> {
807 let configured = match explicit {
808 Some(_) => None,
809 None => configured(start)?,
810 };
811 let (adapter, mut warnings) = resolve(start, explicit.or(configured), &all())?;
812 let mut snapshot = adapter.open(start, feature).ok_or_else(|| Refused {
813 status: CommandStatus::NotAProject,
814 message: (adapter.messages().not_a_project)(start),
815 unmarked: true,
816 })?;
817 snapshot.diagnostics.append(&mut warnings);
818 Ok(Opened { adapter, snapshot })
819}
820
821#[must_use]
825pub fn at(root: &Path) -> &'static dyn Adapter {
826 let chosen = REQUEST
827 .get()
828 .copied()
829 .or_else(|| configured(root).ok().flatten());
830 resolve(root, chosen, &all()).map_or_else(|_| reference(), |(a, _)| a)
831}
832
833#[must_use]
835pub fn of(snapshot: &ProjectSnapshot) -> &'static dyn Adapter {
836 at(Path::new(&snapshot.root))
837}
838
839fn resolve(
843 start: &Path,
844 chosen: Option<FormatKind>,
845 formats: &[&'static dyn Detect],
846) -> Result<(&'static dyn Adapter, Vec<Diagnostic>), Refused> {
847 let refuse = |message: String| Refused {
848 status: CommandStatus::NotAProject,
849 message,
850 unmarked: false,
851 };
852 if let Some(kind) = chosen {
853 return formats
854 .iter()
855 .find(|d| d.kind() == kind)
856 .and_then(|d| d.adapter())
857 .map(|a| (a, Vec::new()))
858 .ok_or_else(|| refuse(not_yet(kind)));
859 }
860 let mut found: Vec<(&'static dyn Detect, PathBuf)> = formats
863 .iter()
864 .filter_map(|d| d.root(start).map(|r| (*d, r)))
865 .collect();
866 let nearest = found.iter().map(|(_, r)| r.components().count()).max();
867 found.retain(|(_, r)| Some(r.components().count()) == nearest);
868 let supported: Vec<&'static dyn Adapter> =
869 found.iter().filter_map(|(d, _)| d.adapter()).collect();
870 let others = || found.iter().filter(|(d, _)| d.adapter().is_none());
871 match supported.as_slice() {
872 [one] => {
873 let warnings = others()
874 .map(|(d, root)| {
875 Diagnostic::new(
876 Severity::Warning,
877 "format.unsupported-marker",
878 format!(
879 "{} is also present at {}; {}",
880 d.kind().title(),
881 root.display(),
882 not_yet(d.kind())
883 ),
884 )
885 })
886 .collect();
887 Ok((*one, warnings))
888 }
889 [] => match others().next() {
890 Some((d, root)) => Err(refuse(format!(
891 "found {} project at {}; {}",
892 article(d.kind()),
893 root.display(),
894 not_yet(d.kind())
895 ))),
896 None => Ok((reference(), Vec::new())),
898 },
899 many => Err(Refused {
900 unmarked: false,
901 status: CommandStatus::DiagnosticsError,
902 message: format!(
903 "this project is in more than one format ({}); choose one with --format",
904 many.iter()
905 .map(|a| a.kind().name())
906 .collect::<Vec<_>>()
907 .join(", ")
908 ),
909 }),
910 }
911}
912
913fn not_yet(kind: FormatKind) -> String {
914 match kind {
915 FormatKind::Kiro => {
916 "speccoder runs Spec Kit and OpenSpec projects, not Kiro specs".to_owned()
917 }
918 k => format!("speccoder does not run {} projects", k.title()),
919 }
920}
921
922fn article(kind: FormatKind) -> String {
923 match kind {
924 FormatKind::Openspec => "an OpenSpec".to_owned(),
925 k => format!("a {}", k.title()),
926 }
927}
928
929fn configured(start: &Path) -> Result<Option<FormatKind>, Refused> {
931 let Some(dir) = crate::verify::find_config(start) else {
932 return Ok(None);
933 };
934 let Ok(text) = std::fs::read_to_string(dir.join(crate::verify::config::FILE)) else {
935 return Ok(None);
936 };
937 parse_config(&text).map_err(|e| Refused {
938 unmarked: false,
939 status: CommandStatus::ConfigError,
940 message: format!("speccoder.toml: {e}"),
941 })
942}
943
944pub fn parse_config(text: &str) -> Result<Option<FormatKind>, String> {
946 #[derive(serde::Deserialize)]
947 #[serde(deny_unknown_fields)]
948 struct Spec {
949 format: Option<String>,
950 }
951 let Ok(table) = toml::from_str::<toml::Table>(text) else {
953 return Ok(None);
954 };
955 let Some(spec) = table.get("spec") else {
956 return Ok(None);
957 };
958 let spec: Spec = spec
959 .clone()
960 .try_into()
961 .map_err(|e: toml::de::Error| format!("[spec]: {}", e.message()))?;
962 let [a, b, c] = FormatKind::ALL.map(FormatKind::name);
963 spec.format
964 .map(|f| {
965 f.parse()
966 .map_err(|_| format!("[spec]: format = \"{f}\" ({a}, {b} or {c})"))
967 })
968 .transpose()
969}
970
971#[cfg(test)]
972mod tests {
973 use super::*;
974
975 struct Fake(FormatKind, bool);
976
977 impl Detect for Fake {
978 fn kind(&self) -> FormatKind {
979 self.0
980 }
981 fn root(&self, _: &Path) -> Option<PathBuf> {
982 Some(PathBuf::from("/p"))
983 }
984 fn adapter(&self) -> Option<&'static dyn Adapter> {
985 self.1.then(reference)
986 }
987 }
988
989 struct At(FormatKind, &'static str);
990
991 impl Detect for At {
992 fn kind(&self) -> FormatKind {
993 self.0
994 }
995 fn root(&self, _: &Path) -> Option<PathBuf> {
996 Some(PathBuf::from(self.1))
997 }
998 fn adapter(&self) -> Option<&'static dyn Adapter> {
999 Some(reference())
1000 }
1001 }
1002
1003 #[test]
1004 fn the_nearest_project_decides() {
1005 static OUTER: At = At(FormatKind::Speckit, "/repo");
1006 static INNER: At = At(FormatKind::Openspec, "/repo/sub/project");
1007 let (_, w) = resolve(Path::new("/repo/sub/project/x"), None, &[&OUTER, &INNER]).unwrap();
1008 assert!(w.is_empty());
1009 static TWIN: At = At(FormatKind::Speckit, "/repo/sub/project");
1010 assert!(resolve(Path::new("/x"), None, &[&TWIN, &INNER]).is_err());
1011 }
1012
1013 static SK: Fake = Fake(FormatKind::Speckit, true);
1014 static OS: Fake = Fake(FormatKind::Openspec, false);
1015 static OS_RUNS: Fake = Fake(FormatKind::Openspec, true);
1016 static KIRO: Fake = Fake(FormatKind::Kiro, false);
1017
1018 #[test]
1019 fn a_choice_wins_and_an_unsupported_one_is_refused() {
1020 let p = Path::new("/p");
1021 let (a, w) = resolve(p, Some(FormatKind::Speckit), &[&SK, &OS]).unwrap();
1022 assert_eq!((a.kind(), w.len()), (FormatKind::Speckit, 0));
1023 let r = resolve(p, Some(FormatKind::Openspec), &[&SK, &OS])
1024 .err()
1025 .unwrap();
1026 assert_eq!(r.status, CommandStatus::NotAProject);
1027 assert_eq!(r.message, "speccoder does not run OpenSpec projects");
1028 let r = resolve(p, Some(FormatKind::Kiro), &[&SK, &KIRO])
1029 .err()
1030 .unwrap();
1031 assert!(r.message.ends_with("not Kiro specs"));
1032 }
1033
1034 #[test]
1035 fn detection_warns_about_other_markers_and_refuses_ambiguity() {
1036 let p = Path::new("/p");
1037 let (a, w) = resolve(p, None, &[&SK, &OS]).unwrap();
1038 assert_eq!(a.kind(), FormatKind::Speckit);
1039 assert_eq!(w.len(), 1);
1040 assert_eq!(w[0].code, "format.unsupported-marker");
1041 let r = resolve(p, None, &[&OS]).err().unwrap();
1042 assert_eq!(
1043 r.message,
1044 "found an OpenSpec project at /p; speccoder does not run OpenSpec projects"
1045 );
1046 let r = resolve(p, None, &[&SK, &OS_RUNS]).err().unwrap();
1047 assert_eq!(r.status, CommandStatus::DiagnosticsError);
1048 assert!(
1049 r.message.contains("choose one with --format"),
1050 "{}",
1051 r.message
1052 );
1053 }
1054
1055 #[test]
1056 fn the_spec_section_names_a_format() {
1057 assert_eq!(parse_config(""), Ok(None));
1058 assert_eq!(
1059 parse_config("[spec]\nformat = \"openspec\"\n"),
1060 Ok(Some(FormatKind::Openspec))
1061 );
1062 assert_eq!(
1063 parse_config("[spec]\nformat = \"x\"\n"),
1064 Err("[spec]: format = \"x\" (speckit, openspec or kiro)".to_owned())
1065 );
1066 assert!(
1067 parse_config("[spec]\nkind = 1\n")
1068 .unwrap_err()
1069 .starts_with("[spec]: unknown field")
1070 );
1071 assert_eq!(parse_config("[format]\nargv = [\"x\"]\n"), Ok(None));
1072 }
1073}