git_adr/cli/
export.rs

1//! Export ADRs to various formats.
2
3use 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/// Arguments for the export command.
13#[derive(ClapArgs, Debug)]
14pub struct Args {
15    /// Output directory.
16    #[arg(long, short, default_value = "./adr-export")]
17    pub output: String,
18
19    /// Export format (markdown, json, html).
20    #[arg(long, short, default_value = "markdown")]
21    pub format: String,
22
23    /// Filter by status.
24    #[arg(long)]
25    pub status: Option<String>,
26
27    /// Filter by tag.
28    #[arg(long)]
29    pub tag: Option<String>,
30
31    /// Generate index file.
32    #[arg(long, default_value = "true")]
33    pub index: bool,
34}
35
36/// Run the export command.
37///
38/// # Errors
39///
40/// Returns an error if export fails.
41#[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    // Filter by status
52    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    // Filter by tag
58    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    // Create output directory
76    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    // Export each ADR
88    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    // Generate index file
116    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
142/// Export a single ADR to HTML.
143fn 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
193/// Export JSON index.
194fn 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
210/// Export HTML index.
211fn 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
254/// Export markdown index.
255fn 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
273/// Escape HTML special characters.
274fn html_escape(s: &str) -> String {
275    s.replace('&', "&amp;")
276        .replace('<', "&lt;")
277        .replace('>', "&gt;")
278        .replace('"', "&quot;")
279        .replace('\'', "&#39;")
280}
281
282/// Simple markdown to HTML conversion (basic support).
283fn 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}