gn_cli/commands/
export.rs1use 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 #[arg(short, long, default_value = "html")]
12 pub format: String,
13
14 #[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 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(¬es);
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 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(¬es);
200 let unopt_dur = start.elapsed();
201
202 let start = Instant::now();
203 let opt_out = generate_markdown(¬es);
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}