1pub mod checklists;
8pub mod features;
9mod hash;
10mod metadata;
11pub mod stories;
12pub mod tasks;
13pub mod workspace;
14
15use std::path::Path;
16
17pub use hash::sha256_hex;
18
19use speccoder_core::snapshot::{
20 Artifacts, Diagnostic, FeatureRef, FeatureSnapshot, ProjectSnapshot, Severity,
21};
22
23use crate::workspace::{ReadError, Workspace};
24
25#[derive(Debug, Clone, PartialEq, Eq)]
27pub struct NotAProject;
28
29pub fn inspect(start: &Path, feature: Option<&str>) -> Result<ProjectSnapshot, NotAProject> {
32 inspect_with(start, feature, None)
33}
34
35pub fn inspect_with(
38 start: &Path,
39 feature: Option<&str>,
40 feature_directory_env: Option<&str>,
41) -> Result<ProjectSnapshot, NotAProject> {
42 let ws = workspace::find_root(start).ok_or(NotAProject)?;
43 let mut diags = Vec::new();
44
45 let speckit = metadata::read(&ws, &mut diags);
46 let constitution = metadata::constitution(&ws, &mut diags);
47 let features = list_features(&ws, &mut diags);
48 let selection = features::select(
49 &features,
50 feature,
51 feature_directory_env,
52 speckit.current_feature.as_deref(),
53 &mut diags,
54 );
55 let feature = selection
56 .feature
57 .as_deref()
58 .and_then(|name| features.iter().find(|f| f.name == name))
59 .map(|f| feature_snapshot(&ws, f, &mut diags));
60
61 Ok(ProjectSnapshot {
62 root: ws.root().display().to_string(),
63 speckit,
64 constitution,
65 features,
66 selection,
67 feature,
68 diagnostics: diags,
69 format: speccoder_core::spec::FormatKind::Speckit,
70 openspec: None,
71 })
72}
73
74fn list_features(ws: &Workspace, diags: &mut Vec<Diagnostic>) -> Vec<FeatureRef> {
75 match ws.list_dirs(features::SPECS) {
76 Ok(listing) => {
77 for name in &listing.escaping {
78 diags.push(metadata::read_error(
79 &format!("{}/{name}", features::SPECS),
80 &ReadError::Escape,
81 ));
82 }
83 features::collect(&listing.inside, diags)
84 }
85 Err(ReadError::Missing) => {
86 diags.push(
87 Diagnostic::new(
88 Severity::Warning,
89 "discover.no-specs-dir",
90 "no specs/ directory",
91 )
92 .hint("speccoder spec specify \"…\" creates the first feature (/speckit-specify in your editor)"),
93 );
94 Vec::new()
95 }
96 Err(e) => {
97 diags.push(metadata::read_error(features::SPECS, &e));
98 Vec::new()
99 }
100 }
101}
102
103fn feature_snapshot(
104 ws: &Workspace,
105 f: &FeatureRef,
106 diags: &mut Vec<Diagnostic>,
107) -> FeatureSnapshot {
108 let p = |name: &str| format!("{}/{name}", f.dir);
109 let tasks_path = p("tasks.md");
110 let artifacts = Artifacts {
111 spec: ws.is_file(&p("spec.md")),
112 plan: ws.is_file(&p("plan.md")),
113 tasks: ws.is_file(&tasks_path),
114 research: ws.is_file(&p("research.md")),
115 data_model: ws.is_file(&p("data-model.md")),
116 contracts: ws.is_dir(&p("contracts")),
117 quickstart: ws.is_file(&p("quickstart.md")),
118 checklists: ws.is_dir(&p("checklists")),
119 };
120 let tasks = match ws.read_text(&tasks_path) {
121 Ok(text) => {
122 let (parsed, mut found) = tasks::parse(&tasks_path, &text);
123 diags.append(&mut found);
124 Some(parsed)
125 }
126 Err(ReadError::Missing) => {
127 diags.push(
128 Diagnostic::new(Severity::Warning, "tasks.missing", "no tasks.md yet")
129 .at(&tasks_path)
130 .hint("speccoder spec plan, then speccoder spec tasks (/speckit-plan, /speckit-tasks in your editor)"),
131 );
132 None
133 }
134 Err(e) => {
135 diags.push(metadata::read_error(&tasks_path, &e));
136 None
137 }
138 };
139 let schedule = tasks.as_ref().map(|t| {
140 let (schedule, mut found) = speccoder_core::graph::schedule(t);
141 diags.append(&mut found);
142 schedule
143 });
144 FeatureSnapshot {
145 feature: f.clone(),
146 artifacts,
147 tasks,
148 schedule,
149 }
150}
151
152pub fn mark_task_complete(
160 tasks_path: &Path,
161 task_id: &str,
162 expected_line_hash: &str,
163) -> Result<(), String> {
164 let text = std::fs::read_to_string(tasks_path)
165 .map_err(|e| format!("{}: {e}", tasks_path.display()))?;
166 let (parsed, _) = tasks::parse("tasks.md", &text);
167 let task = parsed
168 .tasks
169 .iter()
170 .find(|t| t.id == task_id)
171 .ok_or_else(|| format!("{task_id} is not in {}", tasks_path.display()))?;
172 if task.done {
173 return Err(format!("{task_id} is already ticked"));
174 }
175 if task.line_hash != expected_line_hash {
176 return Err(format!(
177 "{task_id}'s line changed since the attempt began; not ticked"
178 ));
179 }
180 let [start, end] = task.byte_range;
181 let item = &text[start..end.min(text.len())];
182 let open = item
183 .find("[ ]")
184 .ok_or_else(|| format!("{task_id} has no `[ ]` checkbox"))?;
185 let at = start + open + 1;
186 let mut bytes = text.into_bytes();
187 bytes[at] = b'X';
188 let tmp = tasks_path.with_extension("md.speccoder-tick");
189 std::fs::write(&tmp, &bytes).map_err(|e| format!("{}: {e}", tmp.display()))?;
190 std::fs::rename(&tmp, tasks_path).map_err(|e| format!("{}: {e}", tasks_path.display()))
191}
192
193pub fn append_convergence(
202 tasks_path: &Path,
203 expected_file_hash: &str,
204 items: &[String],
205) -> Result<Vec<String>, String> {
206 if items.is_empty() {
207 return Ok(Vec::new());
208 }
209 let text = std::fs::read_to_string(tasks_path)
210 .map_err(|e| format!("{}: {e}", tasks_path.display()))?;
211 if hash::sha256_hex(text.as_bytes()) != expected_file_hash {
212 return Err(format!(
213 "{} changed since convergence began; nothing appended",
214 tasks_path.display()
215 ));
216 }
217 let (parsed, _) = tasks::parse("tasks.md", &text);
218 let max_id = parsed
219 .tasks
220 .iter()
221 .filter_map(|t| t.id.strip_prefix('T').and_then(|n| n.parse::<u32>().ok()))
222 .max()
223 .unwrap_or(0);
224 let phase = parsed
225 .phases
226 .iter()
227 .filter_map(|p| {
228 p.title
229 .strip_prefix("Phase ")
230 .and_then(|r| r.split(|c: char| !c.is_ascii_digit()).next())
231 .and_then(|n| n.parse::<u32>().ok())
232 })
233 .max()
234 .unwrap_or(0)
235 + 1;
236 let mut section = String::new();
237 if !text.is_empty() && !text.ends_with('\n') {
238 section.push('\n');
239 }
240 section.push_str(&format!("\n## Phase {phase}: Convergence\n\n"));
241 let mut ids = Vec::new();
242 for (i, item) in items.iter().enumerate() {
243 let id = format!(
244 "T{:03}",
245 max_id + 1 + u32::try_from(i).unwrap_or(u32::MAX - max_id - 1)
246 );
247 let line = item.split_whitespace().collect::<Vec<_>>().join(" ");
249 let line = line.trim_start_matches(['-', '*', ' ']);
250 let line = line.strip_prefix("[ ]").unwrap_or(line).trim();
251 section.push_str(&format!("- [ ] {id} {line}\n"));
252 ids.push(id);
253 }
254 let new = format!("{text}{section}");
255 let (reparsed, _) = tasks::parse("tasks.md", &new);
257 let same = parsed.tasks.iter().all(|old| {
258 reparsed
259 .tasks
260 .iter()
261 .any(|t| t.id == old.id && t.line_hash == old.line_hash && t.done == old.done)
262 });
263 let added = ids
264 .iter()
265 .all(|id| reparsed.tasks.iter().any(|t| &t.id == id && !t.done));
266 if !new.starts_with(&text) || !same || !added {
267 return Err(
268 "the convergence section would change existing tasks; nothing appended".to_owned(),
269 );
270 }
271 let tmp = tasks_path.with_extension("md.speccoder-append");
272 std::fs::write(&tmp, new.as_bytes()).map_err(|e| format!("{}: {e}", tmp.display()))?;
273 std::fs::rename(&tmp, tasks_path).map_err(|e| format!("{}: {e}", tasks_path.display()))?;
274 Ok(ids)
275}