Skip to main content

chant/
deps.rs

1//! Cross-repository dependency resolution for specs.
2//!
3//! Handles resolution of dependencies across multiple repositories using the
4//! `repo:spec-id` syntax in the `depends_on` field.
5//!
6//! # Doc Audit
7//! - audited: 2026-01-27
8//! - docs: concepts/dependencies.md
9//! - ignore: false
10
11use 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
18/// Resolves a spec dependency ID (local or cross-repo) and returns the spec.
19///
20/// Handles both local dependencies (without repo prefix) and cross-repo dependencies
21/// with the `repo:spec-id` format.
22///
23/// # Arguments
24///
25/// * `dep_id` - The dependency ID string (e.g., "2026-01-27-001-abc" or "backend:2026-01-27-001-abc")
26/// * `current_repo_specs_dir` - Path to the current repo's .chant/specs directory
27/// * `repos` - List of configured repositories
28///
29/// # Returns
30///
31/// The resolved spec, or an error if resolution fails
32pub 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        // Cross-repo dependency
41        resolve_cross_repo_dependency(repo_name, dep_id, repos)
42    } else {
43        // Local dependency
44        resolve_local_dependency(dep_id, current_repo_specs_dir)
45    }
46}
47
48/// Resolves a local dependency in the current repository.
49fn resolve_local_dependency(dep_id: &str, specs_dir: &Path) -> Result<Spec> {
50    // Try to load the spec from the current repo
51    let spec_path = specs_dir.join(format!("{}.md", dep_id));
52    if spec_path.exists() {
53        return Spec::load(&spec_path);
54    }
55
56    // Try to find in archive
57    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        // First check for spec directly in archive directory
64        let direct_path = archive_dir.join(format!("{}.md", dep_id));
65        if direct_path.exists() {
66            return Spec::load(&direct_path);
67        }
68
69        // Also search in subdirectories (for projects with nested archives)
70        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
89/// Resolves a cross-repo dependency.
90fn resolve_cross_repo_dependency(
91    repo_name: &str,
92    spec_id: &str,
93    repos: &[RepoConfig],
94) -> Result<Spec> {
95    // Find the repo configuration
96    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    // Extract just the base ID without repo prefix for file lookup
127    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
144/// Checks for circular dependencies across repos.
145///
146/// Returns an error if a circular dependency is detected.
147pub 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    // Find the spec
180    let spec = match find_spec_by_id(spec_id, all_specs, current_repo_specs_dir, repos) {
181        Ok(s) => s,
182        Err(_) => {
183            // If we can't find the spec, we can't check its dependencies
184            // This is handled elsewhere, so just return Ok here
185            return Ok(());
186        }
187    };
188
189    // Check all dependencies of this spec
190    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
206/// Find a spec by ID in the all_specs list, or resolve cross-repo dependency.
207pub 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    // First, try to find in local specs
214    if let Some(spec) = all_specs.iter().find(|s| s.id == spec_id) {
215        return Ok(spec.clone());
216    }
217
218    // Otherwise, try to resolve as a cross-repo dependency
219    resolve_dependency(spec_id, current_repo_specs_dir, repos)
220}
221
222/// Check if a spec ID exists in the archive directory.
223fn 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    // First check for spec directly in archive directory
234    let direct_path = archive_dir.join(format!("{}.md", spec_id));
235    if direct_path.exists() {
236        return true;
237    }
238
239    // Also search in subdirectories (for projects with nested archives)
240    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
255/// Check if a spec is blocked by unmet dependencies, including cross-repo deps.
256pub 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                    // Check if dependency is completed
267                    if dep_spec.frontmatter.status == SpecStatus::Completed {
268                        continue;
269                    }
270                    // Check if the spec is archived (archived specs are treated as completed)
271                    if is_spec_archived(dep_id, current_repo_specs_dir) {
272                        continue;
273                    }
274                    return true; // Unmet dependency (not completed and not archived)
275                }
276                _ => return true, // Unmet dependency (not found)
277            }
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        // Create a spec
296        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        // Resolve it
305        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        // Create an archived dependency that is NOT marked as completed
443        let archived_dependency = Spec {
444            id: "2026-01-27-002-def".to_string(),
445            frontmatter: crate::spec::SpecFrontmatter {
446                status: SpecStatus::InProgress, // Not completed
447                ..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        // Save the archived dependency to the archive directory
460        archived_dependency
461            .save(&archive_dir.join("2026-01-27-002-def.md"))
462            .unwrap();
463
464        // The spec should NOT be blocked because the dependency is in the archive
465        let is_blocked = is_blocked_by_dependencies(&spec_with_dep, &[], &specs_dir, &[]);
466        assert!(!is_blocked);
467    }
468}