Skip to main content

gn_cli/commands/
export.rs

1use anyhow::Result;
2use clap::Args;
3use gn_core::NotesEngine;
4use std::collections::BTreeMap;
5use std::fmt::Write as _;
6use std::fs;
7
8#[derive(Args)]
9pub struct ExportArgs {
10    /// Format (json, html, markdown)
11    #[arg(short, long, default_value = "html")]
12    pub format: String,
13
14    /// Output directory
15    #[arg(short, long, default_value = ".")]
16    pub out_dir: String,
17}
18
19pub fn generate_markdown(all_notes: &[gn_core::Note]) -> String {
20    let mut md = String::from("# Git Notes\n\n");
21
22    let mut notes_by_file: BTreeMap<&str, Vec<&gn_core::Note>> = BTreeMap::new();
23
24    for note in all_notes {
25        if let Some(file) = note.file.as_deref() {
26            notes_by_file.entry(file).or_default().push(note);
27        }
28    }
29
30    for (file, notes) in notes_by_file {
31        let _ = write!(md, "## {}\n\n", file);
32        for note in notes {
33            let _ = write!(
34                md,
35                "### Line {} - {} ({})\n\n",
36                note.line_start.unwrap_or(0),
37                note.author,
38                note.timestamp
39            );
40            let _ = write!(md, "**Status:** {:?}\n\n", note.status);
41            let _ = write!(md, "{}\n\n", note.body);
42        }
43    }
44
45    md
46}
47
48pub fn run(args: &ExportArgs) -> Result<()> {
49    let engine = NotesEngine::new(".");
50    let namespaces = vec!["comments", "review", "todos"];
51
52    let mut all_notes = Vec::new();
53    for ns in &namespaces {
54        let namespace_enum = gn_core::Namespace::Custom(ns.to_string());
55        if let Ok(notes) = engine.read_notes(&namespace_enum) {
56            all_notes.extend(notes);
57        }
58    }
59
60    let out_path = std::path::Path::new(&args.out_dir);
61    if !out_path.exists() {
62        fs::create_dir_all(out_path)?;
63    }
64
65    match args.format.to_lowercase().as_str() {
66        "json" => {
67            let file_path = out_path.join("notes.json");
68            let json = serde_json::to_string_pretty(&all_notes)?;
69            fs::write(&file_path, json)?;
70            println!("✓ Exported notes to {}", file_path.display());
71        }
72        "markdown" => {
73            let file_path = out_path.join("NOTES.md");
74            let md = generate_markdown(&all_notes);
75            fs::write(&file_path, md)?;
76            println!("✓ Exported notes to {}", file_path.display());
77        }
78        "html" => {
79            let file_path = out_path.join("index.html");
80            let json = serde_json::to_string(&all_notes)?;
81            let html = format!(
82                r#"<!DOCTYPE html>
83<html>
84<head>
85    <title>Git Notes</title>
86    <style>
87        body {{ font-family: sans-serif; margin: 0; padding: 20px; }}
88        .note {{ border: 1px solid #ccc; padding: 10px; margin-bottom: 10px; border-radius: 4px; }}
89        .header {{ color: #555; font-size: 0.9em; margin-bottom: 5px; }}
90        .body {{ white-space: pre-wrap; }}
91    </style>
92</head>
93<body>
94    <h1>Git Notes</h1>
95    <div id="notes"></div>
96    <script>
97        const notes = {};
98        const container = document.getElementById('notes');
99        notes.forEach(note => {{
100            const file = note.file || "";
101            const line = note.line_start || 0;
102            const div = document.createElement('div');
103            div.className = 'note';
104            div.innerHTML = `
105                <div class="header"><strong>${{file}}:${{line}}</strong> by ${{note.author}} on ${{note.timestamp}} [${{note.status}}]</div>
106                <div class="body">${{note.body}}</div>
107            `;
108            container.appendChild(div);
109        }});
110    </script>
111</body>
112</html>"#,
113                json
114            );
115            fs::write(&file_path, html)?;
116            println!("✓ Exported notes to {}", file_path.display());
117        }
118        _ => {
119            anyhow::bail!("Unsupported format: {}", args.format);
120        }
121    }
122
123    Ok(())
124}
125
126#[cfg(test)]
127mod tests {
128    use super::*;
129    use gn_core::{Namespace, Note};
130    use std::time::Instant;
131
132    fn make_sample_note(file: &str, line: u32, body: &str) -> Note {
133        Note::new(
134            "commit123".to_string(),
135            Some(file.to_string()),
136            Some(line),
137            None,
138            body.to_string(),
139            "Author <author@example.com>".to_string(),
140            Namespace::Custom("comments".to_string()),
141        )
142    }
143
144    pub fn generate_markdown_unoptimized(all_notes: &[Note]) -> String {
145        let mut md = String::from("# Git Notes\n\n");
146
147        // Group by file
148        let mut files: Vec<String> = all_notes.iter().filter_map(|n| n.file.clone()).collect();
149        files.sort();
150        files.dedup();
151
152        for file in files {
153            md.push_str(&format!("## {}\n\n", file));
154            for note in all_notes
155                .iter()
156                .filter(|n| n.file.as_deref() == Some(file.as_str()))
157            {
158                md.push_str(&format!(
159                    "### Line {} - {} ({})\n\n",
160                    note.line_start.unwrap_or(0),
161                    note.author,
162                    note.timestamp
163                ));
164                md.push_str(&format!("**Status:** {:?}\n\n", note.status));
165                md.push_str(&format!("{}\n\n", note.body));
166            }
167        }
168        md
169    }
170
171    #[test]
172    fn test_export_markdown_correctness() {
173        let notes = vec![
174            make_sample_note("src/b.rs", 10, "Note B"),
175            make_sample_note("src/a.rs", 5, "Note A"),
176            make_sample_note("src/b.rs", 20, "Note B2"),
177        ];
178
179        let md = generate_markdown(&notes);
180        assert!(md.contains("## src/a.rs"));
181        assert!(md.contains("## src/b.rs"));
182        assert!(md.contains("Note A"));
183        assert!(md.contains("Note B"));
184        assert!(md.contains("Note B2"));
185    }
186
187    #[test]
188    fn test_benchmark_markdown_export() {
189        // Generate 5,000 notes spread across 500 files
190        let mut notes = Vec::with_capacity(5_000);
191        for f in 0..500 {
192            let filename = format!("src/file_{:04}.rs", f);
193            for i in 0..10 {
194                notes.push(make_sample_note(&filename, i * 10, &format!("Note {}", i)));
195            }
196        }
197
198        let start = Instant::now();
199        let unopt_out = generate_markdown_unoptimized(&notes);
200        let unopt_dur = start.elapsed();
201
202        let start = Instant::now();
203        let opt_out = generate_markdown(&notes);
204        let opt_dur = start.elapsed();
205
206        println!("\n================ PERFORMANCE COMPARISON ================");
207        println!("Unoptimized generation time (5,000 notes, 500 files): {:?}", unopt_dur);
208        println!("Optimized generation time (5,000 notes, 500 files):   {:?}", opt_dur);
209        println!(
210            "Speedup factor:                                       {:.2}x",
211            unopt_dur.as_secs_f64() / opt_dur.as_secs_f64()
212        );
213        println!("======================================================\n");
214
215        assert_eq!(unopt_out, opt_out);
216    }
217}