1use anyhow::Result;
4use clap::Args as ClapArgs;
5use colored::Colorize;
6use std::fmt::Write;
7use std::fs;
8use std::path::Path;
9
10use crate::core::{AdrStatus, ConfigManager, Git, NotesManager};
11
12#[derive(ClapArgs, Debug)]
14pub struct Args {
15 #[arg(long, short, default_value = "./adr-export")]
17 pub output: String,
18
19 #[arg(long, short, default_value = "markdown")]
21 pub format: String,
22
23 #[arg(long)]
25 pub status: Option<String>,
26
27 #[arg(long)]
29 pub tag: Option<String>,
30
31 #[arg(long, default_value = "true")]
33 pub index: bool,
34}
35
36#[allow(clippy::too_many_lines)]
42pub fn run(args: Args) -> Result<()> {
43 let git = Git::new();
44 git.check_repository()?;
45
46 let config = ConfigManager::new(git.clone()).load()?;
47 let notes = NotesManager::new(git, config);
48
49 let mut adrs = notes.list()?;
50
51 if let Some(status_str) = &args.status {
53 let status: AdrStatus = status_str.parse().map_err(|e| anyhow::anyhow!("{}", e))?;
54 adrs.retain(|a| a.frontmatter.status == status);
55 }
56
57 if let Some(tag) = &args.tag {
59 adrs.retain(|a| a.frontmatter.tags.iter().any(|t| t.contains(tag)));
60 }
61
62 if adrs.is_empty() {
63 eprintln!("{} No ADRs to export", "!".yellow());
64 return Ok(());
65 }
66
67 eprintln!(
68 "{} Exporting {} ADR(s) to {} format in {}",
69 "→".blue(),
70 adrs.len(),
71 args.format.cyan(),
72 args.output.cyan()
73 );
74
75 let output_path = Path::new(&args.output);
77 fs::create_dir_all(output_path)?;
78
79 let extension = if args.format == "json" {
80 "json"
81 } else if args.format == "html" {
82 "html"
83 } else {
84 "md"
85 };
86
87 for adr in &adrs {
89 let filename = format!("{}.{}", adr.id, extension);
90 let filepath = output_path.join(&filename);
91
92 let content = if args.format == "json" {
93 let json = serde_json::json!({
94 "id": adr.id,
95 "title": adr.frontmatter.title,
96 "status": adr.frontmatter.status.to_string(),
97 "date": adr.frontmatter.date.as_ref().map(|d| d.datetime().to_rfc3339()),
98 "tags": adr.frontmatter.tags,
99 "authors": adr.frontmatter.authors,
100 "deciders": adr.frontmatter.deciders,
101 "commit": adr.commit,
102 "body": adr.body,
103 });
104 serde_json::to_string_pretty(&json)?
105 } else if args.format == "html" {
106 export_html_single(adr)?
107 } else {
108 adr.to_markdown()?
109 };
110
111 fs::write(&filepath, content)?;
112 eprintln!(" {} {}", "✓".green(), filename);
113 }
114
115 if args.index {
117 let index_filename = format!("index.{}", extension);
118 let index_path = output_path.join(&index_filename);
119
120 let index_content = if args.format == "json" {
121 export_json_index(&adrs)?
122 } else if args.format == "html" {
123 export_html_index(&adrs)
124 } else {
125 export_markdown_index(&adrs)
126 };
127
128 fs::write(&index_path, index_content)?;
129 eprintln!(" {} {}", "✓".green(), index_filename);
130 }
131
132 eprintln!(
133 "{} Exported {} ADR(s) to {}",
134 "✓".green(),
135 adrs.len(),
136 args.output.cyan()
137 );
138
139 Ok(())
140}
141
142fn export_html_single(adr: &crate::core::Adr) -> Result<String> {
144 let tags_html = if adr.frontmatter.tags.is_empty() {
145 String::new()
146 } else {
147 let tags = adr
148 .frontmatter
149 .tags
150 .iter()
151 .fold(String::new(), |mut acc, t| {
152 let _ = write!(acc, "<span class=\"tag\">{}</span>", html_escape(t));
153 acc
154 });
155 format!("<div class=\"tags\">{tags}</div>")
156 };
157
158 Ok(format!(
159 r#"<!DOCTYPE html>
160<html>
161<head>
162 <meta charset="utf-8">
163 <title>{} - {}</title>
164 <style>
165 body {{ font-family: system-ui, sans-serif; max-width: 800px; margin: 0 auto; padding: 2rem; }}
166 .status {{ display: inline-block; padding: 0.25rem 0.5rem; border-radius: 4px; background: #e0e0e0; }}
167 .status.accepted {{ background: #c8e6c9; }}
168 .status.rejected {{ background: #ffcdd2; }}
169 .status.superseded {{ background: #fff9c4; }}
170 .tags {{ margin: 1rem 0; }}
171 .tag {{ display: inline-block; padding: 0.25rem 0.5rem; margin-right: 0.5rem; border-radius: 4px; background: #e3f2fd; }}
172 pre {{ background: #f5f5f5; padding: 1rem; overflow-x: auto; }}
173 </style>
174</head>
175<body>
176 <h1>{}</h1>
177 <p><span class="status {}">{}</span></p>
178 {}
179 <hr>
180 {}
181</body>
182</html>"#,
183 adr.id,
184 html_escape(&adr.frontmatter.title),
185 html_escape(&adr.frontmatter.title),
186 adr.frontmatter.status,
187 adr.frontmatter.status,
188 tags_html,
189 markdown_to_html(&adr.body)
190 ))
191}
192
193fn export_json_index(adrs: &[crate::core::Adr]) -> Result<String> {
195 let index: Vec<_> = adrs
196 .iter()
197 .map(|adr| {
198 serde_json::json!({
199 "id": adr.id,
200 "title": adr.frontmatter.title,
201 "status": adr.frontmatter.status.to_string(),
202 "date": adr.frontmatter.date.as_ref().map(|d| d.datetime().to_rfc3339()),
203 "tags": adr.frontmatter.tags,
204 })
205 })
206 .collect();
207 Ok(serde_json::to_string_pretty(&index)?)
208}
209
210fn export_html_index(adrs: &[crate::core::Adr]) -> String {
212 let mut items = String::new();
213 for adr in adrs {
214 let _ = write!(
215 items,
216 "<tr><td><a href=\"{}.html\">{}</a></td><td>{}</td><td>{}</td><td>{}</td></tr>",
217 adr.id,
218 adr.id,
219 html_escape(&adr.frontmatter.title),
220 adr.frontmatter.status,
221 adr.frontmatter.tags.join(", ")
222 );
223 }
224
225 format!(
226 r#"<!DOCTYPE html>
227<html>
228<head>
229 <meta charset="utf-8">
230 <title>ADR Index</title>
231 <style>
232 body {{ font-family: system-ui, sans-serif; max-width: 1000px; margin: 0 auto; padding: 2rem; }}
233 table {{ width: 100%; border-collapse: collapse; }}
234 th, td {{ text-align: left; padding: 0.5rem; border-bottom: 1px solid #ddd; }}
235 th {{ background: #f5f5f5; }}
236 a {{ color: #1976d2; }}
237 </style>
238</head>
239<body>
240 <h1>Architecture Decision Records</h1>
241 <table>
242 <thead>
243 <tr><th>ID</th><th>Title</th><th>Status</th><th>Tags</th></tr>
244 </thead>
245 <tbody>
246 {items}
247 </tbody>
248 </table>
249</body>
250</html>"#
251 )
252}
253
254fn export_markdown_index(adrs: &[crate::core::Adr]) -> String {
256 let mut content = String::from("# Architecture Decision Records\n\n");
257 content.push_str("| ID | Title | Status | Tags |\n");
258 content.push_str("|-----|-------|--------|------|\n");
259 for adr in adrs {
260 let _ = writeln!(
261 content,
262 "| [{}]({}.md) | {} | {} | {} |",
263 adr.id,
264 adr.id,
265 adr.frontmatter.title,
266 adr.frontmatter.status,
267 adr.frontmatter.tags.join(", ")
268 );
269 }
270 content
271}
272
273fn html_escape(s: &str) -> String {
275 s.replace('&', "&")
276 .replace('<', "<")
277 .replace('>', ">")
278 .replace('"', """)
279 .replace('\'', "'")
280}
281
282fn markdown_to_html(md: &str) -> String {
284 let mut html = String::new();
285 let mut in_code_block = false;
286
287 for line in md.lines() {
288 if line.starts_with("```") {
289 if in_code_block {
290 html.push_str("</code></pre>\n");
291 in_code_block = false;
292 } else {
293 html.push_str("<pre><code>");
294 in_code_block = true;
295 }
296 continue;
297 }
298
299 if in_code_block {
300 html.push_str(&html_escape(line));
301 html.push('\n');
302 continue;
303 }
304
305 let line = line.trim();
306
307 if line.is_empty() {
308 html.push_str("<p></p>\n");
309 } else if let Some(heading) = line.strip_prefix("### ") {
310 let _ = writeln!(html, "<h3>{}</h3>", html_escape(heading));
311 } else if let Some(heading) = line.strip_prefix("## ") {
312 let _ = writeln!(html, "<h2>{}</h2>", html_escape(heading));
313 } else if let Some(heading) = line.strip_prefix("# ") {
314 let _ = writeln!(html, "<h1>{}</h1>", html_escape(heading));
315 } else if let Some(item) = line.strip_prefix("- ").or_else(|| line.strip_prefix("* ")) {
316 let _ = writeln!(html, "<li>{}</li>", html_escape(item));
317 } else {
318 let _ = writeln!(html, "<p>{}</p>", html_escape(line));
319 }
320 }
321
322 if in_code_block {
323 html.push_str("</code></pre>\n");
324 }
325
326 html
327}