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    /// Enclosing level-2 heading.
204    pub phase: Option<String>,
205    /// Enclosing level-3 heading within the phase.
206    pub subsection: Option<String>,
207    /// 1-based line.
208    pub line: u32,
209    /// Byte range `[start, end)` of the list item in the file.
210    pub byte_range: [usize; 2],
211    /// SHA-256 (hex) of the task's line, without its line ending.
212    pub line_hash: String,
213}
214
215/// Which tasks can start now, and what blocks the rest.
216#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
217pub struct Schedule {
218    /// One entry per task, in document order.
219    pub tasks: Vec<ScheduledTask>,
220    /// Counts over `tasks`.
221    pub totals: ScheduleTotals,
222}
223
224/// A task's dependencies and state.
225#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
226pub struct ScheduledTask {
227    /// Task ID.
228    pub id: String,
229    /// Done, ready or blocked.
230    pub state: TaskState,
231    /// Direct dependencies.
232    pub depends_on: Vec<Dependency>,
233    /// Open, missing or cyclic dependencies, in document order.
234    pub blocked_by: Vec<String>,
235}
236
237/// Whether a task can start.
238#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, schemars::JsonSchema)]
239#[serde(rename_all = "snake_case")]
240pub enum TaskState {
241    /// Ticked in `tasks.md`.
242    Done,
243    /// Open, and every dependency is done.
244    Ready,
245    /// Open, and some dependency is open, missing or part of a cycle.
246    Blocked,
247}
248
249/// One direct dependency.
250#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
251pub struct Dependency {
252    /// The task depended on.
253    pub id: String,
254    /// Where the edge comes from.
255    pub kind: DependencyKind,
256    /// The ID does not exist in the task list.
257    pub missing: bool,
258}
259
260/// Origin of a dependency.
261#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, schemars::JsonSchema)]
262#[serde(rename_all = "snake_case")]
263pub enum DependencyKind {
264    /// From phase, subsection or `[P]` structure.
265    Structural,
266    /// Written in the task text.
267    Explicit,
268}
269
270/// Schedule counts.
271#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, schemars::JsonSchema)]
272pub struct ScheduleTotals {
273    /// Ticked tasks.
274    pub done: usize,
275    /// Tasks that can start now.
276    pub ready: usize,
277    /// Tasks waiting on something.
278    pub blocked: usize,
279}
280
281/// Task counts.
282#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, schemars::JsonSchema)]
283pub struct Totals {
284    /// All tasks.
285    pub total: usize,
286    /// Ticked tasks.
287    pub done: usize,
288    /// Unticked tasks.
289    pub open: usize,
290}
291
292/// A problem found while discovering or parsing.
293#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
294pub struct Diagnostic {
295    /// How serious it is.
296    pub severity: Severity,
297    /// Stable code, e.g. `tasks.duplicate-id`.
298    pub code: String,
299    /// Workspace-relative file, if the problem is in one.
300    pub path: Option<String>,
301    /// 1-based line, if known.
302    pub line: Option<u32>,
303    /// What is wrong.
304    pub message: String,
305    /// What to do about it.
306    pub hint: Option<String>,
307}
308
309impl Diagnostic {
310    /// A diagnostic with no location or hint.
311    #[must_use]
312    pub fn new(severity: Severity, code: &str, message: impl Into<String>) -> Self {
313        Self {
314            severity,
315            code: code.to_owned(),
316            path: None,
317            line: None,
318            message: message.into(),
319            hint: None,
320        }
321    }
322
323    /// Sets the file.
324    #[must_use]
325    pub fn at(mut self, path: impl Into<String>) -> Self {
326        self.path = Some(path.into());
327        self
328    }
329
330    /// Sets the line.
331    #[must_use]
332    pub fn line(mut self, line: u32) -> Self {
333        self.line = Some(line);
334        self
335    }
336
337    /// Sets the hint.
338    #[must_use]
339    pub fn hint(mut self, hint: impl Into<String>) -> Self {
340        self.hint = Some(hint.into());
341        self
342    }
343}
344
345/// Diagnostic severity.
346#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, schemars::JsonSchema)]
347#[serde(rename_all = "snake_case")]
348pub enum Severity {
349    /// Unsafe to act on.
350    Error,
351    /// Suspicious but usable.
352    Warning,
353    /// For information.
354    Info,
355}
356
357/// The feature whose name is nearest to `name` (edit distance over the
358/// part after the numeric prefix), if any is reasonably near.
359#[must_use]
360pub fn closest<'f>(features: &'f [FeatureRef], name: &str) -> Option<&'f FeatureRef> {
361    let slug = |s: &str| {
362        s.rsplit('/')
363            .next()
364            .unwrap_or(s)
365            .trim_start_matches(|c: char| c.is_ascii_digit() || c == '-')
366            .to_lowercase()
367    };
368    let target = slug(name);
369    features
370        .iter()
371        .map(|f| (distance(&slug(&f.name), &target), f))
372        .filter(|(d, f)| *d <= slug(&f.name).len().max(target.len()) / 2)
373        .min_by_key(|(d, _)| *d)
374        .map(|(_, f)| f)
375}
376
377/// Levenshtein distance.
378fn distance(a: &str, b: &str) -> usize {
379    let b: Vec<char> = b.chars().collect();
380    let mut row: Vec<usize> = (0..=b.len()).collect();
381    for (i, ca) in a.chars().enumerate() {
382        let mut prev = row[0];
383        row[0] = i + 1;
384        for (j, cb) in b.iter().enumerate() {
385            let next = (prev + usize::from(ca != *cb))
386                .min(row[j] + 1)
387                .min(row[j + 1] + 1);
388            prev = row[j + 1];
389            row[j + 1] = next;
390        }
391    }
392    row[b.len()]
393}