1use serde::{Deserialize, Serialize};
10use std::collections::{HashMap, HashSet};
11
12#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
18pub struct ExportNode {
19 pub node_id: u64,
21 pub rule_id: String,
23 pub goal: String,
25 pub depth: usize,
27 pub children: Vec<u64>,
29}
30
31#[derive(Debug, Clone, PartialEq, Eq)]
37pub enum ExportFormat {
38 Dot,
40 Json,
42 IndentedText,
44 EdgeList,
46}
47
48#[derive(Debug, Clone)]
54pub struct ExportConfig {
55 pub format: ExportFormat,
57 pub max_depth: Option<usize>,
59 pub include_rule_ids: bool,
61 pub label_max_len: usize,
63}
64
65impl Default for ExportConfig {
66 fn default() -> Self {
67 Self {
68 format: ExportFormat::IndentedText,
69 max_depth: None,
70 include_rule_ids: true,
71 label_max_len: 40,
72 }
73 }
74}
75
76impl ExportConfig {
77 pub fn new(format: ExportFormat) -> Self {
79 Self {
80 format,
81 ..Self::default()
82 }
83 }
84}
85
86pub struct ProofTreeExporter {
92 pub config: ExportConfig,
94}
95
96impl ProofTreeExporter {
97 pub fn new(config: ExportConfig) -> Self {
99 Self { config }
100 }
101
102 pub fn export(&self, nodes: &[ExportNode]) -> String {
110 if nodes.is_empty() {
111 return "(empty)".to_string();
112 }
113
114 let would_be_empty = match self.config.max_depth {
116 None => false,
117 Some(max) => !nodes.iter().any(|n| n.depth <= max),
118 };
119 if would_be_empty {
120 return "(empty)".to_string();
121 }
122
123 match self.config.format {
125 ExportFormat::Dot => self.to_dot(nodes),
126 ExportFormat::Json => self.to_json(nodes),
127 ExportFormat::IndentedText => self.to_indented_text(nodes),
128 ExportFormat::EdgeList => self.to_edge_list(nodes),
129 }
130 }
131
132 pub fn to_dot(&self, nodes: &[ExportNode]) -> String {
134 let filtered_owned = self.owned_filtered(nodes);
135 let nodes: &[ExportNode] = &filtered_owned;
136
137 let mut out = String::from("digraph proof {\n");
138
139 for node in nodes {
140 let label = self.make_label(node);
141 let escaped = label.replace('\\', "\\\\").replace('"', "\\\"");
143 out.push_str(&format!(" {} [label=\"{}\"];\n", node.node_id, escaped));
144 }
145
146 let id_set: HashSet<u64> = nodes.iter().map(|n| n.node_id).collect();
149
150 for node in nodes {
151 for &child_id in &node.children {
152 if id_set.contains(&child_id) {
153 out.push_str(&format!(" {} -> {};\n", node.node_id, child_id));
154 }
155 }
156 }
157
158 out.push('}');
159 out
160 }
161
162 pub fn to_json(&self, nodes: &[ExportNode]) -> String {
164 let filtered_owned = self.owned_filtered(nodes);
165 serde_json::to_string(&filtered_owned).unwrap_or_else(|_| "[]".to_string())
168 }
169
170 pub fn to_indented_text(&self, nodes: &[ExportNode]) -> String {
172 let filtered_owned = self.owned_filtered(nodes);
173 let nodes: &[ExportNode] = &filtered_owned;
174
175 if nodes.is_empty() {
176 return String::new();
177 }
178
179 let root_id = Self::find_root(nodes);
180 let id_map: HashMap<u64, &ExportNode> = nodes.iter().map(|n| (n.node_id, n)).collect();
181
182 let mut out = String::new();
183 let mut stack: Vec<u64> = vec![root_id];
184
185 while let Some(id) = stack.pop() {
186 if let Some(node) = id_map.get(&id) {
187 let indent = " ".repeat(2 * node.depth);
188 if self.config.include_rule_ids {
189 out.push_str(&format!(
190 "{}{} [rule: {}]\n",
191 indent,
192 self.truncate(&node.goal),
193 node.rule_id
194 ));
195 } else {
196 out.push_str(&format!("{}{}\n", indent, self.truncate(&node.goal)));
197 }
198 for &child_id in node.children.iter().rev() {
200 stack.push(child_id);
201 }
202 }
203 }
204
205 if out.ends_with('\n') {
207 out.pop();
208 }
209 out
210 }
211
212 pub fn to_edge_list(&self, nodes: &[ExportNode]) -> String {
214 let filtered_owned = self.owned_filtered(nodes);
215 let nodes: &[ExportNode] = &filtered_owned;
216
217 let id_set: HashSet<u64> = nodes.iter().map(|n| n.node_id).collect();
218
219 let mut lines: Vec<String> = Vec::new();
220 for node in nodes {
221 for &child_id in &node.children {
222 if id_set.contains(&child_id) {
223 lines.push(format!("{} -> {}", node.node_id, child_id));
224 }
225 }
226 }
227
228 lines.join("\n")
229 }
230
231 pub fn node_count(&self, nodes: &[ExportNode]) -> usize {
233 self.owned_filtered(nodes).len()
234 }
235
236 pub fn edge_count(&self, nodes: &[ExportNode]) -> usize {
238 let filtered = self.owned_filtered(nodes);
239 let id_set: HashSet<u64> = filtered.iter().map(|n| n.node_id).collect();
240 filtered
241 .iter()
242 .flat_map(|n| n.children.iter())
243 .filter(|&&child_id| id_set.contains(&child_id))
244 .count()
245 }
246
247 fn owned_filtered(&self, nodes: &[ExportNode]) -> Vec<ExportNode> {
253 match self.config.max_depth {
254 None => nodes.to_vec(),
255 Some(max) => nodes.iter().filter(|n| n.depth <= max).cloned().collect(),
256 }
257 }
258
259 fn make_label(&self, node: &ExportNode) -> String {
261 if self.config.include_rule_ids {
262 let combined = format!("{} ({})", node.goal, node.rule_id);
263 self.truncate(&combined)
264 } else {
265 self.truncate(&node.goal)
266 }
267 }
268
269 fn truncate(&self, s: &str) -> String {
272 let max = self.config.label_max_len;
273 if s.chars().count() <= max {
274 s.to_string()
275 } else {
276 let truncated: String = s.chars().take(max.saturating_sub(1)).collect();
277 format!("{}…", truncated)
278 }
279 }
280
281 fn find_root(nodes: &[ExportNode]) -> u64 {
285 let all_children: HashSet<u64> = nodes
286 .iter()
287 .flat_map(|n| n.children.iter().copied())
288 .collect();
289
290 nodes
291 .iter()
292 .find(|n| !all_children.contains(&n.node_id))
293 .map(|n| n.node_id)
294 .unwrap_or(nodes[0].node_id)
295 }
296}
297
298#[cfg(test)]
303mod tests {
304 use super::*;
305
306 fn leaf(id: u64, rule: &str, goal: &str, depth: usize) -> ExportNode {
311 ExportNode {
312 node_id: id,
313 rule_id: rule.to_string(),
314 goal: goal.to_string(),
315 depth,
316 children: vec![],
317 }
318 }
319
320 fn parent_node(
321 id: u64,
322 rule: &str,
323 goal: &str,
324 depth: usize,
325 children: Vec<u64>,
326 ) -> ExportNode {
327 ExportNode {
328 node_id: id,
329 rule_id: rule.to_string(),
330 goal: goal.to_string(),
331 depth,
332 children,
333 }
334 }
335
336 fn single() -> Vec<ExportNode> {
338 vec![leaf(1, "R1", "fact(a)", 0)]
339 }
340
341 fn two_level() -> Vec<ExportNode> {
343 vec![
344 parent_node(1, "R1", "root_goal", 0, vec![2]),
345 leaf(2, "R2", "child_goal", 1),
346 ]
347 }
348
349 fn three_level() -> Vec<ExportNode> {
351 vec![
352 parent_node(1, "R1", "root_goal", 0, vec![2]),
353 parent_node(2, "R2", "child_goal", 1, vec![3]),
354 leaf(3, "R3", "grandchild_goal", 2),
355 ]
356 }
357
358 #[test]
362 fn test_new_with_config() {
363 let cfg = ExportConfig::new(ExportFormat::Dot);
364 let exporter = ProofTreeExporter::new(cfg);
365 assert_eq!(exporter.config.format, ExportFormat::Dot);
366 assert!(exporter.config.include_rule_ids);
367 assert_eq!(exporter.config.label_max_len, 40);
368 assert!(exporter.config.max_depth.is_none());
369 }
370
371 #[test]
375 fn test_export_empty_returns_empty_sentinel() {
376 let exporter = ProofTreeExporter::new(ExportConfig::new(ExportFormat::Dot));
377 assert_eq!(exporter.export(&[]), "(empty)");
378 }
379
380 #[test]
384 fn test_to_dot_single_node_no_edges() {
385 let exporter = ProofTreeExporter::new(ExportConfig::new(ExportFormat::Dot));
386 let nodes = single();
387 let dot = exporter.to_dot(&nodes);
388 assert!(dot.contains("digraph proof {"));
389 assert!(dot.contains("1 [label="));
390 assert!(!dot.contains("->"));
392 }
393
394 #[test]
398 fn test_to_dot_edge_present() {
399 let exporter = ProofTreeExporter::new(ExportConfig::new(ExportFormat::Dot));
400 let nodes = two_level();
401 let dot = exporter.to_dot(&nodes);
402 assert!(dot.contains("1 -> 2;"));
403 }
404
405 #[test]
409 fn test_to_dot_label_truncated() {
410 let mut cfg = ExportConfig::new(ExportFormat::Dot);
411 cfg.label_max_len = 10;
412 cfg.include_rule_ids = false;
413 let exporter = ProofTreeExporter::new(cfg);
414 let nodes = vec![leaf(1, "R1", "a_very_long_goal_that_exceeds_limit", 0)];
415 let dot = exporter.to_dot(&nodes);
416 assert!(dot.contains('…') || dot.contains("a_very_lo"));
419 assert!(!dot.contains("a_very_long_goal_that_exceeds_limit"));
421 }
422
423 #[test]
427 fn test_to_dot_no_rule_ids() {
428 let mut cfg = ExportConfig::new(ExportFormat::Dot);
429 cfg.include_rule_ids = false;
430 let exporter = ProofTreeExporter::new(cfg);
431 let nodes = single();
432 let dot = exporter.to_dot(&nodes);
433 assert!(!dot.contains("R1"));
434 }
435
436 #[test]
440 fn test_to_json_round_trip() {
441 let exporter = ProofTreeExporter::new(ExportConfig::new(ExportFormat::Json));
442 let nodes = two_level();
443 let json = exporter.to_json(&nodes);
444 let recovered: Vec<ExportNode> = serde_json::from_str(&json).expect("valid JSON");
445 assert_eq!(recovered.len(), 2);
446 assert_eq!(recovered[0].node_id, 1);
447 assert_eq!(recovered[1].node_id, 2);
448 }
449
450 #[test]
454 fn test_to_json_contains_fields() {
455 let exporter = ProofTreeExporter::new(ExportConfig::new(ExportFormat::Json));
456 let nodes = single();
457 let json = exporter.to_json(&nodes);
458 assert!(json.contains("node_id"));
459 assert!(json.contains("goal"));
460 assert!(json.contains("fact(a)"));
461 }
462
463 #[test]
467 fn test_to_indented_text_root_no_indent() {
468 let exporter = ProofTreeExporter::new(ExportConfig::new(ExportFormat::IndentedText));
469 let nodes = two_level();
470 let text = exporter.to_indented_text(&nodes);
471 let first_line = text.lines().next().expect("at least one line");
473 assert!(!first_line.starts_with(' '));
474 assert!(first_line.contains("root_goal"));
475 }
476
477 #[test]
481 fn test_to_indented_text_child_indent_2() {
482 let exporter = ProofTreeExporter::new(ExportConfig::new(ExportFormat::IndentedText));
483 let nodes = two_level();
484 let text = exporter.to_indented_text(&nodes);
485 let child_line = text
486 .lines()
487 .find(|l| l.contains("child_goal"))
488 .expect("child line present");
489 assert!(
490 child_line.starts_with(" "),
491 "expected 2-space indent, got: {:?}",
492 child_line
493 );
494 }
495
496 #[test]
500 fn test_to_indented_text_grandchild_indent_4() {
501 let exporter = ProofTreeExporter::new(ExportConfig::new(ExportFormat::IndentedText));
502 let nodes = three_level();
503 let text = exporter.to_indented_text(&nodes);
504 let gc_line = text
505 .lines()
506 .find(|l| l.contains("grandchild_goal"))
507 .expect("grandchild line present");
508 assert!(
509 gc_line.starts_with(" "),
510 "expected 4-space indent, got: {:?}",
511 gc_line
512 );
513 }
514
515 #[test]
519 fn test_to_edge_list_format() {
520 let exporter = ProofTreeExporter::new(ExportConfig::new(ExportFormat::EdgeList));
521 let nodes = two_level();
522 let list = exporter.to_edge_list(&nodes);
523 assert!(list.contains("1 -> 2"), "output was: {}", list);
524 }
525
526 #[test]
530 fn test_to_edge_list_empty_for_leaf_only() {
531 let exporter = ProofTreeExporter::new(ExportConfig::new(ExportFormat::EdgeList));
532 let nodes = single();
533 let list = exporter.to_edge_list(&nodes);
534 assert!(list.is_empty(), "expected empty edge list, got: {:?}", list);
535 }
536
537 #[test]
541 fn test_max_depth_filters_deep_nodes() {
542 let mut cfg = ExportConfig::new(ExportFormat::IndentedText);
543 cfg.max_depth = Some(1);
544 let exporter = ProofTreeExporter::new(cfg);
545 let nodes = three_level(); let text = exporter.to_indented_text(&nodes);
547 assert!(
548 !text.contains("grandchild_goal"),
549 "depth-2 node should be filtered"
550 );
551 assert!(text.contains("child_goal"), "depth-1 node should remain");
552 }
553
554 #[test]
558 fn test_node_count_with_max_depth() {
559 let mut cfg = ExportConfig::new(ExportFormat::Dot);
560 cfg.max_depth = Some(1);
561 let exporter = ProofTreeExporter::new(cfg);
562 let nodes = three_level(); assert_eq!(exporter.node_count(&nodes), 2); }
565
566 #[test]
570 fn test_edge_count_correct() {
571 let exporter = ProofTreeExporter::new(ExportConfig::new(ExportFormat::Dot));
572 let nodes = three_level(); assert_eq!(exporter.edge_count(&nodes), 2);
574 }
575
576 #[test]
580 fn test_edge_count_with_max_depth() {
581 let mut cfg = ExportConfig::new(ExportFormat::Dot);
582 cfg.max_depth = Some(1);
583 let exporter = ProofTreeExporter::new(cfg);
584 let nodes = three_level();
585 assert_eq!(exporter.edge_count(&nodes), 1);
587 }
588
589 #[test]
593 fn test_export_dispatches_edge_list() {
594 let exporter = ProofTreeExporter::new(ExportConfig::new(ExportFormat::EdgeList));
595 let result = exporter.export(&two_level());
596 assert!(result.contains("->"));
597 }
598
599 #[test]
603 fn test_export_dispatches_json() {
604 let exporter = ProofTreeExporter::new(ExportConfig::new(ExportFormat::Json));
605 let result = exporter.export(&single());
606 assert!(result.starts_with('['));
607 }
608}