Skip to main content

turbovault_export/
lib.rs

1//! # Export System
2//!
3//! Provides data export functionality for vault analysis in multiple formats (JSON, CSV).
4//! Enables downstream processing and reporting of vault metrics and analysis.
5//!
6//! ## Quick Start
7//!
8//! ```no_run
9//! use turbovault_export::{create_health_report, HealthReportExporter};
10//!
11//! # fn example() -> Result<(), Box<dyn std::error::Error>> {
12//! // Create a health report
13//! let report = create_health_report(
14//!     "my-vault",
15//!     85,        // health score
16//!     100,       // total notes
17//!     150,       // total links
18//!     2,         // broken links
19//!     5,         // orphaned notes
20//! );
21//!
22//! // Export as JSON
23//! let json = HealthReportExporter::to_json(&report)?;
24//! println!("JSON:\n{}", json);
25//!
26//! // Export as CSV
27//! let csv = HealthReportExporter::to_csv(&report)?;
28//! println!("CSV:\n{}", csv);
29//! # Ok(())
30//! # }
31//! ```
32//!
33//! ## Export Formats
34//!
35//! The system supports two formats for all exporters:
36//!
37//! ### JSON Export
38//! - Pretty-printed for readability
39//! - Full structure preserved
40//! - Suitable for API responses
41//! - Nested arrays and objects supported
42//!
43//! ### CSV Export
44//! - Flat structure (all values in one row)
45//! - Header row included
46//! - Quoted fields for safety
47//! - Suitable for spreadsheets and databases
48//!
49//! ## Core Exporters
50//!
51//! ### HealthReportExporter
52//!
53//! Exports vault health analysis:
54//! - Health score (0-100)
55//! - Connected vs orphaned notes
56//! - Broken link count
57//! - Connectivity and link density metrics
58//! - Status and recommendations
59//!
60//! ### BrokenLinksExporter
61//!
62//! Exports broken link analysis:
63//! - Source file for each broken link
64//! - Target that could not be resolved
65//! - Line number in source file
66//! - Suggested fixes
67//!
68//! ### VaultStatsExporter
69//!
70//! Exports vault statistics:
71//! - Timestamp of analysis
72//! - Total files and links
73//! - Orphaned file count
74//! - Average links per file
75//!
76//! ### AnalysisReportExporter
77//!
78//! Exports comprehensive analysis combining:
79//! - Health report
80//! - Broken links data
81//! - Recommendations
82//! - Full analysis context
83//!
84//! ## Data Models
85//!
86//! ### Health Metrics
87//!
88//! ```ignore
89//! #[derive(Serialize, Deserialize)]
90//! pub struct HealthReport {
91//!     pub timestamp: String,
92//!     pub vault_name: String,
93//!     pub health_score: u8,
94//!     pub total_notes: usize,
95//!     pub total_links: usize,
96//!     pub broken_links: usize,
97//!     pub orphaned_notes: usize,
98//!     pub connectivity_rate: f64,
99//!     pub link_density: f64,
100//!     pub status: String,
101//!     pub recommendations: Vec<String>,
102//! }
103//! ```
104//!
105//! ### Broken Links
106//!
107//! Each broken link includes:
108//! - Source file path
109//! - Target reference (failed to resolve)
110//! - Line number
111//! - Suggested alternatives
112//!
113//! ## Integration with Analysis
114//!
115//! Export data is typically generated from:
116//! - `turbovault_graph` health analysis (see <https://docs.rs/turbovault-graph>)
117//! - `turbovault_tools` analysis tools (see <https://docs.rs/turbovault-tools>)
118//! - Vault statistics computed at runtime
119//!
120//! Example integration:
121//! ```no_run
122//! use turbovault_export::{create_health_report, HealthReportExporter};
123//!
124//! # async fn example() -> Result<(), Box<dyn std::error::Error>> {
125//! // Get health from graph analysis
126//! // let health = graph.analyze_health().await?;
127//!
128//! // Create report from health data
129//! let report = create_health_report(
130//!     "vault",
131//!     80,
132//!     50,
133//!     100,
134//!     1,
135//!     2,
136//! );
137//!
138//! // Export in desired format
139//! // let json = HealthReportExporter::to_json(&report)?;
140//! # Ok(())
141//! # }
142//! ```
143//!
144//! ## File I/O Patterns
145//!
146//! Exporters return strings (JSON or CSV). To save to files:
147//!
148//! ```ignore
149//! use std::fs;
150//!
151//! let report = create_health_report(...);
152//! let json = HealthReportExporter::to_json(&report)?;
153//! fs::write("health-report.json", json)?;
154//! ```
155//!
156//! ## Performance Considerations
157//!
158//! - JSON serialization is optimized with `serde`
159//! - CSV generation uses string formatting (fast)
160//! - All exporters run in-memory
161//! - No I/O operations within exporters
162//! - Suitable for batch processing large datasets
163
164use chrono::Utc;
165use serde::{Deserialize, Serialize};
166use turbovault_core::prelude::*;
167use turbovault_core::to_json_string;
168
169/// Export format options
170#[derive(Debug, Clone, Copy)]
171pub enum ExportFormat {
172    /// JSON format (pretty-printed)
173    Json,
174    /// CSV format (flattened)
175    Csv,
176}
177
178/// Health report for export
179#[derive(Debug, Clone, Serialize, Deserialize)]
180pub struct HealthReport {
181    pub timestamp: String,
182    pub vault_name: String,
183    pub health_score: u8,
184    pub total_notes: usize,
185    pub total_links: usize,
186    pub broken_links: usize,
187    pub orphaned_notes: usize,
188    pub connectivity_rate: f64,
189    pub link_density: f64,
190    pub status: String,
191    pub recommendations: Vec<String>,
192}
193
194/// Broken link record for export
195#[derive(Debug, Clone, Serialize, Deserialize)]
196pub struct BrokenLinkRecord {
197    pub source_file: String,
198    pub target: String,
199    pub line: usize,
200    pub suggestions: Vec<String>,
201}
202
203/// Vault statistics for export
204#[derive(Debug, Clone, Serialize, Deserialize)]
205pub struct VaultStatsRecord {
206    pub timestamp: String,
207    pub vault_name: String,
208    pub total_files: usize,
209    pub total_links: usize,
210    pub orphaned_files: usize,
211    pub average_links_per_file: f64,
212    /// Total word count across all notes (plain text, excludes markdown syntax)
213    #[serde(default)]
214    pub total_words: usize,
215    /// Total character count across all notes (plain text)
216    #[serde(default)]
217    pub total_readable_chars: usize,
218    /// Average words per note
219    #[serde(default)]
220    pub avg_words_per_note: f64,
221}
222
223/// Full analysis report combining multiple metrics
224#[derive(Debug, Clone, Serialize, Deserialize)]
225pub struct AnalysisReport {
226    pub timestamp: String,
227    pub vault_name: String,
228    pub health: HealthReport,
229    pub broken_links_count: usize,
230    pub orphaned_notes_count: usize,
231    pub recommendations: Vec<String>,
232}
233
234/// Escape a value for safe CSV output.
235///
236/// Always wraps in double-quotes and escapes internal double-quotes by doubling
237/// them (RFC 4180). Prefixes formula-triggering characters (`=`, `+`, `-`, `@`)
238/// with a single-quote to prevent DDE/formula injection in spreadsheets.
239fn csv_escape(value: &str) -> String {
240    let safe_value = if value.starts_with('=')
241        || value.starts_with('+')
242        || value.starts_with('-')
243        || value.starts_with('@')
244    {
245        format!("'{}", value)
246    } else {
247        value.to_string()
248    };
249    format!("\"{}\"", safe_value.replace('"', "\"\""))
250}
251
252/// Health report exporter
253pub struct HealthReportExporter;
254
255impl HealthReportExporter {
256    /// Export health report as JSON
257    pub fn to_json(report: &HealthReport) -> Result<String> {
258        to_json_string(report, "health report")
259    }
260
261    /// Export health report as CSV (single row)
262    pub fn to_csv(report: &HealthReport) -> Result<String> {
263        let csv = format!(
264            "timestamp,vault_name,health_score,total_notes,total_links,broken_links,orphaned_notes,connectivity_rate,link_density,status\n\
265             {},{},{},{},{},{},{},{:.3},{:.3},{}",
266            csv_escape(&report.timestamp),
267            csv_escape(&report.vault_name),
268            report.health_score,
269            report.total_notes,
270            report.total_links,
271            report.broken_links,
272            report.orphaned_notes,
273            report.connectivity_rate,
274            report.link_density,
275            csv_escape(&report.status)
276        );
277
278        Ok(csv)
279    }
280}
281
282/// Broken links exporter
283pub struct BrokenLinksExporter;
284
285impl BrokenLinksExporter {
286    /// Export broken links as JSON
287    pub fn to_json(links: &[BrokenLinkRecord]) -> Result<String> {
288        to_json_string(links, "broken links")
289    }
290
291    /// Export broken links as CSV
292    pub fn to_csv(links: &[BrokenLinkRecord]) -> Result<String> {
293        let mut csv = String::from("source_file,target,line,suggestions\n");
294
295        for link in links {
296            let suggestions = link.suggestions.join("|");
297            csv.push_str(&format!(
298                "{},{},{},{}\n",
299                csv_escape(&link.source_file),
300                csv_escape(&link.target),
301                link.line,
302                csv_escape(&suggestions)
303            ));
304        }
305
306        Ok(csv)
307    }
308}
309
310/// Vault statistics exporter
311pub struct VaultStatsExporter;
312
313impl VaultStatsExporter {
314    /// Export vault stats as JSON
315    pub fn to_json(stats: &VaultStatsRecord) -> Result<String> {
316        to_json_string(stats, "vault stats")
317    }
318
319    /// Export vault stats as CSV
320    pub fn to_csv(stats: &VaultStatsRecord) -> Result<String> {
321        let csv = format!(
322            "timestamp,vault_name,total_files,total_links,orphaned_files,average_links_per_file,total_words,total_readable_chars,avg_words_per_note\n\
323             {},{},{},{},{},{:.3},{},{},{:.1}",
324            csv_escape(&stats.timestamp),
325            csv_escape(&stats.vault_name),
326            stats.total_files,
327            stats.total_links,
328            stats.orphaned_files,
329            stats.average_links_per_file,
330            stats.total_words,
331            stats.total_readable_chars,
332            stats.avg_words_per_note
333        );
334
335        Ok(csv)
336    }
337}
338
339/// Analysis report exporter
340pub struct AnalysisReportExporter;
341
342impl AnalysisReportExporter {
343    /// Export analysis report as JSON
344    pub fn to_json(report: &AnalysisReport) -> Result<String> {
345        to_json_string(report, "analysis report")
346    }
347
348    /// Export analysis report as CSV (health metrics only, flattened)
349    pub fn to_csv(report: &AnalysisReport) -> Result<String> {
350        let csv = format!(
351            "timestamp,vault_name,health_score,total_notes,total_links,broken_links,orphaned_notes,broken_links_count,recommendations\n\
352             {},{},{},{},{},{},{},{},{}",
353            csv_escape(&report.timestamp),
354            csv_escape(&report.vault_name),
355            report.health.health_score,
356            report.health.total_notes,
357            report.health.total_links,
358            report.health.broken_links,
359            report.health.orphaned_notes,
360            report.broken_links_count,
361            csv_escape(&report.recommendations.join("|"))
362        );
363
364        Ok(csv)
365    }
366}
367
368/// Create a health report with recommendations
369pub fn create_health_report(
370    vault_name: &str,
371    health_score: u8,
372    total_notes: usize,
373    total_links: usize,
374    broken_links: usize,
375    orphaned_notes: usize,
376) -> HealthReport {
377    let connectivity_rate = if total_notes > 0 {
378        (total_notes - orphaned_notes) as f64 / total_notes as f64
379    } else {
380        0.0
381    };
382
383    let link_density = if total_notes > 1 {
384        total_links as f64 / ((total_notes as f64) * (total_notes as f64 - 1.0))
385    } else {
386        0.0
387    };
388
389    let status = if health_score >= 80 {
390        "Healthy".to_string()
391    } else if health_score >= 60 {
392        "Fair".to_string()
393    } else if health_score >= 40 {
394        "Needs Attention".to_string()
395    } else {
396        "Critical".to_string()
397    };
398
399    let mut recommendations = Vec::new();
400
401    if broken_links > 0 {
402        recommendations.push(format!(
403            "Found {} broken links. Consider fixing or updating them.",
404            broken_links
405        ));
406    }
407
408    if orphaned_notes as f64 / total_notes as f64 > 0.1 {
409        recommendations
410            .push("Over 10% of notes are orphaned. Link them to improve connectivity.".to_string());
411    }
412
413    if link_density < 0.05 {
414        recommendations.push(
415            "Low link density. Consider adding more cross-references between notes.".to_string(),
416        );
417    }
418
419    HealthReport {
420        timestamp: Utc::now().to_rfc3339(),
421        vault_name: vault_name.to_string(),
422        health_score,
423        total_notes,
424        total_links,
425        broken_links,
426        orphaned_notes,
427        connectivity_rate,
428        link_density,
429        status,
430        recommendations,
431    }
432}
433
434#[cfg(test)]
435mod tests {
436    use super::*;
437
438    #[test]
439    fn test_health_report_creation() {
440        let report = create_health_report("test", 85, 100, 150, 2, 5);
441        assert_eq!(report.vault_name, "test");
442        assert_eq!(report.health_score, 85);
443        assert_eq!(report.status, "Healthy");
444    }
445
446    #[test]
447    fn test_health_report_json_export() {
448        let report = create_health_report("test", 85, 100, 150, 2, 5);
449        let json = HealthReportExporter::to_json(&report).unwrap();
450        assert!(json.contains("test"));
451        assert!(json.contains("85"));
452    }
453
454    #[test]
455    fn test_health_report_csv_export() {
456        let report = create_health_report("test", 85, 100, 150, 2, 5);
457        let csv = HealthReportExporter::to_csv(&report).unwrap();
458        assert!(csv.contains("test"));
459        assert!(csv.contains("85"));
460    }
461
462    #[test]
463    fn test_broken_links_export() {
464        let links = vec![BrokenLinkRecord {
465            source_file: "file.md".to_string(),
466            target: "missing.md".to_string(),
467            line: 5,
468            suggestions: vec!["existing.md".to_string()],
469        }];
470
471        let json = BrokenLinksExporter::to_json(&links).unwrap();
472        assert!(json.contains("file.md"));
473        assert!(json.contains("missing.md"));
474
475        let csv = BrokenLinksExporter::to_csv(&links).unwrap();
476        assert!(csv.contains("file.md"));
477    }
478
479    #[test]
480    fn test_vault_stats_export() {
481        let stats = VaultStatsRecord {
482            timestamp: "2025-01-01T00:00:00Z".to_string(),
483            vault_name: "test".to_string(),
484            total_files: 100,
485            total_links: 150,
486            orphaned_files: 5,
487            average_links_per_file: 1.5,
488            total_words: 25000,
489            total_readable_chars: 150000,
490            avg_words_per_note: 250.0,
491        };
492
493        let json = VaultStatsExporter::to_json(&stats).unwrap();
494        assert!(json.contains("100"));
495        assert!(json.contains("25000"));
496
497        let csv = VaultStatsExporter::to_csv(&stats).unwrap();
498        assert!(csv.contains("100"));
499        assert!(csv.contains("25000"));
500    }
501
502    // =========================================================================
503    // csv_escape tests
504    // =========================================================================
505
506    #[test]
507    fn test_csv_escape_plain_text() {
508        assert_eq!(csv_escape("hello world"), "\"hello world\"");
509    }
510
511    #[test]
512    fn test_csv_escape_embedded_quotes() {
513        assert_eq!(csv_escape(r#"say "hi""#), r#""say ""hi""""#);
514    }
515
516    #[test]
517    fn test_csv_escape_formula_equals() {
518        assert_eq!(csv_escape("=SUM(A1:A2)"), "\"'=SUM(A1:A2)\"");
519    }
520
521    #[test]
522    fn test_csv_escape_formula_plus() {
523        assert_eq!(csv_escape("+1234"), "\"'+1234\"");
524    }
525
526    #[test]
527    fn test_csv_escape_formula_minus() {
528        assert_eq!(csv_escape("-1234"), "\"'-1234\"");
529    }
530
531    #[test]
532    fn test_csv_escape_formula_at() {
533        assert_eq!(csv_escape("@SUM"), "\"'@SUM\"");
534    }
535
536    #[test]
537    fn test_csv_escape_empty_string() {
538        assert_eq!(csv_escape(""), "\"\"");
539    }
540
541    #[test]
542    fn test_csv_escape_with_newlines() {
543        // Newlines embedded inside quoted fields are valid per RFC 4180
544        assert_eq!(csv_escape("line1\nline2"), "\"line1\nline2\"");
545    }
546
547    #[test]
548    fn test_csv_escape_with_commas() {
549        assert_eq!(csv_escape("a,b,c"), "\"a,b,c\"");
550    }
551
552    #[test]
553    fn test_csv_escape_combined_injection() {
554        // Formula prefix AND embedded double-quotes
555        // Input:  =cmd|'/C calc'!A1
556        // After formula-prefix:  '=cmd|'/C calc'!A1
557        // After quote-doubling:  no double-quotes present, so unchanged
558        // Wrapped:               "'=cmd|'/C calc'!A1"
559        assert_eq!(csv_escape("=cmd|'/C calc'!A1"), "\"'=cmd|'/C calc'!A1\"");
560    }
561}