Skip to main content

harn_hostlib/scanner/
mod.rs

1//! Repo scanner host capability.
2//!
3//! Deterministic project-wide file enumeration honoring `.gitignore` and
4//! the [`extensions::EXCLUDED_DIRS`] table, symbol extraction,
5//! import-derived dependency graph, reference + churn + importance
6//! scoring, source/test pairing, folder aggregates, project metadata
7//! (language stats + detected test commands + code-pattern hints),
8//! sub-project detection, and a token-budgeted text repo map.
9//!
10//! `scan_project` returns the full [`result::ScanResult`] alongside an
11//! opaque `snapshot_token` derived from the canonicalized root path. The
12//! result is persisted to `<root>/.harn/hostlib/scanner-snapshot.json` so
13//! that `scan_incremental` can diff against it later — without forcing the
14//! caller to pass the previous result back over the wire.
15
16use 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    // Git exports repository-specific GIT_* variables while running hooks.
46    // Scanner probes must honor their explicit `-C <root>` argument instead.
47    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/// Scanner capability handle.
64#[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// MARK: - Public Rust API (used by tests + by harn-cli embedders).
89
90/// Tunable knobs accepted by [`scan_project`].
91#[derive(Clone, Debug)]
92pub struct ScanProjectOptions {
93    /// Include hidden (`.`) entries during walking.
94    pub include_hidden: bool,
95    /// How much of the shared ignore stack applies.
96    pub ignore_policy: IgnorePolicy,
97    /// Hard cap on file count (0 = unlimited).
98    pub max_files: usize,
99    /// Run `git log` to compute churn scores.
100    pub include_git_history: bool,
101    /// Approximate token budget for the text repo map.
102    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
117/// Run a full scan of `root`, persist a snapshot, and return the result.
118pub fn scan_project(root: &Path, opts: ScanProjectOptions) -> ScanResult {
119    scan_project_with_git(root, opts, &git::CliGitCapabilities)
120}
121
122/// Run a full scan using caller-supplied Git data.
123///
124/// Embedders normally call [`scan_project`]. Tests and hosts that already
125/// virtualize Git can use this entry point to keep scanner behavior
126/// deterministic without depending on ambient process state.
127pub 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/// Result returned by [`scan_incremental`].
190#[derive(Clone, Debug)]
191pub struct IncrementalScan {
192    /// Refreshed scan result.
193    pub result: ScanResult,
194    /// Path delta computed against the snapshot.
195    pub delta: ScanDelta,
196}
197
198/// Refresh the snapshot named by `token`. If the snapshot is missing, the
199/// diff is too large (>30%), or `changed_paths` is empty after `>30%` of
200/// the workspace mtime-mismatched, falls back to a full rescan.
201pub 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
209/// Refresh a snapshot using caller-supplied Git data.
210pub 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(&current, &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    // Incremental path: rebuild only the touched files, then re-finalize.
267    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
346// MARK: - Internals
347
348fn canonicalize(root: &Path) -> PathBuf {
349    std::fs::canonicalize(root).unwrap_or_else(|_| root.to_path_buf())
350}
351
352/// Compute package-manifest dependencies for the root and each detected
353/// sub-project. Centralized here so manifest parsing (in [`manifest`]) is
354/// invoked exactly once per project directory by both the full and the
355/// incremental scan paths.
356fn 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            // Likely a non-utf8 binary; skip symbol/import extraction but still record the file.
388        }
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                // A newer mtime is the cheap common signal, but mtime
477                // granularity collides on same-turn/same-second edits (and on
478                // coarse-granularity filesystems), silently dropping the edit.
479                // A changed byte size is an mtime-independent modification
480                // signal that catches the overwhelmingly common add/remove edit
481                // for free — `meta.len()` is already in hand. Without it, an
482                // agent that writes a file and re-scans in the same instant
483                // keeps reading the pre-edit symbol facts.
484                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
513/// Convert a unix timestamp (seconds, UTC) to civil date components. Uses
514/// Howard Hinnant's algorithm so we don't pull in `chrono` for one
515/// formatter.
516fn 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    // Days from 1970-01-01.
524    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
537// MARK: - Builtin handlers (Harn dict ↔ Rust struct).
538
539fn 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    /// Regression guard: an agent that writes a file and re-scans in the same
830    /// instant must see its own edit. `compute_delta`'s mtime comparison
831    /// collides on same-millisecond/same-second writes (and on
832    /// coarse-granularity filesystems), so the size-change fallback is what
833    /// keeps same-turn index freshness honest. Before the fallback this
834    /// returned the pre-edit symbol set, feeding fuzzy-match-stale loops on
835    /// cheap local models.
836    #[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        // Canonicalize so the snapshot token matches across calls.
844        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        // Add a symbol (byte size grows), then force the mtime back to the
859        // cached value to simulate a same-instant edit the OS couldn't
860        // distinguish by mtime.
861        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    /// Companion guard: even when an edit changes nothing the scanner can
880    /// cheaply detect (same mtime AND same byte size — e.g. a length-preserving
881    /// one-character swap), passing the explicit `changed_paths` signal still
882    /// forces the reindex. The agent loop threads its own write through this
883    /// bypass, so freshness never depends on mtime/size heuristics for the
884    /// agent's own edits.
885    #[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        // 23 bytes.
891        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        // Length-preserving rename (same 23 bytes), same forced mtime: neither
908        // the size nor the mtime heuristic can see this.
909        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        // Without an explicit signal the heuristics legitimately miss this
915        // rare length-preserving same-instant case...
916        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        // ...but the explicit changed-path signal the agent loop passes after
926        // its own write forces the reindex regardless.
927        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}