Skip to main content

ought_spec/
graph.rs

1use std::collections::{HashMap, VecDeque};
2use std::path::{Path, PathBuf};
3
4use crate::parser::Parser;
5use crate::types::{ParseError, Spec};
6
7/// A directed acyclic graph of spec files, built from `requires:` references.
8///
9/// Handles cross-file dependencies, detects circular references,
10/// and provides topological ordering for execution.
11#[derive(Debug)]
12pub struct SpecGraph {
13    specs: Vec<Spec>,
14}
15
16/// Recursively walk a directory and collect all files matching `*.ought.md`.
17fn collect_ought_files(dir: &std::path::Path) -> Vec<PathBuf> {
18    let mut results = Vec::new();
19    if let Ok(entries) = std::fs::read_dir(dir) {
20        for entry in entries.flatten() {
21            let path = entry.path();
22            if path.is_dir() {
23                results.extend(collect_ought_files(&path));
24            } else if let Some(name) = path.file_name().and_then(|n| n.to_str())
25                && name.ends_with(".ought.md") {
26                    results.push(path);
27                }
28        }
29    }
30    results
31}
32
33impl SpecGraph {
34    /// Discover and parse all spec files from the given root directories.
35    /// Builds the dependency graph from `requires:` metadata.
36    pub fn from_roots(roots: &[PathBuf]) -> Result<Self, Vec<ParseError>> {
37        let mut all_files = Vec::new();
38        for root in roots {
39            let files = collect_ought_files(root);
40            all_files.extend(files);
41        }
42
43        // Deduplicate by canonical path
44        all_files.sort();
45        all_files.dedup();
46
47        let mut specs = Vec::new();
48        let mut all_errors = Vec::new();
49
50        for file in &all_files {
51            match Parser::parse_file(file) {
52                Ok(spec) => specs.push(spec),
53                Err(errors) => all_errors.extend(errors),
54            }
55        }
56
57        // Validate that cross-references resolve to existing files
58        let known_paths: std::collections::HashSet<PathBuf> = specs
59            .iter()
60            .map(|s| s.source_path.clone())
61            .collect();
62
63        for spec in &specs {
64            for req in &spec.metadata.requires {
65                // Resolve the requires path relative to the spec's directory
66                let base_dir = spec.source_path.parent().unwrap_or(Path::new("."));
67                let resolved = base_dir.join(&req.path);
68                let canonical = resolved.canonicalize().unwrap_or(resolved.clone());
69
70                if !canonical.exists() && !known_paths.iter().any(|p| {
71                    p.canonicalize().unwrap_or(p.clone()) == canonical
72                }) {
73                    all_errors.push(ParseError {
74                        file: spec.source_path.clone(),
75                        line: 0,
76                        message: format!(
77                            "unresolved cross-reference: '{}' (resolved to '{}')",
78                            req.path.display(),
79                            resolved.display()
80                        ),
81                    });
82                }
83            }
84        }
85
86        // Check for cycles in the dependency graph
87        let cycle_errors = detect_cycles(&specs);
88        all_errors.extend(cycle_errors);
89
90        if !all_errors.is_empty() {
91            return Err(all_errors);
92        }
93
94        Ok(Self { specs })
95    }
96
97    /// All parsed specs.
98    pub fn specs(&self) -> &[Spec] {
99        &self.specs
100    }
101
102    /// Specs in topological order (dependencies before dependents).
103    /// Uses Kahn's algorithm.
104    pub fn topological_order(&self) -> Vec<&Spec> {
105        if self.specs.is_empty() {
106            return Vec::new();
107        }
108
109        // Build index by source_path
110        let path_to_idx: HashMap<&PathBuf, usize> = self
111            .specs
112            .iter()
113            .enumerate()
114            .map(|(i, s)| (&s.source_path, i))
115            .collect();
116
117        let n = self.specs.len();
118        let mut in_degree = vec![0usize; n];
119        let mut adjacency: Vec<Vec<usize>> = vec![Vec::new(); n];
120
121        for (i, spec) in self.specs.iter().enumerate() {
122            for req in &spec.metadata.requires {
123                // Try to resolve the requires path relative to the spec's directory
124                let base_dir = spec.source_path.parent().unwrap_or(std::path::Path::new(""));
125                let resolved = base_dir.join(&req.path);
126
127                // Find matching spec by path (try both resolved and raw)
128                let target_idx = path_to_idx.get(&resolved).or_else(|| path_to_idx.get(&req.path));
129
130                if let Some(&j) = target_idx {
131                    // Edge: j -> i (dependency j must come before dependent i)
132                    adjacency[j].push(i);
133                    in_degree[i] += 1;
134                }
135            }
136        }
137
138        // Kahn's algorithm
139        let mut queue: VecDeque<usize> = VecDeque::new();
140        for (i, &deg) in in_degree.iter().enumerate() {
141            if deg == 0 {
142                queue.push_back(i);
143            }
144        }
145
146        let mut order = Vec::with_capacity(n);
147        while let Some(node) = queue.pop_front() {
148            order.push(&self.specs[node]);
149            for &neighbor in &adjacency[node] {
150                in_degree[neighbor] -= 1;
151                if in_degree[neighbor] == 0 {
152                    queue.push_back(neighbor);
153                }
154            }
155        }
156
157        // If order.len() < n, there's a cycle. We still return what we have.
158        order
159    }
160
161    /// Look up a spec by its source file path.
162    pub fn get_by_path(&self, path: &std::path::Path) -> Option<&Spec> {
163        self.specs.iter().find(|s| s.source_path == path)
164    }
165}
166
167/// Detect cycles in the dependency graph formed by `requires:` references.
168fn detect_cycles(specs: &[Spec]) -> Vec<ParseError> {
169    let path_to_idx: HashMap<&PathBuf, usize> = specs
170        .iter()
171        .enumerate()
172        .map(|(i, s)| (&s.source_path, i))
173        .collect();
174
175    let n = specs.len();
176    let mut adjacency: Vec<Vec<usize>> = vec![Vec::new(); n];
177
178    for (i, spec) in specs.iter().enumerate() {
179        for req in &spec.metadata.requires {
180            let base_dir = spec
181                .source_path
182                .parent()
183                .unwrap_or(std::path::Path::new(""));
184            let resolved = base_dir.join(&req.path);
185
186            let target_idx = path_to_idx.get(&resolved).or_else(|| path_to_idx.get(&req.path));
187
188            if let Some(&j) = target_idx {
189                adjacency[i].push(j);
190            }
191        }
192    }
193
194    // DFS-based cycle detection
195    let mut errors = Vec::new();
196    let mut visited = vec![0u8; n]; // 0=unvisited, 1=in-progress, 2=done
197
198    fn dfs(
199        node: usize,
200        adjacency: &[Vec<usize>],
201        visited: &mut [u8],
202        path: &mut Vec<usize>,
203        specs: &[Spec],
204        errors: &mut Vec<ParseError>,
205    ) {
206        visited[node] = 1;
207        path.push(node);
208
209        for &neighbor in &adjacency[node] {
210            if visited[neighbor] == 1 {
211                // Found a cycle
212                let cycle_start = path.iter().position(|&n| n == neighbor).unwrap();
213                let cycle_names: Vec<String> = path[cycle_start..]
214                    .iter()
215                    .map(|&i| specs[i].source_path.display().to_string())
216                    .collect();
217                errors.push(ParseError {
218                    file: specs[node].source_path.clone(),
219                    line: 0,
220                    message: format!(
221                        "circular dependency detected: {}",
222                        cycle_names.join(" -> ")
223                    ),
224                });
225            } else if visited[neighbor] == 0 {
226                dfs(neighbor, adjacency, visited, path, specs, errors);
227            }
228        }
229
230        path.pop();
231        visited[node] = 2;
232    }
233
234    let mut path = Vec::new();
235    for i in 0..n {
236        if visited[i] == 0 {
237            dfs(i, &adjacency, &mut visited, &mut path, specs, &mut errors);
238        }
239    }
240
241    errors
242}