1pub mod kiro;
9pub mod openspec;
10pub mod speckit;
11
12use std::path::{Path, PathBuf};
13use std::sync::OnceLock;
14
15use speccoder_core::snapshot::{Diagnostic, ParsedTasks, ProjectSnapshot, Severity};
16use speccoder_core::spec::{FormatKind, ScenarioRef};
17use speccoder_core::{ChecklistStatus, CommandStatus};
18
19use crate::context::{Bundle, BundleError};
20
21pub use speckit::Workspace;
22
23#[derive(Debug, Clone, PartialEq, Eq)]
25pub struct Recorded {
26 pub feature: String,
28 pub dir: String,
30 pub task: String,
32 pub requirement: String,
34 pub title: String,
36}
37
38#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
40pub struct Extension {
41 pub id: String,
43 pub version: String,
45 pub enabled: bool,
47 pub source: String,
49}
50
51#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
53#[serde(rename_all = "snake_case")]
54pub enum InventorySource {
55 Cli,
57 Files,
59}
60
61#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
63pub struct Inventory {
64 pub source: InventorySource,
66 pub entries: Vec<Extension>,
68}
69
70impl Inventory {
71 #[must_use]
73 pub fn enabled(&self, id: &str) -> Option<&Extension> {
74 self.entries.iter().find(|e| e.id == id && e.enabled)
75 }
76}
77
78#[derive(Debug, Clone, PartialEq, Eq)]
80pub struct BugRefusal {
81 pub code: &'static str,
83 pub message: String,
85}
86
87impl BugRefusal {
88 #[must_use]
90 pub fn new(code: &'static str, message: impl Into<String>) -> Self {
91 Self {
92 code,
93 message: message.into(),
94 }
95 }
96}
97
98#[derive(Debug, Clone, PartialEq, Eq)]
100pub struct RecordedBug {
101 pub unit: String,
103 pub dir: String,
105 pub requirement: String,
107 pub task: String,
109 pub title: String,
111 pub files: Vec<String>,
113}
114
115#[derive(Debug, Clone, Copy)]
118pub struct BugStep {
119 pub skill: &'static str,
121 pub arguments: fn(&speccoder_core::bug::Triad, &str) -> String,
123 pub rules: &'static Rules,
125}
126
127#[derive(Debug, Clone, PartialEq, Eq)]
129pub struct ReadBug {
130 pub current: Vec<String>,
132 pub expected: Vec<String>,
134 pub unchanged: Vec<String>,
136 pub title: Option<String>,
138 pub file: String,
140}
141
142#[derive(Debug, Clone, PartialEq, Eq)]
145pub struct OracleRequirement {
146 pub key: String,
148 pub title: String,
150 pub text: String,
152 pub scenarios: Vec<ScenarioRef>,
154}
155
156impl OracleRequirement {
157 #[must_use]
160 pub fn digest(&self) -> String {
161 format!(
162 "sha256:{}",
163 crate::integrity::sha256_hex(format!("{}\n{}", self.title, self.text).as_bytes())
164 )
165 }
166}
167
168#[derive(Debug, Clone, PartialEq, Eq)]
170pub struct FormatInit {
171 pub extensions: Vec<speccoder_core::new_project::ExtensionChoice>,
173 pub preset: bool,
176 pub state: PathBuf,
178 pub cache: PathBuf,
181}
182
183#[derive(Debug, Clone, PartialEq, Eq)]
185pub struct InitStep {
186 pub argv: Vec<String>,
188 pub hosts: Vec<String>,
190 pub writes: String,
192 pub env: Vec<(String, String)>,
194 pub marker: &'static str,
196 pub note: Option<String>,
198 pub preset: bool,
200}
201
202#[derive(Debug, Clone, Copy, PartialEq, Eq)]
204pub enum PhaseKind {
205 Setup,
207 Foundational,
209 Story,
211 Polish,
213 Other,
215}
216
217#[derive(Debug, Clone, PartialEq, Eq)]
219pub struct PlannedTask {
220 pub id: String,
222 pub title: String,
224 pub phase: PhaseKind,
226 pub story: Option<String>,
228 pub spans: Vec<String>,
230 pub done: bool,
232}
233
234#[derive(Debug, Clone, PartialEq, Eq, Default)]
237pub struct PlanStructure {
238 pub tasks: Vec<PlannedTask>,
240 pub stories: std::collections::BTreeMap<String, String>,
242}
243
244#[must_use]
246pub fn code_spans(text: &str) -> Vec<String> {
247 text.split('`')
248 .enumerate()
249 .filter(|(i, _)| i % 2 == 1)
250 .map(|(_, s)| s.to_owned())
251 .filter(|s| !s.trim().is_empty())
252 .collect()
253}
254
255pub trait Adapter: Send + Sync + std::fmt::Debug {
257 fn kind(&self) -> FormatKind;
259
260 fn detect(&self, start: &Path) -> Option<PathBuf>;
262
263 fn inspect(&self, start: &Path, feature: Option<&str>) -> Option<ProjectSnapshot>;
266
267 fn open(&self, start: &Path, feature: Option<&str>) -> Option<ProjectSnapshot> {
270 self.inspect(start, feature)
271 }
272
273 fn parse_tasks(&self, path: &str, text: &str) -> (ParsedTasks, Vec<Diagnostic>);
275
276 fn rules(&self) -> &'static Rules;
278
279 fn wording(&self) -> &'static Wording;
281
282 fn messages(&self) -> &'static Messages;
284
285 fn planning(&self) -> &'static Planning;
287
288 fn bundle(&self, snapshot: &ProjectSnapshot, task: &str) -> Result<Bundle, BundleError>;
290
291 fn scenarios(&self, snapshot: &ProjectSnapshot, task: &str) -> Vec<ScenarioRef>;
294
295 fn acceptance_testable(&self, bundle: &Bundle) -> bool;
297
298 fn is_convergence(&self, phase: &str) -> bool;
300
301 fn convergence_texts(&self, root: &Path, unit_dir: &Path) -> [String; 4];
304
305 fn blocking<'s>(&self, _snapshot: &'s ProjectSnapshot) -> Vec<&'s Diagnostic> {
308 Vec::new()
309 }
310
311 fn crosscheck(
314 &self,
315 _snapshot: &ProjectSnapshot,
316 _runner: &dyn crate::verify::process::CommandRunner,
317 ) -> Vec<Diagnostic> {
318 Vec::new()
319 }
320
321 fn checklists(&self, root: &Path, unit_dir: &str) -> Vec<ChecklistStatus>;
323
324 fn plan_structure(&self, _snapshot: &ProjectSnapshot) -> Option<PlanStructure> {
327 None
328 }
329
330 fn doctor_checks(
333 &self,
334 _root: &Path,
335 _env: &dyn Fn(&str) -> Option<String>,
336 ) -> Vec<speccoder_core::DoctorCheck> {
337 Vec::new()
338 }
339
340 fn tool(&self) -> Option<(&'static str, &'static str)> {
343 None
344 }
345
346 fn init_steps(&self, _choices: &FormatInit) -> Vec<InitStep> {
350 Vec::new()
351 }
352
353 fn prepare_init(&self, _choices: &FormatInit) -> Result<(), String> {
360 Ok(())
361 }
362
363 fn requirements_file(&self) -> Option<&'static str> {
367 None
368 }
369
370 fn requirements_in(&self, _text: &str) -> Vec<(String, String)> {
373 Vec::new()
374 }
375
376 fn record_change(
380 &self,
381 _root: &Path,
382 _request: &str,
383 _checks: &[String],
384 ) -> Result<Recorded, String> {
385 Err("this spec format has no place for quick changes: write a spec for it".to_owned())
386 }
387
388 fn extensions(&self, _root: &Path) -> Option<Inventory> {
392 None
393 }
394
395 fn bug_unit(&self, _root: &Path) -> Result<speccoder_core::bug::BugHome, BugRefusal> {
403 Err(BugRefusal::new(
404 "BUG-NO-UNIT",
405 "this spec format has no place for a bug: write a spec for the fix, or `speccoder do` for a change without a reproduction",
406 ))
407 }
408
409 fn record_bug(
417 &self,
418 _root: &Path,
419 _home: &speccoder_core::bug::BugHome,
420 _triad: &speccoder_core::bug::Triad,
421 ) -> Result<RecordedBug, BugRefusal> {
422 Err(BugRefusal::new(
423 "BUG-NO-UNIT",
424 "this spec format has no standing unit for bugs",
425 ))
426 }
427
428 fn bug_spec(&self, _snapshot: &ProjectSnapshot) -> Option<String> {
431 None
432 }
433
434 fn close_bug(&self, _dir: &Path) -> Result<Vec<String>, String> {
441 Ok(Vec::new())
442 }
443
444 fn bug_step(&self) -> Option<BugStep> {
446 None
447 }
448
449 fn bug_dir(
452 &self,
453 _home: &speccoder_core::bug::BugHome,
454 _slug: &str,
455 ) -> Option<(String, String)> {
456 None
457 }
458
459 fn bug_slug(&self, _root: &Path, _home: &speccoder_core::bug::BugHome, _title: &str) -> String {
461 String::new()
462 }
463
464 fn read_bug(
471 &self,
472 _root: &Path,
473 _home: &speccoder_core::bug::BugHome,
474 _key: &str,
475 ) -> Result<ReadBug, BugRefusal> {
476 Err(BugRefusal::new("BUG-NO-UNIT", "no bug home"))
477 }
478
479 fn oracle_requirements(&self, _snapshot: &ProjectSnapshot) -> Vec<OracleRequirement> {
483 Vec::new()
484 }
485
486 fn standing_units(&self, _root: &Path) -> Vec<(String, speccoder_core::ladder::Rung)> {
489 Vec::new()
490 }
491
492 fn requirement_ids(&self, root: &Path, unit_dir: &str) -> Vec<(String, String)> {
496 let Some(file) = self.requirements_file() else {
497 return Vec::new();
498 };
499 let text = std::fs::read_to_string(root.join(unit_dir).join(file)).unwrap_or_default();
500 self.requirements_in(&text)
501 .into_iter()
502 .map(|(id, line)| (id, crate::integrity::sha256_hex(line.as_bytes())))
503 .collect()
504 }
505
506 fn current_pointer(&self) -> Option<&'static str> {
509 None
510 }
511
512 fn set_current(&self, _root: &Path, _unit_dir: Option<&str>) -> Result<String, String> {
516 Err(self.messages().no_current.to_owned())
517 }
518
519 fn documents(&self) -> &'static [(&'static str, &'static str)];
522
523 fn tick(&self, tasks_file: &Path, task: &str, line_hash: &str) -> Result<(), String>;
525
526 fn append(
528 &self,
529 tasks_file: &Path,
530 file_hash: &str,
531 items: &[String],
532 ) -> Result<Vec<String>, String>;
533
534 fn commit_precondition(
537 &self,
538 root: &Path,
539 join: &dyn Fn(&str) -> String,
540 unit_dir: &str,
541 ) -> CommitPrecondition;
542}
543
544pub type FoundEnv = fn(&dyn Fn(&str) -> Option<String>) -> Vec<(String, String)>;
547
548#[must_use]
550pub fn no_found_env(_: &dyn Fn(&str) -> Option<String>) -> Vec<(String, String)> {
551 Vec::new()
552}
553
554pub type CommandRule = fn(&[String], bool) -> Option<Result<(), String>>;
557
558#[derive(Debug)]
561pub struct Rules {
562 pub intent: &'static [&'static str],
564 pub intent_reason: &'static str,
566 pub authoring_path: fn(&str) -> bool,
568 pub command: CommandRule,
572 pub env: &'static [(&'static str, &'static str)],
574 pub found_env: FoundEnv,
578 pub authoring_refusal: fn(&str) -> String,
580 pub judged: fn(&str) -> bool,
584 pub plan: &'static str,
587}
588
589#[derive(Debug)]
592pub struct Wording {
593 pub product: &'static str,
595 pub name: &'static str,
597 pub unit: &'static str,
599 pub intent_paths: &'static str,
601 pub intent_code: &'static str,
603 pub documents: &'static str,
605 pub authoring: &'static str,
608 pub authoring_tool: &'static str,
610 pub story: &'static str,
612 pub constitution: &'static str,
614 pub plan: &'static str,
616 pub plan_short: &'static str,
618 pub examples: &'static str,
620 pub convergence_task: &'static str,
622 pub files: [&'static str; 3],
624 pub assess: Assess,
626}
627
628#[derive(Debug)]
630pub struct Assess {
631 pub rules: &'static str,
633 pub targets: &'static str,
635 pub must: &'static str,
637 pub sources: &'static str,
639 pub critical: &'static str,
641 pub refs: &'static str,
643}
644
645#[derive(Debug)]
647pub struct Messages {
648 pub tasks_file: &'static str,
650 pub make_tasks: &'static str,
652 pub not_a_project: fn(&Path) -> String,
654 pub no_project: &'static str,
656 pub no_project_in_worktree: &'static str,
658 pub not_in_git: &'static str,
660 pub no_skills: &'static str,
662 pub checklists: fn(&str) -> String,
664 pub unit: &'static str,
666 pub no_current: &'static str,
668 pub current_shared: &'static str,
670 pub make_project: (&'static str, &'static str),
672}
673
674#[derive(Debug)]
676pub struct Planning {
677 pub prefix: &'static str,
679 pub core: &'static [&'static str],
681 pub refused: fn(&str) -> Option<String>,
683 pub creates: &'static [&'static str],
686 pub current_env: Option<&'static str>,
689 pub toward_tasks: fn(bool) -> &'static [&'static str],
692 pub revises: Option<&'static str>,
695 pub checks: Option<&'static str>,
698 pub explores: Option<&'static str>,
702 pub explore_paths: fn(&str) -> bool,
704 pub explore_commands: &'static [&'static [&'static str]],
706 pub commands: &'static [SlashCommand],
708}
709
710#[derive(Debug, Clone, Copy)]
712pub struct SlashCommand {
713 pub name: &'static str,
715 pub about: &'static str,
717 pub hint: Option<&'static str>,
719}
720
721#[must_use]
723pub const fn writes_nothing(_rel: &str) -> bool {
724 false
725}
726
727impl Planning {
728 #[must_use]
732 pub fn role_of(&self, short: &str) -> speccoder_core::actions::SkillRole {
733 use speccoder_core::actions::SkillRole;
734 let toward = |plan| (self.toward_tasks)(plan).contains(&short);
735 if self.creates.contains(&short) {
736 SkillRole::Create
737 } else if toward(false) || toward(true) {
738 SkillRole::TowardTasks
739 } else if self.revises == Some(short) {
740 SkillRole::Revise
741 } else if self.checks == Some(short) {
742 SkillRole::Check
743 } else {
744 SkillRole::Other
745 }
746 }
747}
748
749#[derive(Debug, Clone)]
751pub struct CommitPrecondition {
752 pub strict: Vec<String>,
754 pub tracked: Vec<String>,
757 pub runtime: fn(&str) -> bool,
759 pub essentials: Vec<String>,
761 pub message: &'static str,
763 pub hint: &'static str,
765 pub no_commits_hint: &'static str,
767}
768
769impl CommitPrecondition {
770 #[must_use]
772 pub fn paths(&self) -> Vec<String> {
773 self.tracked.iter().chain(&self.strict).cloned().collect()
774 }
775}
776
777fn all() -> [&'static dyn Detect; 3] {
779 [&speckit::SPECKIT, &openspec::OPENSPEC, &kiro::KIRO]
780}
781
782pub trait Detect: Send + Sync {
784 fn kind(&self) -> FormatKind;
786 fn root(&self, start: &Path) -> Option<PathBuf>;
788 fn adapter(&self) -> Option<&'static dyn Adapter>;
790}
791
792#[must_use]
794pub fn adapters() -> Vec<&'static dyn Adapter> {
795 all().into_iter().filter_map(|d| d.adapter()).collect()
796}
797
798#[must_use]
800pub fn adapter(kind: FormatKind) -> Option<&'static dyn Adapter> {
801 all()
802 .into_iter()
803 .find(|d| d.kind() == kind)
804 .and_then(|d| d.adapter())
805}
806
807#[must_use]
809pub fn reference() -> &'static dyn Adapter {
810 &speckit::SPECKIT
811}
812
813static REQUEST: OnceLock<FormatKind> = OnceLock::new();
814
815pub fn request(kind: FormatKind) {
818 let _ = REQUEST.set(kind);
819}
820
821pub struct Opened {
823 pub adapter: &'static dyn Adapter,
825 pub snapshot: ProjectSnapshot,
827}
828
829#[derive(Debug, Clone, PartialEq, Eq)]
831pub struct Refused {
832 pub status: CommandStatus,
834 pub message: String,
836 pub unmarked: bool,
838}
839
840pub fn open(start: &Path, feature: Option<&str>) -> Result<Opened, Refused> {
843 open_with(start, feature, REQUEST.get().copied())
844}
845
846pub fn open_with(
848 start: &Path,
849 feature: Option<&str>,
850 explicit: Option<FormatKind>,
851) -> Result<Opened, Refused> {
852 let configured = match explicit {
853 Some(_) => None,
854 None => configured(start)?,
855 };
856 let (adapter, mut warnings) = resolve(start, explicit.or(configured), &all())?;
857 let mut snapshot = adapter.open(start, feature).ok_or_else(|| Refused {
858 status: CommandStatus::NotAProject,
859 message: (adapter.messages().not_a_project)(start),
860 unmarked: true,
861 })?;
862 snapshot.diagnostics.append(&mut warnings);
863 Ok(Opened { adapter, snapshot })
864}
865
866#[must_use]
870pub fn at(root: &Path) -> &'static dyn Adapter {
871 let chosen = REQUEST
872 .get()
873 .copied()
874 .or_else(|| configured(root).ok().flatten());
875 resolve(root, chosen, &all()).map_or_else(|_| reference(), |(a, _)| a)
876}
877
878#[must_use]
880pub fn of(snapshot: &ProjectSnapshot) -> &'static dyn Adapter {
881 at(Path::new(&snapshot.root))
882}
883
884fn resolve(
888 start: &Path,
889 chosen: Option<FormatKind>,
890 formats: &[&'static dyn Detect],
891) -> Result<(&'static dyn Adapter, Vec<Diagnostic>), Refused> {
892 let refuse = |message: String| Refused {
893 status: CommandStatus::NotAProject,
894 message,
895 unmarked: false,
896 };
897 if let Some(kind) = chosen {
898 return formats
899 .iter()
900 .find(|d| d.kind() == kind)
901 .and_then(|d| d.adapter())
902 .map(|a| (a, Vec::new()))
903 .ok_or_else(|| refuse(not_yet(kind)));
904 }
905 let mut found: Vec<(&'static dyn Detect, PathBuf)> = formats
908 .iter()
909 .filter_map(|d| d.root(start).map(|r| (*d, r)))
910 .collect();
911 let nearest = found.iter().map(|(_, r)| r.components().count()).max();
912 found.retain(|(_, r)| Some(r.components().count()) == nearest);
913 let supported: Vec<&'static dyn Adapter> =
914 found.iter().filter_map(|(d, _)| d.adapter()).collect();
915 let others = || found.iter().filter(|(d, _)| d.adapter().is_none());
916 match supported.as_slice() {
917 [one] => {
918 let warnings = others()
919 .map(|(d, root)| {
920 Diagnostic::new(
921 Severity::Warning,
922 "format.unsupported-marker",
923 format!(
924 "{} is also present at {}; {}",
925 d.kind().title(),
926 root.display(),
927 not_yet(d.kind())
928 ),
929 )
930 })
931 .collect();
932 Ok((*one, warnings))
933 }
934 [] => match others().next() {
935 Some((d, root)) => Err(refuse(format!(
936 "found {} project at {}; {}",
937 article(d.kind()),
938 root.display(),
939 not_yet(d.kind())
940 ))),
941 None => Ok((reference(), Vec::new())),
943 },
944 many => Err(Refused {
945 unmarked: false,
946 status: CommandStatus::DiagnosticsError,
947 message: format!(
948 "this project is in more than one format ({}); choose one with --format",
949 many.iter()
950 .map(|a| a.kind().name())
951 .collect::<Vec<_>>()
952 .join(", ")
953 ),
954 }),
955 }
956}
957
958fn not_yet(kind: FormatKind) -> String {
959 format!("speccoder does not run {} projects", kind.title())
960}
961
962fn article(kind: FormatKind) -> String {
963 match kind {
964 FormatKind::Openspec => "an OpenSpec".to_owned(),
965 k => format!("a {}", k.title()),
966 }
967}
968
969fn configured(start: &Path) -> Result<Option<FormatKind>, Refused> {
971 let Some(dir) = crate::verify::find_config(start) else {
972 return Ok(None);
973 };
974 let Ok(text) = std::fs::read_to_string(dir.join(crate::verify::config::FILE)) else {
975 return Ok(None);
976 };
977 parse_config(&text).map_err(|e| Refused {
978 unmarked: false,
979 status: CommandStatus::ConfigError,
980 message: format!("speccoder.toml: {e}"),
981 })
982}
983
984pub fn parse_config(text: &str) -> Result<Option<FormatKind>, String> {
986 #[derive(serde::Deserialize)]
987 #[serde(deny_unknown_fields)]
988 struct Spec {
989 format: Option<String>,
990 }
991 let Ok(table) = toml::from_str::<toml::Table>(text) else {
993 return Ok(None);
994 };
995 let Some(spec) = table.get("spec") else {
996 return Ok(None);
997 };
998 let spec: Spec = spec
999 .clone()
1000 .try_into()
1001 .map_err(|e: toml::de::Error| format!("[spec]: {}", e.message()))?;
1002 let [a, b, c] = FormatKind::ALL.map(FormatKind::name);
1003 spec.format
1004 .map(|f| {
1005 f.parse()
1006 .map_err(|_| format!("[spec]: format = \"{f}\" ({a}, {b} or {c})"))
1007 })
1008 .transpose()
1009}
1010
1011#[cfg(test)]
1012mod tests {
1013 use super::*;
1014
1015 struct Fake(FormatKind, bool);
1016
1017 impl Detect for Fake {
1018 fn kind(&self) -> FormatKind {
1019 self.0
1020 }
1021 fn root(&self, _: &Path) -> Option<PathBuf> {
1022 Some(PathBuf::from("/p"))
1023 }
1024 fn adapter(&self) -> Option<&'static dyn Adapter> {
1025 self.1.then(reference)
1026 }
1027 }
1028
1029 struct At(FormatKind, &'static str);
1030
1031 impl Detect for At {
1032 fn kind(&self) -> FormatKind {
1033 self.0
1034 }
1035 fn root(&self, _: &Path) -> Option<PathBuf> {
1036 Some(PathBuf::from(self.1))
1037 }
1038 fn adapter(&self) -> Option<&'static dyn Adapter> {
1039 Some(reference())
1040 }
1041 }
1042
1043 #[test]
1044 fn the_nearest_project_decides() {
1045 static OUTER: At = At(FormatKind::Speckit, "/repo");
1046 static INNER: At = At(FormatKind::Openspec, "/repo/sub/project");
1047 let (_, w) = resolve(Path::new("/repo/sub/project/x"), None, &[&OUTER, &INNER]).unwrap();
1048 assert!(w.is_empty());
1049 static TWIN: At = At(FormatKind::Speckit, "/repo/sub/project");
1050 assert!(resolve(Path::new("/x"), None, &[&TWIN, &INNER]).is_err());
1051 }
1052
1053 static SK: Fake = Fake(FormatKind::Speckit, true);
1054 static OS: Fake = Fake(FormatKind::Openspec, false);
1055 static OS_RUNS: Fake = Fake(FormatKind::Openspec, true);
1056 static KIRO: Fake = Fake(FormatKind::Kiro, false);
1057
1058 #[test]
1059 fn a_choice_wins_and_an_unsupported_one_is_refused() {
1060 let p = Path::new("/p");
1061 let (a, w) = resolve(p, Some(FormatKind::Speckit), &[&SK, &OS]).unwrap();
1062 assert_eq!((a.kind(), w.len()), (FormatKind::Speckit, 0));
1063 let r = resolve(p, Some(FormatKind::Openspec), &[&SK, &OS])
1064 .err()
1065 .unwrap();
1066 assert_eq!(r.status, CommandStatus::NotAProject);
1067 assert_eq!(r.message, "speccoder does not run OpenSpec projects");
1068 let r = resolve(p, Some(FormatKind::Kiro), &[&SK, &KIRO])
1069 .err()
1070 .unwrap();
1071 assert_eq!(r.message, "speccoder does not run Kiro projects");
1072 }
1073
1074 #[test]
1075 fn detection_warns_about_other_markers_and_refuses_ambiguity() {
1076 let p = Path::new("/p");
1077 let (a, w) = resolve(p, None, &[&SK, &OS]).unwrap();
1078 assert_eq!(a.kind(), FormatKind::Speckit);
1079 assert_eq!(w.len(), 1);
1080 assert_eq!(w[0].code, "format.unsupported-marker");
1081 let r = resolve(p, None, &[&OS]).err().unwrap();
1082 assert_eq!(
1083 r.message,
1084 "found an OpenSpec project at /p; speccoder does not run OpenSpec projects"
1085 );
1086 let r = resolve(p, None, &[&SK, &OS_RUNS]).err().unwrap();
1087 assert_eq!(r.status, CommandStatus::DiagnosticsError);
1088 assert!(
1089 r.message.contains("choose one with --format"),
1090 "{}",
1091 r.message
1092 );
1093 }
1094
1095 #[test]
1096 fn the_spec_section_names_a_format() {
1097 assert_eq!(parse_config(""), Ok(None));
1098 assert_eq!(
1099 parse_config("[spec]\nformat = \"openspec\"\n"),
1100 Ok(Some(FormatKind::Openspec))
1101 );
1102 assert_eq!(
1103 parse_config("[spec]\nformat = \"x\"\n"),
1104 Err("[spec]: format = \"x\" (speckit, openspec or kiro)".to_owned())
1105 );
1106 assert!(
1107 parse_config("[spec]\nkind = 1\n")
1108 .unwrap_err()
1109 .starts_with("[spec]: unknown field")
1110 );
1111 assert_eq!(parse_config("[format]\nargv = [\"x\"]\n"), Ok(None));
1112 }
1113}