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 pub explores: Option<&'static str>,
691 pub explore_paths: fn(&str) -> bool,
693 pub explore_commands: &'static [&'static [&'static str]],
695}
696
697#[must_use]
699pub const fn writes_nothing(_rel: &str) -> bool {
700 false
701}
702
703impl Planning {
704 #[must_use]
708 pub fn role_of(&self, short: &str) -> speccoder_core::actions::SkillRole {
709 use speccoder_core::actions::SkillRole;
710 let toward = |plan| (self.toward_tasks)(plan).contains(&short);
711 if self.creates.contains(&short) {
712 SkillRole::Create
713 } else if toward(false) || toward(true) {
714 SkillRole::TowardTasks
715 } else if self.revises == Some(short) {
716 SkillRole::Revise
717 } else if self.checks == Some(short) {
718 SkillRole::Check
719 } else {
720 SkillRole::Other
721 }
722 }
723}
724
725#[derive(Debug, Clone)]
727pub struct CommitPrecondition {
728 pub strict: Vec<String>,
730 pub tracked: Vec<String>,
733 pub runtime: fn(&str) -> bool,
735 pub essentials: Vec<String>,
737 pub message: &'static str,
739 pub hint: &'static str,
741 pub no_commits_hint: &'static str,
743}
744
745impl CommitPrecondition {
746 #[must_use]
748 pub fn paths(&self) -> Vec<String> {
749 self.tracked.iter().chain(&self.strict).cloned().collect()
750 }
751}
752
753fn all() -> [&'static dyn Detect; 3] {
755 [&speckit::SPECKIT, &openspec::OPENSPEC, &stubs::KIRO]
756}
757
758pub trait Detect: Send + Sync {
760 fn kind(&self) -> FormatKind;
762 fn root(&self, start: &Path) -> Option<PathBuf>;
764 fn adapter(&self) -> Option<&'static dyn Adapter>;
766}
767
768#[must_use]
770pub fn adapters() -> Vec<&'static dyn Adapter> {
771 all().into_iter().filter_map(|d| d.adapter()).collect()
772}
773
774#[must_use]
776pub fn adapter(kind: FormatKind) -> Option<&'static dyn Adapter> {
777 all()
778 .into_iter()
779 .find(|d| d.kind() == kind)
780 .and_then(|d| d.adapter())
781}
782
783#[must_use]
785pub fn reference() -> &'static dyn Adapter {
786 &speckit::SPECKIT
787}
788
789static REQUEST: OnceLock<FormatKind> = OnceLock::new();
790
791pub fn request(kind: FormatKind) {
794 let _ = REQUEST.set(kind);
795}
796
797pub struct Opened {
799 pub adapter: &'static dyn Adapter,
801 pub snapshot: ProjectSnapshot,
803}
804
805#[derive(Debug, Clone, PartialEq, Eq)]
807pub struct Refused {
808 pub status: CommandStatus,
810 pub message: String,
812 pub unmarked: bool,
814}
815
816pub fn open(start: &Path, feature: Option<&str>) -> Result<Opened, Refused> {
819 open_with(start, feature, REQUEST.get().copied())
820}
821
822pub fn open_with(
824 start: &Path,
825 feature: Option<&str>,
826 explicit: Option<FormatKind>,
827) -> Result<Opened, Refused> {
828 let configured = match explicit {
829 Some(_) => None,
830 None => configured(start)?,
831 };
832 let (adapter, mut warnings) = resolve(start, explicit.or(configured), &all())?;
833 let mut snapshot = adapter.open(start, feature).ok_or_else(|| Refused {
834 status: CommandStatus::NotAProject,
835 message: (adapter.messages().not_a_project)(start),
836 unmarked: true,
837 })?;
838 snapshot.diagnostics.append(&mut warnings);
839 Ok(Opened { adapter, snapshot })
840}
841
842#[must_use]
846pub fn at(root: &Path) -> &'static dyn Adapter {
847 let chosen = REQUEST
848 .get()
849 .copied()
850 .or_else(|| configured(root).ok().flatten());
851 resolve(root, chosen, &all()).map_or_else(|_| reference(), |(a, _)| a)
852}
853
854#[must_use]
856pub fn of(snapshot: &ProjectSnapshot) -> &'static dyn Adapter {
857 at(Path::new(&snapshot.root))
858}
859
860fn resolve(
864 start: &Path,
865 chosen: Option<FormatKind>,
866 formats: &[&'static dyn Detect],
867) -> Result<(&'static dyn Adapter, Vec<Diagnostic>), Refused> {
868 let refuse = |message: String| Refused {
869 status: CommandStatus::NotAProject,
870 message,
871 unmarked: false,
872 };
873 if let Some(kind) = chosen {
874 return formats
875 .iter()
876 .find(|d| d.kind() == kind)
877 .and_then(|d| d.adapter())
878 .map(|a| (a, Vec::new()))
879 .ok_or_else(|| refuse(not_yet(kind)));
880 }
881 let mut found: Vec<(&'static dyn Detect, PathBuf)> = formats
884 .iter()
885 .filter_map(|d| d.root(start).map(|r| (*d, r)))
886 .collect();
887 let nearest = found.iter().map(|(_, r)| r.components().count()).max();
888 found.retain(|(_, r)| Some(r.components().count()) == nearest);
889 let supported: Vec<&'static dyn Adapter> =
890 found.iter().filter_map(|(d, _)| d.adapter()).collect();
891 let others = || found.iter().filter(|(d, _)| d.adapter().is_none());
892 match supported.as_slice() {
893 [one] => {
894 let warnings = others()
895 .map(|(d, root)| {
896 Diagnostic::new(
897 Severity::Warning,
898 "format.unsupported-marker",
899 format!(
900 "{} is also present at {}; {}",
901 d.kind().title(),
902 root.display(),
903 not_yet(d.kind())
904 ),
905 )
906 })
907 .collect();
908 Ok((*one, warnings))
909 }
910 [] => match others().next() {
911 Some((d, root)) => Err(refuse(format!(
912 "found {} project at {}; {}",
913 article(d.kind()),
914 root.display(),
915 not_yet(d.kind())
916 ))),
917 None => Ok((reference(), Vec::new())),
919 },
920 many => Err(Refused {
921 unmarked: false,
922 status: CommandStatus::DiagnosticsError,
923 message: format!(
924 "this project is in more than one format ({}); choose one with --format",
925 many.iter()
926 .map(|a| a.kind().name())
927 .collect::<Vec<_>>()
928 .join(", ")
929 ),
930 }),
931 }
932}
933
934fn not_yet(kind: FormatKind) -> String {
935 match kind {
936 FormatKind::Kiro => {
937 "speccoder runs Spec Kit and OpenSpec projects, not Kiro specs".to_owned()
938 }
939 k => format!("speccoder does not run {} projects", k.title()),
940 }
941}
942
943fn article(kind: FormatKind) -> String {
944 match kind {
945 FormatKind::Openspec => "an OpenSpec".to_owned(),
946 k => format!("a {}", k.title()),
947 }
948}
949
950fn configured(start: &Path) -> Result<Option<FormatKind>, Refused> {
952 let Some(dir) = crate::verify::find_config(start) else {
953 return Ok(None);
954 };
955 let Ok(text) = std::fs::read_to_string(dir.join(crate::verify::config::FILE)) else {
956 return Ok(None);
957 };
958 parse_config(&text).map_err(|e| Refused {
959 unmarked: false,
960 status: CommandStatus::ConfigError,
961 message: format!("speccoder.toml: {e}"),
962 })
963}
964
965pub fn parse_config(text: &str) -> Result<Option<FormatKind>, String> {
967 #[derive(serde::Deserialize)]
968 #[serde(deny_unknown_fields)]
969 struct Spec {
970 format: Option<String>,
971 }
972 let Ok(table) = toml::from_str::<toml::Table>(text) else {
974 return Ok(None);
975 };
976 let Some(spec) = table.get("spec") else {
977 return Ok(None);
978 };
979 let spec: Spec = spec
980 .clone()
981 .try_into()
982 .map_err(|e: toml::de::Error| format!("[spec]: {}", e.message()))?;
983 let [a, b, c] = FormatKind::ALL.map(FormatKind::name);
984 spec.format
985 .map(|f| {
986 f.parse()
987 .map_err(|_| format!("[spec]: format = \"{f}\" ({a}, {b} or {c})"))
988 })
989 .transpose()
990}
991
992#[cfg(test)]
993mod tests {
994 use super::*;
995
996 struct Fake(FormatKind, bool);
997
998 impl Detect for Fake {
999 fn kind(&self) -> FormatKind {
1000 self.0
1001 }
1002 fn root(&self, _: &Path) -> Option<PathBuf> {
1003 Some(PathBuf::from("/p"))
1004 }
1005 fn adapter(&self) -> Option<&'static dyn Adapter> {
1006 self.1.then(reference)
1007 }
1008 }
1009
1010 struct At(FormatKind, &'static str);
1011
1012 impl Detect for At {
1013 fn kind(&self) -> FormatKind {
1014 self.0
1015 }
1016 fn root(&self, _: &Path) -> Option<PathBuf> {
1017 Some(PathBuf::from(self.1))
1018 }
1019 fn adapter(&self) -> Option<&'static dyn Adapter> {
1020 Some(reference())
1021 }
1022 }
1023
1024 #[test]
1025 fn the_nearest_project_decides() {
1026 static OUTER: At = At(FormatKind::Speckit, "/repo");
1027 static INNER: At = At(FormatKind::Openspec, "/repo/sub/project");
1028 let (_, w) = resolve(Path::new("/repo/sub/project/x"), None, &[&OUTER, &INNER]).unwrap();
1029 assert!(w.is_empty());
1030 static TWIN: At = At(FormatKind::Speckit, "/repo/sub/project");
1031 assert!(resolve(Path::new("/x"), None, &[&TWIN, &INNER]).is_err());
1032 }
1033
1034 static SK: Fake = Fake(FormatKind::Speckit, true);
1035 static OS: Fake = Fake(FormatKind::Openspec, false);
1036 static OS_RUNS: Fake = Fake(FormatKind::Openspec, true);
1037 static KIRO: Fake = Fake(FormatKind::Kiro, false);
1038
1039 #[test]
1040 fn a_choice_wins_and_an_unsupported_one_is_refused() {
1041 let p = Path::new("/p");
1042 let (a, w) = resolve(p, Some(FormatKind::Speckit), &[&SK, &OS]).unwrap();
1043 assert_eq!((a.kind(), w.len()), (FormatKind::Speckit, 0));
1044 let r = resolve(p, Some(FormatKind::Openspec), &[&SK, &OS])
1045 .err()
1046 .unwrap();
1047 assert_eq!(r.status, CommandStatus::NotAProject);
1048 assert_eq!(r.message, "speccoder does not run OpenSpec projects");
1049 let r = resolve(p, Some(FormatKind::Kiro), &[&SK, &KIRO])
1050 .err()
1051 .unwrap();
1052 assert!(r.message.ends_with("not Kiro specs"));
1053 }
1054
1055 #[test]
1056 fn detection_warns_about_other_markers_and_refuses_ambiguity() {
1057 let p = Path::new("/p");
1058 let (a, w) = resolve(p, None, &[&SK, &OS]).unwrap();
1059 assert_eq!(a.kind(), FormatKind::Speckit);
1060 assert_eq!(w.len(), 1);
1061 assert_eq!(w[0].code, "format.unsupported-marker");
1062 let r = resolve(p, None, &[&OS]).err().unwrap();
1063 assert_eq!(
1064 r.message,
1065 "found an OpenSpec project at /p; speccoder does not run OpenSpec projects"
1066 );
1067 let r = resolve(p, None, &[&SK, &OS_RUNS]).err().unwrap();
1068 assert_eq!(r.status, CommandStatus::DiagnosticsError);
1069 assert!(
1070 r.message.contains("choose one with --format"),
1071 "{}",
1072 r.message
1073 );
1074 }
1075
1076 #[test]
1077 fn the_spec_section_names_a_format() {
1078 assert_eq!(parse_config(""), Ok(None));
1079 assert_eq!(
1080 parse_config("[spec]\nformat = \"openspec\"\n"),
1081 Ok(Some(FormatKind::Openspec))
1082 );
1083 assert_eq!(
1084 parse_config("[spec]\nformat = \"x\"\n"),
1085 Err("[spec]: format = \"x\" (speckit, openspec or kiro)".to_owned())
1086 );
1087 assert!(
1088 parse_config("[spec]\nkind = 1\n")
1089 .unwrap_err()
1090 .starts_with("[spec]: unknown field")
1091 );
1092 assert_eq!(parse_config("[format]\nargv = [\"x\"]\n"), Ok(None));
1093 }
1094}