An AI agent built to do Ralph loops - plan mode for planning and ralph mode for implementing.
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}