Skip to main content

speccoder_core/
snapshot.rs

1//! What speccoder sees in a Spec Kit project: the read-only snapshot that
2//! `inspect` reports and every later subsystem consumes.
3//!
4//! These are *parsed* facts about artifacts. Runtime task state is a separate
5//! type. Paths are workspace-relative with `/` separators,
6//! except [`ProjectSnapshot::root`].
7
8use serde::Serialize;
9
10/// Everything discovered about one Spec Kit project.
11#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
12pub struct ProjectSnapshot {
13    /// Absolute, canonical project root (the directory containing `.specify/`).
14    pub root: String,
15    /// Spec Kit metadata recorded in the project.
16    pub speckit: SpeckitMeta,
17    /// State of `.specify/memory/constitution.md`.
18    pub constitution: ConstitutionState,
19    /// Feature directories, ordered by numeric prefix then name.
20    pub features: Vec<FeatureRef>,
21    /// Which feature was selected, and why.
22    pub selection: Selection,
23    /// The selected feature, if any.
24    pub feature: Option<FeatureSnapshot>,
25    /// Problems found while discovering and parsing.
26    pub diagnostics: Vec<Diagnostic>,
27    /// The spec format (omitted for Spec Kit, the reference format).
28    #[serde(skip_serializing_if = "crate::spec::FormatKind::is_reference")]
29    pub format: crate::spec::FormatKind,
30    /// What an OpenSpec project adds: its schema, context and the selected
31    /// change's requirements.
32    #[serde(skip_serializing_if = "Option::is_none")]
33    pub openspec: Option<crate::spec::OpenSpecMeta>,
34}
35
36impl ProjectSnapshot {
37    /// Whether any diagnostic is an error.
38    #[must_use]
39    pub fn has_errors(&self) -> bool {
40        self.diagnostics
41            .iter()
42            .any(|d| d.severity == Severity::Error)
43    }
44}
45
46/// Spec Kit metadata from `.specify/*.json`; each field is `None` when absent.
47#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, schemars::JsonSchema)]
48pub struct SpeckitMeta {
49    /// `speckit_version` from `init-options.json`.
50    pub version: Option<String>,
51    /// The coding-agent integration, e.g. `claude`.
52    pub integration: Option<String>,
53    /// Script flavour, e.g. `sh`.
54    pub script: Option<String>,
55    /// `sequential` or `timestamp`.
56    pub feature_numbering: Option<String>,
57    /// `feature_directory` from `feature.json`, as recorded.
58    pub current_feature: Option<String>,
59}
60
61/// State of the project constitution.
62#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, schemars::JsonSchema)]
63#[serde(rename_all = "snake_case")]
64pub enum ConstitutionState {
65    /// Present and filled in.
66    Present,
67    /// Present but still the Spec Kit template.
68    Unfilled,
69    /// Not present.
70    Absent,
71}
72
73/// A feature directory under `specs/`.
74#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
75pub struct FeatureRef {
76    /// Workspace-relative directory, e.g. `specs/002-speckit-inspect`.
77    pub dir: String,
78    /// Directory name, e.g. `002-speckit-inspect`.
79    pub name: String,
80    /// Numeric prefix as written, e.g. `002` or `20260319-143022`.
81    pub prefix: String,
82}
83
84/// How the feature was selected.
85#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
86pub struct Selection {
87    /// Why this feature (or none) was selected.
88    pub reason: SelectionReason,
89    /// Name of the selected feature.
90    pub feature: Option<String>,
91}
92
93/// Why a feature was selected.
94#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, schemars::JsonSchema)]
95#[serde(rename_all = "snake_case")]
96pub enum SelectionReason {
97    /// Named on the command line.
98    Argument,
99    /// From the `SPECIFY_FEATURE_DIRECTORY` environment variable.
100    Environment,
101    /// Recorded in `.specify/feature.json`.
102    Recorded,
103    /// The project has exactly one feature.
104    OnlyFeature,
105    /// Nothing was selected.
106    None,
107}
108
109/// The selected feature's artifacts and tasks.
110#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
111pub struct FeatureSnapshot {
112    /// The feature directory.
113    pub feature: FeatureRef,
114    /// Which artifacts exist.
115    pub artifacts: Artifacts,
116    /// The parsed task list, if `tasks.md` was readable.
117    pub tasks: Option<ParsedTasks>,
118    /// Dependencies and states of the tasks; present iff `tasks` is.
119    pub schedule: Option<Schedule>,
120}
121
122/// Presence of a feature's artifacts.
123#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, schemars::JsonSchema)]
124#[allow(clippy::struct_excessive_bools)] // one flag per Spec Kit artifact
125pub struct Artifacts {
126    /// `spec.md`
127    pub spec: bool,
128    /// `plan.md`
129    pub plan: bool,
130    /// `tasks.md`
131    pub tasks: bool,
132    /// `research.md`
133    pub research: bool,
134    /// `data-model.md`
135    pub data_model: bool,
136    /// `contracts/`
137    pub contracts: bool,
138    /// `quickstart.md`
139    pub quickstart: bool,
140    /// `checklists/`
141    pub checklists: bool,
142}
143
144/// A parsed `tasks.md`.
145#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
146pub struct ParsedTasks {
147    /// Workspace-relative path of the file.
148    pub path: String,
149    /// Level-2 headings, in document order.
150    pub phases: Vec<Phase>,
151    /// Tasks, in document order.
152    pub tasks: Vec<Task>,
153    /// Counts over `tasks`.
154    pub totals: Totals,
155    /// How dependencies within a phase are determined.
156    pub dependency_mode: DependencyMode,
157}
158
159/// How dependencies within a phase are determined.
160#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, schemars::JsonSchema)]
161#[serde(rename_all = "snake_case")]
162pub enum DependencyMode {
163    /// Default template: subsections, `[P]` runs and document order, plus written references.
164    #[default]
165    Structural,
166    /// The Format line documents `(depends on …)`: written references are the
167    /// complete order within a phase; only phase order is added.
168    Declared,
169    /// A chain (OpenSpec): each group waits on every earlier group, each task
170    /// on the one before it in its group; written references are added.
171    Chain,
172}
173
174/// A level-2 heading of `tasks.md`.
175#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
176pub struct Phase {
177    /// Heading text, e.g. `Phase 1: Setup`.
178    pub title: String,
179    /// 1-based line of the heading.
180    pub line: u32,
181    /// SHA-256 (hex) of the section, heading through the byte before the next phase.
182    pub section_hash: String,
183}
184
185/// One task line of `tasks.md`, as written.
186#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
187pub struct Task {
188    /// Task ID, e.g. `T014`.
189    pub id: String,
190    /// `[x]` or `[X]`.
191    pub done: bool,
192    /// Carries the `[P]` marker.
193    pub parallel: bool,
194    /// Story label without brackets, e.g. `US1`.
195    pub story: Option<String>,
196    /// Carries the `[YOU]` label: a step only a person can take.
197    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
198    pub yours: bool,
199    /// Text after the ID and markers.
200    pub title: String,
201    /// Task IDs named after `depends on` / `after` in the text, in order.
202    pub depends_on: Vec<String>,
203    /// The scenarios the task names — `(scenarios US3-1)` — upper-cased, in
204    /// written order: what judges it.
205    #[serde(default, skip_serializing_if = "Vec::is_empty")]
206    pub scenarios: Vec<String>,
207    /// Enclosing level-2 heading.
208    pub phase: Option<String>,
209    /// Enclosing level-3 heading within the phase.
210    pub subsection: Option<String>,
211    /// 1-based line.
212    pub line: u32,
213    /// Byte range `[start, end)` of the list item in the file.
214    pub byte_range: [usize; 2],
215    /// SHA-256 (hex) of the task's line, without its line ending.
216    pub line_hash: String,
217}
218
219/// Which tasks can start now, and what blocks the rest.
220#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
221pub struct Schedule {
222    /// One entry per task, in document order.
223    pub tasks: Vec<ScheduledTask>,
224    /// Counts over `tasks`.
225    pub totals: ScheduleTotals,
226}
227
228/// A task's dependencies and state.
229#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
230pub struct ScheduledTask {
231    /// Task ID.
232    pub id: String,
233    /// Done, ready or blocked.
234    pub state: TaskState,
235    /// Direct dependencies.
236    pub depends_on: Vec<Dependency>,
237    /// Open, missing or cyclic dependencies, in document order.
238    pub blocked_by: Vec<String>,
239}
240
241/// Whether a task can start.
242#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, schemars::JsonSchema)]
243#[serde(rename_all = "snake_case")]
244pub enum TaskState {
245    /// Ticked in `tasks.md`.
246    Done,
247    /// Open, and every dependency is done.
248    Ready,
249    /// Open, and some dependency is open, missing or part of a cycle.
250    Blocked,
251}
252
253/// One direct dependency.
254#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
255pub struct Dependency {
256    /// The task depended on.
257    pub id: String,
258    /// Where the edge comes from.
259    pub kind: DependencyKind,
260    /// The ID does not exist in the task list.
261    pub missing: bool,
262}
263
264/// Origin of a dependency.
265#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, schemars::JsonSchema)]
266#[serde(rename_all = "snake_case")]
267pub enum DependencyKind {
268    /// From phase, subsection or `[P]` structure.
269    Structural,
270    /// Written in the task text.
271    Explicit,
272}
273
274/// Schedule counts.
275#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, schemars::JsonSchema)]
276pub struct ScheduleTotals {
277    /// Ticked tasks.
278    pub done: usize,
279    /// Tasks that can start now.
280    pub ready: usize,
281    /// Tasks waiting on something.
282    pub blocked: usize,
283}
284
285/// Task counts.
286#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, schemars::JsonSchema)]
287pub struct Totals {
288    /// All tasks.
289    pub total: usize,
290    /// Ticked tasks.
291    pub done: usize,
292    /// Unticked tasks.
293    pub open: usize,
294}
295
296/// A problem found while discovering or parsing.
297#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
298pub struct Diagnostic {
299    /// How serious it is.
300    pub severity: Severity,
301    /// Stable code, e.g. `tasks.duplicate-id`.
302    pub code: String,
303    /// Workspace-relative file, if the problem is in one.
304    pub path: Option<String>,
305    /// 1-based line, if known.
306    pub line: Option<u32>,
307    /// What is wrong.
308    pub message: String,
309    /// What to do about it.
310    pub hint: Option<String>,
311}
312
313impl Diagnostic {
314    /// A diagnostic with no location or hint.
315    #[must_use]
316    pub fn new(severity: Severity, code: &str, message: impl Into<String>) -> Self {
317        Self {
318            severity,
319            code: code.to_owned(),
320            path: None,
321            line: None,
322            message: message.into(),
323            hint: None,
324        }
325    }
326
327    /// Sets the file.
328    #[must_use]
329    pub fn at(mut self, path: impl Into<String>) -> Self {
330        self.path = Some(path.into());
331        self
332    }
333
334    /// Sets the line.
335    #[must_use]
336    pub fn line(mut self, line: u32) -> Self {
337        self.line = Some(line);
338        self
339    }
340
341    /// Sets the hint.
342    #[must_use]
343    pub fn hint(mut self, hint: impl Into<String>) -> Self {
344        self.hint = Some(hint.into());
345        self
346    }
347}
348
349/// Diagnostic severity.
350#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, schemars::JsonSchema)]
351#[serde(rename_all = "snake_case")]
352pub enum Severity {
353    /// Unsafe to act on.
354    Error,
355    /// Suspicious but usable.
356    Warning,
357    /// For information.
358    Info,
359}
360
361/// The feature whose name is nearest to `name` (edit distance over the
362/// part after the numeric prefix), if any is reasonably near.
363#[must_use]
364pub fn closest<'f>(features: &'f [FeatureRef], name: &str) -> Option<&'f FeatureRef> {
365    let slug = |s: &str| {
366        s.rsplit('/')
367            .next()
368            .unwrap_or(s)
369            .trim_start_matches(|c: char| c.is_ascii_digit() || c == '-')
370            .to_lowercase()
371    };
372    let target = slug(name);
373    features
374        .iter()
375        .map(|f| (distance(&slug(&f.name), &target), f))
376        .filter(|(d, f)| *d <= slug(&f.name).len().max(target.len()) / 2)
377        .min_by_key(|(d, _)| *d)
378        .map(|(_, f)| f)
379}
380
381/// Levenshtein distance.
382fn distance(a: &str, b: &str) -> usize {
383    let b: Vec<char> = b.chars().collect();
384    let mut row: Vec<usize> = (0..=b.len()).collect();
385    for (i, ca) in a.chars().enumerate() {
386        let mut prev = row[0];
387        row[0] = i + 1;
388        for (j, cb) in b.iter().enumerate() {
389            let next = (prev + usize::from(ca != *cb))
390                .min(row[j] + 1)
391                .min(row[j + 1] + 1);
392            prev = row[j + 1];
393            row[j + 1] = next;
394        }
395    }
396    row[b.len()]
397}