An AI agent built to do Ralph loops - plan mode for planning and ralph mode for implementing.
at new-directions 218 lines 6.8 kB view raw
1use crate::graph::store::{GraphStore, NodeQuery}; 2use anyhow::Result; 3use std::fs; 4use std::path::{Path, PathBuf}; 5 6/// Export all decisions from a project as ADR markdown files 7pub async fn export_adrs( 8 graph_store: &dyn GraphStore, 9 project_id: &str, 10 output_dir: &Path, 11) -> Result<Vec<PathBuf>> { 12 // Create output directory if it doesn't exist 13 fs::create_dir_all(output_dir)?; 14 15 // Query all decision nodes for the project 16 let decisions = graph_store 17 .query_nodes(&NodeQuery { 18 node_type: Some(crate::graph::NodeType::Decision), 19 status: None, 20 project_id: Some(project_id.to_string()), 21 parent_id: None, 22 query: None, 23 }) 24 .await?; 25 26 // Sort by created_at for sequential numbering 27 let mut decisions = decisions; 28 decisions.sort_by_key(|d| d.created_at); 29 30 let mut output_files = vec![]; 31 32 for (index, decision) in decisions.iter().enumerate() { 33 let number = index + 1; 34 let number_str = format!("{:03}", number); 35 let slug = slugify(&decision.title); 36 let filename = format!("{}-{}.md", number_str, slug); 37 let file_path = output_dir.join(&filename); 38 39 // Generate markdown content 40 let content = generate_adr_markdown(graph_store, decision, &number_str).await?; 41 42 // Write file 43 fs::write(&file_path, &content)?; 44 output_files.push(file_path); 45 } 46 47 Ok(output_files) 48} 49 50/// Generate ADR markdown for a decision node 51async fn generate_adr_markdown( 52 graph_store: &dyn GraphStore, 53 decision: &crate::graph::GraphNode, 54 number: &str, 55) -> Result<String> { 56 let mut content = String::new(); 57 58 // Title 59 content.push_str(&format!("# ADR {}: {}\n\n", number, decision.title)); 60 61 // Status 62 content.push_str(&format!("**Status:** {}\n\n", decision.status)); 63 64 // Context 65 content.push_str(&format!("## Context\n\n{}\n\n", decision.description)); 66 67 // Options Considered 68 content.push_str("## Options Considered\n\n"); 69 70 // Get all edges once (both LeadsTo for options and Chosen/Rejected for status) 71 let all_edges = graph_store 72 .get_edges(&decision.id, crate::graph::store::EdgeDirection::Outgoing) 73 .await?; 74 75 // Separate edges by type for efficient lookup 76 let mut option_edges = Vec::new(); 77 let mut status_edges_map: std::collections::HashMap<String, Vec<_>> = 78 std::collections::HashMap::new(); 79 80 for (edge, node) in &all_edges { 81 if edge.edge_type == crate::graph::EdgeType::LeadsTo { 82 option_edges.push((edge, node)); 83 } else if edge.edge_type == crate::graph::EdgeType::Chosen 84 || edge.edge_type == crate::graph::EdgeType::Rejected 85 { 86 status_edges_map 87 .entry(edge.to_node.clone()) 88 .or_insert_with(Vec::new) 89 .push(edge); 90 } 91 } 92 93 let mut has_options = false; 94 for (_edge, option_node) in option_edges { 95 has_options = true; 96 97 // Look up status for this option from pre-fetched edges 98 let mut is_chosen = false; 99 let mut rationale = String::new(); 100 101 if let Some(status_edges) = status_edges_map.get(&option_node.id) { 102 for status_edge in status_edges { 103 if status_edge.edge_type == crate::graph::EdgeType::Chosen { 104 is_chosen = true; 105 if let Some(label) = &status_edge.label { 106 rationale = label.clone(); 107 } 108 } 109 } 110 } 111 112 let status_label = if is_chosen { "CHOSEN" } else { "REJECTED" }; 113 114 content.push_str(&format!("### {} ({})\n\n", option_node.title, status_label)); 115 116 if !option_node.description.is_empty() { 117 content.push_str(&format!("{}\n\n", option_node.description)); 118 } 119 120 if !rationale.is_empty() { 121 content.push_str(&format!("**Rationale:** {}\n\n", rationale)); 122 } 123 124 // Add pros/cons from metadata if available 125 if let Some(pros) = option_node.metadata.get("pros") { 126 content.push_str(&format!("**Pros:**\n{}\n\n", pros)); 127 } 128 if let Some(cons) = option_node.metadata.get("cons") { 129 content.push_str(&format!("**Cons:**\n{}\n\n", cons)); 130 } 131 } 132 133 if !has_options { 134 content.push_str("(None documented)\n\n"); 135 } 136 137 // Outcome 138 content.push_str("## Outcome\n\n"); 139 if let Some(outcome) = &decision.metadata.get("outcome") { 140 content.push_str(outcome); 141 } else { 142 content.push_str("(Pending)"); 143 } 144 content.push_str("\n\n"); 145 146 // Related Tasks 147 content.push_str("## Related Tasks\n\n"); 148 let related_tasks = graph_store 149 .get_edges(&decision.id, crate::graph::store::EdgeDirection::Both) 150 .await?; 151 152 let mut task_lines = vec![]; 153 for (edge, node) in &related_tasks { 154 if node.node_type == crate::graph::NodeType::Task { 155 match edge.edge_type { 156 crate::graph::EdgeType::LeadsTo => { 157 task_lines.push(format!("- {} (leads to task)", node.id)); 158 } 159 crate::graph::EdgeType::DependsOn => { 160 task_lines.push(format!("- {} (depends on task)", node.id)); 161 } 162 crate::graph::EdgeType::Informs => { 163 task_lines.push(format!("- {} (informs task)", node.id)); 164 } 165 _ => {} 166 } 167 } 168 } 169 170 if task_lines.is_empty() { 171 content.push_str("(None)\n\n"); 172 } else { 173 for line in task_lines { 174 content.push_str(&format!("{}\n", line)); 175 } 176 content.push('\n'); 177 } 178 179 Ok(content) 180} 181 182/// Slugify a title for use in filenames 183fn slugify(title: &str) -> String { 184 title 185 .to_lowercase() 186 .chars() 187 .map(|c| { 188 if c.is_alphanumeric() { 189 c 190 } else if c.is_whitespace() { 191 '-' 192 } else { 193 ' ' // Will be filtered out below 194 } 195 }) 196 .collect::<String>() 197 .split_whitespace() 198 .collect::<Vec<_>>() 199 .join("-") 200 .chars() 201 .filter(|c| c.is_alphanumeric() || *c == '-') 202 .collect::<String>() 203 .trim_matches('-') 204 .to_string() 205} 206 207#[cfg(test)] 208mod tests { 209 use super::*; 210 211 #[test] 212 fn test_slugify() { 213 assert_eq!(slugify("My Decision"), "my-decision"); 214 assert_eq!(slugify("Use Rust Framework"), "use-rust-framework"); 215 assert_eq!(slugify(" Leading Spaces "), "leading-spaces"); 216 assert_eq!(slugify("Special-Characters!@#"), "special-characters"); 217 } 218}