1use std::collections::BTreeMap;
2
3use crate::{
4 analyzers::Analyzer,
5 collector::RepositorySnapshot,
6 model::{LanguageStat, Overview},
7};
8
9pub struct OverviewAnalyzer;
10
11impl Analyzer<Overview> for OverviewAnalyzer {
12 fn analyze(&self, snapshot: &RepositorySnapshot) -> Overview {
13 let mut language_map: BTreeMap<String, (usize, usize)> = BTreeMap::new();
14 for file in &snapshot.files {
15 let language = language_for(file.extension.as_deref());
16 let entry = language_map.entry(language.to_string()).or_default();
17 entry.0 += 1;
18 entry.1 += file.lines;
19 }
20
21 let languages = language_map
22 .into_iter()
23 .map(|(language, (files, lines))| LanguageStat {
24 language,
25 files,
26 lines,
27 })
28 .collect::<Vec<_>>();
29 let workspace_members = snapshot
30 .manifests
31 .iter()
32 .flat_map(|manifest| manifest.workspace_members.clone())
33 .collect::<Vec<_>>();
34 let package_count = snapshot
35 .manifests
36 .iter()
37 .filter(|manifest| manifest.package_name.is_some())
38 .count();
39 let total_bytes = snapshot.files.iter().map(|file| file.bytes).sum();
40 let crate_count = package_count.max(workspace_members.len());
41 let cargo_configs = snapshot
42 .manifests
43 .iter()
44 .map(|manifest| manifest.relative_path.clone())
45 .collect::<Vec<_>>();
46 let summary = if workspace_members.is_empty() {
47 format!("Single-package Rust repository with {package_count} Cargo manifest(s).")
48 } else {
49 format!(
50 "Cargo workspace with {} declared member(s) and {package_count} package manifest(s).",
51 workspace_members.len()
52 )
53 };
54
55 Overview {
56 crate_count,
57 package_count,
58 workspace_members,
59 total_files: snapshot.files.len(),
60 total_bytes,
61 languages,
62 cargo_configs,
63 summary,
64 }
65 }
66}
67
68fn language_for(extension: Option<&str>) -> &'static str {
69 match extension {
70 Some("rs") => "Rust",
71 Some("toml") => "TOML",
72 Some("md") => "Markdown",
73 Some("yml") | Some("yaml") => "YAML",
74 Some("json") => "JSON",
75 Some("sh") => "Shell",
76 Some("js") | Some("ts") => "JavaScript/TypeScript",
77 _ => "Other",
78 }
79}
80
81#[cfg(test)]
82mod tests {
83 use std::{collections::BTreeMap, path::PathBuf};
84
85 use crate::{
86 analyzers::Analyzer,
87 collector::{CargoManifest, FileSnapshot, RepositorySnapshot},
88 };
89
90 use super::OverviewAnalyzer;
91
92 #[test]
93 fn empty_repository_reports_empty_overview() {
94 let overview = OverviewAnalyzer.analyze(&snapshot(Vec::new(), Vec::new()));
95
96 assert_eq!(overview.package_count, 0);
97 assert_eq!(overview.total_files, 0);
98 assert!(overview.summary.contains("Single-package"));
99 }
100
101 #[test]
102 fn typical_workspace_reports_members_and_languages() {
103 let overview = OverviewAnalyzer.analyze(&snapshot(
104 vec![
105 file("src/lib.rs", "rs", "pub fn main() {}\n"),
106 file("README.md", "md", "# App\n"),
107 ],
108 vec![manifest(
109 "Cargo.toml",
110 None,
111 vec!["crates/core", "crates/cli"],
112 )],
113 ));
114
115 assert_eq!(overview.crate_count, 2);
116 assert_eq!(overview.workspace_members.len(), 2);
117 assert!(overview
118 .languages
119 .iter()
120 .any(|language| language.language == "Rust"));
121 }
122
123 #[test]
124 fn extreme_file_sizes_are_summed_without_scoring_side_effects() {
125 let overview = OverviewAnalyzer.analyze(&snapshot(
126 vec![FileSnapshot {
127 path: PathBuf::from("/tmp/repo/big.bin"),
128 relative_path: "big.bin".into(),
129 extension: None,
130 bytes: 10_000_000,
131 lines: 0,
132 content: None,
133 }],
134 Vec::new(),
135 ));
136
137 assert_eq!(overview.total_bytes, 10_000_000);
138 assert_eq!(overview.total_files, 1);
139 }
140
141 #[test]
142 fn adversarial_unknown_extension_is_bucketed_as_other() {
143 let overview = OverviewAnalyzer.analyze(&snapshot(
144 vec![file("src/fake.rs.txt", "txt", "fn fake() {}\n")],
145 Vec::new(),
146 ));
147
148 assert!(overview
149 .languages
150 .iter()
151 .any(|language| language.language == "Other" && language.files == 1));
152 }
153
154 fn snapshot(files: Vec<FileSnapshot>, manifests: Vec<CargoManifest>) -> RepositorySnapshot {
155 RepositorySnapshot {
156 root: PathBuf::from("/tmp/repo"),
157 files,
158 manifests,
159 }
160 }
161
162 fn file(relative_path: &str, extension: &str, content: &str) -> FileSnapshot {
163 FileSnapshot {
164 path: PathBuf::from("/tmp/repo").join(relative_path),
165 relative_path: relative_path.into(),
166 extension: Some(extension.into()),
167 bytes: content.len() as u64,
168 lines: content.lines().count(),
169 content: Some(content.into()),
170 }
171 }
172
173 fn manifest(
174 relative_path: &str,
175 package_name: Option<&str>,
176 workspace_members: Vec<&str>,
177 ) -> CargoManifest {
178 CargoManifest {
179 relative_path: relative_path.into(),
180 package_name: package_name.map(str::to_string),
181 workspace_members: workspace_members.into_iter().map(str::to_string).collect(),
182 dependencies: BTreeMap::new(),
183 dev_dependencies: BTreeMap::new(),
184 build_dependencies: BTreeMap::new(),
185 }
186 }
187}