adk_doc_audit/
reporter.rs

1//! Report generation functionality for documentation audits.
2//!
3//! This module provides comprehensive reporting capabilities including:
4//! - Structured audit reports with issue categorization
5//! - Severity assignment and statistics calculation
6//! - Multiple output formats (JSON, Markdown, Console)
7//! - Actionable recommendations for fixing issues
8
9use crate::{AuditError, IssueSeverity, Result};
10use chrono::{DateTime, Utc};
11use serde::{Deserialize, Serialize};
12use std::collections::HashMap;
13use std::fmt::Write;
14use std::io::Write as IoWrite;
15use std::path::PathBuf;
16
17/// Comprehensive audit report containing all findings and statistics.
18#[derive(Debug, Clone, Serialize, Deserialize)]
19pub struct AuditReport {
20    /// High-level summary of the audit results
21    pub summary: AuditSummary,
22    /// Detailed results for each audited file
23    pub file_results: Vec<FileAuditResult>,
24    /// All issues found during the audit
25    pub issues: Vec<AuditIssue>,
26    /// Actionable recommendations for improvements
27    pub recommendations: Vec<Recommendation>,
28    /// When the audit was performed
29    pub timestamp: DateTime<Utc>,
30    /// Configuration used for the audit
31    pub audit_config: AuditReportConfig,
32}
33
34/// High-level statistics and summary of audit results.
35#[derive(Debug, Clone, Serialize, Deserialize)]
36pub struct AuditSummary {
37    /// Total number of files audited
38    pub total_files: usize,
39    /// Number of files that had at least one issue
40    pub files_with_issues: usize,
41    /// Total number of issues found across all files
42    pub total_issues: usize,
43    /// Number of critical severity issues
44    pub critical_issues: usize,
45    /// Number of warning severity issues
46    pub warning_issues: usize,
47    /// Number of info severity issues
48    pub info_issues: usize,
49    /// Percentage of documentation that is accurate (0.0 to 100.0)
50    pub coverage_percentage: f64,
51    /// Average issues per file
52    pub average_issues_per_file: f64,
53    /// Most common issue category
54    pub most_common_issue: Option<IssueCategory>,
55    /// Files with the most issues (top 5)
56    pub problematic_files: Vec<ProblematicFile>,
57}
58
59/// Information about a file with many issues.
60#[derive(Debug, Clone, Serialize, Deserialize)]
61pub struct ProblematicFile {
62    /// Path to the file
63    pub path: PathBuf,
64    /// Number of issues in this file
65    pub issue_count: usize,
66    /// Most severe issue in this file
67    pub max_severity: IssueSeverity,
68}
69
70/// Audit results for a specific file.
71#[derive(Debug, Clone, Serialize, Deserialize)]
72pub struct FileAuditResult {
73    /// Path to the audited file
74    pub file_path: PathBuf,
75    /// Hash of the file content when audited
76    pub file_hash: String,
77    /// When the file was last modified
78    pub last_modified: DateTime<Utc>,
79    /// Number of issues found in this file
80    pub issues_count: usize,
81    /// Issues found in this file
82    pub issues: Vec<AuditIssue>,
83    /// Whether the file passed the audit (no critical issues)
84    pub passed: bool,
85    /// Time taken to audit this file
86    pub audit_duration_ms: u64,
87}
88
89/// A specific issue found during documentation audit.
90#[derive(Debug, Clone, Serialize, Deserialize)]
91pub struct AuditIssue {
92    /// Unique identifier for this issue
93    pub id: String,
94    /// Path to the file containing the issue
95    pub file_path: PathBuf,
96    /// Line number where the issue occurs (if applicable)
97    pub line_number: Option<usize>,
98    /// Column number where the issue occurs (if applicable)
99    pub column_number: Option<usize>,
100    /// Severity level of the issue
101    pub severity: IssueSeverity,
102    /// Category of the issue
103    pub category: IssueCategory,
104    /// Human-readable description of the issue
105    pub message: String,
106    /// Suggested fix for the issue (if available)
107    pub suggestion: Option<String>,
108    /// Additional context or details
109    pub context: Option<String>,
110    /// Code snippet showing the problematic area
111    pub code_snippet: Option<String>,
112    /// Related issues (by ID)
113    pub related_issues: Vec<String>,
114}
115
116/// Categories of issues that can be found during audit.
117#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
118pub enum IssueCategory {
119    /// API reference doesn't match actual implementation
120    ApiMismatch,
121    /// Version numbers are inconsistent or outdated
122    VersionInconsistency,
123    /// Code example fails to compile
124    CompilationError,
125    /// Internal link is broken or incorrect
126    BrokenLink,
127    /// Feature exists but is not documented
128    MissingDocumentation,
129    /// Documentation references deprecated API
130    DeprecatedApi,
131    /// Import statement is invalid or incorrect
132    InvalidImport,
133    /// Configuration parameter is wrong or missing
134    ConfigurationError,
135    /// Async/await pattern is incorrect
136    AsyncPatternError,
137    /// Feature flag reference is invalid
138    InvalidFeatureFlag,
139    /// Crate name reference is invalid
140    InvalidCrateName,
141    /// General documentation quality issue
142    QualityIssue,
143    /// Error occurred while processing the file
144    ProcessingError,
145    /// Error occurred during validation
146    ValidationError,
147}
148
149impl IssueCategory {
150    /// Get a human-readable description of the issue category.
151    pub fn description(&self) -> &'static str {
152        match self {
153            IssueCategory::ApiMismatch => "API reference doesn't match implementation",
154            IssueCategory::VersionInconsistency => "Version numbers are inconsistent",
155            IssueCategory::CompilationError => "Code example fails to compile",
156            IssueCategory::BrokenLink => "Internal link is broken",
157            IssueCategory::MissingDocumentation => "Missing documentation for feature",
158            IssueCategory::DeprecatedApi => "References deprecated API",
159            IssueCategory::InvalidImport => "Import statement is invalid",
160            IssueCategory::ConfigurationError => "Configuration parameter error",
161            IssueCategory::AsyncPatternError => "Async/await pattern is incorrect",
162            IssueCategory::InvalidFeatureFlag => "Feature flag reference is invalid",
163            IssueCategory::InvalidCrateName => "Crate name reference is invalid",
164            IssueCategory::QualityIssue => "General documentation quality issue",
165            IssueCategory::ProcessingError => "Error occurred while processing file",
166            IssueCategory::ValidationError => "Error occurred during validation",
167        }
168    }
169
170    /// Get the default severity for this category.
171    pub fn default_severity(&self) -> IssueSeverity {
172        match self {
173            IssueCategory::ApiMismatch => IssueSeverity::Critical,
174            IssueCategory::CompilationError => IssueSeverity::Critical,
175            IssueCategory::VersionInconsistency => IssueSeverity::Warning,
176            IssueCategory::BrokenLink => IssueSeverity::Warning,
177            IssueCategory::DeprecatedApi => IssueSeverity::Warning,
178            IssueCategory::InvalidImport => IssueSeverity::Critical,
179            IssueCategory::ConfigurationError => IssueSeverity::Warning,
180            IssueCategory::AsyncPatternError => IssueSeverity::Warning,
181            IssueCategory::InvalidFeatureFlag => IssueSeverity::Warning,
182            IssueCategory::InvalidCrateName => IssueSeverity::Warning,
183            IssueCategory::MissingDocumentation => IssueSeverity::Info,
184            IssueCategory::QualityIssue => IssueSeverity::Info,
185            IssueCategory::ProcessingError => IssueSeverity::Critical,
186            IssueCategory::ValidationError => IssueSeverity::Warning,
187        }
188    }
189}
190
191/// Actionable recommendation for improving documentation.
192#[derive(Debug, Clone, Serialize, Deserialize)]
193pub struct Recommendation {
194    /// Unique identifier for this recommendation
195    pub id: String,
196    /// Type of recommendation
197    pub recommendation_type: RecommendationType,
198    /// Priority level (1 = highest, 5 = lowest)
199    pub priority: u8,
200    /// Title of the recommendation
201    pub title: String,
202    /// Detailed description
203    pub description: String,
204    /// Files that would be affected by this recommendation
205    pub affected_files: Vec<PathBuf>,
206    /// Estimated effort to implement (in hours)
207    pub estimated_effort_hours: Option<f32>,
208    /// Issues that would be resolved by this recommendation
209    pub resolves_issues: Vec<String>,
210}
211
212/// Types of recommendations that can be made.
213#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
214pub enum RecommendationType {
215    /// Fix a specific issue
216    FixIssue,
217    /// Improve documentation structure
218    StructuralImprovement,
219    /// Add missing documentation
220    AddDocumentation,
221    /// Update outdated content
222    UpdateContent,
223    /// Improve code examples
224    ImproveExamples,
225    /// Enhance cross-references
226    EnhanceCrossReferences,
227    /// Process improvement
228    ProcessImprovement,
229}
230
231/// Configuration for audit reporting.
232#[derive(Debug, Clone, Serialize, Deserialize)]
233pub struct AuditReportConfig {
234    /// Minimum severity level to include in reports
235    pub min_severity: IssueSeverity,
236    /// Whether to include suggestions in the report
237    pub include_suggestions: bool,
238    /// Whether to include code snippets in issues
239    pub include_code_snippets: bool,
240    /// Maximum number of issues to include per file
241    pub max_issues_per_file: Option<usize>,
242    /// Whether to include statistics in the report
243    pub include_statistics: bool,
244    /// Whether to include recommendations
245    pub include_recommendations: bool,
246}
247
248impl Default for AuditReportConfig {
249    fn default() -> Self {
250        Self {
251            min_severity: IssueSeverity::Info,
252            include_suggestions: true,
253            include_code_snippets: true,
254            max_issues_per_file: None,
255            include_statistics: true,
256            include_recommendations: true,
257        }
258    }
259}
260
261impl AuditReport {
262    /// Create a new audit report with the given configuration.
263    pub fn new(config: AuditReportConfig) -> Self {
264        Self {
265            summary: AuditSummary::default(),
266            file_results: Vec::new(),
267            issues: Vec::new(),
268            recommendations: Vec::new(),
269            timestamp: Utc::now(),
270            audit_config: config,
271        }
272    }
273
274    /// Add a file result to the report.
275    pub fn add_file_result(&mut self, file_result: FileAuditResult) {
276        // Add issues from this file to the main issues list
277        self.issues.extend(file_result.issues.clone());
278        self.file_results.push(file_result);
279    }
280
281    /// Add an issue to the report.
282    pub fn add_issue(&mut self, issue: AuditIssue) {
283        // Check if this issue meets the minimum severity threshold
284        if issue.severity >= self.audit_config.min_severity {
285            self.issues.push(issue);
286        }
287    }
288
289    /// Add a recommendation to the report.
290    pub fn add_recommendation(&mut self, recommendation: Recommendation) {
291        if self.audit_config.include_recommendations {
292            self.recommendations.push(recommendation);
293        }
294    }
295
296    /// Calculate and update the summary statistics.
297    pub fn calculate_summary(&mut self) {
298        let total_files = self.file_results.len();
299        let files_with_issues = self.file_results.iter().filter(|f| f.issues_count > 0).count();
300
301        let total_issues = self.issues.len();
302        let critical_issues =
303            self.issues.iter().filter(|i| i.severity == IssueSeverity::Critical).count();
304        let warning_issues =
305            self.issues.iter().filter(|i| i.severity == IssueSeverity::Warning).count();
306        let info_issues = self.issues.iter().filter(|i| i.severity == IssueSeverity::Info).count();
307
308        // Calculate coverage percentage (files without critical issues / total files)
309        let files_without_critical = self
310            .file_results
311            .iter()
312            .filter(|f| !f.issues.iter().any(|i| i.severity == IssueSeverity::Critical))
313            .count();
314        let coverage_percentage = if total_files > 0 {
315            (files_without_critical as f64 / total_files as f64) * 100.0
316        } else {
317            100.0
318        };
319
320        let average_issues_per_file =
321            if total_files > 0 { total_issues as f64 / total_files as f64 } else { 0.0 };
322
323        // Find most common issue category
324        let mut category_counts: HashMap<IssueCategory, usize> = HashMap::new();
325        for issue in &self.issues {
326            *category_counts.entry(issue.category).or_insert(0) += 1;
327        }
328        let most_common_issue = category_counts
329            .into_iter()
330            .max_by_key(|(_, count)| *count)
331            .map(|(category, _)| category);
332
333        // Find most problematic files (top 5)
334        let mut file_issue_counts: Vec<_> = self
335            .file_results
336            .iter()
337            .map(|f| ProblematicFile {
338                path: f.file_path.clone(),
339                issue_count: f.issues_count,
340                max_severity: f
341                    .issues
342                    .iter()
343                    .map(|i| i.severity)
344                    .max()
345                    .unwrap_or(IssueSeverity::Info),
346            })
347            .collect();
348        file_issue_counts.sort_by(|a, b| b.issue_count.cmp(&a.issue_count));
349        file_issue_counts.truncate(5);
350
351        self.summary = AuditSummary {
352            total_files,
353            files_with_issues,
354            total_issues,
355            critical_issues,
356            warning_issues,
357            info_issues,
358            coverage_percentage,
359            average_issues_per_file,
360            most_common_issue,
361            problematic_files: file_issue_counts,
362        };
363    }
364
365    /// Check if the audit passed (no critical issues).
366    pub fn passed(&self) -> bool {
367        self.summary.critical_issues == 0
368    }
369
370    /// Get issues by category.
371    pub fn issues_by_category(&self) -> HashMap<IssueCategory, Vec<&AuditIssue>> {
372        let mut categorized = HashMap::new();
373        for issue in &self.issues {
374            categorized.entry(issue.category).or_insert_with(Vec::new).push(issue);
375        }
376        categorized
377    }
378
379    /// Get issues by severity.
380    pub fn issues_by_severity(&self) -> HashMap<IssueSeverity, Vec<&AuditIssue>> {
381        let mut by_severity = HashMap::new();
382        for issue in &self.issues {
383            by_severity.entry(issue.severity).or_insert_with(Vec::new).push(issue);
384        }
385        by_severity
386    }
387
388    /// Get issues for a specific file.
389    pub fn issues_for_file(&self, file_path: &PathBuf) -> Vec<&AuditIssue> {
390        self.issues.iter().filter(|issue| &issue.file_path == file_path).collect()
391    }
392}
393
394impl Default for AuditSummary {
395    fn default() -> Self {
396        Self {
397            total_files: 0,
398            files_with_issues: 0,
399            total_issues: 0,
400            critical_issues: 0,
401            warning_issues: 0,
402            info_issues: 0,
403            coverage_percentage: 100.0,
404            average_issues_per_file: 0.0,
405            most_common_issue: None,
406            problematic_files: Vec::new(),
407        }
408    }
409}
410
411impl AuditIssue {
412    /// Create a new audit issue with the given parameters.
413    pub fn new(file_path: PathBuf, category: IssueCategory, message: String) -> Self {
414        let id = uuid::Uuid::new_v4().to_string();
415        let severity = category.default_severity();
416
417        Self {
418            id,
419            file_path,
420            line_number: None,
421            column_number: None,
422            severity,
423            category,
424            message,
425            suggestion: None,
426            context: None,
427            code_snippet: None,
428            related_issues: Vec::new(),
429        }
430    }
431
432    /// Set the line number for this issue.
433    pub fn with_line_number(mut self, line_number: usize) -> Self {
434        self.line_number = Some(line_number);
435        self
436    }
437
438    /// Set the column number for this issue.
439    pub fn with_column_number(mut self, column_number: usize) -> Self {
440        self.column_number = Some(column_number);
441        self
442    }
443
444    /// Set the severity for this issue.
445    pub fn with_severity(mut self, severity: IssueSeverity) -> Self {
446        self.severity = severity;
447        self
448    }
449
450    /// Set a suggestion for fixing this issue.
451    pub fn with_suggestion(mut self, suggestion: String) -> Self {
452        self.suggestion = Some(suggestion);
453        self
454    }
455
456    /// Set additional context for this issue.
457    pub fn with_context(mut self, context: String) -> Self {
458        self.context = Some(context);
459        self
460    }
461
462    /// Set a code snippet showing the problematic area.
463    pub fn with_code_snippet(mut self, code_snippet: String) -> Self {
464        self.code_snippet = Some(code_snippet);
465        self
466    }
467
468    /// Add a related issue ID.
469    pub fn with_related_issue(mut self, issue_id: String) -> Self {
470        self.related_issues.push(issue_id);
471        self
472    }
473}
474
475impl Recommendation {
476    /// Create a new recommendation.
477    pub fn new(
478        recommendation_type: RecommendationType,
479        title: String,
480        description: String,
481    ) -> Self {
482        let id = uuid::Uuid::new_v4().to_string();
483
484        Self {
485            id,
486            recommendation_type,
487            priority: 3, // Default to medium priority
488            title,
489            description,
490            affected_files: Vec::new(),
491            estimated_effort_hours: None,
492            resolves_issues: Vec::new(),
493        }
494    }
495
496    /// Set the priority for this recommendation (1 = highest, 5 = lowest).
497    pub fn with_priority(mut self, priority: u8) -> Self {
498        self.priority = priority.clamp(1, 5);
499        self
500    }
501
502    /// Add an affected file.
503    pub fn with_affected_file(mut self, file_path: PathBuf) -> Self {
504        self.affected_files.push(file_path);
505        self
506    }
507
508    /// Set the estimated effort in hours.
509    pub fn with_estimated_effort(mut self, hours: f32) -> Self {
510        self.estimated_effort_hours = Some(hours);
511        self
512    }
513
514    /// Add an issue that this recommendation would resolve.
515    pub fn resolves_issue(mut self, issue_id: String) -> Self {
516        self.resolves_issues.push(issue_id);
517        self
518    }
519}
520
521#[cfg(test)]
522mod tests {
523    use super::*;
524
525    #[test]
526    fn test_audit_report_creation() {
527        let config = AuditReportConfig::default();
528        let report = AuditReport::new(config);
529
530        assert_eq!(report.summary.total_files, 0);
531        assert_eq!(report.issues.len(), 0);
532        assert!(report.passed());
533    }
534
535    #[test]
536    fn test_issue_creation() {
537        let issue = AuditIssue::new(
538            PathBuf::from("test.md"),
539            IssueCategory::ApiMismatch,
540            "Test issue".to_string(),
541        )
542        .with_line_number(42)
543        .with_suggestion("Fix this".to_string());
544
545        assert_eq!(issue.file_path, PathBuf::from("test.md"));
546        assert_eq!(issue.category, IssueCategory::ApiMismatch);
547        assert_eq!(issue.severity, IssueSeverity::Critical);
548        assert_eq!(issue.line_number, Some(42));
549        assert_eq!(issue.suggestion, Some("Fix this".to_string()));
550    }
551
552    #[test]
553    fn test_issue_category_descriptions() {
554        assert_eq!(
555            IssueCategory::ApiMismatch.description(),
556            "API reference doesn't match implementation"
557        );
558        assert_eq!(IssueCategory::CompilationError.description(), "Code example fails to compile");
559    }
560
561    #[test]
562    fn test_issue_category_default_severity() {
563        assert_eq!(IssueCategory::ApiMismatch.default_severity(), IssueSeverity::Critical);
564        assert_eq!(IssueCategory::VersionInconsistency.default_severity(), IssueSeverity::Warning);
565        assert_eq!(IssueCategory::MissingDocumentation.default_severity(), IssueSeverity::Info);
566    }
567
568    #[test]
569    fn test_recommendation_creation() {
570        let rec = Recommendation::new(
571            RecommendationType::FixIssue,
572            "Fix API references".to_string(),
573            "Update all API references to match current implementation".to_string(),
574        )
575        .with_priority(1)
576        .with_estimated_effort(2.5);
577
578        assert_eq!(rec.recommendation_type, RecommendationType::FixIssue);
579        assert_eq!(rec.priority, 1);
580        assert_eq!(rec.estimated_effort_hours, Some(2.5));
581    }
582
583    #[test]
584    fn test_audit_summary_calculation() {
585        let mut report = AuditReport::new(AuditReportConfig::default());
586
587        // Add some test file results
588        let file1 = FileAuditResult {
589            file_path: PathBuf::from("file1.md"),
590            file_hash: "hash1".to_string(),
591            last_modified: Utc::now(),
592            issues_count: 2,
593            issues: vec![
594                AuditIssue::new(
595                    PathBuf::from("file1.md"),
596                    IssueCategory::ApiMismatch,
597                    "Issue 1".to_string(),
598                ),
599                AuditIssue::new(
600                    PathBuf::from("file1.md"),
601                    IssueCategory::VersionInconsistency,
602                    "Issue 2".to_string(),
603                ),
604            ],
605            passed: false,
606            audit_duration_ms: 100,
607        };
608
609        let file2 = FileAuditResult {
610            file_path: PathBuf::from("file2.md"),
611            file_hash: "hash2".to_string(),
612            last_modified: Utc::now(),
613            issues_count: 0,
614            issues: vec![],
615            passed: true,
616            audit_duration_ms: 50,
617        };
618
619        report.add_file_result(file1);
620        report.add_file_result(file2);
621        report.calculate_summary();
622
623        assert_eq!(report.summary.total_files, 2);
624        assert_eq!(report.summary.files_with_issues, 1);
625        assert_eq!(report.summary.total_issues, 2);
626        assert_eq!(report.summary.critical_issues, 1);
627        assert_eq!(report.summary.warning_issues, 1);
628        assert_eq!(report.summary.coverage_percentage, 50.0); // 1 file without critical issues out of 2
629    }
630
631    #[test]
632    fn test_issues_by_category() {
633        let mut report = AuditReport::new(AuditReportConfig::default());
634
635        report.add_issue(AuditIssue::new(
636            PathBuf::from("test.md"),
637            IssueCategory::ApiMismatch,
638            "API issue".to_string(),
639        ));
640
641        report.add_issue(AuditIssue::new(
642            PathBuf::from("test.md"),
643            IssueCategory::ApiMismatch,
644            "Another API issue".to_string(),
645        ));
646
647        report.add_issue(AuditIssue::new(
648            PathBuf::from("test.md"),
649            IssueCategory::CompilationError,
650            "Compilation issue".to_string(),
651        ));
652
653        let by_category = report.issues_by_category();
654        assert_eq!(by_category.get(&IssueCategory::ApiMismatch).unwrap().len(), 2);
655        assert_eq!(by_category.get(&IssueCategory::CompilationError).unwrap().len(), 1);
656    }
657
658    #[test]
659    fn test_report_generator_json() {
660        let mut report = AuditReport::new(AuditReportConfig::default());
661
662        report.add_issue(AuditIssue::new(
663            PathBuf::from("test.md"),
664            IssueCategory::ApiMismatch,
665            "Test issue".to_string(),
666        ));
667
668        report.calculate_summary();
669
670        let generator = ReportGenerator::new(OutputFormat::Json);
671        let json_output = generator.generate_report_string(&report).unwrap();
672
673        // Verify it's valid JSON
674        let parsed: serde_json::Value = serde_json::from_str(&json_output).unwrap();
675        assert!(parsed.get("summary").is_some());
676        assert!(parsed.get("issues").is_some());
677        assert!(parsed.get("timestamp").is_some());
678    }
679
680    #[test]
681    fn test_report_generator_markdown() {
682        let mut report = AuditReport::new(AuditReportConfig::default());
683
684        report.add_issue(
685            AuditIssue::new(
686                PathBuf::from("test.md"),
687                IssueCategory::CompilationError,
688                "Compilation failed".to_string(),
689            )
690            .with_line_number(42),
691        );
692
693        report.calculate_summary();
694
695        let generator = ReportGenerator::new(OutputFormat::Markdown);
696        let markdown_output = generator.generate_report_string(&report).unwrap();
697
698        // Verify markdown structure
699        assert!(markdown_output.contains("# Documentation Audit Report"));
700        assert!(markdown_output.contains("## Executive Summary"));
701        assert!(markdown_output.contains("test.md"));
702        assert!(markdown_output.contains("line 42"));
703    }
704
705    #[test]
706    fn test_report_generator_console() {
707        let mut report = AuditReport::new(AuditReportConfig::default());
708
709        report.add_issue(AuditIssue::new(
710            PathBuf::from("test.md"),
711            IssueCategory::VersionInconsistency,
712            "Version mismatch".to_string(),
713        ));
714
715        report.calculate_summary();
716
717        let generator = ReportGenerator::new(OutputFormat::Console);
718        let console_output = generator.generate_report_string(&report).unwrap();
719
720        // Verify console structure
721        assert!(console_output.contains("DOCUMENTATION AUDIT REPORT"));
722        assert!(console_output.contains("SUMMARY"));
723        assert!(console_output.contains("Total Files:"));
724        assert!(console_output.contains("🟡 WARNING"));
725    }
726
727    #[test]
728    fn test_wrap_text() {
729        use super::wrap_text;
730
731        let text = "This is a very long line that should be wrapped at the specified width";
732        let wrapped = wrap_text(text, 20);
733
734        for line in wrapped.lines() {
735            assert!(line.len() <= 20);
736        }
737
738        // Should preserve all words
739        let original_words: Vec<&str> = text.split_whitespace().collect();
740        let wrapped_words: Vec<&str> = wrapped.split_whitespace().collect();
741        assert_eq!(original_words, wrapped_words);
742    }
743}
744/// Output formats supported by the report generator.
745#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
746pub enum OutputFormat {
747    /// JSON format for programmatic consumption
748    Json,
749    /// Markdown format for human-readable reports
750    Markdown,
751    /// Console format for interactive use
752    Console,
753}
754
755impl From<crate::config::OutputFormat> for OutputFormat {
756    fn from(config_format: crate::config::OutputFormat) -> Self {
757        match config_format {
758            crate::config::OutputFormat::Console => OutputFormat::Console,
759            crate::config::OutputFormat::Json => OutputFormat::Json,
760            crate::config::OutputFormat::Markdown => OutputFormat::Markdown,
761        }
762    }
763}
764
765/// Report generator that can output audit reports in multiple formats.
766pub struct ReportGenerator {
767    output_format: OutputFormat,
768    config: AuditReportConfig,
769}
770
771impl ReportGenerator {
772    /// Create a new report generator with the specified output format.
773    pub fn new(output_format: OutputFormat) -> Self {
774        Self { output_format, config: AuditReportConfig::default() }
775    }
776
777    /// Create a new report generator with custom configuration.
778    pub fn with_config(output_format: OutputFormat, config: AuditReportConfig) -> Self {
779        Self { output_format, config }
780    }
781
782    /// Generate a report and write it to the provided writer.
783    pub fn generate_report<W: IoWrite>(&self, report: &AuditReport, writer: &mut W) -> Result<()> {
784        match self.output_format {
785            OutputFormat::Json => self.generate_json_report(report, writer),
786            OutputFormat::Markdown => self.generate_markdown_report(report, writer),
787            OutputFormat::Console => self.generate_console_report(report, writer),
788        }
789    }
790
791    /// Generate a report as a string.
792    pub fn generate_report_string(&self, report: &AuditReport) -> Result<String> {
793        let mut buffer = Vec::new();
794        self.generate_report(report, &mut buffer)?;
795        String::from_utf8(buffer).map_err(|e| AuditError::ReportGeneration {
796            details: format!("UTF-8 conversion error: {}", e),
797        })
798    }
799
800    /// Generate JSON format report.
801    fn generate_json_report<W: IoWrite>(&self, report: &AuditReport, writer: &mut W) -> Result<()> {
802        let json = if self.config.include_statistics {
803            serde_json::to_string_pretty(report)
804        } else {
805            // Create a simplified report without detailed statistics
806            let simplified = SimplifiedReport {
807                summary: &report.summary,
808                issues: &report.issues,
809                recommendations: if self.config.include_recommendations {
810                    Some(&report.recommendations)
811                } else {
812                    None
813                },
814                timestamp: report.timestamp,
815            };
816            serde_json::to_string_pretty(&simplified)
817        };
818
819        let json = json.map_err(|e| AuditError::ReportGeneration {
820            details: format!("JSON serialization error: {}", e),
821        })?;
822        writer
823            .write_all(json.as_bytes())
824            .map_err(|e| AuditError::ReportGeneration { details: format!("Write error: {}", e) })?;
825
826        Ok(())
827    }
828
829    /// Generate Markdown format report.
830    fn generate_markdown_report<W: IoWrite>(
831        &self,
832        report: &AuditReport,
833        writer: &mut W,
834    ) -> Result<()> {
835        let mut output = String::new();
836
837        // Title and summary
838        writeln!(output, "# Documentation Audit Report").unwrap();
839        writeln!(output).unwrap();
840        writeln!(output, "**Generated:** {}", report.timestamp.format("%Y-%m-%d %H:%M:%S UTC"))
841            .unwrap();
842        writeln!(output, "**Status:** {}", if report.passed() { "✅ PASSED" } else { "❌ FAILED" })
843            .unwrap();
844        writeln!(output).unwrap();
845
846        // Executive summary
847        writeln!(output, "## Executive Summary").unwrap();
848        writeln!(output).unwrap();
849        writeln!(output, "- **Total Files Audited:** {}", report.summary.total_files).unwrap();
850        writeln!(output, "- **Files with Issues:** {}", report.summary.files_with_issues).unwrap();
851        writeln!(output, "- **Total Issues:** {}", report.summary.total_issues).unwrap();
852        writeln!(output, "- **Critical Issues:** {}", report.summary.critical_issues).unwrap();
853        writeln!(output, "- **Warning Issues:** {}", report.summary.warning_issues).unwrap();
854        writeln!(output, "- **Info Issues:** {}", report.summary.info_issues).unwrap();
855        writeln!(
856            output,
857            "- **Documentation Coverage:** {:.1}%",
858            report.summary.coverage_percentage
859        )
860        .unwrap();
861        writeln!(output).unwrap();
862
863        // Issues by category
864        if !report.issues.is_empty() {
865            writeln!(output, "## Issues by Category").unwrap();
866            writeln!(output).unwrap();
867
868            let issues_by_category = report.issues_by_category();
869            for (category, issues) in issues_by_category {
870                writeln!(output, "### {} ({} issues)", category.description(), issues.len())
871                    .unwrap();
872                writeln!(output).unwrap();
873
874                for issue in
875                    issues.iter().take(self.config.max_issues_per_file.unwrap_or(usize::MAX))
876                {
877                    let severity_icon = match issue.severity {
878                        IssueSeverity::Critical => "🔴",
879                        IssueSeverity::Warning => "🟡",
880                        IssueSeverity::Info => "🔵",
881                    };
882
883                    write!(
884                        output,
885                        "- {} **{}**: {}",
886                        severity_icon,
887                        issue.file_path.display(),
888                        issue.message
889                    )
890                    .unwrap();
891
892                    if let Some(line) = issue.line_number {
893                        write!(output, " (line {})", line).unwrap();
894                    }
895                    writeln!(output).unwrap();
896
897                    if self.config.include_suggestions {
898                        if let Some(suggestion) = &issue.suggestion {
899                            writeln!(output, "  - *Suggestion:* {}", suggestion).unwrap();
900                        }
901                    }
902
903                    if self.config.include_code_snippets {
904                        if let Some(snippet) = &issue.code_snippet {
905                            writeln!(output, "  ```").unwrap();
906                            writeln!(output, "  {}", snippet).unwrap();
907                            writeln!(output, "  ```").unwrap();
908                        }
909                    }
910                }
911                writeln!(output).unwrap();
912            }
913        }
914
915        // Most problematic files
916        if !report.summary.problematic_files.is_empty() {
917            writeln!(output, "## Most Problematic Files").unwrap();
918            writeln!(output).unwrap();
919
920            for (i, file) in report.summary.problematic_files.iter().enumerate() {
921                let severity_icon = match file.max_severity {
922                    IssueSeverity::Critical => "🔴",
923                    IssueSeverity::Warning => "🟡",
924                    IssueSeverity::Info => "🔵",
925                };
926                writeln!(
927                    output,
928                    "{}. {} {} ({} issues)",
929                    i + 1,
930                    severity_icon,
931                    file.path.display(),
932                    file.issue_count
933                )
934                .unwrap();
935            }
936            writeln!(output).unwrap();
937        }
938
939        // Recommendations
940        if self.config.include_recommendations && !report.recommendations.is_empty() {
941            writeln!(output, "## Recommendations").unwrap();
942            writeln!(output).unwrap();
943
944            let mut sorted_recommendations = report.recommendations.clone();
945            sorted_recommendations.sort_by_key(|r| r.priority);
946
947            for rec in sorted_recommendations {
948                let priority_text = match rec.priority {
949                    1 => "🔴 High",
950                    2 => "🟡 Medium-High",
951                    3 => "🟡 Medium",
952                    4 => "🔵 Medium-Low",
953                    5 => "🔵 Low",
954                    _ => "🔵 Low",
955                };
956
957                writeln!(output, "### {} - {}", priority_text, rec.title).unwrap();
958                writeln!(output).unwrap();
959                writeln!(output, "{}", rec.description).unwrap();
960
961                if let Some(effort) = rec.estimated_effort_hours {
962                    writeln!(output, "**Estimated Effort:** {:.1} hours", effort).unwrap();
963                }
964
965                if !rec.affected_files.is_empty() {
966                    writeln!(output, "**Affected Files:**").unwrap();
967                    for file in &rec.affected_files {
968                        writeln!(output, "- {}", file.display()).unwrap();
969                    }
970                }
971                writeln!(output).unwrap();
972            }
973        }
974
975        writer
976            .write_all(output.as_bytes())
977            .map_err(|e| AuditError::ReportGeneration { details: format!("Write error: {}", e) })?;
978
979        Ok(())
980    }
981
982    /// Generate console format report.
983    fn generate_console_report<W: IoWrite>(
984        &self,
985        report: &AuditReport,
986        writer: &mut W,
987    ) -> Result<()> {
988        let mut output = String::new();
989
990        // Header
991        writeln!(
992            output,
993            "╔══════════════════════════════════════════════════════════════════════════════╗"
994        )
995        .unwrap();
996        writeln!(
997            output,
998            "║                          DOCUMENTATION AUDIT REPORT                         ║"
999        )
1000        .unwrap();
1001        writeln!(
1002            output,
1003            "╚══════════════════════════════════════════════════════════════════════════════╝"
1004        )
1005        .unwrap();
1006        writeln!(output).unwrap();
1007
1008        // Status
1009        let status = if report.passed() { "✅ PASSED" } else { "❌ FAILED" };
1010        writeln!(output, "Status: {}", status).unwrap();
1011        writeln!(output, "Generated: {}", report.timestamp.format("%Y-%m-%d %H:%M:%S UTC"))
1012            .unwrap();
1013        writeln!(output).unwrap();
1014
1015        // Summary box
1016        writeln!(
1017            output,
1018            "┌─ SUMMARY ─────────────────────────────────────────────────────────────────────┐"
1019        )
1020        .unwrap();
1021        writeln!(
1022            output,
1023            "│ Total Files:        {:>8}                                                │",
1024            report.summary.total_files
1025        )
1026        .unwrap();
1027        writeln!(
1028            output,
1029            "│ Files with Issues:  {:>8}                                                │",
1030            report.summary.files_with_issues
1031        )
1032        .unwrap();
1033        writeln!(
1034            output,
1035            "│ Total Issues:       {:>8}                                                │",
1036            report.summary.total_issues
1037        )
1038        .unwrap();
1039        writeln!(
1040            output,
1041            "│ Critical Issues:    {:>8}                                                │",
1042            report.summary.critical_issues
1043        )
1044        .unwrap();
1045        writeln!(
1046            output,
1047            "│ Warning Issues:     {:>8}                                                │",
1048            report.summary.warning_issues
1049        )
1050        .unwrap();
1051        writeln!(
1052            output,
1053            "│ Info Issues:        {:>8}                                                │",
1054            report.summary.info_issues
1055        )
1056        .unwrap();
1057        writeln!(
1058            output,
1059            "│ Coverage:           {:>7.1}%                                               │",
1060            report.summary.coverage_percentage
1061        )
1062        .unwrap();
1063        writeln!(
1064            output,
1065            "└───────────────────────────────────────────────────────────────────────────────┘"
1066        )
1067        .unwrap();
1068        writeln!(output).unwrap();
1069
1070        // Issues by severity
1071        if report.summary.total_issues > 0 {
1072            writeln!(output, "ISSUES BY SEVERITY:").unwrap();
1073            writeln!(
1074                output,
1075                "─────────────────────────────────────────────────────────────────────────────"
1076            )
1077            .unwrap();
1078
1079            let issues_by_severity = report.issues_by_severity();
1080
1081            if let Some(critical_issues) = issues_by_severity.get(&IssueSeverity::Critical) {
1082                writeln!(output, "🔴 CRITICAL ({}):", critical_issues.len()).unwrap();
1083                for issue in critical_issues.iter().take(5) {
1084                    writeln!(output, "   {} - {}", issue.file_path.display(), issue.message)
1085                        .unwrap();
1086                }
1087                if critical_issues.len() > 5 {
1088                    writeln!(output, "   ... and {} more", critical_issues.len() - 5).unwrap();
1089                }
1090                writeln!(output).unwrap();
1091            }
1092
1093            if let Some(warning_issues) = issues_by_severity.get(&IssueSeverity::Warning) {
1094                writeln!(output, "🟡 WARNING ({}):", warning_issues.len()).unwrap();
1095                for issue in warning_issues.iter().take(3) {
1096                    writeln!(output, "   {} - {}", issue.file_path.display(), issue.message)
1097                        .unwrap();
1098                }
1099                if warning_issues.len() > 3 {
1100                    writeln!(output, "   ... and {} more", warning_issues.len() - 3).unwrap();
1101                }
1102                writeln!(output).unwrap();
1103            }
1104
1105            if let Some(info_issues) = issues_by_severity.get(&IssueSeverity::Info) {
1106                writeln!(output, "🔵 INFO ({}):", info_issues.len()).unwrap();
1107                for issue in info_issues.iter().take(2) {
1108                    writeln!(output, "   {} - {}", issue.file_path.display(), issue.message)
1109                        .unwrap();
1110                }
1111                if info_issues.len() > 2 {
1112                    writeln!(output, "   ... and {} more", info_issues.len() - 2).unwrap();
1113                }
1114                writeln!(output).unwrap();
1115            }
1116        }
1117
1118        // Most problematic files
1119        if !report.summary.problematic_files.is_empty() {
1120            writeln!(output, "MOST PROBLEMATIC FILES:").unwrap();
1121            writeln!(
1122                output,
1123                "─────────────────────────────────────────────────────────────────────────────"
1124            )
1125            .unwrap();
1126
1127            for (i, file) in report.summary.problematic_files.iter().enumerate() {
1128                let severity_icon = match file.max_severity {
1129                    IssueSeverity::Critical => "🔴",
1130                    IssueSeverity::Warning => "🟡",
1131                    IssueSeverity::Info => "🔵",
1132                };
1133                writeln!(
1134                    output,
1135                    "{}. {} {} ({} issues)",
1136                    i + 1,
1137                    severity_icon,
1138                    file.path.display(),
1139                    file.issue_count
1140                )
1141                .unwrap();
1142            }
1143            writeln!(output).unwrap();
1144        }
1145
1146        // Top recommendations
1147        if self.config.include_recommendations && !report.recommendations.is_empty() {
1148            writeln!(output, "TOP RECOMMENDATIONS:").unwrap();
1149            writeln!(
1150                output,
1151                "─────────────────────────────────────────────────────────────────────────────"
1152            )
1153            .unwrap();
1154
1155            let mut sorted_recommendations = report.recommendations.clone();
1156            sorted_recommendations.sort_by_key(|r| r.priority);
1157
1158            for rec in sorted_recommendations.iter().take(3) {
1159                let priority_text = match rec.priority {
1160                    1 => "🔴 HIGH",
1161                    2 => "🟡 MED-HIGH",
1162                    3 => "🟡 MEDIUM",
1163                    4 => "🔵 MED-LOW",
1164                    5 => "🔵 LOW",
1165                    _ => "🔵 LOW",
1166                };
1167
1168                writeln!(output, "{}: {}", priority_text, rec.title).unwrap();
1169
1170                // Wrap description to fit console width
1171                let wrapped_desc = wrap_text(&rec.description, 75);
1172                for line in wrapped_desc.lines() {
1173                    writeln!(output, "   {}", line).unwrap();
1174                }
1175                writeln!(output).unwrap();
1176            }
1177
1178            if report.recommendations.len() > 3 {
1179                writeln!(
1180                    output,
1181                    "... and {} more recommendations",
1182                    report.recommendations.len() - 3
1183                )
1184                .unwrap();
1185                writeln!(output).unwrap();
1186            }
1187        }
1188
1189        // Footer
1190        writeln!(
1191            output,
1192            "─────────────────────────────────────────────────────────────────────────────"
1193        )
1194        .unwrap();
1195        if report.passed() {
1196            writeln!(output, "✅ Audit completed successfully! No critical issues found.").unwrap();
1197        } else {
1198            writeln!(output, "❌ Audit failed. Please address critical issues before proceeding.")
1199                .unwrap();
1200        }
1201
1202        writer
1203            .write_all(output.as_bytes())
1204            .map_err(|e| AuditError::ReportGeneration { details: format!("Write error: {}", e) })?;
1205
1206        Ok(())
1207    }
1208
1209    /// Save a report to a file.
1210    pub fn save_to_file(&self, report: &AuditReport, file_path: &std::path::Path) -> Result<()> {
1211        use std::fs::File;
1212        use std::io::BufWriter;
1213
1214        let file = File::create(file_path).map_err(|e| AuditError::IoError {
1215            path: file_path.to_path_buf(),
1216            details: format!("Failed to create report file: {}", e),
1217        })?;
1218
1219        let mut writer = BufWriter::new(file);
1220        self.generate_report(report, &mut writer)?;
1221
1222        Ok(())
1223    }
1224}
1225
1226/// Simplified report structure for JSON output when statistics are disabled.
1227#[derive(Serialize)]
1228struct SimplifiedReport<'a> {
1229    summary: &'a AuditSummary,
1230    issues: &'a [AuditIssue],
1231    #[serde(skip_serializing_if = "Option::is_none")]
1232    recommendations: Option<&'a [Recommendation]>,
1233    timestamp: DateTime<Utc>,
1234}
1235
1236/// Wrap text to fit within the specified width.
1237fn wrap_text(text: &str, width: usize) -> String {
1238    let mut result = String::new();
1239    let mut current_line = String::new();
1240
1241    for word in text.split_whitespace() {
1242        if current_line.len() + word.len() + 1 > width && !current_line.is_empty() {
1243            result.push_str(&current_line);
1244            result.push('\n');
1245            current_line.clear();
1246        }
1247
1248        if !current_line.is_empty() {
1249            current_line.push(' ');
1250        }
1251        current_line.push_str(word);
1252    }
1253
1254    if !current_line.is_empty() {
1255        result.push_str(&current_line);
1256    }
1257
1258    result
1259}