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/// One spec format the runtime can run.
41pub trait Adapter: Send + Sync + std::fmt::Debug {
42    /// Which format.
43    fn kind(&self) -> FormatKind;
44
45    /// The project root containing `start`, by the format's marker.
46    fn detect(&self, start: &Path) -> Option<PathBuf>;
47
48    /// Discovers the project containing `start` and selects `feature`.
49    /// Read-only.
50    fn inspect(&self, start: &Path, feature: Option<&str>) -> Option<ProjectSnapshot>;
51
52    /// [`Adapter::inspect`] for a command a person ran: the format's own
53    /// selection settings (its environment variables) apply.
54    fn open(&self, start: &Path, feature: Option<&str>) -> Option<ProjectSnapshot> {
55        self.inspect(start, feature)
56    }
57
58    /// Parses a task list.
59    fn parse_tasks(&self, path: &str, text: &str) -> (ParsedTasks, Vec<Diagnostic>);
60
61    /// Which paths are intent and what planning may write.
62    fn rules(&self) -> &'static Rules;
63
64    /// What the model is told about the format.
65    fn wording(&self) -> &'static Wording;
66
67    /// What a person is told.
68    fn messages(&self) -> &'static Messages;
69
70    /// Hosted planning skills.
71    fn planning(&self) -> &'static Planning;
72
73    /// The context bundle for `task` of the selected unit.
74    fn bundle(&self, snapshot: &ProjectSnapshot, task: &str) -> Result<Bundle, BundleError>;
75
76    /// The scenarios `task`'s acceptance tests cite, with the spec's words
77    /// (judge-side: the implementer sees the spec text, not this list).
78    fn scenarios(&self, snapshot: &ProjectSnapshot, task: &str) -> Vec<ScenarioRef>;
79
80    /// Whether the bundle states acceptance examples to derive tests from.
81    fn acceptance_testable(&self, bundle: &Bundle) -> bool;
82
83    /// Whether a task closes a gap found after implementation (its phase).
84    fn is_convergence(&self, phase: &str) -> bool;
85
86    /// What the convergence assessment reads, in order: the project's rules,
87    /// the spec, the plan, the task list (each may be empty).
88    fn convergence_texts(&self, root: &Path, unit_dir: &Path) -> [String; 4];
89
90    /// Diagnostics that stop a run of the selected unit before any model
91    /// call (the format's preflight).
92    fn blocking<'s>(&self, _snapshot: &'s ProjectSnapshot) -> Vec<&'s Diagnostic> {
93        Vec::new()
94    }
95
96    /// The format's own tools' opinion of the selected unit, when they are
97    /// installed (read-only commands through `runner`).
98    fn crosscheck(
99        &self,
100        _snapshot: &ProjectSnapshot,
101        _runner: &dyn crate::verify::process::CommandRunner,
102    ) -> Vec<Diagnostic> {
103        Vec::new()
104    }
105
106    /// The unit's unfinished-checklist state.
107    fn checklists(&self, root: &Path, unit_dir: &str) -> Vec<ChecklistStatus>;
108
109    /// The format's own command-line tool and how to install it, for
110    /// `doctor` (`None`: the format has none).
111    fn tool(&self) -> Option<(&'static str, &'static str)> {
112        None
113    }
114
115    /// The unit's document that defines its requirements (`spec.md` in
116    /// Spec Kit), relative to the unit's directory; `None` when the format
117    /// has no requirement identifiers.
118    fn requirements_file(&self) -> Option<&'static str> {
119        None
120    }
121
122    /// The requirements `text` (the requirements file) defines, each with
123    /// the line that defines it, in the document's order.
124    fn requirements_in(&self, _text: &str) -> Vec<(String, String)> {
125        Vec::new()
126    }
127
128    /// Records a quick change: `request` (with acceptance `checks`)
129    /// becomes a story, a requirement and a task in the format's standing
130    /// unit for such changes. `Err`: the format has none.
131    fn record_change(
132        &self,
133        _root: &Path,
134        _request: &str,
135        _checks: &[String],
136    ) -> Result<Recorded, String> {
137        Err("this spec format has no place for quick changes: write a spec for it".to_owned())
138    }
139
140    /// The unit's requirement identifiers, each with the hash of the line
141    /// that defines it (`FR-001`, `SC-002` in Spec Kit): the rows of the
142    /// trace matrix. None when the format has no such identifiers.
143    fn requirement_ids(&self, root: &Path, unit_dir: &str) -> Vec<(String, String)> {
144        let Some(file) = self.requirements_file() else {
145            return Vec::new();
146        };
147        let text = std::fs::read_to_string(root.join(unit_dir).join(file)).unwrap_or_default();
148        self.requirements_in(&text)
149            .into_iter()
150            .map(|(id, line)| (id, crate::integrity::sha256_hex(line.as_bytes())))
151            .collect()
152    }
153
154    /// The format's per-checkout pointer to the current unit
155    /// (repository-relative), if it has one.
156    fn current_pointer(&self) -> Option<&'static str> {
157        None
158    }
159
160    /// Records `unit_dir` (repository-relative) as the current unit in the
161    /// format's own per-checkout pointer, or clears it (`None`); returns the
162    /// pointer's path. `Err`: the format has no current unit.
163    fn set_current(&self, _root: &Path, _unit_dir: Option<&str>) -> Result<String, String> {
164        Err(self.messages().no_current.to_owned())
165    }
166
167    /// Ticks `task` (one byte, under the line-hash guard).
168    fn tick(&self, tasks_file: &Path, task: &str, line_hash: &str) -> Result<(), String>;
169
170    /// Appends convergence tasks (prefix-guarded); returns their IDs.
171    fn append(
172        &self,
173        tasks_file: &Path,
174        file_hash: &str,
175        items: &[String],
176    ) -> Result<Vec<String>, String>;
177
178    /// What must be committed before a worktree is made; paths are
179    /// repository-relative, `join` maps a project-relative path to one.
180    fn commit_precondition(
181        &self,
182        root: &Path,
183        join: &dyn Fn(&str) -> String,
184        unit_dir: &str,
185    ) -> CommitPrecondition;
186}
187
188/// A format's ruling on a command (`argv`, whether a planning skill asks):
189/// `Some(Ok)` allowed, `Some(Err(why))` refused, `None` no opinion.
190pub type CommandRule = fn(&[String], bool) -> Option<Result<(), String>>;
191
192/// Which paths are the format's intent, and what a hosted planning skill may
193/// touch.
194#[derive(Debug)]
195pub struct Rules {
196    /// Globs of the intent: never written during implementation.
197    pub intent: &'static [&'static str],
198    /// Why an intent write is refused.
199    pub intent_reason: &'static str,
200    /// What a planning skill writes without asking.
201    pub authoring_path: fn(&str) -> bool,
202    /// The format's own commands: `Some(Ok)` runs without asking,
203    /// `Some(Err(why))` is refused, `None` is classified like any command.
204    /// The flag is whether a hosted planning skill asks.
205    pub command: CommandRule,
206    /// Environment variables every command of the project gets.
207    pub env: &'static [(&'static str, &'static str)],
208    /// Why a planning skill's other write needs approval (`path` given).
209    pub authoring_refusal: fn(&str) -> String,
210}
211
212/// Model-facing fragments; the prompts around them are the same for every
213/// format.
214#[derive(Debug)]
215pub struct Wording {
216    /// The format as the model is told it, e.g. `GitHub Spec Kit`.
217    pub product: &'static str,
218    /// The format's short name, e.g. `Spec Kit`.
219    pub name: &'static str,
220    /// What one unit of work is called, e.g. `feature`.
221    pub unit: &'static str,
222    /// The intent paths, e.g. `.specify/, specs/`.
223    pub intent_paths: &'static str,
224    /// The same as code spans.
225    pub intent_code: &'static str,
226    /// The documents convergence reads, e.g. `spec.md, plan.md and tasks.md`.
227    pub documents: &'static str,
228    /// What a planning skill may do, e.g. `write Spec Kit artifacts (…) and
229    /// run Spec Kit's own scripts under …`.
230    pub authoring: &'static str,
231    /// What a planning skill may leave a commit to, e.g. `a Spec Kit script`.
232    pub authoring_tool: &'static str,
233    /// The heading of the requirement text in a bundle.
234    pub story: &'static str,
235    /// The heading of the project-wide rules (`Project constitution`).
236    pub constitution: &'static str,
237    /// The heading of the unit's plan in the attempt prompt (`Feature plan`).
238    pub plan: &'static str,
239    /// The same in the external agent's prompt (`Plan`).
240    pub plan_short: &'static str,
241    /// What acceptance tests are derived from.
242    pub examples: &'static str,
243    /// A task closing a gap: `A task in a Convergence phase`.
244    pub convergence_task: &'static str,
245    /// The spec, plan and task list headings, as convergence shows them.
246    pub files: [&'static str; 3],
247    /// How convergence is told what to assess.
248    pub assess: Assess,
249}
250
251/// The format's words in the convergence assessment.
252#[derive(Debug)]
253pub struct Assess {
254    /// The governing constraints, e.g. `the constitution`.
255    pub rules: &'static str,
256    /// What each finding is checked against.
257    pub targets: &'static str,
258    /// A rule whose violation is `contradicts`, e.g. `a constitution MUST`.
259    pub must: &'static str,
260    /// What `unrequested` code is not called for by.
261    pub sources: &'static str,
262    /// What makes a finding CRITICAL.
263    pub critical: &'static str,
264    /// Examples of `source_ref`.
265    pub refs: &'static str,
266}
267
268/// What a person is told; texts name the format's own files and commands.
269#[derive(Debug)]
270pub struct Messages {
271    /// The task list's file name.
272    pub tasks_file: &'static str,
273    /// How to make the task list.
274    pub make_tasks: &'static str,
275    /// No project at or above the path (path given).
276    pub not_a_project: fn(&Path) -> String,
277    /// The short form, for commands that only need the root.
278    pub no_project: &'static str,
279    /// A worktree lost its project.
280    pub no_project_in_worktree: &'static str,
281    /// The project is not in a git repository.
282    pub not_in_git: &'static str,
283    /// No skills are installed.
284    pub no_skills: &'static str,
285    /// Unchecked checklists stop a run (the list given).
286    pub checklists: fn(&str) -> String,
287    /// What a unit is called: `feature`, `change`.
288    pub unit: &'static str,
289    /// Why there is no current unit to set.
290    pub no_current: &'static str,
291    /// Who else reads the pointer, said when it changes.
292    pub current_shared: &'static str,
293    /// The command that makes a project of this format here, and why.
294    pub make_project: (&'static str, &'static str),
295}
296
297/// Hosted planning skills.
298#[derive(Debug)]
299pub struct Planning {
300    /// The prefix installed skills carry (`plan` runs `<prefix>plan`).
301    pub prefix: &'static str,
302    /// Skills whose `before_<name>` / `after_<name>` hooks run.
303    pub core: &'static [&'static str],
304    /// Why a skill is not hosted, and what to use instead.
305    pub refused: fn(&str) -> Option<String>,
306    /// Core skills that create a new unit (Spec Kit: `specify`): they get
307    /// no current-unit variable, and the unit they create becomes current.
308    pub creates: &'static [&'static str],
309    /// The variable the format's own scripts read the current unit from
310    /// (Spec Kit: `SPECIFY_FEATURE_DIRECTORY`), passed to planning commands.
311    pub current_env: Option<&'static str>,
312    /// The skills that lead from what exists to a task list, given whether
313    /// a plan exists (Spec Kit: `plan`, `tasks`).
314    pub toward_tasks: fn(bool) -> &'static [&'static str],
315    /// The skill that revises a written spec from a person's comments
316    /// (Spec Kit: `clarify`), if the format has one.
317    pub revises: Option<&'static str>,
318}
319
320/// What a worktree needs committed: a worktree contains only commits.
321#[derive(Debug, Clone)]
322pub struct CommitPrecondition {
323    /// Nothing untracked, ignored or modified under these.
324    pub strict: Vec<String>,
325    /// Nothing untracked or modified under these (the format ignores its
326    /// own per-checkout files), except runtime state.
327    pub tracked: Vec<String>,
328    /// Runtime state under `tracked` that is not intent.
329    pub runtime: fn(&str) -> bool,
330    /// Files that must be tracked at all.
331    pub essentials: Vec<String>,
332    /// The refusal.
333    pub message: &'static str,
334    /// How to fix it.
335    pub hint: &'static str,
336    /// How to fix a repository without commits.
337    pub no_commits_hint: &'static str,
338}
339
340impl CommitPrecondition {
341    /// Every path whose change after the fork means the spec moved.
342    #[must_use]
343    pub fn paths(&self) -> Vec<String> {
344        self.tracked.iter().chain(&self.strict).cloned().collect()
345    }
346}
347
348/// The formats this build knows, in detection order.
349fn all() -> [&'static dyn Detect; 3] {
350    [&speckit::SPECKIT, &openspec::OPENSPEC, &stubs::KIRO]
351}
352
353/// What every known format implements: finding its marker.
354pub trait Detect: Send + Sync {
355    /// Which format.
356    fn kind(&self) -> FormatKind;
357    /// The root containing `start`, by the format's marker.
358    fn root(&self, start: &Path) -> Option<PathBuf>;
359    /// The adapter, when this build runs the format.
360    fn adapter(&self) -> Option<&'static dyn Adapter>;
361}
362
363/// The adapter of `kind`, if this build runs it.
364#[must_use]
365pub fn adapter(kind: FormatKind) -> Option<&'static dyn Adapter> {
366    all()
367        .into_iter()
368        .find(|d| d.kind() == kind)
369        .and_then(|d| d.adapter())
370}
371
372/// The reference format, the default where no project decides.
373#[must_use]
374pub fn reference() -> &'static dyn Adapter {
375    &speckit::SPECKIT
376}
377
378static REQUEST: OnceLock<FormatKind> = OnceLock::new();
379
380/// `--format` / `SPECCODER_FORMAT`: chosen once, by the binary, before any
381/// command runs.
382pub fn request(kind: FormatKind) {
383    let _ = REQUEST.set(kind);
384}
385
386/// The project's format, resolved and opened.
387pub struct Opened {
388    /// The format's adapter.
389    pub adapter: &'static dyn Adapter,
390    /// What it sees.
391    pub snapshot: ProjectSnapshot,
392}
393
394/// Why no project was opened.
395#[derive(Debug, Clone, PartialEq, Eq)]
396pub struct Refused {
397    /// How it ends.
398    pub status: CommandStatus,
399    /// Why.
400    pub message: String,
401    /// No format's marker was found at all.
402    pub unmarked: bool,
403}
404
405/// Opens the project containing `start` in the requested, configured or
406/// detected format.
407pub fn open(start: &Path, feature: Option<&str>) -> Result<Opened, Refused> {
408    open_with(start, feature, REQUEST.get().copied())
409}
410
411/// [`open`] with an explicit choice instead of the process-wide request.
412pub fn open_with(
413    start: &Path,
414    feature: Option<&str>,
415    explicit: Option<FormatKind>,
416) -> Result<Opened, Refused> {
417    let configured = match explicit {
418        Some(_) => None,
419        None => configured(start)?,
420    };
421    let (adapter, mut warnings) = resolve(start, explicit.or(configured), &all())?;
422    let mut snapshot = adapter.open(start, feature).ok_or_else(|| Refused {
423        status: CommandStatus::NotAProject,
424        message: (adapter.messages().not_a_project)(start),
425        unmarked: true,
426    })?;
427    snapshot.diagnostics.append(&mut warnings);
428    Ok(Opened { adapter, snapshot })
429}
430
431/// The adapter of a project already opened at `root` (a worktree's project,
432/// a snapshot's root): resolved the way [`open`] resolved it, the reference
433/// format where nothing decides.
434#[must_use]
435pub fn at(root: &Path) -> &'static dyn Adapter {
436    let chosen = REQUEST
437        .get()
438        .copied()
439        .or_else(|| configured(root).ok().flatten());
440    resolve(root, chosen, &all()).map_or_else(|_| reference(), |(a, _)| a)
441}
442
443/// The adapter of an inspected project.
444#[must_use]
445pub fn of(snapshot: &ProjectSnapshot) -> &'static dyn Adapter {
446    at(Path::new(&snapshot.root))
447}
448
449/// The format of the project at `start`: `chosen` when given, else the one
450/// supported format whose marker is nearest. Markers of formats this build
451/// does not run, at the same root, become warnings.
452fn resolve(
453    start: &Path,
454    chosen: Option<FormatKind>,
455    formats: &[&'static dyn Detect],
456) -> Result<(&'static dyn Adapter, Vec<Diagnostic>), Refused> {
457    let refuse = |message: String| Refused {
458        status: CommandStatus::NotAProject,
459        message,
460        unmarked: false,
461    };
462    if let Some(kind) = chosen {
463        return formats
464            .iter()
465            .find(|d| d.kind() == kind)
466            .and_then(|d| d.adapter())
467            .map(|a| (a, Vec::new()))
468            .ok_or_else(|| refuse(not_yet(kind)));
469    }
470    // The nearest project decides: one nested in another repository's
471    // project is its own. Only markers at the same root compete.
472    let mut found: Vec<(&'static dyn Detect, PathBuf)> = formats
473        .iter()
474        .filter_map(|d| d.root(start).map(|r| (*d, r)))
475        .collect();
476    let nearest = found.iter().map(|(_, r)| r.components().count()).max();
477    found.retain(|(_, r)| Some(r.components().count()) == nearest);
478    let supported: Vec<&'static dyn Adapter> =
479        found.iter().filter_map(|(d, _)| d.adapter()).collect();
480    let others = || found.iter().filter(|(d, _)| d.adapter().is_none());
481    match supported.as_slice() {
482        [one] => {
483            let warnings = others()
484                .map(|(d, root)| {
485                    Diagnostic::new(
486                        Severity::Warning,
487                        "format.unsupported-marker",
488                        format!(
489                            "{} is also present at {}; {}",
490                            d.kind().title(),
491                            root.display(),
492                            not_yet(d.kind())
493                        ),
494                    )
495                })
496                .collect();
497            Ok((*one, warnings))
498        }
499        [] => match others().next() {
500            Some((d, root)) => Err(refuse(format!(
501                "found {} project at {}; {}",
502                article(d.kind()),
503                root.display(),
504                not_yet(d.kind())
505            ))),
506            // No marker at all: the reference format's own answer.
507            None => Ok((reference(), Vec::new())),
508        },
509        many => Err(Refused {
510            unmarked: false,
511            status: CommandStatus::DiagnosticsError,
512            message: format!(
513                "this project is in more than one format ({}); choose one with --format",
514                many.iter()
515                    .map(|a| a.kind().name())
516                    .collect::<Vec<_>>()
517                    .join(", ")
518            ),
519        }),
520    }
521}
522
523fn not_yet(kind: FormatKind) -> String {
524    match kind {
525        FormatKind::Kiro => {
526            "speccoder runs Spec Kit and OpenSpec projects, not Kiro specs".to_owned()
527        }
528        k => format!("speccoder does not run {} projects", k.title()),
529    }
530}
531
532fn article(kind: FormatKind) -> String {
533    match kind {
534        FormatKind::Openspec => "an OpenSpec".to_owned(),
535        k => format!("a {}", k.title()),
536    }
537}
538
539/// `[spec] format` of the nearest `speccoder.toml` at or above `start`.
540fn configured(start: &Path) -> Result<Option<FormatKind>, Refused> {
541    let Some(dir) = crate::verify::find_config(start) else {
542        return Ok(None);
543    };
544    let Ok(text) = std::fs::read_to_string(dir.join(crate::verify::config::FILE)) else {
545        return Ok(None);
546    };
547    parse_config(&text).map_err(|e| Refused {
548        unmarked: false,
549        status: CommandStatus::ConfigError,
550        message: format!("speccoder.toml: {e}"),
551    })
552}
553
554/// `[spec] format` from a `speccoder.toml`'s text.
555pub fn parse_config(text: &str) -> Result<Option<FormatKind>, String> {
556    #[derive(serde::Deserialize)]
557    #[serde(deny_unknown_fields)]
558    struct Spec {
559        format: Option<String>,
560    }
561    // An unreadable file is the configuration loader's to report.
562    let Ok(table) = toml::from_str::<toml::Table>(text) else {
563        return Ok(None);
564    };
565    let Some(spec) = table.get("spec") else {
566        return Ok(None);
567    };
568    let spec: Spec = spec
569        .clone()
570        .try_into()
571        .map_err(|e: toml::de::Error| format!("[spec]: {}", e.message()))?;
572    let [a, b, c] = FormatKind::ALL.map(FormatKind::name);
573    spec.format
574        .map(|f| {
575            f.parse()
576                .map_err(|_| format!("[spec]: format = \"{f}\" ({a}, {b} or {c})"))
577        })
578        .transpose()
579}
580
581#[cfg(test)]
582mod tests {
583    use super::*;
584
585    struct Fake(FormatKind, bool);
586
587    impl Detect for Fake {
588        fn kind(&self) -> FormatKind {
589            self.0
590        }
591        fn root(&self, _: &Path) -> Option<PathBuf> {
592            Some(PathBuf::from("/p"))
593        }
594        fn adapter(&self) -> Option<&'static dyn Adapter> {
595            self.1.then(reference)
596        }
597    }
598
599    struct At(FormatKind, &'static str);
600
601    impl Detect for At {
602        fn kind(&self) -> FormatKind {
603            self.0
604        }
605        fn root(&self, _: &Path) -> Option<PathBuf> {
606            Some(PathBuf::from(self.1))
607        }
608        fn adapter(&self) -> Option<&'static dyn Adapter> {
609            Some(reference())
610        }
611    }
612
613    #[test]
614    fn the_nearest_project_decides() {
615        static OUTER: At = At(FormatKind::Speckit, "/repo");
616        static INNER: At = At(FormatKind::Openspec, "/repo/sub/project");
617        let (_, w) = resolve(Path::new("/repo/sub/project/x"), None, &[&OUTER, &INNER]).unwrap();
618        assert!(w.is_empty());
619        static TWIN: At = At(FormatKind::Speckit, "/repo/sub/project");
620        assert!(resolve(Path::new("/x"), None, &[&TWIN, &INNER]).is_err());
621    }
622
623    static SK: Fake = Fake(FormatKind::Speckit, true);
624    static OS: Fake = Fake(FormatKind::Openspec, false);
625    static OS_RUNS: Fake = Fake(FormatKind::Openspec, true);
626    static KIRO: Fake = Fake(FormatKind::Kiro, false);
627
628    #[test]
629    fn a_choice_wins_and_an_unsupported_one_is_refused() {
630        let p = Path::new("/p");
631        let (a, w) = resolve(p, Some(FormatKind::Speckit), &[&SK, &OS]).unwrap();
632        assert_eq!((a.kind(), w.len()), (FormatKind::Speckit, 0));
633        let r = resolve(p, Some(FormatKind::Openspec), &[&SK, &OS])
634            .err()
635            .unwrap();
636        assert_eq!(r.status, CommandStatus::NotAProject);
637        assert_eq!(r.message, "speccoder does not run OpenSpec projects");
638        let r = resolve(p, Some(FormatKind::Kiro), &[&SK, &KIRO])
639            .err()
640            .unwrap();
641        assert!(r.message.ends_with("not Kiro specs"));
642    }
643
644    #[test]
645    fn detection_warns_about_other_markers_and_refuses_ambiguity() {
646        let p = Path::new("/p");
647        let (a, w) = resolve(p, None, &[&SK, &OS]).unwrap();
648        assert_eq!(a.kind(), FormatKind::Speckit);
649        assert_eq!(w.len(), 1);
650        assert_eq!(w[0].code, "format.unsupported-marker");
651        let r = resolve(p, None, &[&OS]).err().unwrap();
652        assert_eq!(
653            r.message,
654            "found an OpenSpec project at /p; speccoder does not run OpenSpec projects"
655        );
656        let r = resolve(p, None, &[&SK, &OS_RUNS]).err().unwrap();
657        assert_eq!(r.status, CommandStatus::DiagnosticsError);
658        assert!(
659            r.message.contains("choose one with --format"),
660            "{}",
661            r.message
662        );
663    }
664
665    #[test]
666    fn the_spec_section_names_a_format() {
667        assert_eq!(parse_config(""), Ok(None));
668        assert_eq!(
669            parse_config("[spec]\nformat = \"openspec\"\n"),
670            Ok(Some(FormatKind::Openspec))
671        );
672        assert_eq!(
673            parse_config("[spec]\nformat = \"x\"\n"),
674            Err("[spec]: format = \"x\" (speckit, openspec or kiro)".to_owned())
675        );
676        assert!(
677            parse_config("[spec]\nkind = 1\n")
678                .unwrap_err()
679                .starts_with("[spec]: unknown field")
680        );
681        assert_eq!(parse_config("[format]\nargv = [\"x\"]\n"), Ok(None));
682    }
683}