aptu_coder_core/
completion.rs1use crate::cache::AnalysisCache;
9use ignore::WalkBuilder;
10use std::path::Path;
11use tracing::instrument;
12
13#[instrument(skip_all, fields(prefix = %prefix))]
17pub fn path_completions(root: &Path, prefix: &str) -> Vec<String> {
18 if prefix.is_empty() {
19 return Vec::new();
20 }
21
22 let (search_dir, name_prefix) = if let Some(last_slash) = prefix.rfind('/') {
24 let dir_part = &prefix[..=last_slash];
25 let name_part = &prefix[last_slash + 1..];
26 let full_path = root.join(dir_part);
27 (full_path, name_part.to_string())
28 } else {
29 (root.to_path_buf(), prefix.to_string())
30 };
31
32 if !search_dir.exists() {
34 return Vec::new();
35 }
36
37 let canonical_root = match std::fs::canonicalize(root) {
39 Ok(p) => p,
40 Err(_) => return Vec::new(),
41 };
42 let canonical_search = match std::fs::canonicalize(&search_dir) {
43 Ok(p) => p,
44 Err(_) => return Vec::new(),
45 };
46 if !canonical_search.starts_with(&canonical_root) {
47 return Vec::new();
48 }
49
50 let mut results = Vec::new();
51
52 let mut builder = WalkBuilder::new(&search_dir);
54 builder
55 .hidden(true)
56 .standard_filters(true)
57 .max_depth(Some(1));
58
59 for result in builder.build() {
60 if results.len() >= 100 {
61 break;
62 }
63
64 let Ok(entry) = result else { continue };
65 let path = entry.path();
66 if path == search_dir {
68 continue;
69 }
70 if let Some(file_name) = path.file_name().and_then(|n| n.to_str())
72 && file_name.starts_with(&name_prefix)
73 {
74 if let Ok(rel_path) = path.strip_prefix(root) {
76 let rel_str = rel_path.to_string_lossy().to_string();
77 results.push(rel_str);
78 }
79 }
80 }
81
82 results
83}
84
85#[instrument(skip(cache), fields(path = %path.display(), prefix = %prefix))]
89pub fn symbol_completions(cache: &AnalysisCache, path: &Path, prefix: &str) -> Vec<String> {
90 if prefix.is_empty() {
91 return Vec::new();
92 }
93
94 let cache_key = match std::fs::metadata(path) {
96 Ok(meta) => match meta.modified() {
97 Ok(mtime) => crate::cache::CacheKey {
98 path: path.to_path_buf(),
99 modified: mtime,
100 mode: crate::types::AnalysisMode::FileDetails,
101 },
102 Err(_) => return Vec::new(),
103 },
104 Err(_) => return Vec::new(),
105 };
106
107 let Some(cached) = cache.get(&cache_key) else {
109 return Vec::new();
110 };
111
112 let mut results = Vec::new();
113
114 for func in &cached.semantic.functions {
116 if results.len() >= 100 {
117 break;
118 }
119 if func.name.starts_with(prefix) {
120 results.push(func.name.clone());
121 }
122 }
123
124 for class in &cached.semantic.classes {
126 if results.len() >= 100 {
127 break;
128 }
129 if class.name.starts_with(prefix) {
130 results.push(class.name.clone());
131 }
132 }
133
134 results
135}
136
137#[cfg(test)]
138mod tests {
139 use super::*;
140 use std::fs;
141 use tempfile::TempDir;
142
143 #[test]
144 fn test_path_completions_slash_prefix() {
145 let temp = TempDir::new().unwrap();
147 let root = temp.path();
148 fs::create_dir(root.join("src")).unwrap();
149 fs::write(root.join("src/main.rs"), "fn main() {}").unwrap();
150 let results = path_completions(root, "src/ma");
152 assert!(
154 results.iter().any(|r| r.contains("main.rs")),
155 "expected 'main.rs' in completions, got {:?}",
156 results
157 );
158 }
159
160 #[test]
161 fn test_path_completions_empty_prefix() {
164 let temp = TempDir::new().unwrap();
166 let results = path_completions(temp.path(), "");
167 assert!(
168 results.is_empty(),
169 "expected empty results for empty prefix"
170 );
171 }
172
173 #[test]
174 fn test_path_completions_rejects_parent_traversal() {
175 let temp = TempDir::new().unwrap();
177 let root = temp.path();
178
179 fs::write(root.join("file.rs"), "fn main() {}").unwrap();
181
182 let results = path_completions(root, "../");
184 assert!(
185 results.is_empty(),
186 "expected empty results for parent traversal attempt (../)"
187 );
188
189 let results = path_completions(root, "../../");
191 assert!(
192 results.is_empty(),
193 "expected empty results for parent traversal attempt (../../)"
194 );
195 }
196}