Skip to main content

chant/site/
graph.rs

1//! ASCII dependency graph generation for specs.
2//!
3//! This module generates ASCII art representations of spec dependencies
4//! using box-drawing characters.
5
6use std::collections::{HashMap, HashSet};
7
8use crate::config::GraphDetailLevel;
9use crate::spec::Spec;
10
11/// Build an ASCII dependency graph from specs.
12///
13/// Returns:
14/// - The ASCII graph as a string
15/// - List of root spec IDs (no dependencies)
16/// - List of leaf spec IDs (nothing depends on them)
17pub fn build_dependency_graph(
18    specs: &[&Spec],
19    detail_level: GraphDetailLevel,
20) -> (String, Vec<String>, Vec<String>) {
21    // Build adjacency lists
22    let mut depends_on: HashMap<&str, Vec<&str>> = HashMap::new();
23    let mut depended_by: HashMap<&str, Vec<&str>> = HashMap::new();
24    let mut all_ids: HashSet<&str> = HashSet::new();
25
26    for spec in specs {
27        all_ids.insert(&spec.id);
28        if let Some(deps) = &spec.frontmatter.depends_on {
29            for dep in deps {
30                depends_on.entry(&spec.id).or_default().push(dep.as_str());
31                depended_by.entry(dep.as_str()).or_default().push(&spec.id);
32            }
33        }
34    }
35
36    // Find roots (specs with no dependencies)
37    let roots: Vec<String> = specs
38        .iter()
39        .filter(|s| {
40            s.frontmatter
41                .depends_on
42                .as_ref()
43                .map(|d| d.is_empty())
44                .unwrap_or(true)
45        })
46        .map(|s| s.id.clone())
47        .collect();
48
49    // Find leaves (specs that nothing depends on)
50    let leaves: Vec<String> = specs
51        .iter()
52        .filter(|s| !depended_by.contains_key(s.id.as_str()))
53        .map(|s| s.id.clone())
54        .collect();
55
56    // Build the ASCII graph
57    let graph = build_ascii_graph(specs, &depends_on, detail_level);
58
59    (graph, roots, leaves)
60}
61
62/// Build the ASCII representation of the graph
63fn build_ascii_graph(
64    specs: &[&Spec],
65    depends_on: &HashMap<&str, Vec<&str>>,
66    detail_level: GraphDetailLevel,
67) -> String {
68    if specs.is_empty() {
69        return "(No specs to display)".to_string();
70    }
71
72    let mut output = String::new();
73
74    // Group specs by their dependency depth
75    let depths = calculate_depths(specs, depends_on);
76
77    // Find specs at each depth level
78    let mut depth_groups: HashMap<usize, Vec<&Spec>> = HashMap::new();
79    for spec in specs {
80        let depth = *depths.get(spec.id.as_str()).unwrap_or(&0);
81        depth_groups.entry(depth).or_default().push(*spec);
82    }
83
84    // Get max depth
85    let max_depth = depth_groups.keys().max().copied().unwrap_or(0);
86
87    // Render each depth level
88    for depth in 0..=max_depth {
89        if let Some(level_specs) = depth_groups.get(&depth) {
90            // Render boxes for this level
91            let boxes = render_spec_boxes(level_specs, detail_level);
92            output.push_str(&boxes);
93            output.push('\n');
94
95            // Render connections to next level if not last
96            if depth < max_depth {
97                if let Some(next_specs) = depth_groups.get(&(depth + 1)) {
98                    let connections = render_connections(level_specs, next_specs, depends_on);
99                    output.push_str(&connections);
100                    output.push('\n');
101                }
102            }
103        }
104    }
105
106    output
107}
108
109/// Calculate the depth (distance from root) of each spec
110fn calculate_depths<'a>(
111    specs: &[&'a Spec],
112    depends_on: &HashMap<&str, Vec<&str>>,
113) -> HashMap<&'a str, usize> {
114    let mut depths: HashMap<&str, usize> = HashMap::new();
115
116    // Initialize roots at depth 0
117    for spec in specs {
118        let has_deps = depends_on
119            .get(spec.id.as_str())
120            .map(|d| !d.is_empty())
121            .unwrap_or(false);
122
123        if !has_deps {
124            depths.insert(&spec.id, 0);
125        }
126    }
127
128    // Calculate depths iteratively
129    let mut changed = true;
130    while changed {
131        changed = false;
132        for spec in specs {
133            if let Some(deps) = depends_on.get(spec.id.as_str()) {
134                // Find max depth of dependencies
135                let max_dep_depth = deps
136                    .iter()
137                    .filter_map(|d| depths.get(d))
138                    .max()
139                    .copied()
140                    .unwrap_or(0);
141
142                let new_depth = max_dep_depth + 1;
143                let current = depths.get(spec.id.as_str()).copied();
144
145                if current.map(|c| new_depth > c).unwrap_or(true) {
146                    depths.insert(&spec.id, new_depth);
147                    changed = true;
148                }
149            }
150        }
151    }
152
153    // Handle any remaining specs without computed depths
154    for spec in specs {
155        depths.entry(&spec.id).or_insert(0);
156    }
157
158    depths
159}
160
161/// Render spec boxes at the same level
162fn render_spec_boxes(specs: &[&Spec], detail_level: GraphDetailLevel) -> String {
163    if specs.is_empty() {
164        return String::new();
165    }
166
167    let boxes: Vec<String> = specs.iter().map(|s| render_box(s, detail_level)).collect();
168
169    // Find the height of the tallest box
170    let max_height = boxes.iter().map(|b| b.lines().count()).max().unwrap_or(0);
171
172    // Pad all boxes to the same height
173    let padded_boxes: Vec<Vec<String>> = boxes
174        .iter()
175        .map(|b| {
176            let lines: Vec<String> = b.lines().map(|l| l.to_string()).collect();
177            let width = lines.first().map(|l| l.chars().count()).unwrap_or(0);
178            let mut padded = lines;
179            while padded.len() < max_height {
180                padded.push(" ".repeat(width));
181            }
182            padded
183        })
184        .collect();
185
186    // Combine horizontally
187    let mut result = String::new();
188    for row in 0..max_height {
189        for (i, box_lines) in padded_boxes.iter().enumerate() {
190            if i > 0 {
191                result.push_str("     "); // Space between boxes
192            }
193            if row < box_lines.len() {
194                result.push_str(&box_lines[row]);
195            }
196        }
197        result.push('\n');
198    }
199
200    result
201}
202
203/// Render a single spec box
204fn render_box(spec: &Spec, detail_level: GraphDetailLevel) -> String {
205    let short_id = spec.id.split('-').skip(3).collect::<Vec<_>>().join("-");
206
207    let short_id = if short_id.is_empty() {
208        &spec.id
209    } else {
210        &short_id
211    };
212
213    match detail_level {
214        GraphDetailLevel::Minimal => {
215            let width = short_id.len().max(5) + 4;
216            let top = format!("┌{}┐", "─".repeat(width - 2));
217            let content = format!("│ {:^width$} │", short_id, width = width - 4);
218            let bottom = format!("└{}┘", "─".repeat(width - 2));
219            format!("{}\n{}\n{}", top, content, bottom)
220        }
221        GraphDetailLevel::Titles => {
222            let title = spec
223                .title
224                .as_ref()
225                .map(|t| truncate(t, 15))
226                .unwrap_or_else(|| "Untitled".to_string());
227
228            let width = short_id.len().max(title.len()).max(10) + 4;
229            let top = format!("┌{}┐", "─".repeat(width - 2));
230            let id_line = format!("│ {:^width$} │", short_id, width = width - 4);
231            let title_line = format!("│ {:^width$} │", title, width = width - 4);
232            let bottom = format!("└{}┘", "─".repeat(width - 2));
233            format!("{}\n{}\n{}\n{}", top, id_line, title_line, bottom)
234        }
235        GraphDetailLevel::Full => {
236            let title = spec
237                .title
238                .as_ref()
239                .map(|t| truncate(t, 15))
240                .unwrap_or_else(|| "Untitled".to_string());
241
242            let status = format!("{:?}", spec.frontmatter.status);
243            let status = truncate(&status, 12);
244
245            let labels = spec
246                .frontmatter
247                .labels
248                .as_ref()
249                .map(|l| l.join(", "))
250                .map(|l| truncate(&l, 12))
251                .unwrap_or_default();
252
253            let width = short_id
254                .len()
255                .max(title.len())
256                .max(status.len())
257                .max(labels.len())
258                .max(10)
259                + 4;
260
261            let top = format!("┌{}┐", "─".repeat(width - 2));
262            let id_line = format!("│ {:^width$} │", short_id, width = width - 4);
263            let title_line = format!("│ {:^width$} │", title, width = width - 4);
264            let status_line = format!("│ {:^width$} │", status, width = width - 4);
265            let bottom = format!("└{}┘", "─".repeat(width - 2));
266
267            if labels.is_empty() {
268                format!(
269                    "{}\n{}\n{}\n{}\n{}",
270                    top, id_line, title_line, status_line, bottom
271                )
272            } else {
273                let labels_line = format!("│ {:^width$} │", labels, width = width - 4);
274                format!(
275                    "{}\n{}\n{}\n{}\n{}\n{}",
276                    top, id_line, title_line, status_line, labels_line, bottom
277                )
278            }
279        }
280    }
281}
282
283/// Render connections between levels
284fn render_connections(
285    from_specs: &[&Spec],
286    to_specs: &[&Spec],
287    depends_on: &HashMap<&str, Vec<&str>>,
288) -> String {
289    // Simple connection rendering
290    let mut has_connections = false;
291
292    for to_spec in to_specs {
293        if let Some(deps) = depends_on.get(to_spec.id.as_str()) {
294            for dep in deps {
295                if from_specs.iter().any(|s| s.id == *dep) {
296                    has_connections = true;
297                    break;
298                }
299            }
300        }
301        if has_connections {
302            break;
303        }
304    }
305
306    if has_connections {
307        let width = from_specs.len() * 20; // Approximate width
308        let padding = " ".repeat(width / 4);
309        format!("{}│\n{}▼", padding, padding)
310    } else {
311        String::new()
312    }
313}
314
315/// Truncate a string to max length
316fn truncate(s: &str, max_len: usize) -> String {
317    if s.chars().count() <= max_len {
318        s.to_string()
319    } else {
320        let truncated: String = s.chars().take(max_len - 2).collect();
321        format!("{}…", truncated)
322    }
323}
324
325#[cfg(test)]
326mod tests {
327    use super::*;
328    use crate::spec::{SpecFrontmatter, SpecStatus};
329
330    fn make_spec(id: &str, title: &str, deps: Option<Vec<&str>>) -> Spec {
331        Spec {
332            id: id.to_string(),
333            title: Some(title.to_string()),
334            body: String::new(),
335            frontmatter: SpecFrontmatter {
336                status: SpecStatus::Pending,
337                depends_on: deps.map(|d| d.iter().map(|s| s.to_string()).collect()),
338                ..Default::default()
339            },
340        }
341    }
342
343    #[test]
344    fn test_build_dependency_graph_empty() {
345        let specs: Vec<&Spec> = vec![];
346        let (graph, roots, leaves) = build_dependency_graph(&specs, GraphDetailLevel::Minimal);
347        assert!(graph.contains("No specs"));
348        assert!(roots.is_empty());
349        assert!(leaves.is_empty());
350    }
351
352    #[test]
353    fn test_build_dependency_graph_single() {
354        let spec = make_spec("2026-01-30-00a-xyz", "Test Spec", None);
355        let specs = vec![&spec];
356        let (graph, roots, leaves) = build_dependency_graph(&specs, GraphDetailLevel::Minimal);
357        assert!(graph.contains("00a-xyz"));
358        assert_eq!(roots.len(), 1);
359        assert_eq!(leaves.len(), 1);
360    }
361
362    #[test]
363    fn test_truncate() {
364        assert_eq!(truncate("short", 10), "short");
365        assert_eq!(truncate("a very long string", 10), "a very l…");
366    }
367
368    #[test]
369    fn test_render_box_minimal() {
370        let spec = make_spec("2026-01-30-00a-xyz", "Test", None);
371        let box_str = render_box(&spec, GraphDetailLevel::Minimal);
372        assert!(box_str.contains("00a-xyz"));
373        assert!(box_str.contains("┌"));
374        assert!(box_str.contains("└"));
375    }
376}