1pub mod conventions;
15pub mod overview;
17
18use std::path::Path;
19
20use seshat_core::Language;
21use seshat_detectors::AggregatedConvention;
22
23use crate::format::Verbosity;
24
25#[derive(Debug)]
30pub struct ReportData {
31 pub language_breakdown: Vec<LanguageCount>,
33 pub total_files: usize,
35 pub total_dependencies: usize,
37 pub dependency_breakdown: Vec<EcosystemCount>,
39 pub conventions: Vec<AggregatedConvention>,
41 pub files_discovered: usize,
43 pub files_parsed: usize,
45 pub nodes_persisted: usize,
47 pub edges_persisted: usize,
49 pub manifests_analyzed: usize,
51 pub docs_ingested: usize,
53 pub db_path: std::path::PathBuf,
55 pub db_size: u64,
57 pub elapsed: std::time::Duration,
59 pub excluded_submodules: Vec<String>,
65 pub submodules_excluded_by_flag: bool,
71}
72
73#[derive(Debug, Clone)]
75pub struct LanguageCount {
76 pub language: Language,
78 pub count: usize,
80}
81
82#[derive(Debug, Clone)]
84pub struct EcosystemCount {
85 pub label: String,
87 pub count: usize,
89}
90
91pub fn print_report(data: &ReportData, verbosity: Verbosity, color: bool) {
103 use crate::format;
104
105 eprintln!();
106
107 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 if verbosity.show_findings() {
137 eprintln!();
138 overview::print_overview(data, color);
139 }
140
141 if verbosity.show_findings() {
143 conventions::print_conventions(data, verbosity, color);
144 }
145
146 if verbosity.show_findings() {
148 conventions::print_next_steps(color);
149 }
150
151 eprintln!(
153 " {} conventions detected. Run `seshat review` to validate.",
154 data.conventions.len(),
155 );
156
157 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 eprintln!(" Completed in {:.1}s", data.elapsed.as_secs_f64());
168
169 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 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
189pub 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 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 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 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
259fn 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#[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 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 dependencies: vec![
445 seshat_scanner::manifest::DependencyUsageStats {
446 dependency: DeclaredDependency {
447 name: "serde".to_owned(),
448 version: "1.0".to_owned(),
449 is_dev: false,
450 category: DependencyDomain::Serialization,
451 },
452 files_using: 2,
453 is_dead: false,
454 },
455 seshat_scanner::manifest::DependencyUsageStats {
456 dependency: DeclaredDependency {
457 name: "tokio".to_owned(),
458 version: "1.0".to_owned(),
459 is_dev: false,
460 category: DependencyDomain::AsyncRuntime,
461 },
462 files_using: 1,
463 is_dead: false,
464 },
465 ],
466 }];
467
468 let scan_result = seshat_scanner::ScanResult {
469 files_discovered: 2,
470 files_parsed: 2,
471 nodes_persisted: 0,
472 edges_persisted: 0,
473 manifests_analyzed: 1,
474 docs_ingested: 0,
475 manifest_analyses,
476 incremental: None,
477 file_dates: std::collections::HashMap::new(),
478 excluded_submodules: vec![],
479 source_map: std::collections::HashMap::new(),
480 changed_paths: std::collections::HashSet::new(),
481 };
482
483 let data = build_report_data(
484 &scan_result,
485 &[],
486 vec![],
487 &PathBuf::from("/tmp/test.db"),
488 Duration::from_secs(1),
489 false,
490 );
491
492 assert_eq!(data.total_dependencies, 2);
494 assert_eq!(data.dependency_breakdown.len(), 1);
495 assert_eq!(data.dependency_breakdown[0].label, "cargo");
496 assert_eq!(data.dependency_breakdown[0].count, 2);
497 }
498}