Skip to main content

speccoder_runtime/format/
mod.rs

1//! The spec-format seam: everything the runtime knows about
2//! a spec format comes from an [`Adapter`] — where the intent lives, what a
3//! planning skill may write, what the model is told, how a task is ticked.
4//! The judge (verification, evidence, commits) is the same for every format.
5//!
6//! Spec Kit is the reference format; OpenSpec runs too. Kiro is
7//! detected, so a project in it gets a clear answer instead of "not a
8//! project".
9
10pub 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/// Where a quick change was recorded.
26#[derive(Debug, Clone, PartialEq, Eq)]
27pub struct Recorded {
28    /// The unit's name (`004-quick-changes`).
29    pub feature: String,
30    /// Its directory, project-relative.
31    pub dir: String,
32    /// The task (`T003`).
33    pub task: String,
34    /// The requirement (`FR-003`).
35    pub requirement: String,
36    /// The change's title.
37    pub title: String,
38}
39
40/// An installed extension of the format, as its inventory lists it.
41#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
42pub struct Extension {
43    /// Its id (`bug`).
44    pub id: String,
45    /// Its version.
46    pub version: String,
47    /// Enabled (a disabled one counts as not installed).
48    pub enabled: bool,
49    /// Where it was installed from, as the format says (`local`, a catalog).
50    pub source: String,
51}
52
53/// Which source the inventory was read from.
54#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
55#[serde(rename_all = "snake_case")]
56pub enum InventorySource {
57    /// The format's own command-line tool.
58    Cli,
59    /// The format's registry files.
60    Files,
61}
62
63/// The format's installed extensions.
64#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
65pub struct Inventory {
66    /// What was read.
67    pub source: InventorySource,
68    /// Every extension listed.
69    pub entries: Vec<Extension>,
70}
71
72impl Inventory {
73    /// The extension `id`, if installed and enabled.
74    #[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/// Why a bug cannot be recorded or read here.
81#[derive(Debug, Clone, PartialEq, Eq)]
82pub struct BugRefusal {
83    /// The catalog code (`BUG-NO-UNIT`, `BUG-ASSESSMENT-DRIFT`, …).
84    pub code: &'static str,
85    /// What the person is told.
86    pub message: String,
87}
88
89impl BugRefusal {
90    /// A refusal with `code`.
91    #[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/// A bug the adapter recorded in a standing unit.
101#[derive(Debug, Clone, PartialEq, Eq)]
102pub struct RecordedBug {
103    /// The unit (`007-bugfixes`).
104    pub unit: String,
105    /// The unit's directory, project-relative.
106    pub dir: String,
107    /// The requirement (`FR-003`).
108    pub requirement: String,
109    /// The task (`T003`).
110    pub task: String,
111    /// The title.
112    pub title: String,
113    /// The files it wrote, project-relative.
114    pub files: Vec<String>,
115}
116
117/// How a format's own bug step is run: the hosted skill, its arguments
118/// and what it may write.
119#[derive(Debug, Clone, Copy)]
120pub struct BugStep {
121    /// The installed skill that creates a bug.
122    pub skill: &'static str,
123    /// Its arguments: the person's labelled triad and the slug.
124    pub arguments: fn(&speccoder_core::bug::Triad, &str) -> String,
125    /// The policy's rules while it runs: it writes only the bug directory.
126    pub rules: &'static Rules,
127}
128
129/// The triad as a format's own bug file holds it.
130#[derive(Debug, Clone, PartialEq, Eq)]
131pub struct ReadBug {
132    /// Current statements (the first is the symptom).
133    pub current: Vec<String>,
134    /// Expected statements.
135    pub expected: Vec<String>,
136    /// Unchanged statements.
137    pub unchanged: Vec<String>,
138    /// The title the file gives.
139    pub title: Option<String>,
140    /// The file it was read from, project-relative.
141    pub file: String,
142}
143
144/// Why a project in a format this build detects but does not run has no
145/// place for a bug (`BUG-NO-UNIT`), if it is one.
146#[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/// A requirement a review writes tests for: the format's
152/// requirement (`US1`) with the scenario ids its tests cite (`US1-1`).
153#[derive(Debug, Clone, PartialEq, Eq)]
154pub struct OracleRequirement {
155    /// Stable within the unit (`US1`).
156    pub key: String,
157    /// Its title.
158    pub title: String,
159    /// Its whole text, scenarios included.
160    pub text: String,
161    /// The scenarios its tests cite, with the spec's words.
162    pub scenarios: Vec<ScenarioRef>,
163}
164
165impl OracleRequirement {
166    /// `sha256:<hex>` of `title + "\n" + text`: the wording a frozen oracle
167    /// is bound to; any change of title, body or scenario lapses it.
168    #[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/// What a new project asks of the format's own init.
178#[derive(Debug, Clone, PartialEq, Eq)]
179pub struct FormatInit {
180    /// Extensions the person chose (Spec Kit).
181    pub extensions: Vec<speccoder_core::new_project::ExtensionChoice>,
182    /// Install the format's evidence preset with the init where it has one
183    /// (Spec Kit: the bundled `speccoder` preset).
184    pub preset: bool,
185    /// speccoder's state directory (where a bundled preset is extracted).
186    pub state: PathBuf,
187    /// speccoder's private cache for this tool: where its settings go
188    /// instead of the person's config directory.
189    pub cache: PathBuf,
190}
191
192/// One command of the format's own init, as a new project's plan runs it.
193#[derive(Debug, Clone, PartialEq, Eq)]
194pub struct InitStep {
195    /// The command, exactly.
196    pub argv: Vec<String>,
197    /// The hosts it reaches; empty: local.
198    pub hosts: Vec<String>,
199    /// What it writes, in one line.
200    pub writes: String,
201    /// Variables it runs with beside the person's.
202    pub env: Vec<(String, String)>,
203    /// Its effect, for resuming: done when this path exists in the folder.
204    pub marker: &'static str,
205    /// A note the plan shows beside it.
206    pub note: Option<String>,
207    /// It installs the format's evidence preset.
208    pub preset: bool,
209}
210
211/// What kind of phase a task sits in, as the format's structure says.
212#[derive(Debug, Clone, Copy, PartialEq, Eq)]
213pub enum PhaseKind {
214    /// Shared setup (Spec Kit: *Setup*).
215    Setup,
216    /// Prerequisites every story builds on (Spec Kit: *Foundational*).
217    Foundational,
218    /// A user story's phase.
219    Story,
220    /// Cross-cutting polish.
221    Polish,
222    /// Anything else.
223    Other,
224}
225
226/// One task as the plan checks read it.
227#[derive(Debug, Clone, PartialEq, Eq)]
228pub struct PlannedTask {
229    /// Task ID.
230    pub id: String,
231    /// Its title.
232    pub title: String,
233    /// Its phase's kind.
234    pub phase: PhaseKind,
235    /// Its story (`US1`), when it has one.
236    pub story: Option<String>,
237    /// The code spans of its title, in order.
238    pub spans: Vec<String>,
239    /// Ticked.
240    pub done: bool,
241}
242
243/// A unit's plan structure: phases and stories. Formats without
244/// that structure have none.
245#[derive(Debug, Clone, PartialEq, Eq, Default)]
246pub struct PlanStructure {
247    /// Every task, in document order.
248    pub tasks: Vec<PlannedTask>,
249    /// Each story's text (its scenarios), by story.
250    pub stories: std::collections::BTreeMap<String, String>,
251}
252
253/// The code spans of a text (between single backticks).
254#[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
264/// One spec format the runtime can run.
265pub trait Adapter: Send + Sync + std::fmt::Debug {
266    /// Which format.
267    fn kind(&self) -> FormatKind;
268
269    /// The project root containing `start`, by the format's marker.
270    fn detect(&self, start: &Path) -> Option<PathBuf>;
271
272    /// Discovers the project containing `start` and selects `feature`.
273    /// Read-only.
274    fn inspect(&self, start: &Path, feature: Option<&str>) -> Option<ProjectSnapshot>;
275
276    /// [`Adapter::inspect`] for a command a person ran: the format's own
277    /// selection settings (its environment variables) apply.
278    fn open(&self, start: &Path, feature: Option<&str>) -> Option<ProjectSnapshot> {
279        self.inspect(start, feature)
280    }
281
282    /// Parses a task list.
283    fn parse_tasks(&self, path: &str, text: &str) -> (ParsedTasks, Vec<Diagnostic>);
284
285    /// Which paths are intent and what planning may write.
286    fn rules(&self) -> &'static Rules;
287
288    /// What the model is told about the format.
289    fn wording(&self) -> &'static Wording;
290
291    /// What a person is told.
292    fn messages(&self) -> &'static Messages;
293
294    /// Hosted planning skills.
295    fn planning(&self) -> &'static Planning;
296
297    /// The context bundle for `task` of the selected unit.
298    fn bundle(&self, snapshot: &ProjectSnapshot, task: &str) -> Result<Bundle, BundleError>;
299
300    /// The scenarios `task`'s acceptance tests cite, with the spec's words
301    /// (judge-side: the implementer sees the spec text, not this list).
302    fn scenarios(&self, snapshot: &ProjectSnapshot, task: &str) -> Vec<ScenarioRef>;
303
304    /// Whether the bundle states acceptance examples to derive tests from.
305    fn acceptance_testable(&self, bundle: &Bundle) -> bool;
306
307    /// Whether a task closes a gap found after implementation (its phase).
308    fn is_convergence(&self, phase: &str) -> bool;
309
310    /// What the convergence assessment reads, in order: the project's rules,
311    /// the spec, the plan, the task list (each may be empty).
312    fn convergence_texts(&self, root: &Path, unit_dir: &Path) -> [String; 4];
313
314    /// Diagnostics that stop a run of the selected unit before any model
315    /// call (the format's preflight).
316    fn blocking<'s>(&self, _snapshot: &'s ProjectSnapshot) -> Vec<&'s Diagnostic> {
317        Vec::new()
318    }
319
320    /// The format's own tools' opinion of the selected unit, when they are
321    /// installed (read-only commands through `runner`).
322    fn crosscheck(
323        &self,
324        _snapshot: &ProjectSnapshot,
325        _runner: &dyn crate::verify::process::CommandRunner,
326    ) -> Vec<Diagnostic> {
327        Vec::new()
328    }
329
330    /// The unit's unfinished-checklist state.
331    fn checklists(&self, root: &Path, unit_dir: &str) -> Vec<ChecklistStatus>;
332
333    /// The selected unit's phases and stories, for the plan checks that
334    /// need them (`None`: the format has no such structure).
335    fn plan_structure(&self, _snapshot: &ProjectSnapshot) -> Option<PlanStructure> {
336        None
337    }
338
339    /// The format's own checks of what its tool needs on this machine, as
340    /// `doctor` rows, given the person's environment `env`.
341    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    /// The format's own command-line tool and how to install it, for
350    /// `doctor` (`None`: the format has none).
351    fn tool(&self) -> Option<(&'static str, &'static str)> {
352        None
353    }
354
355    /// The commands of the format's own init that make a folder a project
356    /// of it (New Project); empty: speccoder cannot initialise
357    /// this format, the person does ([`Messages::make_project`]).
358    fn init_steps(&self, _choices: &FormatInit) -> Vec<InitStep> {
359        Vec::new()
360    }
361
362    /// Writes what [`Adapter::init_steps`] names in speccoder's state
363    /// before they run (Spec Kit: the bundled preset, extracted).
364    ///
365    /// # Errors
366    ///
367    /// The state directory cannot be written.
368    fn prepare_init(&self, _choices: &FormatInit) -> Result<(), String> {
369        Ok(())
370    }
371
372    /// The unit's document that defines its requirements (`spec.md` in
373    /// Spec Kit), relative to the unit's directory; `None` when the format
374    /// has no requirement identifiers.
375    fn requirements_file(&self) -> Option<&'static str> {
376        None
377    }
378
379    /// The requirements `text` (the requirements file) defines, each with
380    /// the line that defines it, in the document's order.
381    fn requirements_in(&self, _text: &str) -> Vec<(String, String)> {
382        Vec::new()
383    }
384
385    /// Records a quick change: `request` (with acceptance `checks`)
386    /// becomes a story, a requirement and a task in the format's standing
387    /// unit for such changes. `Err`: the format has none.
388    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    /// The format's installed extensions, from its own inventory (`None`:
398    /// the format has none, or none could be read). The only reader of the
399    /// format's extension files.
400    fn extensions(&self, _root: &Path) -> Option<Inventory> {
401        None
402    }
403
404    /// Where a bug lives: the format's own bug structure when it
405    /// has one, else its standing bug-fix unit. `Err`: neither
406    /// (`BUG-NO-UNIT`).
407    ///
408    /// # Errors
409    ///
410    /// The format has no place for a bug.
411    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    /// Records `triad` as a story, an approved-to-be requirement and a task
419    /// in the standing bug-fix unit `home` names (appends only, verified at
420    /// write time).
421    ///
422    /// # Errors
423    ///
424    /// The home is not a standing unit, or the files changed meanwhile.
425    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    /// The format's own bug step, for a native home.
438    fn bug_step(&self) -> Option<BugStep> {
439        None
440    }
441
442    /// A native home's directory for `slug`, project-relative, and its
443    /// unit name.
444    fn bug_dir(
445        &self,
446        _home: &speccoder_core::bug::BugHome,
447        _slug: &str,
448    ) -> Option<(String, String)> {
449        None
450    }
451
452    /// A new slug for `title` that no bug of `home` has yet.
453    fn bug_slug(&self, _root: &Path, _home: &speccoder_core::bug::BugHome, _title: &str) -> String {
454        String::new()
455    }
456
457    /// The triad as the home holds it: the bug `key` (a native home's slug,
458    /// a standing unit's requirement). Pure: no model, no tool.
459    ///
460    /// # Errors
461    ///
462    /// The file is missing or not in a shape the adapter reads.
463    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    /// The selected unit's requirements with their scenarios, in document
473    /// order: what a review writes tests for. None when the format yields
474    /// no requirement with scenarios.
475    fn oracle_requirements(&self, _snapshot: &ProjectSnapshot) -> Vec<OracleRequirement> {
476        Vec::new()
477    }
478
479    /// The project's standing units and their rungs (a quick change's, a
480    /// bug's): what `speccoder_core::ladder::rung_of` decides by.
481    fn standing_units(&self, _root: &Path) -> Vec<(String, speccoder_core::ladder::Rung)> {
482        Vec::new()
483    }
484
485    /// The unit's requirement identifiers, each with the hash of the line
486    /// that defines it (`FR-001`, `SC-002` in Spec Kit): the rows of the
487    /// trace matrix. None when the format has no such identifiers.
488    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    /// The format's per-checkout pointer to the current unit
500    /// (repository-relative), if it has one.
501    fn current_pointer(&self) -> Option<&'static str> {
502        None
503    }
504
505    /// Records `unit_dir` (repository-relative) as the current unit in the
506    /// format's own per-checkout pointer, or clears it (`None`); returns the
507    /// pointer's path. `Err`: the format has no current unit.
508    fn set_current(&self, _root: &Path, _unit_dir: Option<&str>) -> Result<String, String> {
509        Err(self.messages().no_current.to_owned())
510    }
511
512    /// Ticks `task` (one byte, under the line-hash guard).
513    fn tick(&self, tasks_file: &Path, task: &str, line_hash: &str) -> Result<(), String>;
514
515    /// Appends convergence tasks (prefix-guarded); returns their IDs.
516    fn append(
517        &self,
518        tasks_file: &Path,
519        file_hash: &str,
520        items: &[String],
521    ) -> Result<Vec<String>, String>;
522
523    /// What must be committed before a worktree is made; paths are
524    /// repository-relative, `join` maps a project-relative path to one.
525    fn commit_precondition(
526        &self,
527        root: &Path,
528        join: &dyn Fn(&str) -> String,
529        unit_dir: &str,
530    ) -> CommitPrecondition;
531}
532
533/// [`Rules::found_env`]: the variables, given a lookup of the command's
534/// environment.
535pub type FoundEnv = fn(&dyn Fn(&str) -> Option<String>) -> Vec<(String, String)>;
536
537/// No variables found.
538#[must_use]
539pub fn no_found_env(_: &dyn Fn(&str) -> Option<String>) -> Vec<(String, String)> {
540    Vec::new()
541}
542
543/// A format's ruling on a command (`argv`, whether a planning skill asks):
544/// `Some(Ok)` allowed, `Some(Err(why))` refused, `None` no opinion.
545pub type CommandRule = fn(&[String], bool) -> Option<Result<(), String>>;
546
547/// Which paths are the format's intent, and what a hosted planning skill may
548/// touch.
549#[derive(Debug)]
550pub struct Rules {
551    /// Globs of the intent: never written during implementation.
552    pub intent: &'static [&'static str],
553    /// Why an intent write is refused.
554    pub intent_reason: &'static str,
555    /// What a planning skill writes without asking.
556    pub authoring_path: fn(&str) -> bool,
557    /// The format's own commands: `Some(Ok)` runs without asking,
558    /// `Some(Err(why))` is refused, `None` is classified like any command.
559    /// The flag is whether a hosted planning skill asks.
560    pub command: CommandRule,
561    /// Environment variables every command of the project gets.
562    pub env: &'static [(&'static str, &'static str)],
563    /// Variables found on this machine every command of the project gets,
564    /// given a lookup of the command's environment; a variable already in
565    /// it (the person's) is kept.
566    pub found_env: FoundEnv,
567    /// Why a planning skill's other write needs approval (`path` given).
568    pub authoring_refusal: fn(&str) -> String,
569    /// The unit's judged files, relative to its directory (no trailing
570    /// `/`): what a planning task never writes — the requirements, the task
571    /// list, the person's review.
572    pub judged: fn(&str) -> bool,
573    /// The unit's plan, relative to its directory: with the judged files,
574    /// what its ticks were judged against (other design documents are not).
575    pub plan: &'static str,
576}
577
578/// Model-facing fragments; the prompts around them are the same for every
579/// format.
580#[derive(Debug)]
581pub struct Wording {
582    /// The format as the model is told it, e.g. `GitHub Spec Kit`.
583    pub product: &'static str,
584    /// The format's short name, e.g. `Spec Kit`.
585    pub name: &'static str,
586    /// What one unit of work is called, e.g. `feature`.
587    pub unit: &'static str,
588    /// The intent paths, e.g. `.specify/, specs/`.
589    pub intent_paths: &'static str,
590    /// The same as code spans.
591    pub intent_code: &'static str,
592    /// The documents convergence reads, e.g. `spec.md, plan.md and tasks.md`.
593    pub documents: &'static str,
594    /// What a planning skill may do, e.g. `write Spec Kit artifacts (…) and
595    /// run Spec Kit's own scripts under …`.
596    pub authoring: &'static str,
597    /// What a planning skill may leave a commit to, e.g. `a Spec Kit script`.
598    pub authoring_tool: &'static str,
599    /// The heading of the requirement text in a bundle.
600    pub story: &'static str,
601    /// The heading of the project-wide rules (`Project constitution`).
602    pub constitution: &'static str,
603    /// The heading of the unit's plan in the attempt prompt (`Feature plan`).
604    pub plan: &'static str,
605    /// The same in the external agent's prompt (`Plan`).
606    pub plan_short: &'static str,
607    /// What acceptance tests are derived from.
608    pub examples: &'static str,
609    /// A task closing a gap: `A task in a Convergence phase`.
610    pub convergence_task: &'static str,
611    /// The spec, plan and task list headings, as convergence shows them.
612    pub files: [&'static str; 3],
613    /// How convergence is told what to assess.
614    pub assess: Assess,
615}
616
617/// The format's words in the convergence assessment.
618#[derive(Debug)]
619pub struct Assess {
620    /// The governing constraints, e.g. `the constitution`.
621    pub rules: &'static str,
622    /// What each finding is checked against.
623    pub targets: &'static str,
624    /// A rule whose violation is `contradicts`, e.g. `a constitution MUST`.
625    pub must: &'static str,
626    /// What `unrequested` code is not called for by.
627    pub sources: &'static str,
628    /// What makes a finding CRITICAL.
629    pub critical: &'static str,
630    /// Examples of `source_ref`.
631    pub refs: &'static str,
632}
633
634/// What a person is told; texts name the format's own files and commands.
635#[derive(Debug)]
636pub struct Messages {
637    /// The task list's file name.
638    pub tasks_file: &'static str,
639    /// How to make the task list.
640    pub make_tasks: &'static str,
641    /// No project at or above the path (path given).
642    pub not_a_project: fn(&Path) -> String,
643    /// The short form, for commands that only need the root.
644    pub no_project: &'static str,
645    /// A worktree lost its project.
646    pub no_project_in_worktree: &'static str,
647    /// The project is not in a git repository.
648    pub not_in_git: &'static str,
649    /// No skills are installed.
650    pub no_skills: &'static str,
651    /// Unchecked checklists stop a run (the list given).
652    pub checklists: fn(&str) -> String,
653    /// What a unit is called: `feature`, `change`.
654    pub unit: &'static str,
655    /// Why there is no current unit to set.
656    pub no_current: &'static str,
657    /// Who else reads the pointer, said when it changes.
658    pub current_shared: &'static str,
659    /// The command that makes a project of this format here, and why.
660    pub make_project: (&'static str, &'static str),
661}
662
663/// Hosted planning skills.
664#[derive(Debug)]
665pub struct Planning {
666    /// The prefix installed skills carry (`plan` runs `<prefix>plan`).
667    pub prefix: &'static str,
668    /// Skills whose `before_<name>` / `after_<name>` hooks run.
669    pub core: &'static [&'static str],
670    /// Why a skill is not hosted, and what to use instead.
671    pub refused: fn(&str) -> Option<String>,
672    /// Core skills that create a new unit (Spec Kit: `specify`): they get
673    /// no current-unit variable, and the unit they create becomes current.
674    pub creates: &'static [&'static str],
675    /// The variable the format's own scripts read the current unit from
676    /// (Spec Kit: `SPECIFY_FEATURE_DIRECTORY`), passed to planning commands.
677    pub current_env: Option<&'static str>,
678    /// The skills that lead from what exists to a task list, given whether
679    /// a plan exists (Spec Kit: `plan`, `tasks`).
680    pub toward_tasks: fn(bool) -> &'static [&'static str],
681    /// The skill that revises a written spec from a person's comments
682    /// (Spec Kit: `clarify`), if the format has one.
683    pub revises: Option<&'static str>,
684    /// The skill that checks the planning files for consistency (Spec
685    /// Kit: `analyze`), if the format has one.
686    pub checks: Option<&'static str>,
687}
688
689impl Planning {
690    /// The part the skill `short` (without the prefix) plays: it creates a
691    /// unit, leads toward the task list, revises a spec, checks the
692    /// planning, or something else — in that order of precedence.
693    #[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/// What a worktree needs committed: a worktree contains only commits.
712#[derive(Debug, Clone)]
713pub struct CommitPrecondition {
714    /// Nothing untracked, ignored or modified under these.
715    pub strict: Vec<String>,
716    /// Nothing untracked or modified under these (the format ignores its
717    /// own per-checkout files), except runtime state.
718    pub tracked: Vec<String>,
719    /// Runtime state under `tracked` that is not intent.
720    pub runtime: fn(&str) -> bool,
721    /// Files that must be tracked at all.
722    pub essentials: Vec<String>,
723    /// The refusal.
724    pub message: &'static str,
725    /// How to fix it.
726    pub hint: &'static str,
727    /// How to fix a repository without commits.
728    pub no_commits_hint: &'static str,
729}
730
731impl CommitPrecondition {
732    /// Every path whose change after the fork means the spec moved.
733    #[must_use]
734    pub fn paths(&self) -> Vec<String> {
735        self.tracked.iter().chain(&self.strict).cloned().collect()
736    }
737}
738
739/// The formats this build knows, in detection order.
740fn all() -> [&'static dyn Detect; 3] {
741    [&speckit::SPECKIT, &openspec::OPENSPEC, &stubs::KIRO]
742}
743
744/// What every known format implements: finding its marker.
745pub trait Detect: Send + Sync {
746    /// Which format.
747    fn kind(&self) -> FormatKind;
748    /// The root containing `start`, by the format's marker.
749    fn root(&self, start: &Path) -> Option<PathBuf>;
750    /// The adapter, when this build runs the format.
751    fn adapter(&self) -> Option<&'static dyn Adapter>;
752}
753
754/// Every adapter this build runs, in detection order.
755#[must_use]
756pub fn adapters() -> Vec<&'static dyn Adapter> {
757    all().into_iter().filter_map(|d| d.adapter()).collect()
758}
759
760/// The adapter of `kind`, if this build runs it.
761#[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/// The reference format, the default where no project decides.
770#[must_use]
771pub fn reference() -> &'static dyn Adapter {
772    &speckit::SPECKIT
773}
774
775static REQUEST: OnceLock<FormatKind> = OnceLock::new();
776
777/// `--format` / `SPECCODER_FORMAT`: chosen once, by the binary, before any
778/// command runs.
779pub fn request(kind: FormatKind) {
780    let _ = REQUEST.set(kind);
781}
782
783/// The project's format, resolved and opened.
784pub struct Opened {
785    /// The format's adapter.
786    pub adapter: &'static dyn Adapter,
787    /// What it sees.
788    pub snapshot: ProjectSnapshot,
789}
790
791/// Why no project was opened.
792#[derive(Debug, Clone, PartialEq, Eq)]
793pub struct Refused {
794    /// How it ends.
795    pub status: CommandStatus,
796    /// Why.
797    pub message: String,
798    /// No format's marker was found at all.
799    pub unmarked: bool,
800}
801
802/// Opens the project containing `start` in the requested, configured or
803/// detected format.
804pub fn open(start: &Path, feature: Option<&str>) -> Result<Opened, Refused> {
805    open_with(start, feature, REQUEST.get().copied())
806}
807
808/// [`open`] with an explicit choice instead of the process-wide request.
809pub 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/// The adapter of a project already opened at `root` (a worktree's project,
829/// a snapshot's root): resolved the way [`open`] resolved it, the reference
830/// format where nothing decides.
831#[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/// The adapter of an inspected project.
841#[must_use]
842pub fn of(snapshot: &ProjectSnapshot) -> &'static dyn Adapter {
843    at(Path::new(&snapshot.root))
844}
845
846/// The format of the project at `start`: `chosen` when given, else the one
847/// supported format whose marker is nearest. Markers of formats this build
848/// does not run, at the same root, become warnings.
849fn 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    // The nearest project decides: one nested in another repository's
868    // project is its own. Only markers at the same root compete.
869    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            // No marker at all: the reference format's own answer.
904            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
936/// `[spec] format` of the nearest `speccoder.toml` at or above `start`.
937fn 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
951/// `[spec] format` from a `speccoder.toml`'s text.
952pub 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    // An unreadable file is the configuration loader's to report.
959    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}