use std::fs;
use std::path::Path;
use std::time::SystemTime;
use harn_hostlib::scanner::{
scan_incremental, scan_project, scan_project_with_git, FileRecord, GitCapabilities,
ScanProjectOptions, SymbolKind,
};
use tempfile::tempdir;
fn write(root: &Path, rel: &str, content: &str) {
let path = root.join(rel);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).unwrap();
}
fs::write(&path, content).unwrap();
}
fn build_fixture(root: &Path) {
write(
root,
"Cargo.toml",
r#"[package]
name = "scanner-fixture"
version = "0.1.0"
edition = "2024"
"#,
);
write(
root,
"package.json",
r#"{
"name": "scanner-fixture",
"scripts": {
"test": "vitest run",
"lint": "eslint .",
"build": "tsc -b"
}
}
"#,
);
write(
root,
"src/main.rs",
r#"use crate::routes::accounts;
pub struct App;
impl App {
pub fn run(&self) {}
}
pub fn entry() {}
"#,
);
write(
root,
"src/routes/accounts.rs",
r#"use std::collections::HashMap;
pub struct AccountsService;
impl AccountsService {
pub fn lookup(&self, id: u64) -> Option<()> { None }
}
"#,
);
write(
root,
"src/routes/accounts_test.rs",
r#"use super::accounts::AccountsService;
"#,
);
write(
root,
"src/lib/helpers.ts",
r#"export function paginatedResponse<T>(items: T[], total: number) {
return { items, total, page: 1 };
}
export function asyncHandler(fn: any) { return fn; }
"#,
);
write(
root,
"src/lib/__tests__/helpers.test.ts",
r#"import { paginatedResponse } from "../helpers";
"#,
);
write(root, "prisma/schema.prisma", "model User { id Int @id }\n");
write(root, ".env", "SECRET=1\n");
write(root, "node_modules/foo/index.js", "module.exports = 1;\n");
write(root, "target/debug/junk.txt", "ignore me\n");
write(
root,
"services/api/Cargo.toml",
r#"[package]
name = "api"
version = "0.1.0"
edition = "2024"
"#,
);
write(
root,
"services/api/src/lib.rs",
"pub fn ping() -> bool { true }\n",
);
}
fn touch_after(path: &Path, base: SystemTime) {
let new_mtime = base + std::time::Duration::from_secs(60);
let _ = filetime::set_file_mtime(path, filetime::FileTime::from_system_time(new_mtime));
}
#[derive(Default)]
struct MockGit {
files: Option<Vec<String>>,
churn: std::collections::BTreeMap<String, f64>,
}
impl GitCapabilities for MockGit {
fn list_files(&self, _root: &Path) -> Option<Vec<String>> {
self.files.clone()
}
fn churn_scores(&self, _root: &Path) -> std::collections::BTreeMap<String, f64> {
self.churn.clone()
}
}
#[test]
fn scan_project_emits_full_result_shape() {
let tmp = tempdir().unwrap();
build_fixture(tmp.path());
let result = scan_project(tmp.path(), ScanProjectOptions::default());
let paths: Vec<&str> = result
.files
.iter()
.map(|f| f.relative_path.as_str())
.collect();
assert!(paths.contains(&"src/main.rs"));
assert!(paths.contains(&"src/routes/accounts.rs"));
assert!(paths.contains(&"src/lib/helpers.ts"));
assert!(!paths.iter().any(|p| p.starts_with("node_modules")));
assert!(!paths.iter().any(|p| p.starts_with("target")));
let main = result
.files
.iter()
.find(|f| f.relative_path == "src/main.rs")
.expect("main.rs file record");
assert_eq!(main.language, "rs");
assert!(main.line_count > 0);
assert!(main.size_bytes > 0);
assert!(main.imports.iter().any(|i| i.contains("routes")));
let symbol_names: Vec<&str> = result.symbols.iter().map(|s| s.name.as_str()).collect();
assert!(symbol_names.contains(&"App"));
assert!(symbol_names.contains(&"AccountsService"));
assert!(symbol_names.contains(&"paginatedResponse"));
assert!(result
.dependencies
.iter()
.any(|d| d.from_file == "src/routes/accounts.rs" && d.to_module.contains("HashMap")));
let helpers = result
.files
.iter()
.find(|f| f.relative_path == "src/lib/helpers.ts")
.unwrap();
assert_eq!(
helpers.corresponding_test_file.as_deref(),
Some("src/lib/__tests__/helpers.test.ts")
);
assert!(!result.folders.is_empty());
for window in result.folders.windows(2) {
assert!(window[0].line_count >= window[1].line_count);
}
assert_eq!(result.project.name, project_name(tmp.path()));
assert!(result.project.languages.iter().any(|l| l.name == "rs"));
assert!(result.project.languages.iter().any(|l| l.name == "ts"));
assert!(result.project.test_commands.contains_key("cargo test"));
assert!(result.project.test_commands.contains_key("pnpm test"));
let detected = result
.project
.detected_test_command
.as_deref()
.expect("a test command should be detected");
assert!(!detected.is_empty());
assert!(result
.project
.code_patterns
.iter()
.any(|p| p.contains("Prisma")));
let sub_marker_count = result
.sub_projects
.iter()
.filter(|s| s.project_marker == "Cargo.toml" || s.project_marker == "package.json")
.count();
assert!(sub_marker_count >= 2);
assert!(result.repo_map.contains("src/main.rs"));
assert!(result.repo_map.contains("App"));
let sorted: Vec<_> = result
.files
.iter()
.map(|f| &f.relative_path)
.collect::<Vec<_>>();
let mut copy = sorted.clone();
copy.sort();
assert_eq!(sorted, copy);
let snapshot_path = tmp.path().join(".harn/hostlib/scanner-snapshot.json");
assert!(snapshot_path.exists());
}
#[test]
fn scan_project_uses_injected_git_capabilities() {
let tmp = tempdir().unwrap();
build_fixture(tmp.path());
let git = MockGit {
files: Some(vec![
"Cargo.toml".to_string(),
"src/main.rs".to_string(),
"src/routes/accounts.rs".to_string(),
"target/debug/junk.txt".to_string(),
]),
churn: [("src/main.rs".to_string(), 0.75)].into_iter().collect(),
};
let result = scan_project_with_git(tmp.path(), ScanProjectOptions::default(), &git);
let paths: Vec<&str> = result
.files
.iter()
.map(|f| f.relative_path.as_str())
.collect();
assert_eq!(
paths,
vec!["Cargo.toml", "src/main.rs", "src/routes/accounts.rs"]
);
let main = find_file(&result.files, "src/main.rs").unwrap();
assert_eq!(main.churn_score, 0.75);
}
fn project_name(root: &Path) -> String {
let canonical = std::fs::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
canonical
.file_name()
.and_then(|s| s.to_str())
.unwrap_or("")
.to_string()
}
#[test]
fn scan_incremental_picks_up_added_and_removed_files() {
let tmp = tempdir().unwrap();
build_fixture(tmp.path());
let initial = scan_project(tmp.path(), ScanProjectOptions::default());
let token = initial.snapshot_token.clone();
let baseline = SystemTime::now();
write(
tmp.path(),
"src/routes/orders.rs",
"pub struct OrdersService;\n",
);
fs::remove_file(tmp.path().join("src/lib/helpers.ts")).unwrap();
touch_after(&tmp.path().join("src/routes/orders.rs"), baseline);
let scan = scan_incremental(&token, None, ScanProjectOptions::default());
assert!(scan.delta.added.iter().any(|p| p == "src/routes/orders.rs"));
assert!(scan.delta.removed.iter().any(|p| p == "src/lib/helpers.ts"));
let result = &scan.result;
let new_paths: Vec<&str> = result
.files
.iter()
.map(|f| f.relative_path.as_str())
.collect();
assert!(new_paths.contains(&"src/routes/orders.rs"));
assert!(!new_paths.contains(&"src/lib/helpers.ts"));
}
#[test]
fn scan_incremental_full_rescan_when_snapshot_missing() {
let tmp = tempdir().unwrap();
build_fixture(tmp.path());
let token = tmp.path().to_string_lossy().into_owned();
let scan = scan_incremental(&token, None, ScanProjectOptions::default());
assert!(scan.delta.full_rescan);
assert!(!scan.result.files.is_empty());
}
#[test]
fn scan_incremental_modifies_via_explicit_changed_paths() {
let tmp = tempdir().unwrap();
build_fixture(tmp.path());
let initial = scan_project(tmp.path(), ScanProjectOptions::default());
let token = initial.snapshot_token.clone();
write(
tmp.path(),
"src/main.rs",
"pub fn entry() { println!(\"refreshed\"); }\n",
);
let scan = scan_incremental(
&token,
Some(&["src/main.rs".to_string()]),
ScanProjectOptions::default(),
);
assert!(scan.delta.modified.iter().any(|p| p == "src/main.rs"));
let main = find_file(&scan.result.files, "src/main.rs").unwrap();
assert!(main.imports.is_empty());
}
fn find_file<'a>(files: &'a [FileRecord], path: &str) -> Option<&'a FileRecord> {
files.iter().find(|f| f.relative_path == path)
}
#[test]
fn scan_project_truncates_to_max_files() {
let tmp = tempdir().unwrap();
build_fixture(tmp.path());
let opts = ScanProjectOptions {
max_files: 2,
..ScanProjectOptions::default()
};
let result = scan_project(tmp.path(), opts);
assert!(result.truncated);
assert!(result.files.len() <= 2);
}
#[test]
fn symbol_records_carry_canonical_kind() {
let tmp = tempdir().unwrap();
build_fixture(tmp.path());
let result = scan_project(tmp.path(), ScanProjectOptions::default());
let app = result.symbols.iter().find(|s| s.name == "App").unwrap();
assert_eq!(app.kind, SymbolKind::StructDecl);
let entry = result.symbols.iter().find(|s| s.name == "entry").unwrap();
assert_eq!(entry.kind, SymbolKind::Function);
}
#[test]
fn scan_project_workspace_shape_smoke_test() {
let tmp = tempdir().unwrap();
build_fixture(tmp.path());
write(
tmp.path(),
".github/workflows/ci.yml",
"name: ci\non: [push]\njobs:\n test:\n runs-on: ubuntu-latest\n",
);
write(
tmp.path(),
"docs/src/language-spec.md",
"# Language Spec\n\n```harn\nfn main() { 1 }\n```\n",
);
write(
tmp.path(),
"crates/harn-hostlib/src/lib.rs",
"pub mod scanner;\npub fn install() {}\n",
);
let result = scan_project_with_git(
tmp.path(),
ScanProjectOptions {
include_git_history: false,
..ScanProjectOptions::default()
},
&MockGit::default(),
);
assert!(
!result.files.is_empty(),
"workspace fixture should have files"
);
assert!(
!result.symbols.is_empty(),
"workspace fixture should have symbols"
);
let names: Vec<&str> = result
.files
.iter()
.map(|f| f.relative_path.as_str())
.collect();
assert!(names.iter().any(|n| n.ends_with("Cargo.toml")));
assert!(names.iter().any(|n| n.contains("crates/harn-hostlib")));
}