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/// One spec format the runtime can run.
178pub trait Adapter: Send + Sync + std::fmt::Debug {
179    /// Which format.
180    fn kind(&self) -> FormatKind;
181
182    /// The project root containing `start`, by the format's marker.
183    fn detect(&self, start: &Path) -> Option<PathBuf>;
184
185    /// Discovers the project containing `start` and selects `feature`.
186    /// Read-only.
187    fn inspect(&self, start: &Path, feature: Option<&str>) -> Option<ProjectSnapshot>;
188
189    /// [`Adapter::inspect`] for a command a person ran: the format's own
190    /// selection settings (its environment variables) apply.
191    fn open(&self, start: &Path, feature: Option<&str>) -> Option<ProjectSnapshot> {
192        self.inspect(start, feature)
193    }
194
195    /// Parses a task list.
196    fn parse_tasks(&self, path: &str, text: &str) -> (ParsedTasks, Vec<Diagnostic>);
197
198    /// Which paths are intent and what planning may write.
199    fn rules(&self) -> &'static Rules;
200
201    /// What the model is told about the format.
202    fn wording(&self) -> &'static Wording;
203
204    /// What a person is told.
205    fn messages(&self) -> &'static Messages;
206
207    /// Hosted planning skills.
208    fn planning(&self) -> &'static Planning;
209
210    /// The context bundle for `task` of the selected unit.
211    fn bundle(&self, snapshot: &ProjectSnapshot, task: &str) -> Result<Bundle, BundleError>;
212
213    /// The scenarios `task`'s acceptance tests cite, with the spec's words
214    /// (judge-side: the implementer sees the spec text, not this list).
215    fn scenarios(&self, snapshot: &ProjectSnapshot, task: &str) -> Vec<ScenarioRef>;
216
217    /// Whether the bundle states acceptance examples to derive tests from.
218    fn acceptance_testable(&self, bundle: &Bundle) -> bool;
219
220    /// Whether a task closes a gap found after implementation (its phase).
221    fn is_convergence(&self, phase: &str) -> bool;
222
223    /// What the convergence assessment reads, in order: the project's rules,
224    /// the spec, the plan, the task list (each may be empty).
225    fn convergence_texts(&self, root: &Path, unit_dir: &Path) -> [String; 4];
226
227    /// Diagnostics that stop a run of the selected unit before any model
228    /// call (the format's preflight).
229    fn blocking<'s>(&self, _snapshot: &'s ProjectSnapshot) -> Vec<&'s Diagnostic> {
230        Vec::new()
231    }
232
233    /// The format's own tools' opinion of the selected unit, when they are
234    /// installed (read-only commands through `runner`).
235    fn crosscheck(
236        &self,
237        _snapshot: &ProjectSnapshot,
238        _runner: &dyn crate::verify::process::CommandRunner,
239    ) -> Vec<Diagnostic> {
240        Vec::new()
241    }
242
243    /// The unit's unfinished-checklist state.
244    fn checklists(&self, root: &Path, unit_dir: &str) -> Vec<ChecklistStatus>;
245
246    /// The format's own command-line tool and how to install it, for
247    /// `doctor` (`None`: the format has none).
248    fn tool(&self) -> Option<(&'static str, &'static str)> {
249        None
250    }
251
252    /// The unit's document that defines its requirements (`spec.md` in
253    /// Spec Kit), relative to the unit's directory; `None` when the format
254    /// has no requirement identifiers.
255    fn requirements_file(&self) -> Option<&'static str> {
256        None
257    }
258
259    /// The requirements `text` (the requirements file) defines, each with
260    /// the line that defines it, in the document's order.
261    fn requirements_in(&self, _text: &str) -> Vec<(String, String)> {
262        Vec::new()
263    }
264
265    /// Records a quick change: `request` (with acceptance `checks`)
266    /// becomes a story, a requirement and a task in the format's standing
267    /// unit for such changes. `Err`: the format has none.
268    fn record_change(
269        &self,
270        _root: &Path,
271        _request: &str,
272        _checks: &[String],
273    ) -> Result<Recorded, String> {
274        Err("this spec format has no place for quick changes: write a spec for it".to_owned())
275    }
276
277    /// The format's installed extensions, from its own inventory (`None`:
278    /// the format has none, or none could be read). The only reader of the
279    /// format's extension files.
280    fn extensions(&self, _root: &Path) -> Option<Inventory> {
281        None
282    }
283
284    /// Where a bug lives: the format's own bug structure when it
285    /// has one, else its standing bug-fix unit. `Err`: neither
286    /// (`BUG-NO-UNIT`).
287    ///
288    /// # Errors
289    ///
290    /// The format has no place for a bug.
291    fn bug_unit(&self, _root: &Path) -> Result<speccoder_core::bug::BugHome, BugRefusal> {
292        Err(BugRefusal::new(
293            "BUG-NO-UNIT",
294            "this spec format has no place for a bug: write a spec for the fix, or `speccoder do` for a change without a reproduction",
295        ))
296    }
297
298    /// Records `triad` as a story, an approved-to-be requirement and a task
299    /// in the standing bug-fix unit `home` names (appends only, verified at
300    /// write time).
301    ///
302    /// # Errors
303    ///
304    /// The home is not a standing unit, or the files changed meanwhile.
305    fn record_bug(
306        &self,
307        _root: &Path,
308        _home: &speccoder_core::bug::BugHome,
309        _triad: &speccoder_core::bug::Triad,
310    ) -> Result<RecordedBug, BugRefusal> {
311        Err(BugRefusal::new(
312            "BUG-NO-UNIT",
313            "this spec format has no standing unit for bugs",
314        ))
315    }
316
317    /// The format's own bug step, for a native home.
318    fn bug_step(&self) -> Option<BugStep> {
319        None
320    }
321
322    /// A native home's directory for `slug`, project-relative, and its
323    /// unit name.
324    fn bug_dir(
325        &self,
326        _home: &speccoder_core::bug::BugHome,
327        _slug: &str,
328    ) -> Option<(String, String)> {
329        None
330    }
331
332    /// A new slug for `title` that no bug of `home` has yet.
333    fn bug_slug(&self, _root: &Path, _home: &speccoder_core::bug::BugHome, _title: &str) -> String {
334        String::new()
335    }
336
337    /// The triad as the home holds it: the bug `key` (a native home's slug,
338    /// a standing unit's requirement). Pure: no model, no tool.
339    ///
340    /// # Errors
341    ///
342    /// The file is missing or not in a shape the adapter reads.
343    fn read_bug(
344        &self,
345        _root: &Path,
346        _home: &speccoder_core::bug::BugHome,
347        _key: &str,
348    ) -> Result<ReadBug, BugRefusal> {
349        Err(BugRefusal::new("BUG-NO-UNIT", "no bug home"))
350    }
351
352    /// The selected unit's requirements with their scenarios, in document
353    /// order: what a review writes tests for. None when the format yields
354    /// no requirement with scenarios.
355    fn oracle_requirements(&self, _snapshot: &ProjectSnapshot) -> Vec<OracleRequirement> {
356        Vec::new()
357    }
358
359    /// The project's standing units and their rungs (a quick change's, a
360    /// bug's): what `speccoder_core::ladder::rung_of` decides by.
361    fn standing_units(&self, _root: &Path) -> Vec<(String, speccoder_core::ladder::Rung)> {
362        Vec::new()
363    }
364
365    /// The unit's requirement identifiers, each with the hash of the line
366    /// that defines it (`FR-001`, `SC-002` in Spec Kit): the rows of the
367    /// trace matrix. None when the format has no such identifiers.
368    fn requirement_ids(&self, root: &Path, unit_dir: &str) -> Vec<(String, String)> {
369        let Some(file) = self.requirements_file() else {
370            return Vec::new();
371        };
372        let text = std::fs::read_to_string(root.join(unit_dir).join(file)).unwrap_or_default();
373        self.requirements_in(&text)
374            .into_iter()
375            .map(|(id, line)| (id, crate::integrity::sha256_hex(line.as_bytes())))
376            .collect()
377    }
378
379    /// The format's per-checkout pointer to the current unit
380    /// (repository-relative), if it has one.
381    fn current_pointer(&self) -> Option<&'static str> {
382        None
383    }
384
385    /// Records `unit_dir` (repository-relative) as the current unit in the
386    /// format's own per-checkout pointer, or clears it (`None`); returns the
387    /// pointer's path. `Err`: the format has no current unit.
388    fn set_current(&self, _root: &Path, _unit_dir: Option<&str>) -> Result<String, String> {
389        Err(self.messages().no_current.to_owned())
390    }
391
392    /// Ticks `task` (one byte, under the line-hash guard).
393    fn tick(&self, tasks_file: &Path, task: &str, line_hash: &str) -> Result<(), String>;
394
395    /// Appends convergence tasks (prefix-guarded); returns their IDs.
396    fn append(
397        &self,
398        tasks_file: &Path,
399        file_hash: &str,
400        items: &[String],
401    ) -> Result<Vec<String>, String>;
402
403    /// What must be committed before a worktree is made; paths are
404    /// repository-relative, `join` maps a project-relative path to one.
405    fn commit_precondition(
406        &self,
407        root: &Path,
408        join: &dyn Fn(&str) -> String,
409        unit_dir: &str,
410    ) -> CommitPrecondition;
411}
412
413/// A format's ruling on a command (`argv`, whether a planning skill asks):
414/// `Some(Ok)` allowed, `Some(Err(why))` refused, `None` no opinion.
415pub type CommandRule = fn(&[String], bool) -> Option<Result<(), String>>;
416
417/// Which paths are the format's intent, and what a hosted planning skill may
418/// touch.
419#[derive(Debug)]
420pub struct Rules {
421    /// Globs of the intent: never written during implementation.
422    pub intent: &'static [&'static str],
423    /// Why an intent write is refused.
424    pub intent_reason: &'static str,
425    /// What a planning skill writes without asking.
426    pub authoring_path: fn(&str) -> bool,
427    /// The format's own commands: `Some(Ok)` runs without asking,
428    /// `Some(Err(why))` is refused, `None` is classified like any command.
429    /// The flag is whether a hosted planning skill asks.
430    pub command: CommandRule,
431    /// Environment variables every command of the project gets.
432    pub env: &'static [(&'static str, &'static str)],
433    /// Why a planning skill's other write needs approval (`path` given).
434    pub authoring_refusal: fn(&str) -> String,
435}
436
437/// Model-facing fragments; the prompts around them are the same for every
438/// format.
439#[derive(Debug)]
440pub struct Wording {
441    /// The format as the model is told it, e.g. `GitHub Spec Kit`.
442    pub product: &'static str,
443    /// The format's short name, e.g. `Spec Kit`.
444    pub name: &'static str,
445    /// What one unit of work is called, e.g. `feature`.
446    pub unit: &'static str,
447    /// The intent paths, e.g. `.specify/, specs/`.
448    pub intent_paths: &'static str,
449    /// The same as code spans.
450    pub intent_code: &'static str,
451    /// The documents convergence reads, e.g. `spec.md, plan.md and tasks.md`.
452    pub documents: &'static str,
453    /// What a planning skill may do, e.g. `write Spec Kit artifacts (…) and
454    /// run Spec Kit's own scripts under …`.
455    pub authoring: &'static str,
456    /// What a planning skill may leave a commit to, e.g. `a Spec Kit script`.
457    pub authoring_tool: &'static str,
458    /// The heading of the requirement text in a bundle.
459    pub story: &'static str,
460    /// The heading of the project-wide rules (`Project constitution`).
461    pub constitution: &'static str,
462    /// The heading of the unit's plan in the attempt prompt (`Feature plan`).
463    pub plan: &'static str,
464    /// The same in the external agent's prompt (`Plan`).
465    pub plan_short: &'static str,
466    /// What acceptance tests are derived from.
467    pub examples: &'static str,
468    /// A task closing a gap: `A task in a Convergence phase`.
469    pub convergence_task: &'static str,
470    /// The spec, plan and task list headings, as convergence shows them.
471    pub files: [&'static str; 3],
472    /// How convergence is told what to assess.
473    pub assess: Assess,
474}
475
476/// The format's words in the convergence assessment.
477#[derive(Debug)]
478pub struct Assess {
479    /// The governing constraints, e.g. `the constitution`.
480    pub rules: &'static str,
481    /// What each finding is checked against.
482    pub targets: &'static str,
483    /// A rule whose violation is `contradicts`, e.g. `a constitution MUST`.
484    pub must: &'static str,
485    /// What `unrequested` code is not called for by.
486    pub sources: &'static str,
487    /// What makes a finding CRITICAL.
488    pub critical: &'static str,
489    /// Examples of `source_ref`.
490    pub refs: &'static str,
491}
492
493/// What a person is told; texts name the format's own files and commands.
494#[derive(Debug)]
495pub struct Messages {
496    /// The task list's file name.
497    pub tasks_file: &'static str,
498    /// How to make the task list.
499    pub make_tasks: &'static str,
500    /// No project at or above the path (path given).
501    pub not_a_project: fn(&Path) -> String,
502    /// The short form, for commands that only need the root.
503    pub no_project: &'static str,
504    /// A worktree lost its project.
505    pub no_project_in_worktree: &'static str,
506    /// The project is not in a git repository.
507    pub not_in_git: &'static str,
508    /// No skills are installed.
509    pub no_skills: &'static str,
510    /// Unchecked checklists stop a run (the list given).
511    pub checklists: fn(&str) -> String,
512    /// What a unit is called: `feature`, `change`.
513    pub unit: &'static str,
514    /// Why there is no current unit to set.
515    pub no_current: &'static str,
516    /// Who else reads the pointer, said when it changes.
517    pub current_shared: &'static str,
518    /// The command that makes a project of this format here, and why.
519    pub make_project: (&'static str, &'static str),
520}
521
522/// Hosted planning skills.
523#[derive(Debug)]
524pub struct Planning {
525    /// The prefix installed skills carry (`plan` runs `<prefix>plan`).
526    pub prefix: &'static str,
527    /// Skills whose `before_<name>` / `after_<name>` hooks run.
528    pub core: &'static [&'static str],
529    /// Why a skill is not hosted, and what to use instead.
530    pub refused: fn(&str) -> Option<String>,
531    /// Core skills that create a new unit (Spec Kit: `specify`): they get
532    /// no current-unit variable, and the unit they create becomes current.
533    pub creates: &'static [&'static str],
534    /// The variable the format's own scripts read the current unit from
535    /// (Spec Kit: `SPECIFY_FEATURE_DIRECTORY`), passed to planning commands.
536    pub current_env: Option<&'static str>,
537    /// The skills that lead from what exists to a task list, given whether
538    /// a plan exists (Spec Kit: `plan`, `tasks`).
539    pub toward_tasks: fn(bool) -> &'static [&'static str],
540    /// The skill that revises a written spec from a person's comments
541    /// (Spec Kit: `clarify`), if the format has one.
542    pub revises: Option<&'static str>,
543    /// The skill that checks the planning files for consistency (Spec
544    /// Kit: `analyze`), if the format has one.
545    pub checks: Option<&'static str>,
546}
547
548impl Planning {
549    /// The part the skill `short` (without the prefix) plays: it creates a
550    /// unit, leads toward the task list, revises a spec, checks the
551    /// planning, or something else — in that order of precedence.
552    #[must_use]
553    pub fn role_of(&self, short: &str) -> speccoder_core::actions::SkillRole {
554        use speccoder_core::actions::SkillRole;
555        let toward = |plan| (self.toward_tasks)(plan).contains(&short);
556        if self.creates.contains(&short) {
557            SkillRole::Create
558        } else if toward(false) || toward(true) {
559            SkillRole::TowardTasks
560        } else if self.revises == Some(short) {
561            SkillRole::Revise
562        } else if self.checks == Some(short) {
563            SkillRole::Check
564        } else {
565            SkillRole::Other
566        }
567    }
568}
569
570/// What a worktree needs committed: a worktree contains only commits.
571#[derive(Debug, Clone)]
572pub struct CommitPrecondition {
573    /// Nothing untracked, ignored or modified under these.
574    pub strict: Vec<String>,
575    /// Nothing untracked or modified under these (the format ignores its
576    /// own per-checkout files), except runtime state.
577    pub tracked: Vec<String>,
578    /// Runtime state under `tracked` that is not intent.
579    pub runtime: fn(&str) -> bool,
580    /// Files that must be tracked at all.
581    pub essentials: Vec<String>,
582    /// The refusal.
583    pub message: &'static str,
584    /// How to fix it.
585    pub hint: &'static str,
586    /// How to fix a repository without commits.
587    pub no_commits_hint: &'static str,
588}
589
590impl CommitPrecondition {
591    /// Every path whose change after the fork means the spec moved.
592    #[must_use]
593    pub fn paths(&self) -> Vec<String> {
594        self.tracked.iter().chain(&self.strict).cloned().collect()
595    }
596}
597
598/// The formats this build knows, in detection order.
599fn all() -> [&'static dyn Detect; 3] {
600    [&speckit::SPECKIT, &openspec::OPENSPEC, &stubs::KIRO]
601}
602
603/// What every known format implements: finding its marker.
604pub trait Detect: Send + Sync {
605    /// Which format.
606    fn kind(&self) -> FormatKind;
607    /// The root containing `start`, by the format's marker.
608    fn root(&self, start: &Path) -> Option<PathBuf>;
609    /// The adapter, when this build runs the format.
610    fn adapter(&self) -> Option<&'static dyn Adapter>;
611}
612
613/// Every adapter this build runs, in detection order.
614#[must_use]
615pub fn adapters() -> Vec<&'static dyn Adapter> {
616    all().into_iter().filter_map(|d| d.adapter()).collect()
617}
618
619/// The adapter of `kind`, if this build runs it.
620#[must_use]
621pub fn adapter(kind: FormatKind) -> Option<&'static dyn Adapter> {
622    all()
623        .into_iter()
624        .find(|d| d.kind() == kind)
625        .and_then(|d| d.adapter())
626}
627
628/// The reference format, the default where no project decides.
629#[must_use]
630pub fn reference() -> &'static dyn Adapter {
631    &speckit::SPECKIT
632}
633
634static REQUEST: OnceLock<FormatKind> = OnceLock::new();
635
636/// `--format` / `SPECCODER_FORMAT`: chosen once, by the binary, before any
637/// command runs.
638pub fn request(kind: FormatKind) {
639    let _ = REQUEST.set(kind);
640}
641
642/// The project's format, resolved and opened.
643pub struct Opened {
644    /// The format's adapter.
645    pub adapter: &'static dyn Adapter,
646    /// What it sees.
647    pub snapshot: ProjectSnapshot,
648}
649
650/// Why no project was opened.
651#[derive(Debug, Clone, PartialEq, Eq)]
652pub struct Refused {
653    /// How it ends.
654    pub status: CommandStatus,
655    /// Why.
656    pub message: String,
657    /// No format's marker was found at all.
658    pub unmarked: bool,
659}
660
661/// Opens the project containing `start` in the requested, configured or
662/// detected format.
663pub fn open(start: &Path, feature: Option<&str>) -> Result<Opened, Refused> {
664    open_with(start, feature, REQUEST.get().copied())
665}
666
667/// [`open`] with an explicit choice instead of the process-wide request.
668pub fn open_with(
669    start: &Path,
670    feature: Option<&str>,
671    explicit: Option<FormatKind>,
672) -> Result<Opened, Refused> {
673    let configured = match explicit {
674        Some(_) => None,
675        None => configured(start)?,
676    };
677    let (adapter, mut warnings) = resolve(start, explicit.or(configured), &all())?;
678    let mut snapshot = adapter.open(start, feature).ok_or_else(|| Refused {
679        status: CommandStatus::NotAProject,
680        message: (adapter.messages().not_a_project)(start),
681        unmarked: true,
682    })?;
683    snapshot.diagnostics.append(&mut warnings);
684    Ok(Opened { adapter, snapshot })
685}
686
687/// The adapter of a project already opened at `root` (a worktree's project,
688/// a snapshot's root): resolved the way [`open`] resolved it, the reference
689/// format where nothing decides.
690#[must_use]
691pub fn at(root: &Path) -> &'static dyn Adapter {
692    let chosen = REQUEST
693        .get()
694        .copied()
695        .or_else(|| configured(root).ok().flatten());
696    resolve(root, chosen, &all()).map_or_else(|_| reference(), |(a, _)| a)
697}
698
699/// The adapter of an inspected project.
700#[must_use]
701pub fn of(snapshot: &ProjectSnapshot) -> &'static dyn Adapter {
702    at(Path::new(&snapshot.root))
703}
704
705/// The format of the project at `start`: `chosen` when given, else the one
706/// supported format whose marker is nearest. Markers of formats this build
707/// does not run, at the same root, become warnings.
708fn resolve(
709    start: &Path,
710    chosen: Option<FormatKind>,
711    formats: &[&'static dyn Detect],
712) -> Result<(&'static dyn Adapter, Vec<Diagnostic>), Refused> {
713    let refuse = |message: String| Refused {
714        status: CommandStatus::NotAProject,
715        message,
716        unmarked: false,
717    };
718    if let Some(kind) = chosen {
719        return formats
720            .iter()
721            .find(|d| d.kind() == kind)
722            .and_then(|d| d.adapter())
723            .map(|a| (a, Vec::new()))
724            .ok_or_else(|| refuse(not_yet(kind)));
725    }
726    // The nearest project decides: one nested in another repository's
727    // project is its own. Only markers at the same root compete.
728    let mut found: Vec<(&'static dyn Detect, PathBuf)> = formats
729        .iter()
730        .filter_map(|d| d.root(start).map(|r| (*d, r)))
731        .collect();
732    let nearest = found.iter().map(|(_, r)| r.components().count()).max();
733    found.retain(|(_, r)| Some(r.components().count()) == nearest);
734    let supported: Vec<&'static dyn Adapter> =
735        found.iter().filter_map(|(d, _)| d.adapter()).collect();
736    let others = || found.iter().filter(|(d, _)| d.adapter().is_none());
737    match supported.as_slice() {
738        [one] => {
739            let warnings = others()
740                .map(|(d, root)| {
741                    Diagnostic::new(
742                        Severity::Warning,
743                        "format.unsupported-marker",
744                        format!(
745                            "{} is also present at {}; {}",
746                            d.kind().title(),
747                            root.display(),
748                            not_yet(d.kind())
749                        ),
750                    )
751                })
752                .collect();
753            Ok((*one, warnings))
754        }
755        [] => match others().next() {
756            Some((d, root)) => Err(refuse(format!(
757                "found {} project at {}; {}",
758                article(d.kind()),
759                root.display(),
760                not_yet(d.kind())
761            ))),
762            // No marker at all: the reference format's own answer.
763            None => Ok((reference(), Vec::new())),
764        },
765        many => Err(Refused {
766            unmarked: false,
767            status: CommandStatus::DiagnosticsError,
768            message: format!(
769                "this project is in more than one format ({}); choose one with --format",
770                many.iter()
771                    .map(|a| a.kind().name())
772                    .collect::<Vec<_>>()
773                    .join(", ")
774            ),
775        }),
776    }
777}
778
779fn not_yet(kind: FormatKind) -> String {
780    match kind {
781        FormatKind::Kiro => {
782            "speccoder runs Spec Kit and OpenSpec projects, not Kiro specs".to_owned()
783        }
784        k => format!("speccoder does not run {} projects", k.title()),
785    }
786}
787
788fn article(kind: FormatKind) -> String {
789    match kind {
790        FormatKind::Openspec => "an OpenSpec".to_owned(),
791        k => format!("a {}", k.title()),
792    }
793}
794
795/// `[spec] format` of the nearest `speccoder.toml` at or above `start`.
796fn configured(start: &Path) -> Result<Option<FormatKind>, Refused> {
797    let Some(dir) = crate::verify::find_config(start) else {
798        return Ok(None);
799    };
800    let Ok(text) = std::fs::read_to_string(dir.join(crate::verify::config::FILE)) else {
801        return Ok(None);
802    };
803    parse_config(&text).map_err(|e| Refused {
804        unmarked: false,
805        status: CommandStatus::ConfigError,
806        message: format!("speccoder.toml: {e}"),
807    })
808}
809
810/// `[spec] format` from a `speccoder.toml`'s text.
811pub fn parse_config(text: &str) -> Result<Option<FormatKind>, String> {
812    #[derive(serde::Deserialize)]
813    #[serde(deny_unknown_fields)]
814    struct Spec {
815        format: Option<String>,
816    }
817    // An unreadable file is the configuration loader's to report.
818    let Ok(table) = toml::from_str::<toml::Table>(text) else {
819        return Ok(None);
820    };
821    let Some(spec) = table.get("spec") else {
822        return Ok(None);
823    };
824    let spec: Spec = spec
825        .clone()
826        .try_into()
827        .map_err(|e: toml::de::Error| format!("[spec]: {}", e.message()))?;
828    let [a, b, c] = FormatKind::ALL.map(FormatKind::name);
829    spec.format
830        .map(|f| {
831            f.parse()
832                .map_err(|_| format!("[spec]: format = \"{f}\" ({a}, {b} or {c})"))
833        })
834        .transpose()
835}
836
837#[cfg(test)]
838mod tests {
839    use super::*;
840
841    struct Fake(FormatKind, bool);
842
843    impl Detect for Fake {
844        fn kind(&self) -> FormatKind {
845            self.0
846        }
847        fn root(&self, _: &Path) -> Option<PathBuf> {
848            Some(PathBuf::from("/p"))
849        }
850        fn adapter(&self) -> Option<&'static dyn Adapter> {
851            self.1.then(reference)
852        }
853    }
854
855    struct At(FormatKind, &'static str);
856
857    impl Detect for At {
858        fn kind(&self) -> FormatKind {
859            self.0
860        }
861        fn root(&self, _: &Path) -> Option<PathBuf> {
862            Some(PathBuf::from(self.1))
863        }
864        fn adapter(&self) -> Option<&'static dyn Adapter> {
865            Some(reference())
866        }
867    }
868
869    #[test]
870    fn the_nearest_project_decides() {
871        static OUTER: At = At(FormatKind::Speckit, "/repo");
872        static INNER: At = At(FormatKind::Openspec, "/repo/sub/project");
873        let (_, w) = resolve(Path::new("/repo/sub/project/x"), None, &[&OUTER, &INNER]).unwrap();
874        assert!(w.is_empty());
875        static TWIN: At = At(FormatKind::Speckit, "/repo/sub/project");
876        assert!(resolve(Path::new("/x"), None, &[&TWIN, &INNER]).is_err());
877    }
878
879    static SK: Fake = Fake(FormatKind::Speckit, true);
880    static OS: Fake = Fake(FormatKind::Openspec, false);
881    static OS_RUNS: Fake = Fake(FormatKind::Openspec, true);
882    static KIRO: Fake = Fake(FormatKind::Kiro, false);
883
884    #[test]
885    fn a_choice_wins_and_an_unsupported_one_is_refused() {
886        let p = Path::new("/p");
887        let (a, w) = resolve(p, Some(FormatKind::Speckit), &[&SK, &OS]).unwrap();
888        assert_eq!((a.kind(), w.len()), (FormatKind::Speckit, 0));
889        let r = resolve(p, Some(FormatKind::Openspec), &[&SK, &OS])
890            .err()
891            .unwrap();
892        assert_eq!(r.status, CommandStatus::NotAProject);
893        assert_eq!(r.message, "speccoder does not run OpenSpec projects");
894        let r = resolve(p, Some(FormatKind::Kiro), &[&SK, &KIRO])
895            .err()
896            .unwrap();
897        assert!(r.message.ends_with("not Kiro specs"));
898    }
899
900    #[test]
901    fn detection_warns_about_other_markers_and_refuses_ambiguity() {
902        let p = Path::new("/p");
903        let (a, w) = resolve(p, None, &[&SK, &OS]).unwrap();
904        assert_eq!(a.kind(), FormatKind::Speckit);
905        assert_eq!(w.len(), 1);
906        assert_eq!(w[0].code, "format.unsupported-marker");
907        let r = resolve(p, None, &[&OS]).err().unwrap();
908        assert_eq!(
909            r.message,
910            "found an OpenSpec project at /p; speccoder does not run OpenSpec projects"
911        );
912        let r = resolve(p, None, &[&SK, &OS_RUNS]).err().unwrap();
913        assert_eq!(r.status, CommandStatus::DiagnosticsError);
914        assert!(
915            r.message.contains("choose one with --format"),
916            "{}",
917            r.message
918        );
919    }
920
921    #[test]
922    fn the_spec_section_names_a_format() {
923        assert_eq!(parse_config(""), Ok(None));
924        assert_eq!(
925            parse_config("[spec]\nformat = \"openspec\"\n"),
926            Ok(Some(FormatKind::Openspec))
927        );
928        assert_eq!(
929            parse_config("[spec]\nformat = \"x\"\n"),
930            Err("[spec]: format = \"x\" (speckit, openspec or kiro)".to_owned())
931        );
932        assert!(
933            parse_config("[spec]\nkind = 1\n")
934                .unwrap_err()
935                .starts_with("[spec]: unknown field")
936        );
937        assert_eq!(parse_config("[format]\nargv = [\"x\"]\n"), Ok(None));
938    }
939}