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 spec.frontmatter.status = SpecStatus::Blocked;
46 } else if spec.frontmatter.status == SpecStatus::Blocked {
47 spec.frontmatter.status = SpecStatus::Pending;
49 }
50 }
51}
52
53pub fn load_all_specs(specs_dir: &Path) -> Result<Vec<Spec>> {
54 load_all_specs_with_options(specs_dir, true)
55}
56
57pub fn load_all_specs_with_options(
59 specs_dir: &Path,
60 use_branch_resolution: bool,
61) -> Result<Vec<Spec>> {
62 let mut specs = Vec::new();
63
64 if !specs_dir.exists() {
65 return Ok(specs);
66 }
67
68 load_specs_recursive(specs_dir, &mut specs, use_branch_resolution)?;
69
70 apply_blocked_status(&mut specs);
72
73 Ok(specs)
74}
75
76fn load_specs_recursive(
78 dir: &Path,
79 specs: &mut Vec<Spec>,
80 use_branch_resolution: bool,
81) -> Result<()> {
82 if !dir.exists() {
83 return Ok(());
84 }
85
86 for entry in fs::read_dir(dir)? {
87 let entry = entry?;
88 let path = entry.path();
89 let metadata = entry.metadata()?;
90
91 if metadata.is_dir() {
92 load_specs_recursive(&path, specs, use_branch_resolution)?;
94 } else if path.extension().map(|e| e == "md").unwrap_or(false) {
95 let load_result = if use_branch_resolution {
96 Spec::load_with_branch_resolution(&path)
97 } else {
98 Spec::load(&path)
99 };
100
101 match load_result {
102 Ok(spec) => specs.push(spec),
103 Err(e) => {
104 eprintln!("Warning: Failed to load spec {:?}: {}", path, e);
105 }
106 }
107 }
108 }
109
110 Ok(())
111}
112
113pub fn resolve_spec(specs_dir: &Path, partial_id: &str) -> Result<Spec> {
116 let specs = load_all_specs(specs_dir)?;
117
118 if let Some(spec) = specs.iter().find(|s| s.id == partial_id) {
120 return Ok(spec.clone());
121 }
122
123 let suffix_matches: Vec<_> = specs
125 .iter()
126 .filter(|s| s.id.ends_with(partial_id))
127 .collect();
128 if suffix_matches.len() == 1 {
129 return Ok(suffix_matches[0].clone());
130 }
131
132 if partial_id.len() == 3 {
134 let today = chrono::Local::now().format("%Y-%m-%d").to_string();
135 let today_pattern = format!("{}-{}-", today, partial_id);
136 let today_matches: Vec<_> = specs
137 .iter()
138 .filter(|s| s.id.starts_with(&today_pattern))
139 .collect();
140 if today_matches.len() == 1 {
141 return Ok(today_matches[0].clone());
142 }
143 }
144
145 let partial_matches: Vec<_> = specs.iter().filter(|s| s.id.contains(partial_id)).collect();
147 if partial_matches.len() == 1 {
148 return Ok(partial_matches[0].clone());
149 }
150
151 if partial_matches.len() > 1 {
152 anyhow::bail!(
153 "Ambiguous spec ID '{}'. Matches: {}",
154 partial_id,
155 partial_matches
156 .iter()
157 .map(|s| s.id.as_str())
158 .collect::<Vec<_>>()
159 .join(", ")
160 );
161 }
162
163 anyhow::bail!("Spec not found: {}", partial_id)
164}
165
166fn load_spec_from_worktree_or_main(spec_id: &str) -> Result<Spec> {
175 if let Some(worktree_path) = crate::worktree::get_active_worktree(spec_id, None) {
177 let worktree_spec_path = worktree_path
178 .join(".chant/specs")
179 .join(format!("{}.md", spec_id));
180
181 if worktree_spec_path.exists() {
183 return Spec::load(&worktree_spec_path).with_context(|| {
184 format!(
185 "Failed to read spec file from worktree: {}",
186 worktree_spec_path.display()
187 )
188 });
189 }
190 }
191
192 let spec_path = Path::new(".chant/specs").join(format!("{}.md", spec_id));
194 Spec::load(&spec_path)
195 .with_context(|| format!("Failed to read spec file: {}", spec_path.display()))
196}
197
198pub fn is_completed(spec_id: &str) -> Result<bool> {
215 let spec = load_spec_from_worktree_or_main(spec_id)?;
217
218 if spec.frontmatter.status != SpecStatus::InProgress {
220 return Ok(false);
221 }
222
223 let unchecked_count = spec.count_unchecked_checkboxes();
225 if unchecked_count > 0 {
226 return Ok(false);
227 }
228
229 if !has_success_signals(spec_id)? {
232 return Ok(false);
233 }
234
235 is_worktree_clean(spec_id)
237}
238
239pub(crate) fn has_success_signals(spec_id: &str) -> Result<bool> {
248 let pattern = format!("chant({}):", spec_id);
250 let output = Command::new("git")
251 .args(["log", "--all", "--grep", &pattern, "--format=%H"])
252 .output()
253 .context("Failed to check git log for spec commits")?;
254
255 if !output.status.success() {
256 return Ok(false);
257 }
258
259 let commits_output = String::from_utf8_lossy(&output.stdout);
260 let has_commits = !commits_output.trim().is_empty();
261
262 Ok(has_commits)
263}
264
265pub fn is_failed(spec_id: &str) -> Result<bool> {
284 let spec = load_spec_from_worktree_or_main(spec_id)?;
286
287 if spec.frontmatter.status != SpecStatus::InProgress {
289 return Ok(false);
290 }
291
292 let lock_file = Path::new(crate::paths::LOCKS_DIR).join(format!("{}.lock", spec_id));
294 if lock_file.exists() {
295 return Ok(false);
296 }
297
298 let unchecked_count = spec.count_unchecked_checkboxes();
300 if unchecked_count == 0 {
301 return Ok(false);
303 }
304
305 if has_success_signals(spec_id)? {
308 return Ok(false);
309 }
310
311 Ok(true)
313}
314
315fn is_worktree_clean(spec_id: &str) -> Result<bool> {
324 let worktree_path = Path::new("/tmp").join(format!("chant-{}", spec_id));
325
326 let check_path = if worktree_path.exists() {
328 &worktree_path
329 } else {
330 Path::new(".")
331 };
332
333 let output = Command::new("git")
334 .args(["status", "--porcelain"])
335 .current_dir(check_path)
336 .output()
337 .with_context(|| format!("Failed to check git status in {:?}", check_path))?;
338
339 if !output.status.success() {
340 let stderr = String::from_utf8_lossy(&output.stderr);
341 anyhow::bail!("git status failed: {}", stderr);
342 }
343
344 let status_output = String::from_utf8_lossy(&output.stdout);
345 Ok(status_output.trim().is_empty())
346}