1use std::collections::BTreeMap;
30use std::fs;
31use std::path::{Path, PathBuf};
32
33use anyhow::{anyhow, Result};
34use scema_world::{
35 now_secs, Domain, Entity, EntityKind, Extent, Fact, Object, Polarity, Provenance, Scalar,
36 Signal, WorldState,
37};
38
39use crate::observer::Observer;
40
41pub const MAX_FILES: usize = 4_000;
43pub const MAX_DEPTH: usize = 8;
45pub const LARGE_FILE_LINES: usize = 1_200;
47
48const SKIP_DIRS: &[&str] = &[
50 ".git", "target", "node_modules", ".next", "dist", "build", "out", "__pycache__",
51 ".venv", "venv", ".mypy_cache", ".pytest_cache", "vendor", ".idea", ".vscode",
52];
53
54const SOURCE_EXTS: &[&str] = &[
55 "rs", "ts", "tsx", "js", "jsx", "py", "go", "java", "rb", "c", "h", "cpp", "hpp", "cs",
56 "sol", "sh", "toml", "sql",
57];
58
59const CODE_EXTS: &[&str] = &[
67 "rs", "ts", "tsx", "js", "jsx", "py", "go", "java", "rb", "c", "h", "cpp", "hpp", "cs",
68 "sol",
69];
70
71fn display_path(p: &Path) -> String {
81 let s = p.to_string_lossy().to_string();
82 match s.strip_prefix(r"\\?\") {
83 Some(rest) => rest.to_string(),
84 None => s,
85 }
86}
87
88struct Unit {
90 id: String,
91 label: String,
92 path: PathBuf,
93 kind: &'static str,
94 files: usize,
95 code_files: usize,
97 lines: usize,
98 tests: usize,
99 markers: usize,
100 documented: bool,
101}
102
103#[derive(Clone, Debug, Default)]
105pub struct RepoObserver;
106
107impl RepoObserver {
108 pub fn new() -> Self {
109 RepoObserver
110 }
111}
112
113struct Walk {
114 files: Vec<PathBuf>,
115 blind_spots: Vec<String>,
116 truncated: bool,
117 dirs_seen: usize,
118}
119
120fn walk(root: &Path) -> Walk {
121 let mut out = Walk { files: vec![], blind_spots: vec![], truncated: false, dirs_seen: 0 };
122 let mut stack: Vec<(PathBuf, usize)> = vec![(root.to_path_buf(), 0)];
123
124 while let Some((dir, depth)) = stack.pop() {
125 if out.files.len() >= MAX_FILES {
126 out.truncated = true;
127 break;
128 }
129 if depth > MAX_DEPTH {
130 out.truncated = true;
131 continue;
132 }
133 out.dirs_seen += 1;
134 let entries = match fs::read_dir(&dir) {
135 Ok(e) => e,
136 Err(e) => {
137 out.blind_spots.push(format!(
140 "{} ({e})",
141 dir.strip_prefix(root).unwrap_or(&dir).display()
142 ));
143 continue;
144 }
145 };
146 for entry in entries.flatten() {
147 let path = entry.path();
148 let name = entry.file_name().to_string_lossy().to_string();
149 let is_dir = entry.file_type().map(|t| t.is_dir()).unwrap_or(false);
150 if is_dir {
151 if SKIP_DIRS.contains(&name.as_str()) || name.starts_with('.') {
152 continue;
153 }
154 stack.push((path, depth + 1));
155 } else {
156 out.files.push(path);
157 }
158 }
159 }
160 out
161}
162
163fn ext_of(p: &Path) -> String {
164 p.extension().and_then(|s| s.to_str()).unwrap_or("").to_lowercase()
165}
166
167fn is_source(p: &Path) -> bool {
168 SOURCE_EXTS.contains(&ext_of(p).as_str())
169}
170
171fn unit_roots(root: &Path, files: &[PathBuf]) -> Vec<(PathBuf, &'static str)> {
174 let mut roots: Vec<(PathBuf, &'static str)> = Vec::new();
175 for f in files {
176 let name = f.file_name().and_then(|s| s.to_str()).unwrap_or("");
177 let kind = match name {
178 "Cargo.toml" => Some("crate"),
179 "package.json" => Some("package"),
180 "pyproject.toml" | "setup.py" => Some("python-package"),
181 "go.mod" => Some("module"),
182 _ => None,
183 };
184 if let Some(kind) = kind {
185 if let Some(dir) = f.parent() {
186 if !roots.iter().any(|(p, _)| p == dir) {
189 roots.push((dir.to_path_buf(), kind));
190 }
191 }
192 }
193 }
194 if roots.is_empty() {
195 if let Ok(entries) = fs::read_dir(root) {
196 for e in entries.flatten() {
197 let p = e.path();
198 let name = e.file_name().to_string_lossy().to_string();
199 if p.is_dir() && !SKIP_DIRS.contains(&name.as_str()) && !name.starts_with('.') {
200 roots.push((p, "directory"));
201 }
202 }
203 }
204 }
205 if roots.is_empty() {
206 roots.push((root.to_path_buf(), "directory"));
207 }
208 roots
209}
210
211fn count_in(body: &str) -> (usize, usize, usize) {
212 let lines = body.lines().count();
213 let tests = body.matches("#[test]").count()
214 + body.matches("#[tokio::test]").count()
215 + body.matches("def test_").count()
216 + body.matches("it(").count()
217 + body.matches("test(").count();
218 let markers = body.matches("TODO").count()
219 + body.matches("FIXME").count()
220 + body.matches("HACK").count();
221 (lines, tests, markers)
222}
223
224impl Observer for RepoObserver {
225 fn name(&self) -> &str {
226 "repo"
227 }
228
229 fn about(&self) -> &str {
230 "Walks a source tree and counts units, tests, markers and oversized files. Counts only; estimates nothing."
231 }
232
233 fn handles(&self, locator: &str) -> bool {
234 !locator.is_empty() && !locator.starts_with("http://") && !locator.starts_with("https://")
235 }
236
237 fn observe(&self, locator: &str) -> Result<WorldState> {
238 let root = fs::canonicalize(locator)
239 .map_err(|e| anyhow!("cannot read `{locator}`: {e}"))?;
240 if !root.is_dir() {
241 return Err(anyhow!("`{locator}` is not a directory"));
242 }
243
244 let w = walk(&root);
245 let mut blind_spots = w.blind_spots.clone();
246
247 let roots = unit_roots(&root, &w.files);
248 let mut units: Vec<Unit> = roots
249 .iter()
250 .map(|(path, kind)| Unit {
251 id: format!(
252 "unit:{}",
253 path.strip_prefix(&root)
254 .unwrap_or(path)
255 .to_string_lossy()
256 .replace('\\', "/")
257 ),
258 label: path
259 .file_name()
260 .map(|s| s.to_string_lossy().to_string())
261 .unwrap_or_else(|| ".".into()),
262 path: path.clone(),
263 kind,
264 files: 0,
265 code_files: 0,
266 lines: 0,
267 tests: 0,
268 markers: 0,
269 documented: false,
270 })
271 .collect();
272
273 let mut by_ext: BTreeMap<String, u64> = BTreeMap::new();
274 let mut large_files: Vec<(String, usize)> = Vec::new();
275 let mut unreadable = 0usize;
276
277 for f in &w.files {
278 let ext = ext_of(f);
279 if !ext.is_empty() {
280 *by_ext.entry(ext.clone()).or_insert(0) += 1;
281 }
282 let name = f.file_name().and_then(|s| s.to_str()).unwrap_or("");
283 let owner = units
286 .iter()
287 .enumerate()
288 .filter(|(_, u)| f.starts_with(&u.path))
289 .max_by_key(|(_, u)| u.path.components().count())
290 .map(|(i, _)| i);
291
292 if let Some(i) = owner {
293 if name.eq_ignore_ascii_case("README.md") || name.eq_ignore_ascii_case("README") {
294 units[i].documented = true;
295 }
296 }
297 if !is_source(f) {
298 continue;
299 }
300 let body = match fs::read_to_string(f) {
301 Ok(b) => b,
302 Err(e) => {
303 unreadable += 1;
304 if blind_spots.len() < 20 {
305 blind_spots.push(format!(
306 "{} ({e})",
307 f.strip_prefix(&root).unwrap_or(f).display()
308 ));
309 }
310 continue;
311 }
312 };
313 let (lines, tests, markers) = count_in(&body);
314 if lines > LARGE_FILE_LINES {
315 large_files.push((
316 f.strip_prefix(&root).unwrap_or(f).to_string_lossy().replace('\\', "/"),
317 lines,
318 ));
319 }
320 if let Some(i) = owner {
321 units[i].files += 1;
322 if CODE_EXTS.contains(&ext.as_str()) {
323 units[i].code_files += 1;
324 }
325 units[i].lines += lines;
326 units[i].tests += tests;
327 units[i].markers += markers;
328 if body.contains("//!") || body.contains("\"\"\"") {
329 units[i].documented = true;
330 }
331 }
332 }
333
334 let mut objects: Vec<Object> = Vec::new();
338 for u in &units {
339 if u.files == 0 {
340 objects.push(Object::new(
341 u.id.clone(),
342 u.kind,
343 u.label.clone(),
344 Provenance::Absent,
345 ));
346 continue;
347 }
348 objects.push(
349 Object::new(u.id.clone(), u.kind, u.label.clone(), Provenance::Live { age_secs: 0 })
350 .with("files", Scalar::Int(u.files as i64))
351 .with("lines", Scalar::Int(u.lines as i64))
352 .with("tests", Scalar::Int(u.tests as i64))
353 .with("markers", Scalar::Int(u.markers as i64))
354 .with("documented", Scalar::Bool(u.documented)),
355 );
356 }
357
358 let mut signals: Vec<Signal> = Vec::new();
359 for u in &units {
360 if u.files == 0 {
361 continue;
362 }
363 if u.tests == 0 && u.code_files > 0 {
364 signals.push(Signal {
365 id: format!("untested:{}", u.label),
366 polarity: Polarity::Risk,
367 label: format!("`{}` has no tests", u.label),
368 detail: format!(
369 "{} source file(s), {} line(s), zero test attributes found",
370 u.files, u.lines
371 ),
372 magnitude: (u.lines as f64 / 3_000.0).min(1.0),
373 measured: true,
374 targets: vec![u.id.clone()],
375 evidence: vec![format!(
376 "counted 0 of `#[test]`/`#[tokio::test]`/`def test_`/`it(` across {} file(s)",
377 u.files
378 )],
379 });
380 }
381 if u.markers > 0 {
382 signals.push(Signal {
383 id: format!("markers:{}", u.label),
384 polarity: Polarity::Opportunity,
385 label: format!("{} marker(s) in `{}`", u.markers, u.label),
386 detail: "TODO / FIXME / HACK left in source".into(),
387 magnitude: (u.markers as f64 / 50.0).min(1.0),
388 measured: true,
389 targets: vec![u.id.clone()],
390 evidence: vec![format!("counted {} marker(s)", u.markers)],
391 });
392 }
393 if !u.documented && u.code_files > 0 {
394 signals.push(Signal {
395 id: format!("undocumented:{}", u.label),
396 polarity: Polarity::Opportunity,
397 label: format!("`{}` has no README or module doc", u.label),
398 detail: "no README and no `//!` / docstring found in its sources".into(),
399 magnitude: (u.lines as f64 / 5_000.0).min(1.0),
400 measured: true,
401 targets: vec![u.id.clone()],
402 evidence: vec!["presence check over the unit's files".into()],
403 });
404 }
405 }
406 if !large_files.is_empty() {
407 large_files.sort_by_key(|(_, l)| std::cmp::Reverse(*l));
408 signals.push(Signal {
409 id: "oversized-files".into(),
410 polarity: Polarity::Risk,
411 label: format!("{} file(s) over {LARGE_FILE_LINES} lines", large_files.len()),
412 detail: large_files
413 .iter()
414 .take(5)
415 .map(|(p, l)| format!("{p} ({l})"))
416 .collect::<Vec<_>>()
417 .join(", "),
418 magnitude: (large_files.len() as f64 / 10.0).min(1.0),
419 measured: true,
420 targets: large_files.iter().take(5).map(|(p, _)| p.clone()).collect(),
421 evidence: vec![format!("line counts over {} file(s)", large_files.len())],
422 });
423 }
424
425 let root_display = display_path(&root);
426 let mut facts: Vec<Fact> = vec![Fact {
427 subject: root_display.clone(),
428 predicate: "is_git_repository".into(),
429 object: root.join(".git").exists().to_string(),
430 confidence: 1.0,
431 evidence: vec![".git directory presence".into()],
432 provenance: Provenance::Live { age_secs: 0 },
433 }];
434 for (ext, n) in &by_ext {
435 if *n >= 5 {
436 facts.push(Fact {
437 subject: root_display.clone(),
438 predicate: format!("file_count.{ext}"),
439 object: n.to_string(),
440 confidence: 1.0,
441 evidence: vec!["counted during the walk".into()],
442 provenance: Provenance::Live { age_secs: 0 },
443 });
444 }
445 }
446 if unreadable > 0 {
447 facts.push(Fact {
448 subject: root_display.clone(),
449 predicate: "unreadable_source_files".into(),
450 object: unreadable.to_string(),
451 confidence: 1.0,
452 evidence: vec!["read errors during the walk".into()],
453 provenance: Provenance::Live { age_secs: 0 },
454 });
455 }
456
457 let extent = if w.truncated {
458 Extent::partial(
459 w.files.len() as u64,
460 format!("walk capped at {MAX_FILES} files / depth {MAX_DEPTH}; the tree is larger"),
461 )
462 } else {
463 Extent::complete(w.files.len() as u64, format!("walked {} directories", w.dirs_seen))
464 };
465
466 Ok(WorldState {
467 observer: self.name().to_string(),
468 entity: Entity {
469 kind: EntityKind::Repository,
470 locator: root_display.clone(),
471 label: root
472 .file_name()
473 .map(|s| s.to_string_lossy().to_string())
474 .unwrap_or_else(|| locator.to_string()),
475 },
476 domain: Domain::Software,
477 observed_at: now_secs(),
478 objects,
479 facts,
480 signals,
481 extent,
482 blind_spots,
483 })
484 }
485}
486
487#[cfg(test)]
488mod tests {
489 use super::*;
490
491 fn scratch() -> PathBuf {
492 let p = std::env::temp_dir().join(format!(
493 "scema-omni-repo-{}-{}",
494 std::process::id(),
495 std::time::SystemTime::now()
496 .duration_since(std::time::UNIX_EPOCH)
497 .unwrap()
498 .as_nanos()
499 ));
500 fs::create_dir_all(&p).unwrap();
501 p
502 }
503
504 fn write(root: &Path, rel: &str, body: &str) {
505 let p = root.join(rel);
506 fs::create_dir_all(p.parent().unwrap()).unwrap();
507 fs::write(p, body).unwrap();
508 }
509
510 #[test]
511 fn an_untested_crate_produces_a_counted_risk_signal() {
512 let root = scratch();
513 write(&root, "Cargo.toml", "[package]\nname = \"x\"\n");
514 write(&root, "src/lib.rs", &"fn a() {}\n".repeat(100));
515 let w = RepoObserver.observe(root.to_str().unwrap()).unwrap();
516
517 let s = w.risks().find(|s| s.id.starts_with("untested:")).expect("expected an untested risk");
518 assert!(s.measured, "a zero count is still a count");
519 assert!(s.evidence[0].contains("counted 0"));
520 fs::remove_dir_all(&root).ok();
521 }
522
523 #[test]
524 fn a_tested_crate_produces_no_untested_signal() {
525 let root = scratch();
526 write(&root, "Cargo.toml", "[package]\nname = \"x\"\n");
527 write(&root, "src/lib.rs", "fn a() {}\n#[test]\nfn t() {}\n");
528 let w = RepoObserver.observe(root.to_str().unwrap()).unwrap();
529 assert!(w.risks().all(|s| !s.id.starts_with("untested:")));
530 fs::remove_dir_all(&root).ok();
531 }
532
533 #[test]
534 fn deliberate_exclusions_are_not_reported_as_blind_spots() {
535 let root = scratch();
538 write(&root, "Cargo.toml", "[package]\nname = \"x\"\n");
539 write(&root, "src/lib.rs", "fn a() {}\n");
540 write(&root, "target/debug/junk.rs", "fn junk() {}\n");
541 write(&root, "node_modules/pkg/index.js", "module.exports = 1\n");
542 let w = RepoObserver.observe(root.to_str().unwrap()).unwrap();
543 assert!(w.blind_spots.is_empty(), "got {:?}", w.blind_spots);
544 assert!(
545 w.objects.iter().all(|o| !o.label.contains("node_modules")),
546 "excluded trees must not become units"
547 );
548 fs::remove_dir_all(&root).ok();
549 }
550
551 #[test]
552 fn a_unit_with_no_readable_files_is_absent_not_empty() {
553 let root = scratch();
554 write(&root, "Cargo.toml", "[package]\nname = \"root\"\n");
555 write(&root, "src/lib.rs", "fn a() {}\n");
556 write(&root, "sub/Cargo.toml", "[package]\nname = \"sub\"\n");
558 let w = RepoObserver.observe(root.to_str().unwrap()).unwrap();
559 let sub = w
560 .objects
561 .iter()
562 .find(|o| o.label == "sub")
563 .expect("the sub unit must appear");
564 if sub.provenance == Provenance::Absent {
567 assert!(sub.attrs.is_empty(), "an absent object must carry no values");
568 }
569 fs::remove_dir_all(&root).ok();
570 }
571
572 #[test]
573 fn a_complete_walk_reports_a_bounded_extent() {
574 let root = scratch();
575 write(&root, "Cargo.toml", "[package]\nname = \"x\"\n");
576 write(&root, "src/lib.rs", "fn a() {}\n");
577 let w = RepoObserver.observe(root.to_str().unwrap()).unwrap();
578 assert!(w.extent.fraction().is_some(), "a complete walk must not read as unbounded");
579 fs::remove_dir_all(&root).ok();
580 }
581
582 #[test]
583 fn a_missing_directory_is_an_error_not_an_empty_world() {
584 let err = RepoObserver.observe("definitely-not-a-real-path-9f2a").unwrap_err();
587 assert!(err.to_string().contains("cannot read"));
588 }
589
590 #[test]
591 fn a_manifest_only_unit_is_not_reported_as_untested_code() {
592 let root = scratch();
596 write(&root, "Cargo.toml", "[workspace]
597members = [\"a\"]
598");
599 write(&root, "a/Cargo.toml", "[package]
600name = \"a\"
601");
602 write(&root, "a/src/lib.rs", "fn f() {}
603");
604 let w = RepoObserver.observe(root.to_str().unwrap()).unwrap();
605
606 let untested: Vec<&str> = w.risks().map(|s| s.id.as_str()).collect();
607 assert!(
608 untested.iter().any(|id| id.ends_with(":a")),
609 "the crate with code must still be flagged, got {untested:?}"
610 );
611 let root_label = root.file_name().unwrap().to_string_lossy().to_string();
612 assert!(
613 !untested.iter().any(|id| id.ends_with(&format!(":{root_label}"))),
614 "the manifest-only workspace root must not be, got {untested:?}"
615 );
616 fs::remove_dir_all(&root).ok();
617 }
618
619 #[test]
620 fn the_locator_carries_no_platform_path_prefix() {
621 let root = scratch();
624 write(&root, "Cargo.toml", "[package]
625name = \"x\"
626");
627 write(&root, "src/lib.rs", "fn a() {}
628");
629 let w = RepoObserver.observe(root.to_str().unwrap()).unwrap();
630 assert!(!w.entity.locator.starts_with(r"\?\"), "got {}", w.entity.locator);
631 fs::remove_dir_all(&root).ok();
632 }
633
634 #[test]
635 fn markers_are_counted_and_cited() {
636 let root = scratch();
637 write(&root, "Cargo.toml", "[package]\nname = \"x\"\n");
638 write(&root, "src/lib.rs", "// TODO: one\n// FIXME: two\n#[test]\nfn t() {}\n");
639 let w = RepoObserver.observe(root.to_str().unwrap()).unwrap();
640 let s = w
641 .opportunities()
642 .find(|s| s.id.starts_with("markers:"))
643 .expect("expected a marker signal");
644 assert!(s.evidence[0].contains("counted 2"), "got {:?}", s.evidence);
645 fs::remove_dir_all(&root).ok();
646 }
647}