forge_workspace/
file_index.rs1use std::collections::BTreeMap;
11use std::path::Path;
12
13pub use forge_agent::env::file_index::FileCandidate;
14
15#[derive(Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
17pub struct FileIndex {
18 pub entries: BTreeMap<String, FileCandidate>,
19}
20
21pub 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 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 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
60pub 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
76fn 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 #[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 #[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 #[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 #[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 #[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}