1use chrono::Utc;
165use serde::{Deserialize, Serialize};
166use turbovault_core::prelude::*;
167use turbovault_core::to_json_string;
168
169#[derive(Debug, Clone, Copy)]
171pub enum ExportFormat {
172 Json,
174 Csv,
176}
177
178#[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#[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#[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 #[serde(default)]
214 pub total_words: usize,
215 #[serde(default)]
217 pub total_readable_chars: usize,
218 #[serde(default)]
220 pub avg_words_per_note: f64,
221}
222
223#[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
234fn 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
252pub struct HealthReportExporter;
254
255impl HealthReportExporter {
256 pub fn to_json(report: &HealthReport) -> Result<String> {
258 to_json_string(report, "health report")
259 }
260
261 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
282pub struct BrokenLinksExporter;
284
285impl BrokenLinksExporter {
286 pub fn to_json(links: &[BrokenLinkRecord]) -> Result<String> {
288 to_json_string(links, "broken links")
289 }
290
291 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
310pub struct VaultStatsExporter;
312
313impl VaultStatsExporter {
314 pub fn to_json(stats: &VaultStatsRecord) -> Result<String> {
316 to_json_string(stats, "vault stats")
317 }
318
319 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
339pub struct AnalysisReportExporter;
341
342impl AnalysisReportExporter {
343 pub fn to_json(report: &AnalysisReport) -> Result<String> {
345 to_json_string(report, "analysis report")
346 }
347
348 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
368pub 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 #[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 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 assert_eq!(csv_escape("=cmd|'/C calc'!A1"), "\"'=cmd|'/C calc'!A1\"");
560 }
561}