scribe/
report.rs

1use chrono::{DateTime, Local, Utc};
2use handlebars::Handlebars;
3use serde_json::json;
4use std::error::Error;
5use std::fmt::Write;
6use std::path::{Path, PathBuf};
7use std::time::SystemTime;
8
9/// Output format supported by the reporting utilities.
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub enum ReportFormat {
12    Html,
13    Cxml,
14    Repomix,
15    Xml,
16    Json,
17    Text,
18    Markdown,
19}
20
21/// Minimal representation of a file selected for inclusion in the final report.
22#[derive(Debug, Clone)]
23pub struct ReportFile {
24    pub path: PathBuf,
25    pub relative_path: String,
26    pub content: String,
27    pub size: u64,
28    pub estimated_tokens: usize,
29    pub importance_score: f64,
30    pub centrality_score: f64,
31    pub query_relevance_score: f64,
32    pub entry_point_proximity: f64,
33    pub content_quality_score: f64,
34    pub repository_role_score: f64,
35    pub recency_score: f64,
36    pub modified: Option<SystemTime>,
37}
38
39/// Summary of the selection process used when generating reports.
40#[derive(Debug, Clone)]
41pub struct SelectionMetrics {
42    pub total_files_discovered: usize,
43    pub files_selected: usize,
44    pub total_tokens_estimated: usize,
45    pub selection_time_ms: u128,
46    pub algorithm_used: String,
47    pub coverage_score: f64,
48    pub relevance_score: f64,
49}
50
51pub fn generate_report(
52    format: ReportFormat,
53    files: &[ReportFile],
54    metrics: &SelectionMetrics,
55) -> Result<String, Box<dyn Error>> {
56    match format {
57        ReportFormat::Html => generate_html_output(files, metrics),
58        ReportFormat::Cxml => generate_cxml_output(files, metrics),
59        ReportFormat::Repomix => generate_repomix_output(files, metrics),
60        ReportFormat::Xml => generate_xml_output(files, metrics),
61        ReportFormat::Json => generate_json_output(files, metrics),
62        ReportFormat::Text => generate_text_output(files, metrics),
63        ReportFormat::Markdown => generate_markdown_output(files, metrics),
64    }
65}
66
67pub fn generate_html_output(
68    files: &[ReportFile],
69    metrics: &SelectionMetrics,
70) -> Result<String, Box<dyn Error>> {
71    // Use CDN-based template for smaller output size
72    // This reduces the generated HTML file size by ~60% by using CDN links
73    // for highlight.js instead of embedding a 268KB React bundle
74    let template_str = include_str!("../templates/report_cdn.html");
75    let mut handlebars = Handlebars::new();
76    handlebars.register_template_string("report", template_str)?;
77
78    handlebars.register_helper(
79        "add",
80        Box::new(
81            |h: &handlebars::Helper,
82             _: &Handlebars,
83             _: &handlebars::Context,
84             _: &mut handlebars::RenderContext,
85             out: &mut dyn handlebars::Output|
86             -> Result<(), handlebars::RenderError> {
87                let a = h.param(0).and_then(|v| v.value().as_u64()).unwrap_or(0);
88                let b = h.param(1).and_then(|v| v.value().as_u64()).unwrap_or(0);
89                out.write(&(a + b).to_string())?;
90                Ok(())
91            },
92        ),
93    );
94
95    let total_tokens: usize = files.iter().map(|f| f.estimated_tokens).sum();
96    let total_size: u64 = files.iter().map(|f| f.size).sum();
97    let total_files = files.len();
98
99    let template_data = json!({
100        "repository_name": "Scribe Analysis",
101        "algorithm": metrics.algorithm_used,
102        "generated_time": Utc::now().format("%Y-%m-%d %H:%M:%S UTC").to_string(),
103        "selection_time_ms": metrics.selection_time_ms,
104        "total_files": total_files,
105        "total_tokens": format_number(total_tokens),
106        "total_size": format_bytes(total_size),
107        "coverage_percentage": format!("{:.1}", metrics.coverage_score * 100.0),
108        "files": files.iter().map(|file| {
109            json!({
110                "relative_path": html_escape(&file.relative_path),
111                "content": html_escape(&file.content),
112                "size": format_bytes(file.size),
113                "estimated_tokens": format_number(file.estimated_tokens),
114                "importance_score": format!("{:.2}", file.importance_score),
115                "centrality_score": format!("{:.2}", file.centrality_score),
116                "query_relevance_score": format!("{:.2}", file.query_relevance_score),
117                "entry_point_proximity": format!("{:.2}", file.entry_point_proximity),
118                "content_quality_score": format!("{:.2}", file.content_quality_score),
119                "repository_role_score": format!("{:.2}", file.repository_role_score),
120                "recency_score": format!("{:.2}", file.recency_score),
121                "modified": format_timestamp(file.modified),
122                "icon": get_file_icon(&file.relative_path)
123            })
124        }).collect::<Vec<_>>()
125    });
126
127    let html = handlebars.render("report", &template_data)?;
128    Ok(html)
129}
130
131pub fn generate_cxml_output(
132    files: &[ReportFile],
133    metrics: &SelectionMetrics,
134) -> Result<String, Box<dyn Error>> {
135    let mut output = String::new();
136    writeln!(output, "<?xml version=\"1.0\" encoding=\"UTF-8\"?>")?;
137    writeln!(output, "<context>")?;
138    writeln!(
139        output,
140        "  <metadata total_files=\"{}\" total_tokens=\"{}\" algorithm=\"{}\"/>",
141        files.len(),
142        metrics.total_tokens_estimated,
143        metrics.algorithm_used
144    )?;
145
146    for file in files {
147        let path = escape_cxml(&file.relative_path);
148        let modified = escape_cxml(&format_timestamp(file.modified));
149        writeln!(
150            output,
151            "  <file path=\"{}\" modified=\"{}\">",
152            path, modified
153        )?;
154        writeln!(output, "    <![CDATA[")?;
155        output.push_str(&file.content);
156        if !file.content.ends_with('\n') {
157            output.push('\n');
158        }
159        writeln!(output, "    ]]>")?;
160        writeln!(output, "  </file>")?;
161    }
162
163    writeln!(output, "</context>")?;
164    Ok(output)
165}
166
167pub fn generate_repomix_output(
168    files: &[ReportFile],
169    metrics: &SelectionMetrics,
170) -> Result<String, Box<dyn Error>> {
171    let mut output = String::new();
172    writeln!(output, "# RepoMix Export")?;
173    writeln!(output, "- Total files: {}", files.len())?;
174    writeln!(output, "- Total tokens: {}", metrics.total_tokens_estimated)?;
175    writeln!(output, "- Algorithm: {}", metrics.algorithm_used)?;
176    writeln!(output, "")?;
177
178    for file in files {
179        writeln!(output, "## {}", file.relative_path)?;
180        writeln!(
181            output,
182            "- Last modified: {}",
183            format_timestamp(file.modified)
184        )?;
185        writeln!(output, "```")?;
186        output.push_str(&file.content);
187        if !file.content.ends_with('\n') {
188            output.push('\n');
189        }
190        writeln!(output, "```")?;
191        writeln!(output, "")?;
192    }
193
194    Ok(output)
195}
196
197pub fn generate_xml_output(
198    files: &[ReportFile],
199    metrics: &SelectionMetrics,
200) -> Result<String, Box<dyn Error>> {
201    let mut output = String::new();
202    writeln!(output, "<?xml version=\"1.0\" encoding=\"UTF-8\"?>")?;
203    writeln!(output, "<repository>")?;
204    writeln!(
205        output,
206        "  <summary files=\"{}\" tokens=\"{}\" algorithm=\"{}\" coverage=\"{:.1}\"/>",
207        files.len(),
208        metrics.total_tokens_estimated,
209        metrics.algorithm_used,
210        metrics.coverage_score * 100.0
211    )?;
212
213    for file in files {
214        let path = escape_cxml(&file.relative_path);
215        let modified = escape_cxml(&format_timestamp(file.modified));
216        writeln!(
217            output,
218            "  <file path=\"{}\" modified=\"{}\">",
219            path, modified
220        )?;
221        writeln!(
222            output,
223            "    <size bytes=\"{}\" tokens=\"{}\"/>",
224            file.size, file.estimated_tokens
225        )?;
226        writeln!(
227            output,
228            "    <scores importance=\"{:.2}\" centrality=\"{:.2}\" quality=\"{:.2}\"/>",
229            file.importance_score, file.centrality_score, file.content_quality_score
230        )?;
231        writeln!(output, "    <content><![CDATA[")?;
232        output.push_str(&file.content);
233        if !file.content.ends_with('\n') {
234            output.push('\n');
235        }
236        writeln!(output, "    ]]></content>")?;
237        writeln!(output, "  </file>")?;
238    }
239
240    writeln!(output, "</repository>")?;
241    Ok(output)
242}
243
244pub fn generate_json_output(
245    files: &[ReportFile],
246    metrics: &SelectionMetrics,
247) -> Result<String, Box<dyn Error>> {
248    let data = json!({
249        "summary": {
250            "total_files": files.len(),
251            "total_tokens": metrics.total_tokens_estimated,
252            "algorithm": metrics.algorithm_used,
253            "selection_time_ms": metrics.selection_time_ms,
254            "coverage_score": metrics.coverage_score,
255            "relevance_score": metrics.relevance_score,
256        },
257        "files": files.iter().map(|file| {
258            json!({
259                "path": file.relative_path,
260                "modified": format_timestamp(file.modified),
261                "size_bytes": file.size,
262                "estimated_tokens": file.estimated_tokens,
263                "importance_score": file.importance_score,
264                "centrality_score": file.centrality_score,
265                "query_relevance_score": file.query_relevance_score,
266                "entry_point_proximity": file.entry_point_proximity,
267                "content_quality_score": file.content_quality_score,
268                "repository_role_score": file.repository_role_score,
269                "recency_score": file.recency_score,
270                "content": file.content,
271            })
272        }).collect::<Vec<_>>()
273    });
274
275    Ok(serde_json::to_string_pretty(&data)?)
276}
277
278pub fn generate_text_output(
279    files: &[ReportFile],
280    metrics: &SelectionMetrics,
281) -> Result<String, Box<dyn Error>> {
282    let mut output = String::new();
283    writeln!(output, "Scribe Report")?;
284    writeln!(output, "============")?;
285    writeln!(output, "Total files: {}", files.len())?;
286    writeln!(output, "Total tokens: {}", metrics.total_tokens_estimated)?;
287    writeln!(output, "Algorithm: {}", metrics.algorithm_used)?;
288    writeln!(output, "")?;
289
290    for file in files {
291        writeln!(
292            output,
293            "--- {} ({} tokens) ---",
294            file.relative_path, file.estimated_tokens
295        )?;
296        writeln!(output, "Last modified: {}", format_timestamp(file.modified))?;
297        output.push_str(&file.content);
298        if !file.content.ends_with('\n') {
299            output.push('\n');
300        }
301        writeln!(output)?;
302    }
303
304    Ok(output)
305}
306
307pub fn generate_markdown_output(
308    files: &[ReportFile],
309    metrics: &SelectionMetrics,
310) -> Result<String, Box<dyn Error>> {
311    let mut output = String::new();
312    writeln!(output, "# Scribe Report")?;
313    writeln!(output, "- Total files: {}", files.len())?;
314    writeln!(output, "- Total tokens: {}", metrics.total_tokens_estimated)?;
315    writeln!(output, "- Algorithm: {}", metrics.algorithm_used)?;
316    writeln!(output, "")?;
317
318    for file in files {
319        writeln!(output, "## {}", file.relative_path)?;
320        writeln!(output, "- Size: {}", format_bytes(file.size))?;
321        writeln!(output, "- Tokens: {}", file.estimated_tokens)?;
322        writeln!(output, "- Importance: {:.2}", file.importance_score)?;
323        writeln!(output, "- Modified: {}", format_timestamp(file.modified))?;
324        writeln!(output, "")?;
325        writeln!(output, "```")?;
326        output.push_str(&file.content);
327        if !file.content.ends_with('\n') {
328            output.push('\n');
329        }
330        writeln!(output, "```")?;
331        writeln!(output, "")?;
332    }
333
334    Ok(output)
335}
336
337pub fn format_timestamp(time: Option<SystemTime>) -> String {
338    match time {
339        Some(ts) => {
340            let datetime: DateTime<Local> = ts.into();
341            datetime.format("%Y-%m-%d %H:%M:%S").to_string()
342        }
343        None => "N/A".to_string(),
344    }
345}
346
347pub fn format_bytes(bytes: u64) -> String {
348    const UNITS: [&str; 5] = ["B", "KB", "MB", "GB", "TB"];
349    if bytes == 0 {
350        return "0 B".to_string();
351    }
352
353    let i = (bytes as f64).log10() / 3.0;
354    let idx = i.floor() as usize;
355    let idx = idx.min(UNITS.len() - 1);
356    let value = bytes as f64 / 1000_f64.powi(idx as i32);
357    format!("{:.2} {}", value, UNITS[idx])
358}
359
360pub fn format_number(value: usize) -> String {
361    let mut s = value.to_string();
362    let mut i = s.len() as isize - 3;
363    while i > 0 {
364        s.insert(i as usize, ',');
365        i -= 3;
366    }
367    s
368}
369
370fn html_escape(value: &str) -> String {
371    value
372        .replace('&', "&amp;")
373        .replace('<', "&lt;")
374        .replace('>', "&gt;")
375        .replace('"', "&quot;")
376        .replace('\'', "&#39;")
377}
378
379fn escape_cxml(value: &str) -> String {
380    value
381        .replace('&', "&amp;")
382        .replace('<', "&lt;")
383        .replace('>', "&gt;")
384        .replace('"', "&quot;")
385}
386
387pub fn get_file_icon(file_path: &str) -> &'static str {
388    let path = Path::new(file_path);
389    let ext = path
390        .extension()
391        .and_then(|s| s.to_str())
392        .unwrap_or("")
393        .to_lowercase();
394    let name = path
395        .file_name()
396        .and_then(|s| s.to_str())
397        .unwrap_or("")
398        .to_lowercase();
399
400    if name.starts_with("readme") {
401        return "book-open";
402    } else if name == "license" || name == "licence" {
403        return "scale";
404    } else if name == "dockerfile" || name.contains("docker-compose") {
405        return "box";
406    } else if name == "makefile" {
407        return "settings";
408    } else if name.starts_with(".git") {
409        return "git-branch";
410    } else if name == "package.json" || name == "cargo.toml" || name == "go.mod" {
411        return "package";
412    }
413
414    match ext.as_str() {
415        "py" | "pyw" => "file-code",
416        "js" | "jsx" | "ts" | "tsx" | "mjs" | "cjs" => "file-code",
417        "html" | "htm" | "xml" | "xhtml" => "globe",
418        "css" | "scss" | "sass" | "less" => "palette",
419        "json" | "jsonc" | "json5" => "braces",
420        "yml" | "yaml" => "list",
421        "md" | "markdown" | "mdx" => "file-text",
422        "txt" | "text" => "file-text",
423        "rs" => "file-code",
424        "go" => "file-code",
425        "java" | "kt" | "scala" => "file-code",
426        "c" | "cpp" | "cc" | "h" | "hpp" => "file-code",
427        "cs" | "fs" | "vb" => "file-code",
428        "php" | "rb" | "pl" | "r" | "swift" | "dart" => "file-code",
429        "sh" | "bash" | "zsh" | "fish" | "ps1" | "bat" | "cmd" => "terminal",
430        "sql" | "sqlite" | "db" => "database",
431        "png" | "jpg" | "jpeg" | "gif" | "svg" | "webp" | "ico" => "image",
432        "pdf" => "file-text",
433        "zip" | "tar" | "gz" | "bz2" | "7z" | "rar" => "archive",
434        "toml" => "settings",
435        _ => "file",
436    }
437}