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 pub judged: fn(&str) -> bool,
573 pub plan: &'static str,
576}
577
578#[derive(Debug)]
581pub struct Wording {
582 pub product: &'static str,
584 pub name: &'static str,
586 pub unit: &'static str,
588 pub intent_paths: &'static str,
590 pub intent_code: &'static str,
592 pub documents: &'static str,
594 pub authoring: &'static str,
597 pub authoring_tool: &'static str,
599 pub story: &'static str,
601 pub constitution: &'static str,
603 pub plan: &'static str,
605 pub plan_short: &'static str,
607 pub examples: &'static str,
609 pub convergence_task: &'static str,
611 pub files: [&'static str; 3],
613 pub assess: Assess,
615}
616
617#[derive(Debug)]
619pub struct Assess {
620 pub rules: &'static str,
622 pub targets: &'static str,
624 pub must: &'static str,
626 pub sources: &'static str,
628 pub critical: &'static str,
630 pub refs: &'static str,
632}
633
634#[derive(Debug)]
636pub struct Messages {
637 pub tasks_file: &'static str,
639 pub make_tasks: &'static str,
641 pub not_a_project: fn(&Path) -> String,
643 pub no_project: &'static str,
645 pub no_project_in_worktree: &'static str,
647 pub not_in_git: &'static str,
649 pub no_skills: &'static str,
651 pub checklists: fn(&str) -> String,
653 pub unit: &'static str,
655 pub no_current: &'static str,
657 pub current_shared: &'static str,
659 pub make_project: (&'static str, &'static str),
661}
662
663#[derive(Debug)]
665pub struct Planning {
666 pub prefix: &'static str,
668 pub core: &'static [&'static str],
670 pub refused: fn(&str) -> Option<String>,
672 pub creates: &'static [&'static str],
675 pub current_env: Option<&'static str>,
678 pub toward_tasks: fn(bool) -> &'static [&'static str],
681 pub revises: Option<&'static str>,
684 pub checks: Option<&'static str>,
687}
688
689impl Planning {
690 #[must_use]
694 pub fn role_of(&self, short: &str) -> speccoder_core::actions::SkillRole {
695 use speccoder_core::actions::SkillRole;
696 let toward = |plan| (self.toward_tasks)(plan).contains(&short);
697 if self.creates.contains(&short) {
698 SkillRole::Create
699 } else if toward(false) || toward(true) {
700 SkillRole::TowardTasks
701 } else if self.revises == Some(short) {
702 SkillRole::Revise
703 } else if self.checks == Some(short) {
704 SkillRole::Check
705 } else {
706 SkillRole::Other
707 }
708 }
709}
710
711#[derive(Debug, Clone)]
713pub struct CommitPrecondition {
714 pub strict: Vec<String>,
716 pub tracked: Vec<String>,
719 pub runtime: fn(&str) -> bool,
721 pub essentials: Vec<String>,
723 pub message: &'static str,
725 pub hint: &'static str,
727 pub no_commits_hint: &'static str,
729}
730
731impl CommitPrecondition {
732 #[must_use]
734 pub fn paths(&self) -> Vec<String> {
735 self.tracked.iter().chain(&self.strict).cloned().collect()
736 }
737}
738
739fn all() -> [&'static dyn Detect; 3] {
741 [&speckit::SPECKIT, &openspec::OPENSPEC, &stubs::KIRO]
742}
743
744pub trait Detect: Send + Sync {
746 fn kind(&self) -> FormatKind;
748 fn root(&self, start: &Path) -> Option<PathBuf>;
750 fn adapter(&self) -> Option<&'static dyn Adapter>;
752}
753
754#[must_use]
756pub fn adapters() -> Vec<&'static dyn Adapter> {
757 all().into_iter().filter_map(|d| d.adapter()).collect()
758}
759
760#[must_use]
762pub fn adapter(kind: FormatKind) -> Option<&'static dyn Adapter> {
763 all()
764 .into_iter()
765 .find(|d| d.kind() == kind)
766 .and_then(|d| d.adapter())
767}
768
769#[must_use]
771pub fn reference() -> &'static dyn Adapter {
772 &speckit::SPECKIT
773}
774
775static REQUEST: OnceLock<FormatKind> = OnceLock::new();
776
777pub fn request(kind: FormatKind) {
780 let _ = REQUEST.set(kind);
781}
782
783pub struct Opened {
785 pub adapter: &'static dyn Adapter,
787 pub snapshot: ProjectSnapshot,
789}
790
791#[derive(Debug, Clone, PartialEq, Eq)]
793pub struct Refused {
794 pub status: CommandStatus,
796 pub message: String,
798 pub unmarked: bool,
800}
801
802pub fn open(start: &Path, feature: Option<&str>) -> Result<Opened, Refused> {
805 open_with(start, feature, REQUEST.get().copied())
806}
807
808pub fn open_with(
810 start: &Path,
811 feature: Option<&str>,
812 explicit: Option<FormatKind>,
813) -> Result<Opened, Refused> {
814 let configured = match explicit {
815 Some(_) => None,
816 None => configured(start)?,
817 };
818 let (adapter, mut warnings) = resolve(start, explicit.or(configured), &all())?;
819 let mut snapshot = adapter.open(start, feature).ok_or_else(|| Refused {
820 status: CommandStatus::NotAProject,
821 message: (adapter.messages().not_a_project)(start),
822 unmarked: true,
823 })?;
824 snapshot.diagnostics.append(&mut warnings);
825 Ok(Opened { adapter, snapshot })
826}
827
828#[must_use]
832pub fn at(root: &Path) -> &'static dyn Adapter {
833 let chosen = REQUEST
834 .get()
835 .copied()
836 .or_else(|| configured(root).ok().flatten());
837 resolve(root, chosen, &all()).map_or_else(|_| reference(), |(a, _)| a)
838}
839
840#[must_use]
842pub fn of(snapshot: &ProjectSnapshot) -> &'static dyn Adapter {
843 at(Path::new(&snapshot.root))
844}
845
846fn resolve(
850 start: &Path,
851 chosen: Option<FormatKind>,
852 formats: &[&'static dyn Detect],
853) -> Result<(&'static dyn Adapter, Vec<Diagnostic>), Refused> {
854 let refuse = |message: String| Refused {
855 status: CommandStatus::NotAProject,
856 message,
857 unmarked: false,
858 };
859 if let Some(kind) = chosen {
860 return formats
861 .iter()
862 .find(|d| d.kind() == kind)
863 .and_then(|d| d.adapter())
864 .map(|a| (a, Vec::new()))
865 .ok_or_else(|| refuse(not_yet(kind)));
866 }
867 let mut found: Vec<(&'static dyn Detect, PathBuf)> = formats
870 .iter()
871 .filter_map(|d| d.root(start).map(|r| (*d, r)))
872 .collect();
873 let nearest = found.iter().map(|(_, r)| r.components().count()).max();
874 found.retain(|(_, r)| Some(r.components().count()) == nearest);
875 let supported: Vec<&'static dyn Adapter> =
876 found.iter().filter_map(|(d, _)| d.adapter()).collect();
877 let others = || found.iter().filter(|(d, _)| d.adapter().is_none());
878 match supported.as_slice() {
879 [one] => {
880 let warnings = others()
881 .map(|(d, root)| {
882 Diagnostic::new(
883 Severity::Warning,
884 "format.unsupported-marker",
885 format!(
886 "{} is also present at {}; {}",
887 d.kind().title(),
888 root.display(),
889 not_yet(d.kind())
890 ),
891 )
892 })
893 .collect();
894 Ok((*one, warnings))
895 }
896 [] => match others().next() {
897 Some((d, root)) => Err(refuse(format!(
898 "found {} project at {}; {}",
899 article(d.kind()),
900 root.display(),
901 not_yet(d.kind())
902 ))),
903 None => Ok((reference(), Vec::new())),
905 },
906 many => Err(Refused {
907 unmarked: false,
908 status: CommandStatus::DiagnosticsError,
909 message: format!(
910 "this project is in more than one format ({}); choose one with --format",
911 many.iter()
912 .map(|a| a.kind().name())
913 .collect::<Vec<_>>()
914 .join(", ")
915 ),
916 }),
917 }
918}
919
920fn not_yet(kind: FormatKind) -> String {
921 match kind {
922 FormatKind::Kiro => {
923 "speccoder runs Spec Kit and OpenSpec projects, not Kiro specs".to_owned()
924 }
925 k => format!("speccoder does not run {} projects", k.title()),
926 }
927}
928
929fn article(kind: FormatKind) -> String {
930 match kind {
931 FormatKind::Openspec => "an OpenSpec".to_owned(),
932 k => format!("a {}", k.title()),
933 }
934}
935
936fn configured(start: &Path) -> Result<Option<FormatKind>, Refused> {
938 let Some(dir) = crate::verify::find_config(start) else {
939 return Ok(None);
940 };
941 let Ok(text) = std::fs::read_to_string(dir.join(crate::verify::config::FILE)) else {
942 return Ok(None);
943 };
944 parse_config(&text).map_err(|e| Refused {
945 unmarked: false,
946 status: CommandStatus::ConfigError,
947 message: format!("speccoder.toml: {e}"),
948 })
949}
950
951pub fn parse_config(text: &str) -> Result<Option<FormatKind>, String> {
953 #[derive(serde::Deserialize)]
954 #[serde(deny_unknown_fields)]
955 struct Spec {
956 format: Option<String>,
957 }
958 let Ok(table) = toml::from_str::<toml::Table>(text) else {
960 return Ok(None);
961 };
962 let Some(spec) = table.get("spec") else {
963 return Ok(None);
964 };
965 let spec: Spec = spec
966 .clone()
967 .try_into()
968 .map_err(|e: toml::de::Error| format!("[spec]: {}", e.message()))?;
969 let [a, b, c] = FormatKind::ALL.map(FormatKind::name);
970 spec.format
971 .map(|f| {
972 f.parse()
973 .map_err(|_| format!("[spec]: format = \"{f}\" ({a}, {b} or {c})"))
974 })
975 .transpose()
976}
977
978#[cfg(test)]
979mod tests {
980 use super::*;
981
982 struct Fake(FormatKind, bool);
983
984 impl Detect for Fake {
985 fn kind(&self) -> FormatKind {
986 self.0
987 }
988 fn root(&self, _: &Path) -> Option<PathBuf> {
989 Some(PathBuf::from("/p"))
990 }
991 fn adapter(&self) -> Option<&'static dyn Adapter> {
992 self.1.then(reference)
993 }
994 }
995
996 struct At(FormatKind, &'static str);
997
998 impl Detect for At {
999 fn kind(&self) -> FormatKind {
1000 self.0
1001 }
1002 fn root(&self, _: &Path) -> Option<PathBuf> {
1003 Some(PathBuf::from(self.1))
1004 }
1005 fn adapter(&self) -> Option<&'static dyn Adapter> {
1006 Some(reference())
1007 }
1008 }
1009
1010 #[test]
1011 fn the_nearest_project_decides() {
1012 static OUTER: At = At(FormatKind::Speckit, "/repo");
1013 static INNER: At = At(FormatKind::Openspec, "/repo/sub/project");
1014 let (_, w) = resolve(Path::new("/repo/sub/project/x"), None, &[&OUTER, &INNER]).unwrap();
1015 assert!(w.is_empty());
1016 static TWIN: At = At(FormatKind::Speckit, "/repo/sub/project");
1017 assert!(resolve(Path::new("/x"), None, &[&TWIN, &INNER]).is_err());
1018 }
1019
1020 static SK: Fake = Fake(FormatKind::Speckit, true);
1021 static OS: Fake = Fake(FormatKind::Openspec, false);
1022 static OS_RUNS: Fake = Fake(FormatKind::Openspec, true);
1023 static KIRO: Fake = Fake(FormatKind::Kiro, false);
1024
1025 #[test]
1026 fn a_choice_wins_and_an_unsupported_one_is_refused() {
1027 let p = Path::new("/p");
1028 let (a, w) = resolve(p, Some(FormatKind::Speckit), &[&SK, &OS]).unwrap();
1029 assert_eq!((a.kind(), w.len()), (FormatKind::Speckit, 0));
1030 let r = resolve(p, Some(FormatKind::Openspec), &[&SK, &OS])
1031 .err()
1032 .unwrap();
1033 assert_eq!(r.status, CommandStatus::NotAProject);
1034 assert_eq!(r.message, "speccoder does not run OpenSpec projects");
1035 let r = resolve(p, Some(FormatKind::Kiro), &[&SK, &KIRO])
1036 .err()
1037 .unwrap();
1038 assert!(r.message.ends_with("not Kiro specs"));
1039 }
1040
1041 #[test]
1042 fn detection_warns_about_other_markers_and_refuses_ambiguity() {
1043 let p = Path::new("/p");
1044 let (a, w) = resolve(p, None, &[&SK, &OS]).unwrap();
1045 assert_eq!(a.kind(), FormatKind::Speckit);
1046 assert_eq!(w.len(), 1);
1047 assert_eq!(w[0].code, "format.unsupported-marker");
1048 let r = resolve(p, None, &[&OS]).err().unwrap();
1049 assert_eq!(
1050 r.message,
1051 "found an OpenSpec project at /p; speccoder does not run OpenSpec projects"
1052 );
1053 let r = resolve(p, None, &[&SK, &OS_RUNS]).err().unwrap();
1054 assert_eq!(r.status, CommandStatus::DiagnosticsError);
1055 assert!(
1056 r.message.contains("choose one with --format"),
1057 "{}",
1058 r.message
1059 );
1060 }
1061
1062 #[test]
1063 fn the_spec_section_names_a_format() {
1064 assert_eq!(parse_config(""), Ok(None));
1065 assert_eq!(
1066 parse_config("[spec]\nformat = \"openspec\"\n"),
1067 Ok(Some(FormatKind::Openspec))
1068 );
1069 assert_eq!(
1070 parse_config("[spec]\nformat = \"x\"\n"),
1071 Err("[spec]: format = \"x\" (speckit, openspec or kiro)".to_owned())
1072 );
1073 assert!(
1074 parse_config("[spec]\nkind = 1\n")
1075 .unwrap_err()
1076 .starts_with("[spec]: unknown field")
1077 );
1078 assert_eq!(parse_config("[format]\nargv = [\"x\"]\n"), Ok(None));
1079 }
1080}