1use std::path::{Path, PathBuf};
17use std::process::Command;
18use std::sync::Arc;
19use std::time::{SystemTime, UNIX_EPOCH};
20
21use harn_vm::ignore_policy::IgnorePolicy;
22use harn_vm::VmValue;
23
24use crate::error::HostlibError;
25use crate::registry::{BuiltinRegistry, HostlibCapability};
26use crate::tools::args::{
27 build_dict, dict_arg, optional_bool, optional_int, optional_string, require_string, str_value,
28};
29
30mod commands;
31mod discover;
32mod extensions;
33mod fingerprint;
34mod folders;
35mod git;
36mod imports;
37mod manifest;
38mod result;
39mod scoring;
40mod snapshot;
41mod subproject;
42mod symbols;
43mod test_mapping;
44
45fn strip_ambient_git_env(cmd: &mut Command) {
46 for (key, _) in std::env::vars() {
49 if key.starts_with("GIT_") {
50 cmd.env_remove(&key);
51 }
52 }
53}
54
55pub use git::GitCapabilities;
56pub use result::{
57 DependencyEdge, FileRecord, FolderRecord, LanguageStat, ProjectMetadata, ScanDelta, ScanResult,
58 SubProject, SymbolKind, SymbolRecord,
59};
60
61const SCAN_PROJECT_BUILTIN: &str = "hostlib_scanner_scan_project";
62const SCAN_INCREMENTAL_BUILTIN: &str = "hostlib_scanner_scan_incremental";
63
64#[derive(Default)]
66pub struct ScannerCapability;
67
68impl HostlibCapability for ScannerCapability {
69 fn module_name(&self) -> &'static str {
70 "scanner"
71 }
72
73 fn register_builtins(&self, registry: &mut BuiltinRegistry) {
74 registry.register_fn(
75 "scanner",
76 SCAN_PROJECT_BUILTIN,
77 "scan_project",
78 scan_project_handler,
79 );
80 registry.register_fn(
81 "scanner",
82 SCAN_INCREMENTAL_BUILTIN,
83 "scan_incremental",
84 scan_incremental_handler,
85 );
86 }
87}
88
89#[derive(Clone, Debug)]
93pub struct ScanProjectOptions {
94 pub include_hidden: bool,
96 pub ignore_policy: IgnorePolicy,
98 pub max_files: usize,
100 pub include_git_history: bool,
102 pub repo_map_token_budget: usize,
104}
105
106impl Default for ScanProjectOptions {
107 fn default() -> Self {
108 Self {
109 include_hidden: false,
110 ignore_policy: IgnorePolicy::default(),
111 max_files: 0,
112 include_git_history: true,
113 repo_map_token_budget: 1200,
114 }
115 }
116}
117
118pub fn scan_project(root: &Path, opts: ScanProjectOptions) -> ScanResult {
120 scan_project_with_git(root, opts, &git::CliGitCapabilities)
121}
122
123pub fn scan_project_with_git(
129 root: &Path,
130 opts: ScanProjectOptions,
131 git: &dyn GitCapabilities,
132) -> ScanResult {
133 let canonical = canonicalize(root);
134 let discover_opts = discover::DiscoverOptions {
135 include_hidden: opts.include_hidden,
136 ignore_policy: opts.ignore_policy,
137 };
138 let mut discovered = discover::discover_files(&canonical, discover_opts, git);
139 let truncated = if opts.max_files > 0 && discovered.len() > opts.max_files {
140 discovered.truncate(opts.max_files);
141 true
142 } else {
143 false
144 };
145
146 let (mut files, mut symbols, mut dependencies) = extract_per_file(&discovered);
147
148 scoring::compute_reference_counts(&mut symbols, &files);
149
150 if opts.include_git_history {
151 let churn = git.churn_scores(&canonical);
152 scoring::apply_churn(&mut files, &churn);
153 }
154 scoring::compute_importance_scores(&mut symbols, &files);
155
156 test_mapping::map_test_files(&mut files);
157
158 let folder_records = folders::build_folder_records(&files, &symbols);
159 let test_commands = commands::detect_test_commands(&canonical);
160 let code_patterns = commands::detect_code_patterns(&files, &canonical);
161 let mut project = folders::build_project_metadata(
162 &canonical,
163 &files,
164 test_commands,
165 code_patterns,
166 now_iso8601(),
167 );
168 let repo_map = folders::build_repo_map(&symbols, &files, opts.repo_map_token_budget);
169 let mut sub_projects = subproject::detect_subprojects(&canonical, 2);
170 attach_manifest_dependencies(&canonical, &mut project, &mut sub_projects);
171 let codebase_fingerprint =
172 fingerprint::build(&canonical, &project, &files, &symbols, &dependencies);
173
174 sort_for_output(&mut files, &mut symbols, &mut dependencies);
175
176 let token = snapshot::root_to_token(&canonical);
177 let result = ScanResult {
178 snapshot_token: token,
179 truncated,
180 project,
181 folders: folder_records,
182 files,
183 symbols,
184 dependencies,
185 sub_projects,
186 repo_map,
187 codebase_fingerprint,
188 };
189 snapshot::save(&canonical, &result);
190 result
191}
192
193#[derive(Clone, Debug)]
195pub struct IncrementalScan {
196 pub result: ScanResult,
198 pub delta: ScanDelta,
200}
201
202pub fn scan_incremental(
206 token: &str,
207 explicit_changed: Option<&[String]>,
208 opts: ScanProjectOptions,
209) -> IncrementalScan {
210 scan_incremental_with_git(token, explicit_changed, opts, &git::CliGitCapabilities)
211}
212
213pub fn scan_incremental_with_git(
215 token: &str,
216 explicit_changed: Option<&[String]>,
217 opts: ScanProjectOptions,
218 git: &dyn GitCapabilities,
219) -> IncrementalScan {
220 let root = snapshot::token_to_root(token);
221 let canonical = canonicalize(&root);
222
223 let cached = snapshot::load(&canonical);
224 let cached = match cached {
225 Some(c) => c,
226 None => {
227 let result = scan_project_with_git(&canonical, opts, git);
228 return IncrementalScan {
229 result,
230 delta: ScanDelta {
231 full_rescan: true,
232 ..ScanDelta::default()
233 },
234 };
235 }
236 };
237
238 let discover_opts = discover::DiscoverOptions {
239 include_hidden: opts.include_hidden,
240 ignore_policy: opts.ignore_policy,
241 };
242 let mut current = discover::discover_files(&canonical, discover_opts, git);
243 if opts.max_files > 0 && current.len() > opts.max_files {
244 current.truncate(opts.max_files);
245 }
246
247 let delta = compute_delta(¤t, &cached, explicit_changed);
248 let total = current.len();
249 let needs_full_rescan =
250 total > 0 && (delta.added.len() + delta.modified.len()) * 10 > total * 3;
251
252 if needs_full_rescan {
253 let result = scan_project_with_git(&canonical, opts, git);
254 return IncrementalScan {
255 result,
256 delta: ScanDelta {
257 full_rescan: true,
258 ..delta
259 },
260 };
261 }
262
263 if delta.added.is_empty() && delta.modified.is_empty() && delta.removed.is_empty() {
264 return IncrementalScan {
265 result: cached,
266 delta,
267 };
268 }
269
270 let mut files = cached.files;
272 let mut symbols = cached.symbols;
273 let mut dependencies = cached.dependencies;
274
275 let removed_set: std::collections::HashSet<&str> =
276 delta.removed.iter().map(|s| s.as_str()).collect();
277 let touched_set: std::collections::HashSet<&str> = delta
278 .added
279 .iter()
280 .chain(delta.modified.iter())
281 .map(|s| s.as_str())
282 .collect();
283
284 files.retain(|f| !removed_set.contains(f.relative_path.as_str()));
285 symbols.retain(|s| {
286 !removed_set.contains(s.file_path.as_str()) && !touched_set.contains(s.file_path.as_str())
287 });
288 dependencies.retain(|d| {
289 !removed_set.contains(d.from_file.as_str()) && !touched_set.contains(d.from_file.as_str())
290 });
291
292 let touched_entries: Vec<discover::DiscoveredFile> = current
293 .iter()
294 .filter(|e| touched_set.contains(e.relative_path.as_str()))
295 .cloned()
296 .collect();
297 let (new_files, new_symbols, new_deps) = extract_per_file(&touched_entries);
298
299 let mut by_path: std::collections::BTreeMap<String, FileRecord> = files
300 .into_iter()
301 .map(|f| (f.relative_path.clone(), f))
302 .collect();
303 for new_file in new_files {
304 by_path.insert(new_file.relative_path.clone(), new_file);
305 }
306 let mut files: Vec<FileRecord> = by_path.into_values().collect();
307 symbols.extend(new_symbols);
308 dependencies.extend(new_deps);
309
310 scoring::compute_reference_counts(&mut symbols, &files);
311 if opts.include_git_history {
312 let churn = git.churn_scores(&canonical);
313 scoring::apply_churn(&mut files, &churn);
314 }
315 scoring::compute_importance_scores(&mut symbols, &files);
316 test_mapping::map_test_files(&mut files);
317
318 let folder_records = folders::build_folder_records(&files, &symbols);
319 let test_commands = commands::detect_test_commands(&canonical);
320 let code_patterns = commands::detect_code_patterns(&files, &canonical);
321 let mut project = folders::build_project_metadata(
322 &canonical,
323 &files,
324 test_commands,
325 code_patterns,
326 now_iso8601(),
327 );
328 let repo_map = folders::build_repo_map(&symbols, &files, opts.repo_map_token_budget);
329 let mut sub_projects = subproject::detect_subprojects(&canonical, 2);
330 attach_manifest_dependencies(&canonical, &mut project, &mut sub_projects);
331 let codebase_fingerprint =
332 fingerprint::build(&canonical, &project, &files, &symbols, &dependencies);
333
334 sort_for_output(&mut files, &mut symbols, &mut dependencies);
335
336 let token = snapshot::root_to_token(&canonical);
337 let result = ScanResult {
338 snapshot_token: token,
339 truncated: cached.truncated,
340 project,
341 folders: folder_records,
342 files,
343 symbols,
344 dependencies,
345 sub_projects,
346 repo_map,
347 codebase_fingerprint,
348 };
349 snapshot::save(&canonical, &result);
350 IncrementalScan { result, delta }
351}
352
353fn canonicalize(root: &Path) -> PathBuf {
356 std::fs::canonicalize(root).unwrap_or_else(|_| root.to_path_buf())
357}
358
359fn attach_manifest_dependencies(
364 canonical: &Path,
365 project: &mut ProjectMetadata,
366 sub_projects: &mut [SubProject],
367) {
368 project.available_dependencies = manifest::directory_dependencies(canonical);
369 for sp in sub_projects.iter_mut() {
370 sp.dependencies = manifest::directory_dependencies(Path::new(&sp.path));
371 }
372}
373
374fn extract_per_file(
375 discovered: &[discover::DiscoveredFile],
376) -> (Vec<FileRecord>, Vec<SymbolRecord>, Vec<DependencyEdge>) {
377 let mut files: Vec<FileRecord> = Vec::with_capacity(discovered.len());
378 let mut symbols: Vec<SymbolRecord> = Vec::new();
379 let mut dependencies: Vec<DependencyEdge> = Vec::new();
380
381 for entry in discovered {
382 let metadata = std::fs::metadata(&entry.absolute_path);
383 let size = metadata.as_ref().map(|m| m.len()).unwrap_or(0);
384 let modified = metadata
385 .as_ref()
386 .ok()
387 .and_then(|m| m.modified().ok())
388 .and_then(|t| t.duration_since(UNIX_EPOCH).ok())
389 .map(|d| d.as_millis() as i64)
390 .unwrap_or(0);
391
392 let content = std::fs::read_to_string(&entry.absolute_path).unwrap_or_default();
393 if content.is_empty() && size != 0 {
394 }
396 let language = extensions::file_extension(&entry.relative_path);
397 let imports = imports::extract_imports(&content, &language);
398 let file_symbols = symbols::extract_symbols(&content, &language, &entry.relative_path);
399 let line_count = crate::text::count_lines(content.as_bytes()) as usize;
400
401 for imp in &imports {
402 dependencies.push(DependencyEdge {
403 from_file: entry.relative_path.clone(),
404 to_module: imp.clone(),
405 });
406 }
407 symbols.extend(file_symbols);
408
409 files.push(FileRecord {
410 id: entry.relative_path.clone(),
411 relative_path: entry.relative_path.clone(),
412 file_name: extensions::file_name(&entry.relative_path).to_string(),
413 language,
414 line_count,
415 size_bytes: size,
416 last_modified_unix_ms: modified,
417 imports,
418 churn_score: 0.0,
419 corresponding_test_file: None,
420 });
421 }
422
423 (files, symbols, dependencies)
424}
425
426fn sort_for_output(
427 files: &mut [FileRecord],
428 symbols: &mut [SymbolRecord],
429 dependencies: &mut [DependencyEdge],
430) {
431 files.sort_by(|a, b| a.relative_path.cmp(&b.relative_path));
432 symbols.sort_by(|a, b| a.id.cmp(&b.id));
433 dependencies.sort_by(|a, b| {
434 a.from_file
435 .cmp(&b.from_file)
436 .then_with(|| a.to_module.cmp(&b.to_module))
437 });
438}
439
440fn compute_delta(
441 current: &[discover::DiscoveredFile],
442 cached: &ScanResult,
443 explicit_changed: Option<&[String]>,
444) -> ScanDelta {
445 let cached_files: std::collections::BTreeMap<&str, &FileRecord> = cached
446 .files
447 .iter()
448 .map(|f| (f.relative_path.as_str(), f))
449 .collect();
450 let current_paths: std::collections::HashSet<&str> =
451 current.iter().map(|e| e.relative_path.as_str()).collect();
452
453 let added: Vec<String> = current
454 .iter()
455 .filter(|e| !cached_files.contains_key(e.relative_path.as_str()))
456 .map(|e| e.relative_path.clone())
457 .collect();
458 let removed: Vec<String> = cached
459 .files
460 .iter()
461 .filter(|f| !current_paths.contains(f.relative_path.as_str()))
462 .map(|f| f.relative_path.clone())
463 .collect();
464
465 let modified: Vec<String> = if let Some(explicit) = explicit_changed {
466 explicit
467 .iter()
468 .filter(|p| cached_files.contains_key(p.as_str()) && current_paths.contains(p.as_str()))
469 .cloned()
470 .collect()
471 } else {
472 let mut out = Vec::new();
473 for entry in current {
474 if let Some(prev) = cached_files.get(entry.relative_path.as_str()) {
475 let meta = std::fs::metadata(&entry.absolute_path).ok();
476 let mtime = meta
477 .as_ref()
478 .and_then(|m| m.modified().ok())
479 .and_then(|t| t.duration_since(UNIX_EPOCH).ok())
480 .map(|d| d.as_millis() as i64)
481 .unwrap_or(0);
482 let size = meta.as_ref().map(|m| m.len()).unwrap_or(prev.size_bytes);
483 if mtime > prev.last_modified_unix_ms || size != prev.size_bytes {
492 out.push(entry.relative_path.clone());
493 }
494 }
495 }
496 out
497 };
498
499 ScanDelta {
500 added,
501 modified,
502 removed,
503 full_rescan: false,
504 }
505}
506
507fn now_iso8601() -> String {
508 let now = SystemTime::now()
509 .duration_since(UNIX_EPOCH)
510 .unwrap_or_default();
511 let secs = now.as_secs() as i64;
512 let nanos = now.subsec_nanos();
513 let (year, month, day, hour, minute, second) = unix_to_civil(secs);
514 format!(
515 "{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}.{millis:03}Z",
516 millis = nanos / 1_000_000
517 )
518}
519
520fn unix_to_civil(secs: i64) -> (i64, u32, u32, u32, u32, u32) {
524 let days = secs.div_euclid(86_400);
525 let day_secs = secs.rem_euclid(86_400);
526 let hour = (day_secs / 3600) as u32;
527 let minute = ((day_secs % 3600) / 60) as u32;
528 let second = (day_secs % 60) as u32;
529
530 let z = days + 719_468;
532 let era = z.div_euclid(146_097);
533 let doe = z.rem_euclid(146_097) as u64;
534 let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
535 let y = yoe as i64 + era * 400;
536 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
537 let mp = (5 * doy + 2) / 153;
538 let day = (doy - (153 * mp + 2) / 5 + 1) as u32;
539 let month = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
540 let year = if month <= 2 { y + 1 } else { y };
541 (year, month, day, hour, minute, second)
542}
543
544fn scan_project_handler(args: &[VmValue]) -> Result<VmValue, HostlibError> {
547 let raw = dict_arg(SCAN_PROJECT_BUILTIN, args)?;
548 let dict = raw.as_ref();
549 let root = require_string(SCAN_PROJECT_BUILTIN, dict, "root")?;
550 let opts = parse_options(SCAN_PROJECT_BUILTIN, dict)?;
551 let result = scan_project(Path::new(&root), opts);
552 Ok(scan_result_to_value(&result, None))
553}
554
555fn scan_incremental_handler(args: &[VmValue]) -> Result<VmValue, HostlibError> {
556 let raw = dict_arg(SCAN_INCREMENTAL_BUILTIN, args)?;
557 let dict = raw.as_ref();
558 let token = require_string(SCAN_INCREMENTAL_BUILTIN, dict, "snapshot_token")?;
559 let opts = parse_options(SCAN_INCREMENTAL_BUILTIN, dict)?;
560 let changed = parse_changed_paths(SCAN_INCREMENTAL_BUILTIN, dict)?;
561 let scan = scan_incremental(&token, changed.as_deref(), opts);
562 Ok(scan_result_to_value(&scan.result, Some(&scan.delta)))
563}
564
565fn ignore_policy_arg(
566 builtin: &'static str,
567 dict: &harn_vm::value::DictMap,
568) -> Result<IgnorePolicy, HostlibError> {
569 let Some(raw) = optional_string(builtin, dict, IgnorePolicy::OPTION_KEY)? else {
570 return Ok(IgnorePolicy::default());
571 };
572 IgnorePolicy::parse_for(builtin, &raw).map_err(|message| HostlibError::InvalidParameter {
573 builtin,
574 param: "ignore_policy",
575 message,
576 })
577}
578
579fn parse_options(
580 builtin: &'static str,
581 dict: &harn_vm::value::DictMap,
582) -> Result<ScanProjectOptions, HostlibError> {
583 let include_hidden = optional_bool(builtin, dict, "include_hidden", false)?;
584 let ignore_policy = ignore_policy_arg(builtin, dict)?;
585 let max_files = optional_int(builtin, dict, "max_files", 0)?;
586 let include_git_history_default = builtin == SCAN_PROJECT_BUILTIN;
587 let include_git_history = optional_bool(
588 builtin,
589 dict,
590 "include_git_history",
591 include_git_history_default,
592 )?;
593 let repo_map_token_budget = optional_int(builtin, dict, "repo_map_token_budget", 1200)?;
594 if max_files < 0 {
595 return Err(HostlibError::InvalidParameter {
596 builtin,
597 param: "max_files",
598 message: "must be >= 0".to_string(),
599 });
600 }
601 if repo_map_token_budget < 0 {
602 return Err(HostlibError::InvalidParameter {
603 builtin,
604 param: "repo_map_token_budget",
605 message: "must be >= 0".to_string(),
606 });
607 }
608 Ok(ScanProjectOptions {
609 include_hidden,
610 ignore_policy,
611 max_files: max_files as usize,
612 include_git_history,
613 repo_map_token_budget: repo_map_token_budget as usize,
614 })
615}
616
617fn parse_changed_paths(
618 builtin: &'static str,
619 dict: &harn_vm::value::DictMap,
620) -> Result<Option<Vec<String>>, HostlibError> {
621 let value = match dict.get("changed_paths") {
622 None | Some(VmValue::Nil) => return Ok(None),
623 Some(v) => v,
624 };
625 let list = match value {
626 VmValue::List(items) => items,
627 other => {
628 return Err(HostlibError::InvalidParameter {
629 builtin,
630 param: "changed_paths",
631 message: format!("expected list of strings, got {}", other.type_name()),
632 });
633 }
634 };
635 let mut out = Vec::with_capacity(list.len());
636 for item in list.iter() {
637 match item {
638 VmValue::String(s) => out.push(s.to_string()),
639 other => {
640 return Err(HostlibError::InvalidParameter {
641 builtin,
642 param: "changed_paths",
643 message: format!("non-string entry: {}", other.type_name()),
644 });
645 }
646 }
647 }
648 Ok(Some(out))
649}
650
651fn scan_result_to_value(result: &ScanResult, delta: Option<&ScanDelta>) -> VmValue {
652 let mut entries: Vec<(&'static str, VmValue)> = vec![
653 ("snapshot_token", str_value(&result.snapshot_token)),
654 ("truncated", VmValue::Bool(result.truncated)),
655 ("project", project_to_value(&result.project)),
656 ("folders", list_of(&result.folders, folder_to_value)),
657 ("files", list_of(&result.files, file_to_value)),
658 ("symbols", list_of(&result.symbols, symbol_to_value)),
659 (
660 "dependencies",
661 list_of(&result.dependencies, dependency_to_value),
662 ),
663 (
664 "sub_projects",
665 list_of(&result.sub_projects, subproject_to_value),
666 ),
667 ("repo_map", str_value(&result.repo_map)),
668 (
669 "codebase_fingerprint",
670 str_value(&result.codebase_fingerprint),
671 ),
672 ];
673 if let Some(d) = delta {
674 entries.push(("delta", delta_to_value(d)));
675 }
676 build_dict(entries)
677}
678
679fn list_of<T>(items: &[T], to_value: fn(&T) -> VmValue) -> VmValue {
680 let list: Vec<VmValue> = items.iter().map(to_value).collect();
681 VmValue::List(Arc::new(list))
682}
683
684fn project_to_value(project: &ProjectMetadata) -> VmValue {
685 let test_commands_entries: Vec<(String, VmValue)> = project
686 .test_commands
687 .iter()
688 .map(|(k, v)| (k.clone(), str_value(v)))
689 .collect();
690 let test_commands_dict = build_dict(test_commands_entries);
691
692 let detected: VmValue = project
693 .detected_test_command
694 .as_deref()
695 .map(str_value)
696 .unwrap_or(VmValue::Nil);
697
698 let code_patterns: Vec<VmValue> = project.code_patterns.iter().map(str_value).collect();
699 let available_dependencies: Vec<VmValue> = project
700 .available_dependencies
701 .iter()
702 .map(str_value)
703 .collect();
704
705 build_dict([
706 ("name", str_value(&project.name)),
707 ("root_path", str_value(&project.root_path)),
708 ("languages", list_of(&project.languages, language_to_value)),
709 ("test_commands", test_commands_dict),
710 ("detected_test_command", detected),
711 ("code_patterns", VmValue::List(Arc::new(code_patterns))),
712 ("total_files", VmValue::Int(project.total_files as i64)),
713 ("total_lines", VmValue::Int(project.total_lines as i64)),
714 ("last_scanned_at", str_value(&project.last_scanned_at)),
715 (
716 "available_dependencies",
717 VmValue::List(Arc::new(available_dependencies)),
718 ),
719 ])
720}
721
722fn language_to_value(stat: &LanguageStat) -> VmValue {
723 build_dict([
724 ("name", str_value(&stat.name)),
725 ("file_count", VmValue::Int(stat.file_count as i64)),
726 ("line_count", VmValue::Int(stat.line_count as i64)),
727 ("percentage", VmValue::Float(stat.percentage)),
728 ])
729}
730
731fn folder_to_value(folder: &FolderRecord) -> VmValue {
732 let names: Vec<VmValue> = folder.key_symbol_names.iter().map(str_value).collect();
733 build_dict([
734 ("id", str_value(&folder.id)),
735 ("relative_path", str_value(&folder.relative_path)),
736 ("file_count", VmValue::Int(folder.file_count as i64)),
737 ("line_count", VmValue::Int(folder.line_count as i64)),
738 ("dominant_language", str_value(&folder.dominant_language)),
739 ("key_symbol_names", VmValue::List(Arc::new(names))),
740 ])
741}
742
743fn file_to_value(file: &FileRecord) -> VmValue {
744 let imports: Vec<VmValue> = file.imports.iter().map(str_value).collect();
745 let test_pair = file
746 .corresponding_test_file
747 .as_deref()
748 .map(str_value)
749 .unwrap_or(VmValue::Nil);
750 build_dict([
751 ("id", str_value(&file.id)),
752 ("relative_path", str_value(&file.relative_path)),
753 ("file_name", str_value(&file.file_name)),
754 ("language", str_value(&file.language)),
755 ("line_count", VmValue::Int(file.line_count as i64)),
756 ("size_bytes", VmValue::Int(file.size_bytes as i64)),
757 (
758 "last_modified_unix_ms",
759 VmValue::Int(file.last_modified_unix_ms),
760 ),
761 ("imports", VmValue::List(Arc::new(imports))),
762 ("churn_score", VmValue::Float(file.churn_score)),
763 ("corresponding_test_file", test_pair),
764 ])
765}
766
767fn symbol_to_value(symbol: &SymbolRecord) -> VmValue {
768 let container = symbol
769 .container
770 .as_deref()
771 .map(str_value)
772 .unwrap_or(VmValue::Nil);
773 build_dict([
774 ("id", str_value(&symbol.id)),
775 ("name", str_value(&symbol.name)),
776 ("kind", str_value(symbol.kind.keyword())),
777 ("file_path", str_value(&symbol.file_path)),
778 ("line", VmValue::Int(symbol.line as i64)),
779 ("signature", str_value(&symbol.signature)),
780 ("container", container),
781 (
782 "reference_count",
783 VmValue::Int(symbol.reference_count as i64),
784 ),
785 ("importance_score", VmValue::Float(symbol.importance_score)),
786 ])
787}
788
789fn dependency_to_value(dep: &DependencyEdge) -> VmValue {
790 build_dict([
791 ("from_file", str_value(&dep.from_file)),
792 ("to_module", str_value(&dep.to_module)),
793 ])
794}
795
796fn subproject_to_value(sp: &SubProject) -> VmValue {
797 let dependencies: Vec<VmValue> = sp.dependencies.iter().map(str_value).collect();
798 build_dict([
799 ("path", str_value(&sp.path)),
800 ("name", str_value(&sp.name)),
801 ("language", str_value(&sp.language)),
802 ("project_marker", str_value(&sp.project_marker)),
803 ("dependencies", VmValue::List(Arc::new(dependencies))),
804 ])
805}
806
807fn delta_to_value(delta: &ScanDelta) -> VmValue {
808 let added: Vec<VmValue> = delta.added.iter().map(str_value).collect();
809 let modified: Vec<VmValue> = delta.modified.iter().map(str_value).collect();
810 let removed: Vec<VmValue> = delta.removed.iter().map(str_value).collect();
811 build_dict([
812 ("added", VmValue::List(Arc::new(added))),
813 ("modified", VmValue::List(Arc::new(modified))),
814 ("removed", VmValue::List(Arc::new(removed))),
815 ("full_rescan", VmValue::Bool(delta.full_rescan)),
816 ])
817}
818
819#[cfg(test)]
820mod tests {
821 use super::*;
822 use filetime::{set_file_mtime, FileTime};
823 use std::fs;
824
825 #[test]
826 fn builtin_option_defaults_match_request_schemas() {
827 let dict = harn_vm::value::DictMap::new();
828
829 let scan_project = parse_options(SCAN_PROJECT_BUILTIN, &dict).unwrap();
830 let scan_incremental = parse_options(SCAN_INCREMENTAL_BUILTIN, &dict).unwrap();
831
832 assert!(scan_project.include_git_history);
833 assert!(!scan_incremental.include_git_history);
834 }
835
836 fn symbol_names(scan: &IncrementalScan) -> Vec<String> {
837 scan.result.symbols.iter().map(|s| s.name.clone()).collect()
838 }
839
840 #[test]
848 fn scan_incremental_detects_same_mtime_size_changing_edit() {
849 let dir = tempfile::tempdir().unwrap();
850 fs::create_dir_all(dir.path().join("src")).unwrap();
851 let file = dir.path().join("src/lib.rs");
852 fs::write(&file, "pub fn old_symbol() {}\n").unwrap();
853
854 let canonical = std::fs::canonicalize(dir.path()).unwrap();
856 let token = canonical.to_string_lossy().to_string();
857 let opts = ScanProjectOptions::default();
858
859 let first = scan_incremental(&token, None, opts.clone());
860 let cached_mtime = first
861 .result
862 .files
863 .iter()
864 .find(|r| r.relative_path == "src/lib.rs")
865 .expect("seed file indexed")
866 .last_modified_unix_ms;
867 assert!(symbol_names(&first).iter().any(|n| n == "old_symbol"));
868
869 fs::write(
873 &file,
874 "pub fn old_symbol() {}\npub fn brand_new_symbol() {}\n",
875 )
876 .unwrap();
877 let secs = cached_mtime / 1000;
878 let nanos = ((cached_mtime % 1000) * 1_000_000) as u32;
879 set_file_mtime(&file, FileTime::from_unix_time(secs, nanos)).unwrap();
880
881 let second = scan_incremental(&token, None, opts);
882 let names = symbol_names(&second);
883 assert!(
884 names.iter().any(|n| n == "brand_new_symbol"),
885 "same-mtime size-changing edit must be reindexed, got {names:?} (delta.modified={:?})",
886 second.delta.modified,
887 );
888 }
889
890 #[test]
897 fn scan_incremental_changed_paths_bypasses_metadata_heuristics() {
898 let dir = tempfile::tempdir().unwrap();
899 fs::create_dir_all(dir.path().join("src")).unwrap();
900 let file = dir.path().join("src/lib.rs");
901 fs::write(&file, "pub fn alpha_name() {}\n").unwrap();
903
904 let canonical = std::fs::canonicalize(dir.path()).unwrap();
905 let token = canonical.to_string_lossy().to_string();
906 let opts = ScanProjectOptions::default();
907
908 let first = scan_incremental(&token, None, opts.clone());
909 let cached_mtime = first
910 .result
911 .files
912 .iter()
913 .find(|r| r.relative_path == "src/lib.rs")
914 .expect("seed file indexed")
915 .last_modified_unix_ms;
916 assert!(symbol_names(&first).iter().any(|n| n == "alpha_name"));
917
918 fs::write(&file, "pub fn omega_name() {}\n").unwrap();
921 let secs = cached_mtime / 1000;
922 let nanos = ((cached_mtime % 1000) * 1_000_000) as u32;
923 set_file_mtime(&file, FileTime::from_unix_time(secs, nanos)).unwrap();
924
925 let heuristic_only = scan_incremental(&token, None, opts.clone());
928 assert!(
929 !heuristic_only
930 .delta
931 .modified
932 .contains(&"src/lib.rs".to_string()),
933 "documenting the heuristic's known blind spot",
934 );
935
936 let explicit = scan_incremental(&token, Some(&["src/lib.rs".to_string()]), opts);
939 assert!(
940 symbol_names(&explicit).iter().any(|n| n == "omega_name"),
941 "explicit changed_paths must always reindex, got {:?}",
942 symbol_names(&explicit),
943 );
944 }
945}