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 (spec 101).
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 (spec 101's 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}
570
571/// Model-facing fragments; the prompts around them are the same for every
572/// format.
573#[derive(Debug)]
574pub struct Wording {
575    /// The format as the model is told it, e.g. `GitHub Spec Kit`.
576    pub product: &'static str,
577    /// The format's short name, e.g. `Spec Kit`.
578    pub name: &'static str,
579    /// What one unit of work is called, e.g. `feature`.
580    pub unit: &'static str,
581    /// The intent paths, e.g. `.specify/, specs/`.
582    pub intent_paths: &'static str,
583    /// The same as code spans.
584    pub intent_code: &'static str,
585    /// The documents convergence reads, e.g. `spec.md, plan.md and tasks.md`.
586    pub documents: &'static str,
587    /// What a planning skill may do, e.g. `write Spec Kit artifacts (…) and
588    /// run Spec Kit's own scripts under …`.
589    pub authoring: &'static str,
590    /// What a planning skill may leave a commit to, e.g. `a Spec Kit script`.
591    pub authoring_tool: &'static str,
592    /// The heading of the requirement text in a bundle.
593    pub story: &'static str,
594    /// The heading of the project-wide rules (`Project constitution`).
595    pub constitution: &'static str,
596    /// The heading of the unit's plan in the attempt prompt (`Feature plan`).
597    pub plan: &'static str,
598    /// The same in the external agent's prompt (`Plan`).
599    pub plan_short: &'static str,
600    /// What acceptance tests are derived from.
601    pub examples: &'static str,
602    /// A task closing a gap: `A task in a Convergence phase`.
603    pub convergence_task: &'static str,
604    /// The spec, plan and task list headings, as convergence shows them.
605    pub files: [&'static str; 3],
606    /// How convergence is told what to assess.
607    pub assess: Assess,
608}
609
610/// The format's words in the convergence assessment.
611#[derive(Debug)]
612pub struct Assess {
613    /// The governing constraints, e.g. `the constitution`.
614    pub rules: &'static str,
615    /// What each finding is checked against.
616    pub targets: &'static str,
617    /// A rule whose violation is `contradicts`, e.g. `a constitution MUST`.
618    pub must: &'static str,
619    /// What `unrequested` code is not called for by.
620    pub sources: &'static str,
621    /// What makes a finding CRITICAL.
622    pub critical: &'static str,
623    /// Examples of `source_ref`.
624    pub refs: &'static str,
625}
626
627/// What a person is told; texts name the format's own files and commands.
628#[derive(Debug)]
629pub struct Messages {
630    /// The task list's file name.
631    pub tasks_file: &'static str,
632    /// How to make the task list.
633    pub make_tasks: &'static str,
634    /// No project at or above the path (path given).
635    pub not_a_project: fn(&Path) -> String,
636    /// The short form, for commands that only need the root.
637    pub no_project: &'static str,
638    /// A worktree lost its project.
639    pub no_project_in_worktree: &'static str,
640    /// The project is not in a git repository.
641    pub not_in_git: &'static str,
642    /// No skills are installed.
643    pub no_skills: &'static str,
644    /// Unchecked checklists stop a run (the list given).
645    pub checklists: fn(&str) -> String,
646    /// What a unit is called: `feature`, `change`.
647    pub unit: &'static str,
648    /// Why there is no current unit to set.
649    pub no_current: &'static str,
650    /// Who else reads the pointer, said when it changes.
651    pub current_shared: &'static str,
652    /// The command that makes a project of this format here, and why.
653    pub make_project: (&'static str, &'static str),
654}
655
656/// Hosted planning skills.
657#[derive(Debug)]
658pub struct Planning {
659    /// The prefix installed skills carry (`plan` runs `<prefix>plan`).
660    pub prefix: &'static str,
661    /// Skills whose `before_<name>` / `after_<name>` hooks run.
662    pub core: &'static [&'static str],
663    /// Why a skill is not hosted, and what to use instead.
664    pub refused: fn(&str) -> Option<String>,
665    /// Core skills that create a new unit (Spec Kit: `specify`): they get
666    /// no current-unit variable, and the unit they create becomes current.
667    pub creates: &'static [&'static str],
668    /// The variable the format's own scripts read the current unit from
669    /// (Spec Kit: `SPECIFY_FEATURE_DIRECTORY`), passed to planning commands.
670    pub current_env: Option<&'static str>,
671    /// The skills that lead from what exists to a task list, given whether
672    /// a plan exists (Spec Kit: `plan`, `tasks`).
673    pub toward_tasks: fn(bool) -> &'static [&'static str],
674    /// The skill that revises a written spec from a person's comments
675    /// (Spec Kit: `clarify`), if the format has one.
676    pub revises: Option<&'static str>,
677    /// The skill that checks the planning files for consistency (Spec
678    /// Kit: `analyze`), if the format has one.
679    pub checks: Option<&'static str>,
680}
681
682impl Planning {
683    /// The part the skill `short` (without the prefix) plays: it creates a
684    /// unit, leads toward the task list, revises a spec, checks the
685    /// planning, or something else — in that order of precedence.
686    #[must_use]
687    pub fn role_of(&self, short: &str) -> speccoder_core::actions::SkillRole {
688        use speccoder_core::actions::SkillRole;
689        let toward = |plan| (self.toward_tasks)(plan).contains(&short);
690        if self.creates.contains(&short) {
691            SkillRole::Create
692        } else if toward(false) || toward(true) {
693            SkillRole::TowardTasks
694        } else if self.revises == Some(short) {
695            SkillRole::Revise
696        } else if self.checks == Some(short) {
697            SkillRole::Check
698        } else {
699            SkillRole::Other
700        }
701    }
702}
703
704/// What a worktree needs committed: a worktree contains only commits.
705#[derive(Debug, Clone)]
706pub struct CommitPrecondition {
707    /// Nothing untracked, ignored or modified under these.
708    pub strict: Vec<String>,
709    /// Nothing untracked or modified under these (the format ignores its
710    /// own per-checkout files), except runtime state.
711    pub tracked: Vec<String>,
712    /// Runtime state under `tracked` that is not intent.
713    pub runtime: fn(&str) -> bool,
714    /// Files that must be tracked at all.
715    pub essentials: Vec<String>,
716    /// The refusal.
717    pub message: &'static str,
718    /// How to fix it.
719    pub hint: &'static str,
720    /// How to fix a repository without commits.
721    pub no_commits_hint: &'static str,
722}
723
724impl CommitPrecondition {
725    /// Every path whose change after the fork means the spec moved.
726    #[must_use]
727    pub fn paths(&self) -> Vec<String> {
728        self.tracked.iter().chain(&self.strict).cloned().collect()
729    }
730}
731
732/// The formats this build knows, in detection order.
733fn all() -> [&'static dyn Detect; 3] {
734    [&speckit::SPECKIT, &openspec::OPENSPEC, &stubs::KIRO]
735}
736
737/// What every known format implements: finding its marker.
738pub trait Detect: Send + Sync {
739    /// Which format.
740    fn kind(&self) -> FormatKind;
741    /// The root containing `start`, by the format's marker.
742    fn root(&self, start: &Path) -> Option<PathBuf>;
743    /// The adapter, when this build runs the format.
744    fn adapter(&self) -> Option<&'static dyn Adapter>;
745}
746
747/// Every adapter this build runs, in detection order.
748#[must_use]
749pub fn adapters() -> Vec<&'static dyn Adapter> {
750    all().into_iter().filter_map(|d| d.adapter()).collect()
751}
752
753/// The adapter of `kind`, if this build runs it.
754#[must_use]
755pub fn adapter(kind: FormatKind) -> Option<&'static dyn Adapter> {
756    all()
757        .into_iter()
758        .find(|d| d.kind() == kind)
759        .and_then(|d| d.adapter())
760}
761
762/// The reference format, the default where no project decides.
763#[must_use]
764pub fn reference() -> &'static dyn Adapter {
765    &speckit::SPECKIT
766}
767
768static REQUEST: OnceLock<FormatKind> = OnceLock::new();
769
770/// `--format` / `SPECCODER_FORMAT`: chosen once, by the binary, before any
771/// command runs.
772pub fn request(kind: FormatKind) {
773    let _ = REQUEST.set(kind);
774}
775
776/// The project's format, resolved and opened.
777pub struct Opened {
778    /// The format's adapter.
779    pub adapter: &'static dyn Adapter,
780    /// What it sees.
781    pub snapshot: ProjectSnapshot,
782}
783
784/// Why no project was opened.
785#[derive(Debug, Clone, PartialEq, Eq)]
786pub struct Refused {
787    /// How it ends.
788    pub status: CommandStatus,
789    /// Why.
790    pub message: String,
791    /// No format's marker was found at all.
792    pub unmarked: bool,
793}
794
795/// Opens the project containing `start` in the requested, configured or
796/// detected format.
797pub fn open(start: &Path, feature: Option<&str>) -> Result<Opened, Refused> {
798    open_with(start, feature, REQUEST.get().copied())
799}
800
801/// [`open`] with an explicit choice instead of the process-wide request.
802pub fn open_with(
803    start: &Path,
804    feature: Option<&str>,
805    explicit: Option<FormatKind>,
806) -> Result<Opened, Refused> {
807    let configured = match explicit {
808        Some(_) => None,
809        None => configured(start)?,
810    };
811    let (adapter, mut warnings) = resolve(start, explicit.or(configured), &all())?;
812    let mut snapshot = adapter.open(start, feature).ok_or_else(|| Refused {
813        status: CommandStatus::NotAProject,
814        message: (adapter.messages().not_a_project)(start),
815        unmarked: true,
816    })?;
817    snapshot.diagnostics.append(&mut warnings);
818    Ok(Opened { adapter, snapshot })
819}
820
821/// The adapter of a project already opened at `root` (a worktree's project,
822/// a snapshot's root): resolved the way [`open`] resolved it, the reference
823/// format where nothing decides.
824#[must_use]
825pub fn at(root: &Path) -> &'static dyn Adapter {
826    let chosen = REQUEST
827        .get()
828        .copied()
829        .or_else(|| configured(root).ok().flatten());
830    resolve(root, chosen, &all()).map_or_else(|_| reference(), |(a, _)| a)
831}
832
833/// The adapter of an inspected project.
834#[must_use]
835pub fn of(snapshot: &ProjectSnapshot) -> &'static dyn Adapter {
836    at(Path::new(&snapshot.root))
837}
838
839/// The format of the project at `start`: `chosen` when given, else the one
840/// supported format whose marker is nearest. Markers of formats this build
841/// does not run, at the same root, become warnings.
842fn resolve(
843    start: &Path,
844    chosen: Option<FormatKind>,
845    formats: &[&'static dyn Detect],
846) -> Result<(&'static dyn Adapter, Vec<Diagnostic>), Refused> {
847    let refuse = |message: String| Refused {
848        status: CommandStatus::NotAProject,
849        message,
850        unmarked: false,
851    };
852    if let Some(kind) = chosen {
853        return formats
854            .iter()
855            .find(|d| d.kind() == kind)
856            .and_then(|d| d.adapter())
857            .map(|a| (a, Vec::new()))
858            .ok_or_else(|| refuse(not_yet(kind)));
859    }
860    // The nearest project decides: one nested in another repository's
861    // project is its own. Only markers at the same root compete.
862    let mut found: Vec<(&'static dyn Detect, PathBuf)> = formats
863        .iter()
864        .filter_map(|d| d.root(start).map(|r| (*d, r)))
865        .collect();
866    let nearest = found.iter().map(|(_, r)| r.components().count()).max();
867    found.retain(|(_, r)| Some(r.components().count()) == nearest);
868    let supported: Vec<&'static dyn Adapter> =
869        found.iter().filter_map(|(d, _)| d.adapter()).collect();
870    let others = || found.iter().filter(|(d, _)| d.adapter().is_none());
871    match supported.as_slice() {
872        [one] => {
873            let warnings = others()
874                .map(|(d, root)| {
875                    Diagnostic::new(
876                        Severity::Warning,
877                        "format.unsupported-marker",
878                        format!(
879                            "{} is also present at {}; {}",
880                            d.kind().title(),
881                            root.display(),
882                            not_yet(d.kind())
883                        ),
884                    )
885                })
886                .collect();
887            Ok((*one, warnings))
888        }
889        [] => match others().next() {
890            Some((d, root)) => Err(refuse(format!(
891                "found {} project at {}; {}",
892                article(d.kind()),
893                root.display(),
894                not_yet(d.kind())
895            ))),
896            // No marker at all: the reference format's own answer.
897            None => Ok((reference(), Vec::new())),
898        },
899        many => Err(Refused {
900            unmarked: false,
901            status: CommandStatus::DiagnosticsError,
902            message: format!(
903                "this project is in more than one format ({}); choose one with --format",
904                many.iter()
905                    .map(|a| a.kind().name())
906                    .collect::<Vec<_>>()
907                    .join(", ")
908            ),
909        }),
910    }
911}
912
913fn not_yet(kind: FormatKind) -> String {
914    match kind {
915        FormatKind::Kiro => {
916            "speccoder runs Spec Kit and OpenSpec projects, not Kiro specs".to_owned()
917        }
918        k => format!("speccoder does not run {} projects", k.title()),
919    }
920}
921
922fn article(kind: FormatKind) -> String {
923    match kind {
924        FormatKind::Openspec => "an OpenSpec".to_owned(),
925        k => format!("a {}", k.title()),
926    }
927}
928
929/// `[spec] format` of the nearest `speccoder.toml` at or above `start`.
930fn configured(start: &Path) -> Result<Option<FormatKind>, Refused> {
931    let Some(dir) = crate::verify::find_config(start) else {
932        return Ok(None);
933    };
934    let Ok(text) = std::fs::read_to_string(dir.join(crate::verify::config::FILE)) else {
935        return Ok(None);
936    };
937    parse_config(&text).map_err(|e| Refused {
938        unmarked: false,
939        status: CommandStatus::ConfigError,
940        message: format!("speccoder.toml: {e}"),
941    })
942}
943
944/// `[spec] format` from a `speccoder.toml`'s text.
945pub fn parse_config(text: &str) -> Result<Option<FormatKind>, String> {
946    #[derive(serde::Deserialize)]
947    #[serde(deny_unknown_fields)]
948    struct Spec {
949        format: Option<String>,
950    }
951    // An unreadable file is the configuration loader's to report.
952    let Ok(table) = toml::from_str::<toml::Table>(text) else {
953        return Ok(None);
954    };
955    let Some(spec) = table.get("spec") else {
956        return Ok(None);
957    };
958    let spec: Spec = spec
959        .clone()
960        .try_into()
961        .map_err(|e: toml::de::Error| format!("[spec]: {}", e.message()))?;
962    let [a, b, c] = FormatKind::ALL.map(FormatKind::name);
963    spec.format
964        .map(|f| {
965            f.parse()
966                .map_err(|_| format!("[spec]: format = \"{f}\" ({a}, {b} or {c})"))
967        })
968        .transpose()
969}
970
971#[cfg(test)]
972mod tests {
973    use super::*;
974
975    struct Fake(FormatKind, bool);
976
977    impl Detect for Fake {
978        fn kind(&self) -> FormatKind {
979            self.0
980        }
981        fn root(&self, _: &Path) -> Option<PathBuf> {
982            Some(PathBuf::from("/p"))
983        }
984        fn adapter(&self) -> Option<&'static dyn Adapter> {
985            self.1.then(reference)
986        }
987    }
988
989    struct At(FormatKind, &'static str);
990
991    impl Detect for At {
992        fn kind(&self) -> FormatKind {
993            self.0
994        }
995        fn root(&self, _: &Path) -> Option<PathBuf> {
996            Some(PathBuf::from(self.1))
997        }
998        fn adapter(&self) -> Option<&'static dyn Adapter> {
999            Some(reference())
1000        }
1001    }
1002
1003    #[test]
1004    fn the_nearest_project_decides() {
1005        static OUTER: At = At(FormatKind::Speckit, "/repo");
1006        static INNER: At = At(FormatKind::Openspec, "/repo/sub/project");
1007        let (_, w) = resolve(Path::new("/repo/sub/project/x"), None, &[&OUTER, &INNER]).unwrap();
1008        assert!(w.is_empty());
1009        static TWIN: At = At(FormatKind::Speckit, "/repo/sub/project");
1010        assert!(resolve(Path::new("/x"), None, &[&TWIN, &INNER]).is_err());
1011    }
1012
1013    static SK: Fake = Fake(FormatKind::Speckit, true);
1014    static OS: Fake = Fake(FormatKind::Openspec, false);
1015    static OS_RUNS: Fake = Fake(FormatKind::Openspec, true);
1016    static KIRO: Fake = Fake(FormatKind::Kiro, false);
1017
1018    #[test]
1019    fn a_choice_wins_and_an_unsupported_one_is_refused() {
1020        let p = Path::new("/p");
1021        let (a, w) = resolve(p, Some(FormatKind::Speckit), &[&SK, &OS]).unwrap();
1022        assert_eq!((a.kind(), w.len()), (FormatKind::Speckit, 0));
1023        let r = resolve(p, Some(FormatKind::Openspec), &[&SK, &OS])
1024            .err()
1025            .unwrap();
1026        assert_eq!(r.status, CommandStatus::NotAProject);
1027        assert_eq!(r.message, "speccoder does not run OpenSpec projects");
1028        let r = resolve(p, Some(FormatKind::Kiro), &[&SK, &KIRO])
1029            .err()
1030            .unwrap();
1031        assert!(r.message.ends_with("not Kiro specs"));
1032    }
1033
1034    #[test]
1035    fn detection_warns_about_other_markers_and_refuses_ambiguity() {
1036        let p = Path::new("/p");
1037        let (a, w) = resolve(p, None, &[&SK, &OS]).unwrap();
1038        assert_eq!(a.kind(), FormatKind::Speckit);
1039        assert_eq!(w.len(), 1);
1040        assert_eq!(w[0].code, "format.unsupported-marker");
1041        let r = resolve(p, None, &[&OS]).err().unwrap();
1042        assert_eq!(
1043            r.message,
1044            "found an OpenSpec project at /p; speccoder does not run OpenSpec projects"
1045        );
1046        let r = resolve(p, None, &[&SK, &OS_RUNS]).err().unwrap();
1047        assert_eq!(r.status, CommandStatus::DiagnosticsError);
1048        assert!(
1049            r.message.contains("choose one with --format"),
1050            "{}",
1051            r.message
1052        );
1053    }
1054
1055    #[test]
1056    fn the_spec_section_names_a_format() {
1057        assert_eq!(parse_config(""), Ok(None));
1058        assert_eq!(
1059            parse_config("[spec]\nformat = \"openspec\"\n"),
1060            Ok(Some(FormatKind::Openspec))
1061        );
1062        assert_eq!(
1063            parse_config("[spec]\nformat = \"x\"\n"),
1064            Err("[spec]: format = \"x\" (speckit, openspec or kiro)".to_owned())
1065        );
1066        assert!(
1067            parse_config("[spec]\nkind = 1\n")
1068                .unwrap_err()
1069                .starts_with("[spec]: unknown field")
1070        );
1071        assert_eq!(parse_config("[format]\nargv = [\"x\"]\n"), Ok(None));
1072    }
1073}