Skip to main content

rta_core/analyzers/
overview.rs

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}