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
40pub trait Adapter: Send + Sync + std::fmt::Debug {
42 fn kind(&self) -> FormatKind;
44
45 fn detect(&self, start: &Path) -> Option<PathBuf>;
47
48 fn inspect(&self, start: &Path, feature: Option<&str>) -> Option<ProjectSnapshot>;
51
52 fn open(&self, start: &Path, feature: Option<&str>) -> Option<ProjectSnapshot> {
55 self.inspect(start, feature)
56 }
57
58 fn parse_tasks(&self, path: &str, text: &str) -> (ParsedTasks, Vec<Diagnostic>);
60
61 fn rules(&self) -> &'static Rules;
63
64 fn wording(&self) -> &'static Wording;
66
67 fn messages(&self) -> &'static Messages;
69
70 fn planning(&self) -> &'static Planning;
72
73 fn bundle(&self, snapshot: &ProjectSnapshot, task: &str) -> Result<Bundle, BundleError>;
75
76 fn scenarios(&self, snapshot: &ProjectSnapshot, task: &str) -> Vec<ScenarioRef>;
79
80 fn acceptance_testable(&self, bundle: &Bundle) -> bool;
82
83 fn is_convergence(&self, phase: &str) -> bool;
85
86 fn convergence_texts(&self, root: &Path, unit_dir: &Path) -> [String; 4];
89
90 fn blocking<'s>(&self, _snapshot: &'s ProjectSnapshot) -> Vec<&'s Diagnostic> {
93 Vec::new()
94 }
95
96 fn crosscheck(
99 &self,
100 _snapshot: &ProjectSnapshot,
101 _runner: &dyn crate::verify::process::CommandRunner,
102 ) -> Vec<Diagnostic> {
103 Vec::new()
104 }
105
106 fn checklists(&self, root: &Path, unit_dir: &str) -> Vec<ChecklistStatus>;
108
109 fn tool(&self) -> Option<(&'static str, &'static str)> {
112 None
113 }
114
115 fn requirements_file(&self) -> Option<&'static str> {
119 None
120 }
121
122 fn requirements_in(&self, _text: &str) -> Vec<(String, String)> {
125 Vec::new()
126 }
127
128 fn record_change(
132 &self,
133 _root: &Path,
134 _request: &str,
135 _checks: &[String],
136 ) -> Result<Recorded, String> {
137 Err("this spec format has no place for quick changes: write a spec for it".to_owned())
138 }
139
140 fn requirement_ids(&self, root: &Path, unit_dir: &str) -> Vec<(String, String)> {
144 let Some(file) = self.requirements_file() else {
145 return Vec::new();
146 };
147 let text = std::fs::read_to_string(root.join(unit_dir).join(file)).unwrap_or_default();
148 self.requirements_in(&text)
149 .into_iter()
150 .map(|(id, line)| (id, crate::integrity::sha256_hex(line.as_bytes())))
151 .collect()
152 }
153
154 fn current_pointer(&self) -> Option<&'static str> {
157 None
158 }
159
160 fn set_current(&self, _root: &Path, _unit_dir: Option<&str>) -> Result<String, String> {
164 Err(self.messages().no_current.to_owned())
165 }
166
167 fn tick(&self, tasks_file: &Path, task: &str, line_hash: &str) -> Result<(), String>;
169
170 fn append(
172 &self,
173 tasks_file: &Path,
174 file_hash: &str,
175 items: &[String],
176 ) -> Result<Vec<String>, String>;
177
178 fn commit_precondition(
181 &self,
182 root: &Path,
183 join: &dyn Fn(&str) -> String,
184 unit_dir: &str,
185 ) -> CommitPrecondition;
186}
187
188pub type CommandRule = fn(&[String], bool) -> Option<Result<(), String>>;
191
192#[derive(Debug)]
195pub struct Rules {
196 pub intent: &'static [&'static str],
198 pub intent_reason: &'static str,
200 pub authoring_path: fn(&str) -> bool,
202 pub command: CommandRule,
206 pub env: &'static [(&'static str, &'static str)],
208 pub authoring_refusal: fn(&str) -> String,
210}
211
212#[derive(Debug)]
215pub struct Wording {
216 pub product: &'static str,
218 pub name: &'static str,
220 pub unit: &'static str,
222 pub intent_paths: &'static str,
224 pub intent_code: &'static str,
226 pub documents: &'static str,
228 pub authoring: &'static str,
231 pub authoring_tool: &'static str,
233 pub story: &'static str,
235 pub constitution: &'static str,
237 pub plan: &'static str,
239 pub plan_short: &'static str,
241 pub examples: &'static str,
243 pub convergence_task: &'static str,
245 pub files: [&'static str; 3],
247 pub assess: Assess,
249}
250
251#[derive(Debug)]
253pub struct Assess {
254 pub rules: &'static str,
256 pub targets: &'static str,
258 pub must: &'static str,
260 pub sources: &'static str,
262 pub critical: &'static str,
264 pub refs: &'static str,
266}
267
268#[derive(Debug)]
270pub struct Messages {
271 pub tasks_file: &'static str,
273 pub make_tasks: &'static str,
275 pub not_a_project: fn(&Path) -> String,
277 pub no_project: &'static str,
279 pub no_project_in_worktree: &'static str,
281 pub not_in_git: &'static str,
283 pub no_skills: &'static str,
285 pub checklists: fn(&str) -> String,
287 pub unit: &'static str,
289 pub no_current: &'static str,
291 pub current_shared: &'static str,
293 pub make_project: (&'static str, &'static str),
295}
296
297#[derive(Debug)]
299pub struct Planning {
300 pub prefix: &'static str,
302 pub core: &'static [&'static str],
304 pub refused: fn(&str) -> Option<String>,
306 pub creates: &'static [&'static str],
309 pub current_env: Option<&'static str>,
312 pub toward_tasks: fn(bool) -> &'static [&'static str],
315 pub revises: Option<&'static str>,
318}
319
320#[derive(Debug, Clone)]
322pub struct CommitPrecondition {
323 pub strict: Vec<String>,
325 pub tracked: Vec<String>,
328 pub runtime: fn(&str) -> bool,
330 pub essentials: Vec<String>,
332 pub message: &'static str,
334 pub hint: &'static str,
336 pub no_commits_hint: &'static str,
338}
339
340impl CommitPrecondition {
341 #[must_use]
343 pub fn paths(&self) -> Vec<String> {
344 self.tracked.iter().chain(&self.strict).cloned().collect()
345 }
346}
347
348fn all() -> [&'static dyn Detect; 3] {
350 [&speckit::SPECKIT, &openspec::OPENSPEC, &stubs::KIRO]
351}
352
353pub trait Detect: Send + Sync {
355 fn kind(&self) -> FormatKind;
357 fn root(&self, start: &Path) -> Option<PathBuf>;
359 fn adapter(&self) -> Option<&'static dyn Adapter>;
361}
362
363#[must_use]
365pub fn adapter(kind: FormatKind) -> Option<&'static dyn Adapter> {
366 all()
367 .into_iter()
368 .find(|d| d.kind() == kind)
369 .and_then(|d| d.adapter())
370}
371
372#[must_use]
374pub fn reference() -> &'static dyn Adapter {
375 &speckit::SPECKIT
376}
377
378static REQUEST: OnceLock<FormatKind> = OnceLock::new();
379
380pub fn request(kind: FormatKind) {
383 let _ = REQUEST.set(kind);
384}
385
386pub struct Opened {
388 pub adapter: &'static dyn Adapter,
390 pub snapshot: ProjectSnapshot,
392}
393
394#[derive(Debug, Clone, PartialEq, Eq)]
396pub struct Refused {
397 pub status: CommandStatus,
399 pub message: String,
401 pub unmarked: bool,
403}
404
405pub fn open(start: &Path, feature: Option<&str>) -> Result<Opened, Refused> {
408 open_with(start, feature, REQUEST.get().copied())
409}
410
411pub fn open_with(
413 start: &Path,
414 feature: Option<&str>,
415 explicit: Option<FormatKind>,
416) -> Result<Opened, Refused> {
417 let configured = match explicit {
418 Some(_) => None,
419 None => configured(start)?,
420 };
421 let (adapter, mut warnings) = resolve(start, explicit.or(configured), &all())?;
422 let mut snapshot = adapter.open(start, feature).ok_or_else(|| Refused {
423 status: CommandStatus::NotAProject,
424 message: (adapter.messages().not_a_project)(start),
425 unmarked: true,
426 })?;
427 snapshot.diagnostics.append(&mut warnings);
428 Ok(Opened { adapter, snapshot })
429}
430
431#[must_use]
435pub fn at(root: &Path) -> &'static dyn Adapter {
436 let chosen = REQUEST
437 .get()
438 .copied()
439 .or_else(|| configured(root).ok().flatten());
440 resolve(root, chosen, &all()).map_or_else(|_| reference(), |(a, _)| a)
441}
442
443#[must_use]
445pub fn of(snapshot: &ProjectSnapshot) -> &'static dyn Adapter {
446 at(Path::new(&snapshot.root))
447}
448
449fn resolve(
453 start: &Path,
454 chosen: Option<FormatKind>,
455 formats: &[&'static dyn Detect],
456) -> Result<(&'static dyn Adapter, Vec<Diagnostic>), Refused> {
457 let refuse = |message: String| Refused {
458 status: CommandStatus::NotAProject,
459 message,
460 unmarked: false,
461 };
462 if let Some(kind) = chosen {
463 return formats
464 .iter()
465 .find(|d| d.kind() == kind)
466 .and_then(|d| d.adapter())
467 .map(|a| (a, Vec::new()))
468 .ok_or_else(|| refuse(not_yet(kind)));
469 }
470 let mut found: Vec<(&'static dyn Detect, PathBuf)> = formats
473 .iter()
474 .filter_map(|d| d.root(start).map(|r| (*d, r)))
475 .collect();
476 let nearest = found.iter().map(|(_, r)| r.components().count()).max();
477 found.retain(|(_, r)| Some(r.components().count()) == nearest);
478 let supported: Vec<&'static dyn Adapter> =
479 found.iter().filter_map(|(d, _)| d.adapter()).collect();
480 let others = || found.iter().filter(|(d, _)| d.adapter().is_none());
481 match supported.as_slice() {
482 [one] => {
483 let warnings = others()
484 .map(|(d, root)| {
485 Diagnostic::new(
486 Severity::Warning,
487 "format.unsupported-marker",
488 format!(
489 "{} is also present at {}; {}",
490 d.kind().title(),
491 root.display(),
492 not_yet(d.kind())
493 ),
494 )
495 })
496 .collect();
497 Ok((*one, warnings))
498 }
499 [] => match others().next() {
500 Some((d, root)) => Err(refuse(format!(
501 "found {} project at {}; {}",
502 article(d.kind()),
503 root.display(),
504 not_yet(d.kind())
505 ))),
506 None => Ok((reference(), Vec::new())),
508 },
509 many => Err(Refused {
510 unmarked: false,
511 status: CommandStatus::DiagnosticsError,
512 message: format!(
513 "this project is in more than one format ({}); choose one with --format",
514 many.iter()
515 .map(|a| a.kind().name())
516 .collect::<Vec<_>>()
517 .join(", ")
518 ),
519 }),
520 }
521}
522
523fn not_yet(kind: FormatKind) -> String {
524 match kind {
525 FormatKind::Kiro => {
526 "speccoder runs Spec Kit and OpenSpec projects, not Kiro specs".to_owned()
527 }
528 k => format!("speccoder does not run {} projects", k.title()),
529 }
530}
531
532fn article(kind: FormatKind) -> String {
533 match kind {
534 FormatKind::Openspec => "an OpenSpec".to_owned(),
535 k => format!("a {}", k.title()),
536 }
537}
538
539fn configured(start: &Path) -> Result<Option<FormatKind>, Refused> {
541 let Some(dir) = crate::verify::find_config(start) else {
542 return Ok(None);
543 };
544 let Ok(text) = std::fs::read_to_string(dir.join(crate::verify::config::FILE)) else {
545 return Ok(None);
546 };
547 parse_config(&text).map_err(|e| Refused {
548 unmarked: false,
549 status: CommandStatus::ConfigError,
550 message: format!("speccoder.toml: {e}"),
551 })
552}
553
554pub fn parse_config(text: &str) -> Result<Option<FormatKind>, String> {
556 #[derive(serde::Deserialize)]
557 #[serde(deny_unknown_fields)]
558 struct Spec {
559 format: Option<String>,
560 }
561 let Ok(table) = toml::from_str::<toml::Table>(text) else {
563 return Ok(None);
564 };
565 let Some(spec) = table.get("spec") else {
566 return Ok(None);
567 };
568 let spec: Spec = spec
569 .clone()
570 .try_into()
571 .map_err(|e: toml::de::Error| format!("[spec]: {}", e.message()))?;
572 let [a, b, c] = FormatKind::ALL.map(FormatKind::name);
573 spec.format
574 .map(|f| {
575 f.parse()
576 .map_err(|_| format!("[spec]: format = \"{f}\" ({a}, {b} or {c})"))
577 })
578 .transpose()
579}
580
581#[cfg(test)]
582mod tests {
583 use super::*;
584
585 struct Fake(FormatKind, bool);
586
587 impl Detect for Fake {
588 fn kind(&self) -> FormatKind {
589 self.0
590 }
591 fn root(&self, _: &Path) -> Option<PathBuf> {
592 Some(PathBuf::from("/p"))
593 }
594 fn adapter(&self) -> Option<&'static dyn Adapter> {
595 self.1.then(reference)
596 }
597 }
598
599 struct At(FormatKind, &'static str);
600
601 impl Detect for At {
602 fn kind(&self) -> FormatKind {
603 self.0
604 }
605 fn root(&self, _: &Path) -> Option<PathBuf> {
606 Some(PathBuf::from(self.1))
607 }
608 fn adapter(&self) -> Option<&'static dyn Adapter> {
609 Some(reference())
610 }
611 }
612
613 #[test]
614 fn the_nearest_project_decides() {
615 static OUTER: At = At(FormatKind::Speckit, "/repo");
616 static INNER: At = At(FormatKind::Openspec, "/repo/sub/project");
617 let (_, w) = resolve(Path::new("/repo/sub/project/x"), None, &[&OUTER, &INNER]).unwrap();
618 assert!(w.is_empty());
619 static TWIN: At = At(FormatKind::Speckit, "/repo/sub/project");
620 assert!(resolve(Path::new("/x"), None, &[&TWIN, &INNER]).is_err());
621 }
622
623 static SK: Fake = Fake(FormatKind::Speckit, true);
624 static OS: Fake = Fake(FormatKind::Openspec, false);
625 static OS_RUNS: Fake = Fake(FormatKind::Openspec, true);
626 static KIRO: Fake = Fake(FormatKind::Kiro, false);
627
628 #[test]
629 fn a_choice_wins_and_an_unsupported_one_is_refused() {
630 let p = Path::new("/p");
631 let (a, w) = resolve(p, Some(FormatKind::Speckit), &[&SK, &OS]).unwrap();
632 assert_eq!((a.kind(), w.len()), (FormatKind::Speckit, 0));
633 let r = resolve(p, Some(FormatKind::Openspec), &[&SK, &OS])
634 .err()
635 .unwrap();
636 assert_eq!(r.status, CommandStatus::NotAProject);
637 assert_eq!(r.message, "speccoder does not run OpenSpec projects");
638 let r = resolve(p, Some(FormatKind::Kiro), &[&SK, &KIRO])
639 .err()
640 .unwrap();
641 assert!(r.message.ends_with("not Kiro specs"));
642 }
643
644 #[test]
645 fn detection_warns_about_other_markers_and_refuses_ambiguity() {
646 let p = Path::new("/p");
647 let (a, w) = resolve(p, None, &[&SK, &OS]).unwrap();
648 assert_eq!(a.kind(), FormatKind::Speckit);
649 assert_eq!(w.len(), 1);
650 assert_eq!(w[0].code, "format.unsupported-marker");
651 let r = resolve(p, None, &[&OS]).err().unwrap();
652 assert_eq!(
653 r.message,
654 "found an OpenSpec project at /p; speccoder does not run OpenSpec projects"
655 );
656 let r = resolve(p, None, &[&SK, &OS_RUNS]).err().unwrap();
657 assert_eq!(r.status, CommandStatus::DiagnosticsError);
658 assert!(
659 r.message.contains("choose one with --format"),
660 "{}",
661 r.message
662 );
663 }
664
665 #[test]
666 fn the_spec_section_names_a_format() {
667 assert_eq!(parse_config(""), Ok(None));
668 assert_eq!(
669 parse_config("[spec]\nformat = \"openspec\"\n"),
670 Ok(Some(FormatKind::Openspec))
671 );
672 assert_eq!(
673 parse_config("[spec]\nformat = \"x\"\n"),
674 Err("[spec]: format = \"x\" (speckit, openspec or kiro)".to_owned())
675 );
676 assert!(
677 parse_config("[spec]\nkind = 1\n")
678 .unwrap_err()
679 .starts_with("[spec]: unknown field")
680 );
681 assert_eq!(parse_config("[format]\nargv = [\"x\"]\n"), Ok(None));
682 }
683}