1use crate::config::RepoConfig;
12use crate::id::SpecId;
13use crate::spec::{Spec, SpecStatus};
14use anyhow::{anyhow, Context, Result};
15use std::collections::HashSet;
16use std::path::{Path, PathBuf};
17
18pub fn resolve_dependency(
33 dep_id: &str,
34 current_repo_specs_dir: &Path,
35 repos: &[RepoConfig],
36) -> Result<Spec> {
37 let parsed_id = SpecId::parse(dep_id)?;
38
39 if let Some(repo_name) = &parsed_id.repo {
40 resolve_cross_repo_dependency(repo_name, dep_id, repos)
42 } else {
43 resolve_local_dependency(dep_id, current_repo_specs_dir)
45 }
46}
47
48fn resolve_local_dependency(dep_id: &str, specs_dir: &Path) -> Result<Spec> {
50 let spec_path = specs_dir.join(format!("{}.md", dep_id));
52 if spec_path.exists() {
53 return Spec::load(&spec_path);
54 }
55
56 let archive_dir = specs_dir
58 .parent()
59 .ok_or_else(|| anyhow!("Cannot determine archive directory"))?
60 .join("archive");
61
62 if archive_dir.exists() {
63 let direct_path = archive_dir.join(format!("{}.md", dep_id));
65 if direct_path.exists() {
66 return Spec::load(&direct_path);
67 }
68
69 for entry in std::fs::read_dir(&archive_dir).context("Failed to read archive directory")? {
71 let entry = entry?;
72 let path = entry.path();
73 if path.is_dir() {
74 let spec_path = path.join(format!("{}.md", dep_id));
75 if spec_path.exists() {
76 return Spec::load(&spec_path);
77 }
78 }
79 }
80 }
81
82 Err(anyhow!(
83 "Spec not found: {} in {}",
84 dep_id,
85 specs_dir.display()
86 ))
87}
88
89fn resolve_cross_repo_dependency(
91 repo_name: &str,
92 spec_id: &str,
93 repos: &[RepoConfig],
94) -> Result<Spec> {
95 let repo = repos
97 .iter()
98 .find(|r| r.name == repo_name)
99 .ok_or_else(|| {
100 anyhow!(
101 "Repository '{}' not found in config. Add it to ~/.config/chant/config.md:\n\nrepos:\n - name: {}\n path: /path/to/{}",
102 repo_name, repo_name, repo_name
103 )
104 })?;
105
106 let repo_path = PathBuf::from(shellexpand::tilde(&repo.path).to_string());
107
108 if !repo_path.exists() {
109 return Err(anyhow!(
110 "Repository path '{}' does not exist for repo '{}'",
111 repo_path.display(),
112 repo_name
113 ));
114 }
115
116 let specs_dir = repo_path.join(".chant/specs");
117
118 if !specs_dir.exists() {
119 return Err(anyhow!(
120 "Specs directory '{}' does not exist for repo '{}'",
121 specs_dir.display(),
122 repo_name
123 ));
124 }
125
126 let parsed_id =
128 SpecId::parse(spec_id).context(format!("Failed to parse spec ID: {}", spec_id))?;
129 let base_spec_id = parsed_id.to_string();
130 let spec_path = specs_dir.join(format!("{}.md", base_spec_id));
131
132 if !spec_path.exists() {
133 return Err(anyhow!(
134 "Spec '{}' not found in repository '{}' at {}",
135 base_spec_id,
136 repo_name,
137 specs_dir.display()
138 ));
139 }
140
141 Spec::load(&spec_path)
142}
143
144pub fn check_circular_dependencies(
148 spec_id: &str,
149 all_specs: &[Spec],
150 current_repo_specs_dir: &Path,
151 repos: &[RepoConfig],
152) -> Result<()> {
153 let mut visited = HashSet::new();
154 check_circular_deps_recursive(
155 spec_id,
156 &mut visited,
157 all_specs,
158 current_repo_specs_dir,
159 repos,
160 )
161}
162
163fn check_circular_deps_recursive(
164 spec_id: &str,
165 visited: &mut HashSet<String>,
166 all_specs: &[Spec],
167 current_repo_specs_dir: &Path,
168 repos: &[RepoConfig],
169) -> Result<()> {
170 if visited.contains(spec_id) {
171 return Err(anyhow!(
172 "Circular dependency detected involving spec '{}'",
173 spec_id
174 ));
175 }
176
177 visited.insert(spec_id.to_string());
178
179 let spec = match find_spec_by_id(spec_id, all_specs, current_repo_specs_dir, repos) {
181 Ok(s) => s,
182 Err(_) => {
183 return Ok(());
186 }
187 };
188
189 if let Some(deps) = &spec.frontmatter.depends_on {
191 for dep_id in deps {
192 check_circular_deps_recursive(
193 dep_id,
194 visited,
195 all_specs,
196 current_repo_specs_dir,
197 repos,
198 )?;
199 }
200 }
201
202 visited.remove(spec_id);
203 Ok(())
204}
205
206pub fn find_spec_by_id(
208 spec_id: &str,
209 all_specs: &[Spec],
210 current_repo_specs_dir: &Path,
211 repos: &[RepoConfig],
212) -> Result<Spec> {
213 if let Some(spec) = all_specs.iter().find(|s| s.id == spec_id) {
215 return Ok(spec.clone());
216 }
217
218 resolve_dependency(spec_id, current_repo_specs_dir, repos)
220}
221
222fn is_spec_archived(spec_id: &str, specs_dir: &Path) -> bool {
224 let archive_dir = match specs_dir.parent() {
225 Some(parent) => parent.join("archive"),
226 None => return false,
227 };
228
229 if !archive_dir.exists() {
230 return false;
231 }
232
233 let direct_path = archive_dir.join(format!("{}.md", spec_id));
235 if direct_path.exists() {
236 return true;
237 }
238
239 if let Ok(entries) = std::fs::read_dir(&archive_dir) {
241 for entry in entries.flatten() {
242 let path = entry.path();
243 if path.is_dir() {
244 let spec_path = path.join(format!("{}.md", spec_id));
245 if spec_path.exists() {
246 return true;
247 }
248 }
249 }
250 }
251
252 false
253}
254
255pub fn is_blocked_by_dependencies(
257 spec: &Spec,
258 all_specs: &[Spec],
259 current_repo_specs_dir: &Path,
260 repos: &[RepoConfig],
261) -> bool {
262 if let Some(deps) = &spec.frontmatter.depends_on {
263 for dep_id in deps {
264 match find_spec_by_id(dep_id, all_specs, current_repo_specs_dir, repos) {
265 Ok(dep_spec) => {
266 if dep_spec.frontmatter.status == SpecStatus::Completed {
268 continue;
269 }
270 if is_spec_archived(dep_id, current_repo_specs_dir) {
272 continue;
273 }
274 return true; }
276 _ => return true, }
278 }
279 }
280 false
281}
282
283#[cfg(test)]
284mod tests {
285 use super::*;
286 use std::fs;
287 use tempfile::TempDir;
288
289 #[test]
290 fn test_resolve_local_dependency() {
291 let temp_dir = TempDir::new().unwrap();
292 let specs_dir = temp_dir.path().join("specs");
293 fs::create_dir_all(&specs_dir).unwrap();
294
295 let spec = Spec {
297 id: "2026-01-27-001-abc".to_string(),
298 frontmatter: Default::default(),
299 title: Some("Test".to_string()),
300 body: "# Test\n\nBody.".to_string(),
301 };
302 spec.save(&specs_dir.join("2026-01-27-001-abc.md")).unwrap();
303
304 let resolved = resolve_local_dependency("2026-01-27-001-abc", &specs_dir).unwrap();
306 assert_eq!(resolved.id, "2026-01-27-001-abc");
307 }
308
309 #[test]
310 fn test_resolve_nonexistent_local_dependency() {
311 let temp_dir = TempDir::new().unwrap();
312 let specs_dir = temp_dir.path().join("specs");
313 fs::create_dir_all(&specs_dir).unwrap();
314
315 let result = resolve_local_dependency("2026-01-27-001-xyz", &specs_dir);
316 assert!(result.is_err());
317 }
318
319 #[test]
320 fn test_cross_repo_dependency_repo_not_configured() {
321 let temp_dir = TempDir::new().unwrap();
322 let specs_dir = temp_dir.path().join("specs");
323 fs::create_dir_all(&specs_dir).unwrap();
324
325 let result = resolve_cross_repo_dependency("backend", "backend:2026-01-27-001-abc", &[]);
326 assert!(result.is_err());
327 assert!(result
328 .unwrap_err()
329 .to_string()
330 .contains("not found in config"));
331 }
332
333 #[test]
334 fn test_cross_repo_dependency_path_not_exists() {
335 let repos = vec![RepoConfig {
336 name: "backend".to_string(),
337 path: "/nonexistent/path".to_string(),
338 }];
339
340 let result = resolve_cross_repo_dependency("backend", "backend:2026-01-27-001-abc", &repos);
341 assert!(result.is_err());
342 assert!(result.unwrap_err().to_string().contains("does not exist"));
343 }
344
345 #[test]
346 fn test_check_circular_dependencies_simple() {
347 let temp_dir = TempDir::new().unwrap();
348 let specs_dir = temp_dir.path().join("specs");
349 fs::create_dir_all(&specs_dir).unwrap();
350
351 let spec = Spec {
352 id: "2026-01-27-001-abc".to_string(),
353 frontmatter: crate::spec::SpecFrontmatter {
354 depends_on: Some(vec!["2026-01-27-001-abc".to_string()]),
355 ..Default::default()
356 },
357 title: Some("Test".to_string()),
358 body: "# Test\n\nBody.".to_string(),
359 };
360 spec.save(&specs_dir.join("2026-01-27-001-abc.md")).unwrap();
361
362 let result = check_circular_dependencies("2026-01-27-001-abc", &[spec], &specs_dir, &[]);
363 assert!(result.is_err());
364 assert!(result
365 .unwrap_err()
366 .to_string()
367 .contains("Circular dependency"));
368 }
369
370 #[test]
371 fn test_is_blocked_by_dependencies_unmet() {
372 let spec_with_dep = Spec {
373 id: "2026-01-27-001-abc".to_string(),
374 frontmatter: crate::spec::SpecFrontmatter {
375 depends_on: Some(vec!["2026-01-27-002-def".to_string()]),
376 ..Default::default()
377 },
378 title: Some("Test".to_string()),
379 body: "# Test\n\nBody.".to_string(),
380 };
381
382 let dependency = Spec {
383 id: "2026-01-27-002-def".to_string(),
384 frontmatter: crate::spec::SpecFrontmatter {
385 status: SpecStatus::Pending,
386 ..Default::default()
387 },
388 title: Some("Dep".to_string()),
389 body: "# Dep\n\nBody.".to_string(),
390 };
391
392 let temp_dir = TempDir::new().unwrap();
393 let specs_dir = temp_dir.path().join("specs");
394 fs::create_dir_all(&specs_dir).unwrap();
395
396 let is_blocked = is_blocked_by_dependencies(&spec_with_dep, &[dependency], &specs_dir, &[]);
397 assert!(is_blocked);
398 }
399
400 #[test]
401 fn test_is_blocked_by_dependencies_met() {
402 let spec_with_dep = Spec {
403 id: "2026-01-27-001-abc".to_string(),
404 frontmatter: crate::spec::SpecFrontmatter {
405 depends_on: Some(vec!["2026-01-27-002-def".to_string()]),
406 ..Default::default()
407 },
408 title: Some("Test".to_string()),
409 body: "# Test\n\nBody.".to_string(),
410 };
411
412 let dependency = Spec {
413 id: "2026-01-27-002-def".to_string(),
414 frontmatter: crate::spec::SpecFrontmatter {
415 status: SpecStatus::Completed,
416 ..Default::default()
417 },
418 title: Some("Dep".to_string()),
419 body: "# Dep\n\nBody.".to_string(),
420 };
421
422 let temp_dir = TempDir::new().unwrap();
423 let specs_dir = temp_dir.path().join("specs");
424 fs::create_dir_all(&specs_dir).unwrap();
425
426 let is_blocked = is_blocked_by_dependencies(&spec_with_dep, &[dependency], &specs_dir, &[]);
427 assert!(!is_blocked);
428 }
429
430 #[test]
431 fn test_is_blocked_by_dependencies_archived() {
432 let spec_with_dep = Spec {
433 id: "2026-01-27-001-abc".to_string(),
434 frontmatter: crate::spec::SpecFrontmatter {
435 depends_on: Some(vec!["2026-01-27-002-def".to_string()]),
436 ..Default::default()
437 },
438 title: Some("Test".to_string()),
439 body: "# Test\n\nBody.".to_string(),
440 };
441
442 let archived_dependency = Spec {
444 id: "2026-01-27-002-def".to_string(),
445 frontmatter: crate::spec::SpecFrontmatter {
446 status: SpecStatus::InProgress, ..Default::default()
448 },
449 title: Some("Archived Dep".to_string()),
450 body: "# Archived Dep\n\nBody.".to_string(),
451 };
452
453 let temp_dir = TempDir::new().unwrap();
454 let specs_dir = temp_dir.path().join("specs");
455 let archive_dir = temp_dir.path().join("archive").join("2026-01-27");
456 fs::create_dir_all(&specs_dir).unwrap();
457 fs::create_dir_all(&archive_dir).unwrap();
458
459 archived_dependency
461 .save(&archive_dir.join("2026-01-27-002-def.md"))
462 .unwrap();
463
464 let is_blocked = is_blocked_by_dependencies(&spec_with_dep, &[], &specs_dir, &[]);
466 assert!(!is_blocked);
467 }
468}