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