1use anyhow::{Context, Result};
4use std::fs;
5use std::path::Path;
6use std::process::Command;
7
8use super::frontmatter::SpecStatus;
9use super::parse::Spec;
10
11fn apply_blocked_status(specs: &mut [Spec]) {
15 apply_blocked_status_with_repos(specs, std::path::Path::new(".chant/specs"), &[]);
16}
17
18pub fn apply_blocked_status_with_repos(
21 specs: &mut [Spec],
22 specs_dir: &std::path::Path,
23 repos: &[crate::config::RepoConfig],
24) {
25 let specs_snapshot = specs.to_vec();
27
28 for spec in specs.iter_mut() {
29 if spec.frontmatter.status != SpecStatus::Pending
31 && spec.frontmatter.status != SpecStatus::Blocked
32 {
33 continue;
34 }
35
36 let is_blocked_locally = spec.is_blocked(&specs_snapshot);
38
39 let is_blocked_cross_repo = !repos.is_empty()
41 && crate::deps::is_blocked_by_dependencies(spec, &specs_snapshot, specs_dir, repos);
42
43 if is_blocked_locally || is_blocked_cross_repo {
44 let _ = spec.set_status(SpecStatus::Blocked);
47 } else if spec.frontmatter.status == SpecStatus::Blocked {
48 let _ = spec.set_status(SpecStatus::Pending);
51 }
52 }
53}
54
55pub fn load_all_specs(specs_dir: &Path) -> Result<Vec<Spec>> {
56 load_all_specs_with_options(specs_dir, true)
57}
58
59pub fn load_all_specs_with_options(
61 specs_dir: &Path,
62 use_branch_resolution: bool,
63) -> Result<Vec<Spec>> {
64 let mut specs = Vec::new();
65
66 if !specs_dir.exists() {
67 return Ok(specs);
68 }
69
70 load_specs_recursive(specs_dir, &mut specs, use_branch_resolution)?;
71
72 apply_blocked_status(&mut specs);
74
75 check_for_orphaned_members(&specs);
77
78 Ok(specs)
79}
80
81fn check_for_orphaned_members(specs: &[Spec]) {
83 use crate::spec_group::extract_driver_id;
84 use std::collections::HashSet;
85
86 let driver_ids: HashSet<String> = specs.iter().map(|s| s.id.clone()).collect();
88
89 for spec in specs {
91 if let Some(driver_id) = extract_driver_id(&spec.id) {
92 if !driver_ids.contains(&driver_id) {
93 eprintln!(
94 "Warning: Orphaned member spec {} — driver {} not found",
95 spec.id, driver_id
96 );
97 }
98 }
99 }
100}
101
102fn load_specs_recursive(
104 dir: &Path,
105 specs: &mut Vec<Spec>,
106 use_branch_resolution: bool,
107) -> Result<()> {
108 if !dir.exists() {
109 return Ok(());
110 }
111
112 for entry in fs::read_dir(dir)? {
113 let entry = entry?;
114 let path = entry.path();
115 let metadata = entry.metadata()?;
116
117 if metadata.is_dir() {
118 load_specs_recursive(&path, specs, use_branch_resolution)?;
120 } else if path.extension().map(|e| e == "md").unwrap_or(false) {
121 let load_result = if use_branch_resolution {
122 Spec::load_with_branch_resolution(&path)
123 } else {
124 Spec::load(&path)
125 };
126
127 match load_result {
128 Ok(spec) => specs.push(spec),
129 Err(e) => {
130 eprintln!("Warning: Failed to load spec {:?}: {}", path, e);
131 }
132 }
133 }
134 }
135
136 Ok(())
137}
138
139pub fn resolve_spec(specs_dir: &Path, partial_id: &str) -> Result<Spec> {
142 let specs = load_all_specs(specs_dir)?;
143
144 if let Some(spec) = specs.iter().find(|s| s.id == partial_id) {
146 return Ok(spec.clone());
147 }
148
149 let suffix_matches: Vec<_> = specs
151 .iter()
152 .filter(|s| s.id.ends_with(partial_id))
153 .collect();
154 if suffix_matches.len() == 1 {
155 return Ok(suffix_matches[0].clone());
156 }
157
158 if partial_id.len() == 3 {
160 let today = chrono::Local::now().format("%Y-%m-%d").to_string();
161 let today_pattern = format!("{}-{}-", today, partial_id);
162 let today_matches: Vec<_> = specs
163 .iter()
164 .filter(|s| s.id.starts_with(&today_pattern))
165 .collect();
166 if today_matches.len() == 1 {
167 return Ok(today_matches[0].clone());
168 }
169 }
170
171 let partial_matches: Vec<_> = specs.iter().filter(|s| s.id.contains(partial_id)).collect();
173 if partial_matches.len() == 1 {
174 return Ok(partial_matches[0].clone());
175 }
176
177 if partial_matches.len() > 1 {
178 anyhow::bail!(
179 "Ambiguous spec ID '{}'. Matches: {}",
180 partial_id,
181 partial_matches
182 .iter()
183 .map(|s| s.id.as_str())
184 .collect::<Vec<_>>()
185 .join(", ")
186 );
187 }
188
189 anyhow::bail!("Spec not found: {}", partial_id)
190}
191
192fn load_spec_from_worktree_or_main(spec_id: &str) -> Result<Spec> {
201 if let Some(worktree_path) = crate::worktree::get_active_worktree(spec_id, None) {
203 let worktree_spec_path = worktree_path
204 .join(".chant/specs")
205 .join(format!("{}.md", spec_id));
206
207 if worktree_spec_path.exists() {
209 return Spec::load(&worktree_spec_path).with_context(|| {
210 format!(
211 "Failed to read spec file from worktree: {}",
212 worktree_spec_path.display()
213 )
214 });
215 }
216 }
217
218 let spec_path = Path::new(".chant/specs").join(format!("{}.md", spec_id));
220 Spec::load(&spec_path)
221 .with_context(|| format!("Failed to read spec file: {}", spec_path.display()))
222}
223
224pub fn is_completed(spec_id: &str) -> Result<bool> {
241 let spec = load_spec_from_worktree_or_main(spec_id)?;
243
244 if spec.frontmatter.status != SpecStatus::InProgress {
246 return Ok(false);
247 }
248
249 let unchecked_count = spec.count_unchecked_checkboxes();
251 if unchecked_count > 0 {
252 return Ok(false);
253 }
254
255 if !has_success_signals(spec_id)? {
258 return Ok(false);
259 }
260
261 is_worktree_clean(spec_id)
263}
264
265pub(crate) fn has_success_signals(spec_id: &str) -> Result<bool> {
274 let pattern = format!("chant({}):", spec_id);
276 let output = Command::new("git")
277 .args(["log", "--all", "--grep", &pattern, "--format=%H"])
278 .output()
279 .context("Failed to check git log for spec commits")?;
280
281 if !output.status.success() {
282 return Ok(false);
283 }
284
285 let commits_output = String::from_utf8_lossy(&output.stdout);
286 let has_commits = !commits_output.trim().is_empty();
287
288 Ok(has_commits)
289}
290
291pub fn is_failed(spec_id: &str) -> Result<bool> {
310 let spec = load_spec_from_worktree_or_main(spec_id)?;
312
313 if spec.frontmatter.status != SpecStatus::InProgress {
315 return Ok(false);
316 }
317
318 if crate::lock::is_locked(spec_id) {
320 return Ok(false);
321 }
322
323 let unchecked_count = spec.count_unchecked_checkboxes();
325 if unchecked_count == 0 {
326 return Ok(false);
328 }
329
330 if has_success_signals(spec_id)? {
333 return Ok(false);
334 }
335
336 Ok(true)
338}
339
340fn is_worktree_clean(spec_id: &str) -> Result<bool> {
349 let worktree_path = Path::new("/tmp").join(format!("chant-{}", spec_id));
350
351 let check_path = if worktree_path.exists() {
353 &worktree_path
354 } else {
355 Path::new(".")
356 };
357
358 let output = Command::new("git")
359 .args(["status", "--porcelain"])
360 .current_dir(check_path)
361 .output()
362 .with_context(|| format!("Failed to check git status in {:?}", check_path))?;
363
364 if !output.status.success() {
365 let stderr = String::from_utf8_lossy(&output.stderr);
366 anyhow::bail!("git status failed: {}", stderr);
367 }
368
369 let status_output = String::from_utf8_lossy(&output.stdout);
370 Ok(status_output.trim().is_empty())
371}