Skip to main content

forge_workspace/
file_index.rs

1//! One working tree's files, and the ranking the `@` trigger reads a query
2//! against.
3//!
4//! **The walk is `forge_agent::env::file_index`'s and the shape is here**
5//! because the readers are not one: a view's mention list and the seat's
6//! own loop, which walks it and pushes the whole index as
7//! `FileIndexChanged`. The walk itself stays where it was, with the
8//! rest of the environment's I/O.
9
10use std::collections::BTreeMap;
11use std::path::Path;
12
13pub use forge_agent::env::file_index::FileCandidate;
14
15/// The files under one root, keyed by their path relative to it.
16#[derive(Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
17pub struct FileIndex {
18    pub entries: BTreeMap<String, FileCandidate>,
19}
20
21/// Whether a walk honours gitignore, from the CLI's per-user preferences
22/// document: its `respectGitignore` key, absent or not a boolean reading
23/// as `true`, which is the CLI's own default. Every view reads the key
24/// here, so none can drift on how it is read.
25pub fn respect_gitignore(preferences: Option<&serde_json::Value>) -> bool {
26    preferences
27        .and_then(|document| document.get("respectGitignore"))
28        .and_then(serde_json::Value::as_bool)
29        .unwrap_or(true)
30}
31
32impl FileIndex {
33    /// Walk `root` once and index what is under it. `honour_gitignore` is
34    /// the reader's own preference, which the walker honours.
35    pub fn scan(root: &Path, honour_gitignore: bool) -> Self {
36        let entries =
37            forge_agent::env::file_index::collect_candidates(root, root, honour_gitignore)
38                .into_iter()
39                .map(|candidate| (candidate.rel_path.clone(), candidate))
40                .collect();
41        Self { entries }
42    }
43
44    /// The candidates `query` matches, best first, at most `limit` of
45    /// them. The cap is the caller's: a dropdown and a page want
46    /// different ones.
47    pub fn visible(&self, query: &str, limit: usize) -> Vec<FileCandidate> {
48        let query_lower = query.to_lowercase();
49        let mut filtered: Vec<FileCandidate> = self
50            .entries
51            .values()
52            .filter(|candidate| match_tier(candidate, &query_lower).is_some())
53            .cloned()
54            .collect();
55        rank_and_truncate_candidates(&mut filtered, &query_lower, limit);
56        filtered
57    }
58}
59
60/// Best match first, then shallowest, then alphabetically, cut to
61/// `limit`.
62pub fn rank_and_truncate_candidates(
63    candidates: &mut Vec<FileCandidate>,
64    query_lower: &str,
65    limit: usize,
66) {
67    candidates.sort_unstable_by(|a, b| {
68        match_tier(a, query_lower)
69            .cmp(&match_tier(b, query_lower))
70            .then_with(|| a.depth.cmp(&b.depth))
71            .then_with(|| a.rel_path.cmp(&b.rel_path))
72    });
73    candidates.truncate(limit);
74}
75
76/// How well `candidate` matches, smaller being better. An empty query
77/// matches everything equally, which is what a bare `@` shows.
78fn match_tier(candidate: &FileCandidate, query_lower: &str) -> Option<u8> {
79    if query_lower.is_empty() {
80        return Some(0);
81    }
82
83    if candidate.basename_lower.starts_with(query_lower) {
84        Some(0)
85    } else if candidate.rel_path_lower.starts_with(query_lower) {
86        Some(1)
87    } else if candidate.basename_lower.contains(query_lower) {
88        Some(2)
89    } else if candidate.rel_path_lower.contains(query_lower) {
90        Some(3)
91    } else {
92        None
93    }
94}
95
96#[cfg(test)]
97mod tests {
98    use super::{FileCandidate, FileIndex, rank_and_truncate_candidates, respect_gitignore};
99
100    fn candidate(rel_path: &str) -> FileCandidate {
101        FileCandidate {
102            rel_path: rel_path.to_owned(),
103            rel_path_lower: rel_path.to_lowercase(),
104            basename_lower: rel_path.rsplit('/').next().unwrap_or(rel_path).to_lowercase(),
105            depth: rel_path.matches('/').count(),
106        }
107    }
108
109    /// The preference is the document's own key, and the CLI's default is
110    /// to respect the file: a reader with no document, or one that never
111    /// set the key, hides ignored files.
112    #[test]
113    fn the_ignore_preference_is_the_documents_key_and_defaults_to_true() {
114        use serde_json::json;
115
116        assert!(respect_gitignore(None), "no document reads as the CLI's own default");
117        assert!(respect_gitignore(Some(&json!({}))), "and so does one without the key");
118        assert!(
119            !respect_gitignore(Some(&json!({"respectGitignore": false}))),
120            "the key is what the walk follows",
121        );
122        assert!(
123            respect_gitignore(Some(&json!({"respectGitignore": "no"}))),
124            "and a value that is not a boolean reads as the default rather than as off",
125        );
126    }
127
128    /// The walk is recursive and keys every file by its path from the
129    /// root, so a nested file is reachable by its own path rather than
130    /// by its basename alone.
131    #[test]
132    fn a_scan_indexes_nested_files_under_their_relative_paths() {
133        let tmp = tempfile::tempdir().expect("tempdir");
134        for file in ["top.rs", "src/nested.rs"] {
135            let path = tmp.path().join(file);
136            std::fs::create_dir_all(path.parent().expect("parent")).expect("mkdir");
137            std::fs::write(&path, "").expect("write");
138        }
139
140        let index = FileIndex::scan(tmp.path(), true);
141
142        assert!(
143            index.entries.contains_key("src/nested.rs"),
144            "a nested file is indexed by its path: {:?}",
145            index.entries.keys().collect::<Vec<_>>(),
146        );
147        assert!(index.entries.contains_key("top.rs"), "and so is a top-level one");
148    }
149
150    /// A basename prefix beats a substring that happens to sit in a
151    /// shallower path, which is the whole reason the tiers exist.
152    #[test]
153    fn a_basename_prefix_ranks_ahead_of_a_shallow_path_substring() {
154        let mut candidates = vec![candidate("docs/guide-rs.txt"), candidate("src/rs-helper.rs")];
155
156        rank_and_truncate_candidates(&mut candidates, "rs", 10);
157
158        assert_eq!(candidates[0].rel_path, "src/rs-helper.rs");
159    }
160
161    /// The query is matched case-insensitively, against the lowercased
162    /// paths the walk already carries.
163    #[test]
164    fn a_query_matches_regardless_of_case() {
165        let mut index = FileIndex::default();
166        index.entries.insert("src/Main.rs".to_owned(), candidate("src/Main.rs"));
167
168        let matched = index.visible("main", 10);
169
170        assert_eq!(matched.len(), 1, "a capitalised path answers a lowercase query");
171        assert_eq!(matched[0].rel_path, "src/Main.rs");
172    }
173
174    /// The cap is the caller's, and a caller that hands one over gets it.
175    #[test]
176    fn a_query_stops_at_the_callers_limit() {
177        let mut index = FileIndex::default();
178        for i in 0..5 {
179            let path = format!("file{i}.rs");
180            index.entries.insert(path.clone(), candidate(&path));
181        }
182
183        assert_eq!(index.visible("file", 2).len(), 2, "the limit is honoured");
184        assert_eq!(index.visible("file", 50).len(), 5, "and nothing is dropped below it");
185    }
186}