1use std::collections::{HashMap, HashSet};
7
8use crate::config::GraphDetailLevel;
9use crate::spec::Spec;
10
11pub fn build_dependency_graph(
18 specs: &[&Spec],
19 detail_level: GraphDetailLevel,
20) -> (String, Vec<String>, Vec<String>) {
21 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 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 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 let graph = build_ascii_graph(specs, &depends_on, detail_level);
58
59 (graph, roots, leaves)
60}
61
62fn 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 let depths = calculate_depths(specs, depends_on);
76
77 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 let max_depth = depth_groups.keys().max().copied().unwrap_or(0);
86
87 for depth in 0..=max_depth {
89 if let Some(level_specs) = depth_groups.get(&depth) {
90 let boxes = render_spec_boxes(level_specs, detail_level);
92 output.push_str(&boxes);
93 output.push('\n');
94
95 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
109fn 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 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 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 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 for spec in specs {
155 depths.entry(&spec.id).or_insert(0);
156 }
157
158 depths
159}
160
161fn 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 let max_height = boxes.iter().map(|b| b.lines().count()).max().unwrap_or(0);
171
172 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 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(" "); }
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
203fn 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
283fn render_connections(
285 from_specs: &[&Spec],
286 to_specs: &[&Spec],
287 depends_on: &HashMap<&str, Vec<&str>>,
288) -> String {
289 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; let padding = " ".repeat(width / 4);
309 format!("{}│\n{}▼", padding, padding)
310 } else {
311 String::new()
312 }
313}
314
315fn 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}