1pub mod crosscheck;
10pub mod delta;
11pub mod discover;
12mod hash;
13pub mod preflight;
14pub mod tasks;
15pub mod write;
16
17use std::fs;
18use std::path::{Path, PathBuf};
19
20use speccoder_core::snapshot::{
21 Artifacts, ConstitutionState, Diagnostic, FeatureRef, FeatureSnapshot, ProjectSnapshot,
22 Selection, SelectionReason, Severity, SpeckitMeta,
23};
24use speccoder_core::spec::{DeltaRequirement, FormatKind, OpenSpecMeta};
25
26pub use hash::sha256_hex;
27
28#[derive(Debug, Clone, PartialEq, Eq)]
30pub struct NotAProject;
31
32#[derive(Debug, Clone, PartialEq, Eq)]
34pub struct Change {
35 pub id: String,
37 pub dir: String,
39 pub schema: String,
41 pub proposal: Option<String>,
43 pub design: Option<String>,
45 pub deltas: Vec<delta::Delta>,
47}
48
49#[must_use]
51pub fn change_dir(id: &str) -> String {
52 format!("{}/changes/{id}", discover::DIR)
53}
54
55#[must_use]
57pub fn load_change(root: &Path, id: &str, project_schema: &str) -> (Change, Vec<Diagnostic>) {
58 let dir = change_dir(id);
59 let abs = root.join(&dir);
60 let read = |name: &str| fs::read_to_string(abs.join(name)).ok();
61 let mut diags = Vec::new();
62 let deltas = discover::spec_files(&abs.join("specs"))
63 .into_iter()
64 .filter_map(|(cap, path)| {
65 let text = fs::read_to_string(&path).ok()?;
66 let rel = format!("{dir}/specs/{cap}/spec.md");
67 let (d, mut found) = delta::parse(&cap, &rel, &text);
68 diags.append(&mut found);
69 Some(d)
70 })
71 .collect();
72 (
73 Change {
74 id: id.to_owned(),
75 schema: discover::change_schema(&abs).unwrap_or_else(|| project_schema.to_owned()),
76 proposal: read("proposal.md"),
77 design: read("design.md"),
78 dir,
79 deltas,
80 },
81 diags,
82 )
83}
84
85#[must_use]
87pub fn main_specs(
88 root: &Path,
89) -> std::collections::BTreeMap<String, std::collections::BTreeSet<String>> {
90 let files: Vec<(String, String)> =
91 discover::spec_files(&root.join(discover::DIR).join("specs"))
92 .into_iter()
93 .filter_map(|(cap, p)| fs::read_to_string(p).ok().map(|t| (cap, t)))
94 .collect();
95 delta::main_requirements(&files)
96}
97
98pub fn inspect(start: &Path, change: Option<&str>) -> Result<ProjectSnapshot, NotAProject> {
102 let root: PathBuf = discover::find_root(start).ok_or(NotAProject)?;
103 let mut diags = Vec::new();
104 let config = discover::config(&root, &mut diags);
105 preflight::project(&root, &config, &mut diags);
106 let ids = discover::changes(&root);
107 let features: Vec<FeatureRef> = ids
108 .iter()
109 .map(|id| FeatureRef {
110 dir: change_dir(id),
111 name: id.clone(),
112 prefix: String::new(),
113 })
114 .collect();
115
116 let selection = select(&ids, change, &mut diags);
117 let mut requirements = Vec::new();
118 let feature = selection.feature.as_deref().map(|id| {
119 let (c, mut found) = load_change(&root, id, &config.schema);
120 diags.append(&mut found);
121 let tasks_rel = format!("{}/tasks.md", c.dir);
122 let tasks = match fs::read_to_string(root.join(&tasks_rel)) {
123 Ok(text) => {
124 let (parsed, mut found) = tasks::parse(&tasks_rel, &text);
125 diags.append(&mut found);
126 Some(parsed)
127 }
128 Err(_) => {
129 diags.push(
130 Diagnostic::new(Severity::Error, "no_tasks", "the change has no tasks.md")
131 .at(&tasks_rel)
132 .hint("create it with the change's propose skill"),
133 );
134 None
135 }
136 };
137 let schedule = tasks.as_ref().map(|t| {
138 let (s, mut found) = speccoder_core::graph::schedule(t);
139 diags.append(&mut found);
140 s
141 });
142 preflight::change(&c, &main_specs(&root), &mut diags);
143 requirements = requirements_of(&c);
144 let abs = root.join(&c.dir);
145 FeatureSnapshot {
146 feature: FeatureRef {
147 dir: c.dir.clone(),
148 name: c.id.clone(),
149 prefix: String::new(),
150 },
151 artifacts: Artifacts {
152 spec: !c.deltas.is_empty(),
153 plan: abs.join("design.md").is_file(),
154 tasks: tasks.is_some(),
155 research: abs.join("proposal.md").is_file(),
156 ..Artifacts::default()
157 },
158 tasks,
159 schedule,
160 }
161 });
162
163 Ok(ProjectSnapshot {
164 root: root.display().to_string(),
165 speckit: SpeckitMeta::default(),
166 constitution: if config.context.is_some() {
167 ConstitutionState::Present
168 } else {
169 ConstitutionState::Absent
170 },
171 features,
172 selection,
173 feature,
174 diagnostics: diags,
175 format: FormatKind::Openspec,
176 openspec: Some(OpenSpecMeta {
177 schema: config.schema,
178 context: config.context,
179 unit_kind: "change".to_owned(),
180 requirements,
181 }),
182 })
183}
184
185fn select(ids: &[String], wanted: Option<&str>, diags: &mut Vec<Diagnostic>) -> Selection {
186 let none = Selection {
187 reason: SelectionReason::None,
188 feature: None,
189 };
190 match wanted {
191 Some(w) => {
192 if ids.iter().any(|i| i == w) {
193 Selection {
194 reason: SelectionReason::Argument,
195 feature: Some(w.to_owned()),
196 }
197 } else {
198 diags.push(
199 Diagnostic::new(
200 Severity::Error,
201 "select.not-found",
202 format!("no active change `{w}`"),
203 )
204 .hint(if ids.is_empty() {
205 "there are no active changes under openspec/changes/".to_owned()
206 } else {
207 format!("active changes: {}", ids.join(", "))
208 }),
209 );
210 none
211 }
212 }
213 None => match ids {
214 [only] => Selection {
215 reason: SelectionReason::OnlyFeature,
216 feature: Some(only.clone()),
217 },
218 [] => {
219 diags.push(
220 Diagnostic::new(
221 Severity::Warning,
222 "select.no-features",
223 "no active changes under openspec/changes/",
224 )
225 .hint("propose one with `speccoder spec propose`"),
226 );
227 none
228 }
229 many => {
230 diags.push(
231 Diagnostic::new(
232 Severity::Error,
233 "select.ambiguous",
234 format!("{} active changes: {}", many.len(), many.join(", ")),
235 )
236 .hint("choose one with --change <id>"),
237 );
238 none
239 }
240 },
241 }
242}
243
244fn requirements_of(c: &Change) -> Vec<DeltaRequirement> {
246 let mut out = Vec::new();
247 for d in &c.deltas {
248 for r in &d.requirements {
249 out.push(DeltaRequirement {
250 capability: d.capability.clone(),
251 operation: r.operation.name().to_owned(),
252 name: r.name.clone(),
253 scenarios: r.scenarios.iter().map(|s| s.name.clone()).collect(),
254 path: d.path.clone(),
255 line: r.line,
256 });
257 }
258 for n in &d.renames {
259 out.push(DeltaRequirement {
260 capability: d.capability.clone(),
261 operation: "renamed".to_owned(),
262 name: n.to.clone(),
263 scenarios: Vec::new(),
264 path: d.path.clone(),
265 line: n.line,
266 });
267 }
268 }
269 out
270}