Skip to main content

seshat_cli/report/
mod.rs

1//! Scan report rendering.
2//!
3//! This module formats and prints the scan report to stderr. Report code
4//! is separated from scan logic so that `scan.rs` handles orchestration
5//! while `report/` handles presentation.
6//!
7//! ## Architecture
8//!
9//! The report module receives pre-computed `ReportData` — it never queries
10//! the database directly. Data collection happens in `scan.rs` which passes
11//! a fully populated `ReportData` struct.
12
13/// Conventions Detected and Next Steps sections.
14pub mod conventions;
15/// Project Overview section (language breakdown, dependency counts).
16pub mod overview;
17
18use std::path::Path;
19
20use seshat_core::Language;
21use seshat_detectors::AggregatedConvention;
22
23use crate::format::Verbosity;
24
25/// Pre-computed data for the scan report.
26///
27/// Assembled in `scan.rs` from the scan result, parsed files, and aggregated
28/// conventions. The report module only reads this — no database access.
29#[derive(Debug)]
30pub struct ReportData {
31    /// Per-language file counts, sorted by count descending.
32    pub language_breakdown: Vec<LanguageCount>,
33    /// Total number of parsed files across all languages.
34    pub total_files: usize,
35    /// Total number of dependencies (unique packages across all files).
36    pub total_dependencies: usize,
37    /// Dependency counts per ecosystem (language), sorted by count descending.
38    pub dependency_breakdown: Vec<EcosystemCount>,
39    /// Aggregated convention findings from detectors.
40    pub conventions: Vec<AggregatedConvention>,
41    /// Number of files discovered (including unparseable).
42    pub files_discovered: usize,
43    /// Number of files successfully parsed.
44    pub files_parsed: usize,
45    /// Number of knowledge graph nodes persisted.
46    pub nodes_persisted: usize,
47    /// Number of knowledge graph edges persisted.
48    pub edges_persisted: usize,
49    /// Number of manifests analyzed.
50    pub manifests_analyzed: usize,
51    /// Number of docs ingested.
52    pub docs_ingested: usize,
53    /// Path to the database file.
54    pub db_path: std::path::PathBuf,
55    /// Database file size in bytes.
56    pub db_size: u64,
57    /// Total scan duration.
58    pub elapsed: std::time::Duration,
59    /// Submodule paths excluded from root file walk.
60    ///
61    /// These are always populated when `.gitmodules` declares submodules (their
62    /// files are excluded from the root walk regardless of whether they are
63    /// scanned separately).
64    pub excluded_submodules: Vec<String>,
65    /// Whether the user explicitly passed `--exclude-submodules`.
66    ///
67    /// When `true`, submodules were **not** scanned at all and the report
68    /// should tell the user how to include them. When `false`, submodules
69    /// were scanned into separate DBs and no warning is needed.
70    pub submodules_excluded_by_flag: bool,
71}
72
73/// File count for a single language.
74#[derive(Debug, Clone)]
75pub struct LanguageCount {
76    /// The language.
77    pub language: Language,
78    /// Number of files in this language.
79    pub count: usize,
80}
81
82/// Dependency count for a single ecosystem (language).
83#[derive(Debug, Clone)]
84pub struct EcosystemCount {
85    /// The ecosystem label (e.g., "npm", "pip", "cargo").
86    pub label: String,
87    /// Number of unique packages in this ecosystem.
88    pub count: usize,
89}
90
91/// Print the full scan report, respecting verbosity and color settings.
92///
93/// Report structure:
94/// 1. Scan stats (always)
95/// 2. Project Overview (default + verbose)
96/// 3. Conventions Detected (default + verbose)
97/// 4. Next Steps (default + verbose)
98/// 5. Summary line with convention count (always)
99/// 6. Database path (default + verbose)
100/// 7. Timing breakdown (verbose only)
101/// 8. Warnings (default + verbose)
102pub fn print_report(data: &ReportData, verbosity: Verbosity, color: bool) {
103    use crate::format;
104
105    eprintln!();
106
107    // Scan stats — always shown (even in quiet mode).
108    eprintln!(
109        "  Scanned {} files, parsed {}, {} nodes, {} edges",
110        format::format_number(data.files_discovered as u64),
111        format::format_number(data.files_parsed as u64),
112        format::format_number(data.nodes_persisted as u64),
113        format::format_number(data.edges_persisted as u64),
114    );
115
116    if data.manifests_analyzed > 0 && verbosity.show_warnings() {
117        eprintln!(
118            "  Analyzed {} manifest(s), ingested {} doc(s)",
119            data.manifests_analyzed, data.docs_ingested,
120        );
121    }
122
123    if data.submodules_excluded_by_flag
124        && !data.excluded_submodules.is_empty()
125        && verbosity.show_warnings()
126    {
127        let paths_joined = data.excluded_submodules.join(", ");
128        eprintln!(
129            "  Skipped {} submodule(s): {} (remove --exclude-submodules to include)",
130            data.excluded_submodules.len(),
131            paths_joined,
132        );
133    }
134
135    // Project Overview — shown in default and verbose.
136    if verbosity.show_findings() {
137        eprintln!();
138        overview::print_overview(data, color);
139    }
140
141    // Conventions Detected — shown in default and verbose.
142    if verbosity.show_findings() {
143        conventions::print_conventions(data, verbosity, color);
144    }
145
146    // Next Steps — shown in default and verbose.
147    if verbosity.show_findings() {
148        conventions::print_next_steps(color);
149    }
150
151    // Summary line — always shown.
152    eprintln!(
153        "  {} conventions detected. Run `seshat review` to validate.",
154        data.conventions.len(),
155    );
156
157    // Database path with human-readable size — shown in default and verbose.
158    if verbosity.show_warnings() {
159        eprintln!(
160            "  Database: {} ({})",
161            data.db_path.display(),
162            format::format_human_size(data.db_size),
163        );
164    }
165
166    // Scan timing — always shown.
167    eprintln!("  Completed in {:.1}s", data.elapsed.as_secs_f64());
168
169    // Verbose: detailed timing breakdown.
170    if verbosity.show_verbose() {
171        eprintln!();
172        eprintln!("{}", format::format_section_header("Timing", color));
173        eprintln!("  Total: {:.3}s", data.elapsed.as_secs_f64());
174    }
175
176    // Warnings — shown in default and verbose.
177    if verbosity.show_warnings() && data.files_discovered == 0 {
178        eprintln!();
179        eprintln!(
180            "  {}",
181            format::format_warn(
182                "no files discovered — check that the path contains source code",
183                color,
184            ),
185        );
186    }
187}
188
189/// Build [`ReportData`] from scan results and parsed files.
190///
191/// This is the single point of data collection for the report. It computes
192/// language breakdown from the in-memory file list, and dependency counts
193/// from manifest analysis results — no database queries needed.
194pub fn build_report_data(
195    scan_result: &seshat_scanner::ScanResult,
196    files: &[seshat_core::ProjectFile],
197    conventions: Vec<AggregatedConvention>,
198    db_path: &Path,
199    elapsed: std::time::Duration,
200    submodules_excluded_by_flag: bool,
201) -> ReportData {
202    use std::collections::HashMap;
203
204    // -- Language breakdown ------------------------------------------------
205    let mut lang_counts: HashMap<Language, usize> = HashMap::new();
206    for file in files {
207        *lang_counts.entry(file.language).or_default() += 1;
208    }
209    let mut language_breakdown: Vec<LanguageCount> = lang_counts
210        .into_iter()
211        .map(|(language, count)| LanguageCount { language, count })
212        .collect();
213    language_breakdown.sort_by_key(|b| std::cmp::Reverse(b.count));
214
215    // -- Dependency counts from manifest analysis -------------------------
216    // Count declared dependencies per ecosystem (manifest type).
217    let mut ecosystem_counts: HashMap<&str, usize> = HashMap::new();
218    for analysis in &scan_result.manifest_analyses {
219        let label = manifest_ecosystem_label(analysis.manifest_type);
220        let count = analysis.dependencies.len();
221        *ecosystem_counts.entry(label).or_default() += count;
222    }
223
224    let total_dependencies: usize = ecosystem_counts.values().sum();
225
226    let mut dependency_breakdown: Vec<EcosystemCount> = ecosystem_counts
227        .into_iter()
228        .filter(|(_, count)| *count > 0)
229        .map(|(label, count)| EcosystemCount {
230            label: label.to_owned(),
231            count,
232        })
233        .collect();
234    dependency_breakdown.sort_by_key(|b| std::cmp::Reverse(b.count));
235
236    // -- Database size ----------------------------------------------------
237    let db_size = std::fs::metadata(db_path).map(|m| m.len()).unwrap_or(0);
238
239    ReportData {
240        language_breakdown,
241        total_files: files.len(),
242        total_dependencies,
243        dependency_breakdown,
244        conventions,
245        files_discovered: scan_result.files_discovered,
246        files_parsed: scan_result.files_parsed,
247        nodes_persisted: scan_result.nodes_persisted,
248        edges_persisted: scan_result.edges_persisted,
249        manifests_analyzed: scan_result.manifests_analyzed,
250        docs_ingested: scan_result.docs_ingested,
251        db_path: db_path.to_path_buf(),
252        db_size,
253        elapsed,
254        excluded_submodules: scan_result.excluded_submodules.clone(),
255        submodules_excluded_by_flag,
256    }
257}
258
259/// Map a manifest type to its ecosystem label (used in dependency breakdown).
260fn manifest_ecosystem_label(manifest_type: seshat_scanner::ManifestType) -> &'static str {
261    match manifest_type {
262        seshat_scanner::ManifestType::CargoToml => "cargo",
263        seshat_scanner::ManifestType::PackageJson => "npm",
264        seshat_scanner::ManifestType::PyprojectToml => "pip",
265    }
266}
267
268// ══════════════════════════════════════════════════════════════════════
269// Tests
270// ══════════════════════════════════════════════════════════════════════
271
272#[cfg(test)]
273mod tests {
274    use super::*;
275
276    #[test]
277    fn test_manifest_ecosystem_label_cargo() {
278        assert_eq!(
279            manifest_ecosystem_label(seshat_scanner::ManifestType::CargoToml),
280            "cargo",
281        );
282    }
283
284    #[test]
285    fn test_manifest_ecosystem_label_npm() {
286        assert_eq!(
287            manifest_ecosystem_label(seshat_scanner::ManifestType::PackageJson),
288            "npm",
289        );
290    }
291
292    #[test]
293    fn test_manifest_ecosystem_label_pip() {
294        assert_eq!(
295            manifest_ecosystem_label(seshat_scanner::ManifestType::PyprojectToml),
296            "pip",
297        );
298    }
299
300    #[test]
301    fn test_build_report_data_empty() {
302        use std::path::PathBuf;
303        use std::time::Duration;
304
305        let scan_result = seshat_scanner::ScanResult {
306            files_discovered: 0,
307            files_parsed: 0,
308            nodes_persisted: 0,
309            edges_persisted: 0,
310            manifests_analyzed: 0,
311            docs_ingested: 0,
312            manifest_analyses: vec![],
313            incremental: None,
314            file_dates: std::collections::HashMap::new(),
315            excluded_submodules: vec![],
316            source_map: std::collections::HashMap::new(),
317            changed_paths: std::collections::HashSet::new(),
318        };
319
320        let data = build_report_data(
321            &scan_result,
322            &[],
323            vec![],
324            &PathBuf::from("/tmp/test.db"),
325            Duration::from_secs(1),
326            false,
327        );
328
329        assert_eq!(data.total_files, 0);
330        assert_eq!(data.total_dependencies, 0);
331        assert!(data.language_breakdown.is_empty());
332        assert!(data.dependency_breakdown.is_empty());
333    }
334
335    #[test]
336    fn test_build_report_data_language_breakdown() {
337        use seshat_core::{LanguageIR, ProjectFile, RustIR};
338        use std::path::PathBuf;
339        use std::time::Duration;
340
341        let files = vec![
342            ProjectFile {
343                path: PathBuf::from("src/main.rs"),
344                language: Language::Rust,
345                content_hash: "a".to_owned(),
346                imports: vec![],
347                exports: vec![],
348                functions: vec![],
349                types: vec![],
350                dependencies_used: vec![],
351                language_ir: LanguageIR::Rust(RustIR {
352                    mod_declarations: vec![],
353                    derive_macros: vec![],
354                    trait_implementations: vec![],
355                    error_types: vec![],
356                    macro_calls: vec![],
357                    function_calls: vec![],
358                }),
359                file_doc: None,
360            },
361            ProjectFile {
362                path: PathBuf::from("src/lib.rs"),
363                language: Language::Rust,
364                content_hash: "b".to_owned(),
365                imports: vec![],
366                exports: vec![],
367                functions: vec![],
368                types: vec![],
369                dependencies_used: vec![],
370                language_ir: LanguageIR::Rust(RustIR {
371                    mod_declarations: vec![],
372                    derive_macros: vec![],
373                    trait_implementations: vec![],
374                    error_types: vec![],
375                    macro_calls: vec![],
376                    function_calls: vec![],
377                }),
378                file_doc: None,
379            },
380            ProjectFile {
381                path: PathBuf::from("app.py"),
382                language: Language::Python,
383                content_hash: "c".to_owned(),
384                imports: vec![],
385                exports: vec![],
386                functions: vec![],
387                types: vec![],
388                dependencies_used: vec![],
389                language_ir: LanguageIR::Python(seshat_core::PythonIR {
390                    has_all_export: false,
391                    is_init_file: false,
392                    type_hints_used: false,
393                    decorators: vec![],
394                    function_calls: vec![],
395                }),
396                file_doc: None,
397            },
398        ];
399
400        let scan_result = seshat_scanner::ScanResult {
401            files_discovered: 3,
402            files_parsed: 3,
403            nodes_persisted: 10,
404            edges_persisted: 5,
405            manifests_analyzed: 0,
406            docs_ingested: 0,
407            manifest_analyses: vec![],
408            incremental: None,
409            file_dates: std::collections::HashMap::new(),
410            excluded_submodules: vec![],
411            source_map: std::collections::HashMap::new(),
412            changed_paths: std::collections::HashSet::new(),
413        };
414
415        let data = build_report_data(
416            &scan_result,
417            &files,
418            vec![],
419            &PathBuf::from("/tmp/test.db"),
420            Duration::from_secs(2),
421            false,
422        );
423
424        assert_eq!(data.total_files, 3);
425        assert_eq!(data.language_breakdown.len(), 2);
426        // Sorted by count descending — Rust (2) before Python (1).
427        assert_eq!(data.language_breakdown[0].language, Language::Rust);
428        assert_eq!(data.language_breakdown[0].count, 2);
429        assert_eq!(data.language_breakdown[1].language, Language::Python);
430        assert_eq!(data.language_breakdown[1].count, 1);
431    }
432
433    #[test]
434    fn test_build_report_data_dependency_breakdown() {
435        use seshat_core::DependencyDomain;
436        use seshat_scanner::{DeclaredDependency, ManifestAnalysis};
437        use std::path::PathBuf;
438        use std::time::Duration;
439
440        let manifest_analyses = vec![ManifestAnalysis {
441            manifest_path: PathBuf::from("Cargo.toml"),
442            manifest_type: seshat_scanner::ManifestType::CargoToml,
443            internal_names: vec!["seshat_scanner".to_owned()],
444            path_aliases: Vec::new(),
445            dependencies: vec![
446                seshat_scanner::manifest::DependencyUsageStats {
447                    dependency: DeclaredDependency {
448                        name: "serde".to_owned(),
449                        version: "1.0".to_owned(),
450                        is_dev: false,
451                        category: DependencyDomain::Serialization,
452                    },
453                    files_using: 2,
454                    is_dead: false,
455                },
456                seshat_scanner::manifest::DependencyUsageStats {
457                    dependency: DeclaredDependency {
458                        name: "tokio".to_owned(),
459                        version: "1.0".to_owned(),
460                        is_dev: false,
461                        category: DependencyDomain::AsyncRuntime,
462                    },
463                    files_using: 1,
464                    is_dead: false,
465                },
466            ],
467        }];
468
469        let scan_result = seshat_scanner::ScanResult {
470            files_discovered: 2,
471            files_parsed: 2,
472            nodes_persisted: 0,
473            edges_persisted: 0,
474            manifests_analyzed: 1,
475            docs_ingested: 0,
476            manifest_analyses,
477            incremental: None,
478            file_dates: std::collections::HashMap::new(),
479            excluded_submodules: vec![],
480            source_map: std::collections::HashMap::new(),
481            changed_paths: std::collections::HashSet::new(),
482        };
483
484        let data = build_report_data(
485            &scan_result,
486            &[],
487            vec![],
488            &PathBuf::from("/tmp/test.db"),
489            Duration::from_secs(1),
490            false,
491        );
492
493        // serde + tokio = 2 declared dependencies from Cargo.toml.
494        assert_eq!(data.total_dependencies, 2);
495        assert_eq!(data.dependency_breakdown.len(), 1);
496        assert_eq!(data.dependency_breakdown[0].label, "cargo");
497        assert_eq!(data.dependency_breakdown[0].count, 2);
498    }
499}