1use std::collections::{BTreeMap, BTreeSet};
4use std::io::Read;
5use std::path::Path;
6
7use anyhow::Result;
8use ignore::WalkBuilder;
9use perspt_sdk::ProjectMap;
10use serde::{Deserialize, Serialize};
11
12#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
17pub struct WorkspaceMap {
18 pub project_map: ProjectMap,
19 pub input_witnesses: Vec<String>,
21}
22
23const MAX_PACKAGE_ROOTS: usize = 512;
24const MAX_ENTRY_POINTS: usize = 64;
25const MAX_RISK_HOTSPOTS: usize = 64;
26const MAX_MANIFEST_WITNESSES: usize = 512;
27
28pub fn map_workspace(root: &Path) -> Result<WorkspaceMap> {
32 let mut scan = WorkspaceScan::default();
33 let mut walker = WalkBuilder::new(root);
34 let walk_root = root.to_path_buf();
35 walker
36 .hidden(false)
37 .git_ignore(true)
38 .git_global(true)
39 .git_exclude(true)
40 .max_depth(Some(12))
41 .filter_entry(move |entry| include_entry(&walk_root, entry.path()));
42 for entry in walker
43 .build()
44 .flatten()
45 .filter(|entry| entry.file_type().is_some_and(|kind| kind.is_file()))
46 {
47 scan.observe(root, entry.path());
48 }
49
50 let mut languages: Vec<String> = scan
51 .language_counts
52 .into_iter()
53 .map(|(language, count)| format!("{language}:{count}"))
54 .collect();
55 languages.sort();
56 let map = ProjectMap {
57 languages,
58 package_roots: scan.package_roots.into_iter().collect(),
59 build_systems: scan.build_systems.into_iter().collect(),
60 entry_points: scan.entry_points.into_iter().collect(),
61 risk_hotspots: scan.risk_hotspots.into_iter().collect(),
62 };
63 Ok(WorkspaceMap {
64 project_map: map,
65 input_witnesses: scan
66 .witnesses
67 .into_iter()
68 .map(|(path, hash)| format!("{path}:{hash}"))
69 .collect(),
70 })
71}
72
73fn include_entry(root: &Path, path: &Path) -> bool {
74 if path == root {
75 return true;
76 }
77 let relative = path.strip_prefix(root).unwrap_or(path);
78 !relative.components().any(|component| {
79 matches!(
80 component.as_os_str().to_str(),
81 Some(
82 ".git"
83 | ".perspt"
84 | ".venv"
85 | ".pytest_cache"
86 | ".ruff_cache"
87 | ".mypy_cache"
88 | "__pycache__"
89 | "node_modules"
90 | "target"
91 )
92 )
93 })
94}
95
96#[derive(Default)]
97struct WorkspaceScan {
98 language_counts: BTreeMap<&'static str, usize>,
99 package_roots: BTreeSet<String>,
100 build_systems: BTreeSet<String>,
101 entry_points: BTreeSet<String>,
102 risk_hotspots: BTreeSet<String>,
103 witnesses: BTreeMap<String, String>,
104}
105
106impl WorkspaceScan {
107 fn observe(&mut self, root: &Path, path: &Path) {
108 let relative = path.strip_prefix(root).unwrap_or(path);
109 let rendered = relative.to_string_lossy().replace('\\', "/");
110 if let Some(language) = language_for(path) {
111 *self.language_counts.entry(language).or_default() += 1;
112 }
113 if let Some(name) = path.file_name().and_then(|name| name.to_str()) {
114 if let Some(system) = build_system(name) {
115 self.build_systems.insert(system.to_string());
116 bounded_insert(
117 &mut self.package_roots,
118 relative
119 .parent()
120 .unwrap_or_else(|| Path::new("."))
121 .to_string_lossy()
122 .replace('\\', "/"),
123 MAX_PACKAGE_ROOTS,
124 );
125 if let Ok(hash) = hash_file(path) {
126 bounded_insert_map(
127 &mut self.witnesses,
128 rendered.clone(),
129 hash,
130 MAX_MANIFEST_WITNESSES,
131 );
132 }
133 }
134 if matches!(
135 name,
136 "main.rs" | "main.py" | "main.ts" | "main.js" | "lib.rs"
137 ) || name.starts_with("app.")
138 {
139 bounded_insert(&mut self.entry_points, rendered.clone(), MAX_ENTRY_POINTS);
140 }
141 }
142 if rendered.contains("migrations/")
143 || rendered.contains(".github/workflows/")
144 || rendered.contains("auth")
145 || rendered.contains("security")
146 || rendered.ends_with("Cargo.lock")
147 || rendered.ends_with("package-lock.json")
148 {
149 bounded_insert(&mut self.risk_hotspots, rendered, MAX_RISK_HOTSPOTS);
150 }
151 }
152}
153
154fn bounded_insert(values: &mut BTreeSet<String>, value: String, cap: usize) {
158 values.insert(value);
159 if values.len() > cap {
160 values.pop_last();
161 }
162}
163
164fn bounded_insert_map(
165 values: &mut BTreeMap<String, String>,
166 key: String,
167 value: String,
168 cap: usize,
169) {
170 values.insert(key, value);
171 if values.len() > cap {
172 values.pop_last();
173 }
174}
175
176fn hash_file(path: &Path) -> std::io::Result<String> {
179 use sha2::Digest;
180 let mut file = std::fs::File::open(path)?;
181 let mut hasher = sha2::Sha256::new();
182 let mut buffer = [0u8; 64 * 1024];
183 loop {
184 let read = file.read(&mut buffer)?;
185 if read == 0 {
186 break;
187 }
188 hasher.update(&buffer[..read]);
189 }
190 let digest = hasher.finalize();
191 let mut hex = String::with_capacity(digest.len() * 2);
192 for byte in digest {
193 use std::fmt::Write as _;
194 write!(&mut hex, "{byte:02x}").expect("writing into a string cannot fail");
195 }
196 Ok(hex)
197}
198
199fn language_for(path: &Path) -> Option<&'static str> {
200 match path.extension()?.to_str()?.to_ascii_lowercase().as_str() {
201 "rs" => Some("rust"),
202 "py" => Some("python"),
203 "ts" | "tsx" => Some("typescript"),
204 "js" | "jsx" => Some("javascript"),
205 "go" => Some("go"),
206 "java" => Some("java"),
207 "kt" | "kts" => Some("kotlin"),
208 "c" | "h" => Some("c"),
209 "cc" | "cpp" | "hpp" => Some("cpp"),
210 "zig" => Some("zig"),
211 "rb" => Some("ruby"),
212 _ => None,
213 }
214}
215
216fn build_system(name: &str) -> Option<&'static str> {
217 match name {
218 "Cargo.toml" => Some("cargo"),
219 "pyproject.toml" => Some("python-pyproject"),
220 "package.json" => Some("node"),
221 "go.mod" => Some("go-modules"),
222 "pom.xml" => Some("maven"),
223 "build.gradle" | "build.gradle.kts" => Some("gradle"),
224 "build.zig" => Some("zig-build"),
225 "Makefile" => Some("make"),
226 _ => None,
227 }
228}
229
230#[cfg(test)]
231mod tests {
232 use super::*;
233
234 #[test]
235 fn project_map_is_deterministic_and_manifest_backed() {
236 let root = tempfile::tempdir().unwrap();
237 std::fs::create_dir_all(root.path().join("src")).unwrap();
238 std::fs::write(root.path().join("Cargo.toml"), "[package]\nname='x'\n").unwrap();
239 std::fs::write(root.path().join("src/main.rs"), "fn main() {}\n").unwrap();
240 let left = map_workspace(root.path()).unwrap();
241 let right = map_workspace(root.path()).unwrap();
242 assert_eq!(left.project_map, right.project_map);
243 assert_eq!(left.input_witnesses, right.input_witnesses);
244 assert_eq!(left.project_map.build_systems, ["cargo"]);
245 }
246
247 #[test]
248 fn project_map_excludes_installed_environments_and_build_outputs() {
249 let root = tempfile::tempdir().unwrap();
250 std::fs::create_dir_all(root.path().join("src")).unwrap();
251 std::fs::create_dir_all(root.path().join(".venv/lib/site-packages/pkg")).unwrap();
252 std::fs::create_dir_all(root.path().join("target/generated")).unwrap();
253 std::fs::write(root.path().join("pyproject.toml"), "[project]\nname='x'\n").unwrap();
254 std::fs::write(root.path().join("src/main.py"), "print('x')\n").unwrap();
255 std::fs::write(
256 root.path().join(".venv/lib/site-packages/pkg/main.py"),
257 "installed = True\n",
258 )
259 .unwrap();
260 std::fs::write(
261 root.path().join("target/generated/main.py"),
262 "generated = True\n",
263 )
264 .unwrap();
265
266 let report = map_workspace(root.path()).unwrap();
267 assert_eq!(report.project_map.languages, ["python:1"]);
268 assert_eq!(report.project_map.entry_points, ["src/main.py"]);
269 }
270
271 #[test]
272 fn bounded_summaries_are_independent_of_traversal_order() {
273 let values: Vec<String> = (0..600).map(|index| format!("crate-{index:04}")).collect();
274 let mut forward = BTreeSet::new();
275 let mut reverse = BTreeSet::new();
276 for value in &values {
277 bounded_insert(&mut forward, value.clone(), MAX_PACKAGE_ROOTS);
278 }
279 for value in values.iter().rev() {
280 bounded_insert(&mut reverse, value.clone(), MAX_PACKAGE_ROOTS);
281 }
282 assert_eq!(forward, reverse);
283 assert_eq!(forward.len(), MAX_PACKAGE_ROOTS);
284 assert!(forward.contains("crate-0000"));
285 assert!(!forward.contains("crate-0599"));
286 }
287
288 #[test]
289 fn streamed_manifest_witness_matches_the_content_hash() {
290 let root = tempfile::tempdir().unwrap();
291 let mut manifest = b"[package]\nname='large'\n#".to_vec();
292 manifest.extend(std::iter::repeat_n(b'x', 200_000));
293 let path = root.path().join("Cargo.toml");
294 std::fs::write(&path, &manifest).unwrap();
295 assert_eq!(
296 hash_file(&path).unwrap(),
297 perspt_sdk::ledger::content_hash(&manifest)
298 );
299 }
300}