1use serde::Serialize;
9
10#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
12pub struct ProjectSnapshot {
13 pub root: String,
15 pub speckit: SpeckitMeta,
17 pub constitution: ConstitutionState,
19 pub features: Vec<FeatureRef>,
21 pub selection: Selection,
23 pub feature: Option<FeatureSnapshot>,
25 pub diagnostics: Vec<Diagnostic>,
27 #[serde(skip_serializing_if = "crate::spec::FormatKind::is_reference")]
29 pub format: crate::spec::FormatKind,
30 #[serde(skip_serializing_if = "Option::is_none")]
33 pub openspec: Option<crate::spec::OpenSpecMeta>,
34}
35
36impl ProjectSnapshot {
37 #[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#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, schemars::JsonSchema)]
48pub struct SpeckitMeta {
49 pub version: Option<String>,
51 pub integration: Option<String>,
53 pub script: Option<String>,
55 pub feature_numbering: Option<String>,
57 pub current_feature: Option<String>,
59}
60
61#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, schemars::JsonSchema)]
63#[serde(rename_all = "snake_case")]
64pub enum ConstitutionState {
65 Present,
67 Unfilled,
69 Absent,
71}
72
73#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
75pub struct FeatureRef {
76 pub dir: String,
78 pub name: String,
80 pub prefix: String,
82}
83
84#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
86pub struct Selection {
87 pub reason: SelectionReason,
89 pub feature: Option<String>,
91}
92
93#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, schemars::JsonSchema)]
95#[serde(rename_all = "snake_case")]
96pub enum SelectionReason {
97 Argument,
99 Environment,
101 Recorded,
103 OnlyFeature,
105 None,
107}
108
109#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
111pub struct FeatureSnapshot {
112 pub feature: FeatureRef,
114 pub artifacts: Artifacts,
116 pub tasks: Option<ParsedTasks>,
118 pub schedule: Option<Schedule>,
120}
121
122#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, schemars::JsonSchema)]
124#[allow(clippy::struct_excessive_bools)] pub struct Artifacts {
126 pub spec: bool,
128 pub plan: bool,
130 pub tasks: bool,
132 pub research: bool,
134 pub data_model: bool,
136 pub contracts: bool,
138 pub quickstart: bool,
140 pub checklists: bool,
142}
143
144#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
146pub struct ParsedTasks {
147 pub path: String,
149 pub phases: Vec<Phase>,
151 pub tasks: Vec<Task>,
153 pub totals: Totals,
155 pub dependency_mode: DependencyMode,
157}
158
159#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, schemars::JsonSchema)]
161#[serde(rename_all = "snake_case")]
162pub enum DependencyMode {
163 #[default]
165 Structural,
166 Declared,
169 Chain,
172}
173
174#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
176pub struct Phase {
177 pub title: String,
179 pub line: u32,
181 pub section_hash: String,
183}
184
185#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
187pub struct Task {
188 pub id: String,
190 pub done: bool,
192 pub parallel: bool,
194 pub story: Option<String>,
196 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
198 pub yours: bool,
199 pub title: String,
201 pub depends_on: Vec<String>,
203 #[serde(default, skip_serializing_if = "Vec::is_empty")]
206 pub scenarios: Vec<String>,
207 pub phase: Option<String>,
209 pub subsection: Option<String>,
211 pub line: u32,
213 pub byte_range: [usize; 2],
215 pub line_hash: String,
217}
218
219#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
221pub struct Schedule {
222 pub tasks: Vec<ScheduledTask>,
224 pub totals: ScheduleTotals,
226}
227
228#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
230pub struct ScheduledTask {
231 pub id: String,
233 pub state: TaskState,
235 pub depends_on: Vec<Dependency>,
237 pub blocked_by: Vec<String>,
239}
240
241#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, schemars::JsonSchema)]
243#[serde(rename_all = "snake_case")]
244pub enum TaskState {
245 Done,
247 Ready,
249 Blocked,
251}
252
253#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
255pub struct Dependency {
256 pub id: String,
258 pub kind: DependencyKind,
260 pub missing: bool,
262}
263
264#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, schemars::JsonSchema)]
266#[serde(rename_all = "snake_case")]
267pub enum DependencyKind {
268 Structural,
270 Explicit,
272}
273
274#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, schemars::JsonSchema)]
276pub struct ScheduleTotals {
277 pub done: usize,
279 pub ready: usize,
281 pub blocked: usize,
283}
284
285#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, schemars::JsonSchema)]
287pub struct Totals {
288 pub total: usize,
290 pub done: usize,
292 pub open: usize,
294}
295
296#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
298pub struct Diagnostic {
299 pub severity: Severity,
301 pub code: String,
303 pub path: Option<String>,
305 pub line: Option<u32>,
307 pub message: String,
309 pub hint: Option<String>,
311}
312
313impl Diagnostic {
314 #[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 #[must_use]
329 pub fn at(mut self, path: impl Into<String>) -> Self {
330 self.path = Some(path.into());
331 self
332 }
333
334 #[must_use]
336 pub fn line(mut self, line: u32) -> Self {
337 self.line = Some(line);
338 self
339 }
340
341 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, schemars::JsonSchema)]
351#[serde(rename_all = "snake_case")]
352pub enum Severity {
353 Error,
355 Warning,
357 Info,
359}
360
361#[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
381fn 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}