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