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