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
177pub trait Adapter: Send + Sync + std::fmt::Debug {
179 fn kind(&self) -> FormatKind;
181
182 fn detect(&self, start: &Path) -> Option<PathBuf>;
184
185 fn inspect(&self, start: &Path, feature: Option<&str>) -> Option<ProjectSnapshot>;
188
189 fn open(&self, start: &Path, feature: Option<&str>) -> Option<ProjectSnapshot> {
192 self.inspect(start, feature)
193 }
194
195 fn parse_tasks(&self, path: &str, text: &str) -> (ParsedTasks, Vec<Diagnostic>);
197
198 fn rules(&self) -> &'static Rules;
200
201 fn wording(&self) -> &'static Wording;
203
204 fn messages(&self) -> &'static Messages;
206
207 fn planning(&self) -> &'static Planning;
209
210 fn bundle(&self, snapshot: &ProjectSnapshot, task: &str) -> Result<Bundle, BundleError>;
212
213 fn scenarios(&self, snapshot: &ProjectSnapshot, task: &str) -> Vec<ScenarioRef>;
216
217 fn acceptance_testable(&self, bundle: &Bundle) -> bool;
219
220 fn is_convergence(&self, phase: &str) -> bool;
222
223 fn convergence_texts(&self, root: &Path, unit_dir: &Path) -> [String; 4];
226
227 fn blocking<'s>(&self, _snapshot: &'s ProjectSnapshot) -> Vec<&'s Diagnostic> {
230 Vec::new()
231 }
232
233 fn crosscheck(
236 &self,
237 _snapshot: &ProjectSnapshot,
238 _runner: &dyn crate::verify::process::CommandRunner,
239 ) -> Vec<Diagnostic> {
240 Vec::new()
241 }
242
243 fn checklists(&self, root: &Path, unit_dir: &str) -> Vec<ChecklistStatus>;
245
246 fn tool(&self) -> Option<(&'static str, &'static str)> {
249 None
250 }
251
252 fn requirements_file(&self) -> Option<&'static str> {
256 None
257 }
258
259 fn requirements_in(&self, _text: &str) -> Vec<(String, String)> {
262 Vec::new()
263 }
264
265 fn record_change(
269 &self,
270 _root: &Path,
271 _request: &str,
272 _checks: &[String],
273 ) -> Result<Recorded, String> {
274 Err("this spec format has no place for quick changes: write a spec for it".to_owned())
275 }
276
277 fn extensions(&self, _root: &Path) -> Option<Inventory> {
281 None
282 }
283
284 fn bug_unit(&self, _root: &Path) -> Result<speccoder_core::bug::BugHome, BugRefusal> {
292 Err(BugRefusal::new(
293 "BUG-NO-UNIT",
294 "this spec format has no place for a bug: write a spec for the fix, or `speccoder do` for a change without a reproduction",
295 ))
296 }
297
298 fn record_bug(
306 &self,
307 _root: &Path,
308 _home: &speccoder_core::bug::BugHome,
309 _triad: &speccoder_core::bug::Triad,
310 ) -> Result<RecordedBug, BugRefusal> {
311 Err(BugRefusal::new(
312 "BUG-NO-UNIT",
313 "this spec format has no standing unit for bugs",
314 ))
315 }
316
317 fn bug_step(&self) -> Option<BugStep> {
319 None
320 }
321
322 fn bug_dir(
325 &self,
326 _home: &speccoder_core::bug::BugHome,
327 _slug: &str,
328 ) -> Option<(String, String)> {
329 None
330 }
331
332 fn bug_slug(&self, _root: &Path, _home: &speccoder_core::bug::BugHome, _title: &str) -> String {
334 String::new()
335 }
336
337 fn read_bug(
344 &self,
345 _root: &Path,
346 _home: &speccoder_core::bug::BugHome,
347 _key: &str,
348 ) -> Result<ReadBug, BugRefusal> {
349 Err(BugRefusal::new("BUG-NO-UNIT", "no bug home"))
350 }
351
352 fn oracle_requirements(&self, _snapshot: &ProjectSnapshot) -> Vec<OracleRequirement> {
356 Vec::new()
357 }
358
359 fn standing_units(&self, _root: &Path) -> Vec<(String, speccoder_core::ladder::Rung)> {
362 Vec::new()
363 }
364
365 fn requirement_ids(&self, root: &Path, unit_dir: &str) -> Vec<(String, String)> {
369 let Some(file) = self.requirements_file() else {
370 return Vec::new();
371 };
372 let text = std::fs::read_to_string(root.join(unit_dir).join(file)).unwrap_or_default();
373 self.requirements_in(&text)
374 .into_iter()
375 .map(|(id, line)| (id, crate::integrity::sha256_hex(line.as_bytes())))
376 .collect()
377 }
378
379 fn current_pointer(&self) -> Option<&'static str> {
382 None
383 }
384
385 fn set_current(&self, _root: &Path, _unit_dir: Option<&str>) -> Result<String, String> {
389 Err(self.messages().no_current.to_owned())
390 }
391
392 fn tick(&self, tasks_file: &Path, task: &str, line_hash: &str) -> Result<(), String>;
394
395 fn append(
397 &self,
398 tasks_file: &Path,
399 file_hash: &str,
400 items: &[String],
401 ) -> Result<Vec<String>, String>;
402
403 fn commit_precondition(
406 &self,
407 root: &Path,
408 join: &dyn Fn(&str) -> String,
409 unit_dir: &str,
410 ) -> CommitPrecondition;
411}
412
413pub type CommandRule = fn(&[String], bool) -> Option<Result<(), String>>;
416
417#[derive(Debug)]
420pub struct Rules {
421 pub intent: &'static [&'static str],
423 pub intent_reason: &'static str,
425 pub authoring_path: fn(&str) -> bool,
427 pub command: CommandRule,
431 pub env: &'static [(&'static str, &'static str)],
433 pub authoring_refusal: fn(&str) -> String,
435}
436
437#[derive(Debug)]
440pub struct Wording {
441 pub product: &'static str,
443 pub name: &'static str,
445 pub unit: &'static str,
447 pub intent_paths: &'static str,
449 pub intent_code: &'static str,
451 pub documents: &'static str,
453 pub authoring: &'static str,
456 pub authoring_tool: &'static str,
458 pub story: &'static str,
460 pub constitution: &'static str,
462 pub plan: &'static str,
464 pub plan_short: &'static str,
466 pub examples: &'static str,
468 pub convergence_task: &'static str,
470 pub files: [&'static str; 3],
472 pub assess: Assess,
474}
475
476#[derive(Debug)]
478pub struct Assess {
479 pub rules: &'static str,
481 pub targets: &'static str,
483 pub must: &'static str,
485 pub sources: &'static str,
487 pub critical: &'static str,
489 pub refs: &'static str,
491}
492
493#[derive(Debug)]
495pub struct Messages {
496 pub tasks_file: &'static str,
498 pub make_tasks: &'static str,
500 pub not_a_project: fn(&Path) -> String,
502 pub no_project: &'static str,
504 pub no_project_in_worktree: &'static str,
506 pub not_in_git: &'static str,
508 pub no_skills: &'static str,
510 pub checklists: fn(&str) -> String,
512 pub unit: &'static str,
514 pub no_current: &'static str,
516 pub current_shared: &'static str,
518 pub make_project: (&'static str, &'static str),
520}
521
522#[derive(Debug)]
524pub struct Planning {
525 pub prefix: &'static str,
527 pub core: &'static [&'static str],
529 pub refused: fn(&str) -> Option<String>,
531 pub creates: &'static [&'static str],
534 pub current_env: Option<&'static str>,
537 pub toward_tasks: fn(bool) -> &'static [&'static str],
540 pub revises: Option<&'static str>,
543 pub checks: Option<&'static str>,
546}
547
548impl Planning {
549 #[must_use]
553 pub fn role_of(&self, short: &str) -> speccoder_core::actions::SkillRole {
554 use speccoder_core::actions::SkillRole;
555 let toward = |plan| (self.toward_tasks)(plan).contains(&short);
556 if self.creates.contains(&short) {
557 SkillRole::Create
558 } else if toward(false) || toward(true) {
559 SkillRole::TowardTasks
560 } else if self.revises == Some(short) {
561 SkillRole::Revise
562 } else if self.checks == Some(short) {
563 SkillRole::Check
564 } else {
565 SkillRole::Other
566 }
567 }
568}
569
570#[derive(Debug, Clone)]
572pub struct CommitPrecondition {
573 pub strict: Vec<String>,
575 pub tracked: Vec<String>,
578 pub runtime: fn(&str) -> bool,
580 pub essentials: Vec<String>,
582 pub message: &'static str,
584 pub hint: &'static str,
586 pub no_commits_hint: &'static str,
588}
589
590impl CommitPrecondition {
591 #[must_use]
593 pub fn paths(&self) -> Vec<String> {
594 self.tracked.iter().chain(&self.strict).cloned().collect()
595 }
596}
597
598fn all() -> [&'static dyn Detect; 3] {
600 [&speckit::SPECKIT, &openspec::OPENSPEC, &stubs::KIRO]
601}
602
603pub trait Detect: Send + Sync {
605 fn kind(&self) -> FormatKind;
607 fn root(&self, start: &Path) -> Option<PathBuf>;
609 fn adapter(&self) -> Option<&'static dyn Adapter>;
611}
612
613#[must_use]
615pub fn adapters() -> Vec<&'static dyn Adapter> {
616 all().into_iter().filter_map(|d| d.adapter()).collect()
617}
618
619#[must_use]
621pub fn adapter(kind: FormatKind) -> Option<&'static dyn Adapter> {
622 all()
623 .into_iter()
624 .find(|d| d.kind() == kind)
625 .and_then(|d| d.adapter())
626}
627
628#[must_use]
630pub fn reference() -> &'static dyn Adapter {
631 &speckit::SPECKIT
632}
633
634static REQUEST: OnceLock<FormatKind> = OnceLock::new();
635
636pub fn request(kind: FormatKind) {
639 let _ = REQUEST.set(kind);
640}
641
642pub struct Opened {
644 pub adapter: &'static dyn Adapter,
646 pub snapshot: ProjectSnapshot,
648}
649
650#[derive(Debug, Clone, PartialEq, Eq)]
652pub struct Refused {
653 pub status: CommandStatus,
655 pub message: String,
657 pub unmarked: bool,
659}
660
661pub fn open(start: &Path, feature: Option<&str>) -> Result<Opened, Refused> {
664 open_with(start, feature, REQUEST.get().copied())
665}
666
667pub fn open_with(
669 start: &Path,
670 feature: Option<&str>,
671 explicit: Option<FormatKind>,
672) -> Result<Opened, Refused> {
673 let configured = match explicit {
674 Some(_) => None,
675 None => configured(start)?,
676 };
677 let (adapter, mut warnings) = resolve(start, explicit.or(configured), &all())?;
678 let mut snapshot = adapter.open(start, feature).ok_or_else(|| Refused {
679 status: CommandStatus::NotAProject,
680 message: (adapter.messages().not_a_project)(start),
681 unmarked: true,
682 })?;
683 snapshot.diagnostics.append(&mut warnings);
684 Ok(Opened { adapter, snapshot })
685}
686
687#[must_use]
691pub fn at(root: &Path) -> &'static dyn Adapter {
692 let chosen = REQUEST
693 .get()
694 .copied()
695 .or_else(|| configured(root).ok().flatten());
696 resolve(root, chosen, &all()).map_or_else(|_| reference(), |(a, _)| a)
697}
698
699#[must_use]
701pub fn of(snapshot: &ProjectSnapshot) -> &'static dyn Adapter {
702 at(Path::new(&snapshot.root))
703}
704
705fn resolve(
709 start: &Path,
710 chosen: Option<FormatKind>,
711 formats: &[&'static dyn Detect],
712) -> Result<(&'static dyn Adapter, Vec<Diagnostic>), Refused> {
713 let refuse = |message: String| Refused {
714 status: CommandStatus::NotAProject,
715 message,
716 unmarked: false,
717 };
718 if let Some(kind) = chosen {
719 return formats
720 .iter()
721 .find(|d| d.kind() == kind)
722 .and_then(|d| d.adapter())
723 .map(|a| (a, Vec::new()))
724 .ok_or_else(|| refuse(not_yet(kind)));
725 }
726 let mut found: Vec<(&'static dyn Detect, PathBuf)> = formats
729 .iter()
730 .filter_map(|d| d.root(start).map(|r| (*d, r)))
731 .collect();
732 let nearest = found.iter().map(|(_, r)| r.components().count()).max();
733 found.retain(|(_, r)| Some(r.components().count()) == nearest);
734 let supported: Vec<&'static dyn Adapter> =
735 found.iter().filter_map(|(d, _)| d.adapter()).collect();
736 let others = || found.iter().filter(|(d, _)| d.adapter().is_none());
737 match supported.as_slice() {
738 [one] => {
739 let warnings = others()
740 .map(|(d, root)| {
741 Diagnostic::new(
742 Severity::Warning,
743 "format.unsupported-marker",
744 format!(
745 "{} is also present at {}; {}",
746 d.kind().title(),
747 root.display(),
748 not_yet(d.kind())
749 ),
750 )
751 })
752 .collect();
753 Ok((*one, warnings))
754 }
755 [] => match others().next() {
756 Some((d, root)) => Err(refuse(format!(
757 "found {} project at {}; {}",
758 article(d.kind()),
759 root.display(),
760 not_yet(d.kind())
761 ))),
762 None => Ok((reference(), Vec::new())),
764 },
765 many => Err(Refused {
766 unmarked: false,
767 status: CommandStatus::DiagnosticsError,
768 message: format!(
769 "this project is in more than one format ({}); choose one with --format",
770 many.iter()
771 .map(|a| a.kind().name())
772 .collect::<Vec<_>>()
773 .join(", ")
774 ),
775 }),
776 }
777}
778
779fn not_yet(kind: FormatKind) -> String {
780 match kind {
781 FormatKind::Kiro => {
782 "speccoder runs Spec Kit and OpenSpec projects, not Kiro specs".to_owned()
783 }
784 k => format!("speccoder does not run {} projects", k.title()),
785 }
786}
787
788fn article(kind: FormatKind) -> String {
789 match kind {
790 FormatKind::Openspec => "an OpenSpec".to_owned(),
791 k => format!("a {}", k.title()),
792 }
793}
794
795fn configured(start: &Path) -> Result<Option<FormatKind>, Refused> {
797 let Some(dir) = crate::verify::find_config(start) else {
798 return Ok(None);
799 };
800 let Ok(text) = std::fs::read_to_string(dir.join(crate::verify::config::FILE)) else {
801 return Ok(None);
802 };
803 parse_config(&text).map_err(|e| Refused {
804 unmarked: false,
805 status: CommandStatus::ConfigError,
806 message: format!("speccoder.toml: {e}"),
807 })
808}
809
810pub fn parse_config(text: &str) -> Result<Option<FormatKind>, String> {
812 #[derive(serde::Deserialize)]
813 #[serde(deny_unknown_fields)]
814 struct Spec {
815 format: Option<String>,
816 }
817 let Ok(table) = toml::from_str::<toml::Table>(text) else {
819 return Ok(None);
820 };
821 let Some(spec) = table.get("spec") else {
822 return Ok(None);
823 };
824 let spec: Spec = spec
825 .clone()
826 .try_into()
827 .map_err(|e: toml::de::Error| format!("[spec]: {}", e.message()))?;
828 let [a, b, c] = FormatKind::ALL.map(FormatKind::name);
829 spec.format
830 .map(|f| {
831 f.parse()
832 .map_err(|_| format!("[spec]: format = \"{f}\" ({a}, {b} or {c})"))
833 })
834 .transpose()
835}
836
837#[cfg(test)]
838mod tests {
839 use super::*;
840
841 struct Fake(FormatKind, bool);
842
843 impl Detect for Fake {
844 fn kind(&self) -> FormatKind {
845 self.0
846 }
847 fn root(&self, _: &Path) -> Option<PathBuf> {
848 Some(PathBuf::from("/p"))
849 }
850 fn adapter(&self) -> Option<&'static dyn Adapter> {
851 self.1.then(reference)
852 }
853 }
854
855 struct At(FormatKind, &'static str);
856
857 impl Detect for At {
858 fn kind(&self) -> FormatKind {
859 self.0
860 }
861 fn root(&self, _: &Path) -> Option<PathBuf> {
862 Some(PathBuf::from(self.1))
863 }
864 fn adapter(&self) -> Option<&'static dyn Adapter> {
865 Some(reference())
866 }
867 }
868
869 #[test]
870 fn the_nearest_project_decides() {
871 static OUTER: At = At(FormatKind::Speckit, "/repo");
872 static INNER: At = At(FormatKind::Openspec, "/repo/sub/project");
873 let (_, w) = resolve(Path::new("/repo/sub/project/x"), None, &[&OUTER, &INNER]).unwrap();
874 assert!(w.is_empty());
875 static TWIN: At = At(FormatKind::Speckit, "/repo/sub/project");
876 assert!(resolve(Path::new("/x"), None, &[&TWIN, &INNER]).is_err());
877 }
878
879 static SK: Fake = Fake(FormatKind::Speckit, true);
880 static OS: Fake = Fake(FormatKind::Openspec, false);
881 static OS_RUNS: Fake = Fake(FormatKind::Openspec, true);
882 static KIRO: Fake = Fake(FormatKind::Kiro, false);
883
884 #[test]
885 fn a_choice_wins_and_an_unsupported_one_is_refused() {
886 let p = Path::new("/p");
887 let (a, w) = resolve(p, Some(FormatKind::Speckit), &[&SK, &OS]).unwrap();
888 assert_eq!((a.kind(), w.len()), (FormatKind::Speckit, 0));
889 let r = resolve(p, Some(FormatKind::Openspec), &[&SK, &OS])
890 .err()
891 .unwrap();
892 assert_eq!(r.status, CommandStatus::NotAProject);
893 assert_eq!(r.message, "speccoder does not run OpenSpec projects");
894 let r = resolve(p, Some(FormatKind::Kiro), &[&SK, &KIRO])
895 .err()
896 .unwrap();
897 assert!(r.message.ends_with("not Kiro specs"));
898 }
899
900 #[test]
901 fn detection_warns_about_other_markers_and_refuses_ambiguity() {
902 let p = Path::new("/p");
903 let (a, w) = resolve(p, None, &[&SK, &OS]).unwrap();
904 assert_eq!(a.kind(), FormatKind::Speckit);
905 assert_eq!(w.len(), 1);
906 assert_eq!(w[0].code, "format.unsupported-marker");
907 let r = resolve(p, None, &[&OS]).err().unwrap();
908 assert_eq!(
909 r.message,
910 "found an OpenSpec project at /p; speccoder does not run OpenSpec projects"
911 );
912 let r = resolve(p, None, &[&SK, &OS_RUNS]).err().unwrap();
913 assert_eq!(r.status, CommandStatus::DiagnosticsError);
914 assert!(
915 r.message.contains("choose one with --format"),
916 "{}",
917 r.message
918 );
919 }
920
921 #[test]
922 fn the_spec_section_names_a_format() {
923 assert_eq!(parse_config(""), Ok(None));
924 assert_eq!(
925 parse_config("[spec]\nformat = \"openspec\"\n"),
926 Ok(Some(FormatKind::Openspec))
927 );
928 assert_eq!(
929 parse_config("[spec]\nformat = \"x\"\n"),
930 Err("[spec]: format = \"x\" (speckit, openspec or kiro)".to_owned())
931 );
932 assert!(
933 parse_config("[spec]\nkind = 1\n")
934 .unwrap_err()
935 .starts_with("[spec]: unknown field")
936 );
937 assert_eq!(parse_config("[format]\nargv = [\"x\"]\n"), Ok(None));
938 }
939}