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 pub phase: Option<String>,
205 pub subsection: Option<String>,
207 pub line: u32,
209 pub byte_range: [usize; 2],
211 pub line_hash: String,
213}
214
215#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
217pub struct Schedule {
218 pub tasks: Vec<ScheduledTask>,
220 pub totals: ScheduleTotals,
222}
223
224#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
226pub struct ScheduledTask {
227 pub id: String,
229 pub state: TaskState,
231 pub depends_on: Vec<Dependency>,
233 pub blocked_by: Vec<String>,
235}
236
237#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, schemars::JsonSchema)]
239#[serde(rename_all = "snake_case")]
240pub enum TaskState {
241 Done,
243 Ready,
245 Blocked,
247}
248
249#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
251pub struct Dependency {
252 pub id: String,
254 pub kind: DependencyKind,
256 pub missing: bool,
258}
259
260#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, schemars::JsonSchema)]
262#[serde(rename_all = "snake_case")]
263pub enum DependencyKind {
264 Structural,
266 Explicit,
268}
269
270#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, schemars::JsonSchema)]
272pub struct ScheduleTotals {
273 pub done: usize,
275 pub ready: usize,
277 pub blocked: usize,
279}
280
281#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, schemars::JsonSchema)]
283pub struct Totals {
284 pub total: usize,
286 pub done: usize,
288 pub open: usize,
290}
291
292#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
294pub struct Diagnostic {
295 pub severity: Severity,
297 pub code: String,
299 pub path: Option<String>,
301 pub line: Option<u32>,
303 pub message: String,
305 pub hint: Option<String>,
307}
308
309impl Diagnostic {
310 #[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 #[must_use]
325 pub fn at(mut self, path: impl Into<String>) -> Self {
326 self.path = Some(path.into());
327 self
328 }
329
330 #[must_use]
332 pub fn line(mut self, line: u32) -> Self {
333 self.line = Some(line);
334 self
335 }
336
337 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, schemars::JsonSchema)]
347#[serde(rename_all = "snake_case")]
348pub enum Severity {
349 Error,
351 Warning,
353 Info,
355}
356
357#[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
377fn 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}