1use std::collections::{BTreeMap, BTreeSet};
7use std::path::PathBuf;
8use std::sync::{Arc, RwLock};
9
10use anyhow::{bail, Result};
11use ignore::WalkBuilder;
12use serde_json::{json, Value};
13use tracing::warn;
14
15use crate::chat::actor::ActorState;
16use crate::chat::events::{
17 ChatMessage, ToolExecutionResult, ToolRequest as ToolRequestEvent, ToolRequestType,
18};
19use crate::module::Module;
20use crate::module::PromptComponent;
21use crate::module::{ContextComponent, ContextComponentId, SlashCommand};
22use crate::settings::SettingsManager;
23use crate::tools::r#trait::{
24 ContinuationPreference, ToolCallHandle, ToolCategory, ToolExecutor, ToolOutput, ToolRequest,
25};
26use crate::tools::ToolName;
27
28use super::access::FileAccessManager;
29use super::config::File;
30use super::resolver::Resolver;
31
32pub const FILE_TREE_ID: ContextComponentId = ContextComponentId("file_tree");
33pub const TRACKED_FILES_ID: ContextComponentId = ContextComponentId("tracked_files");
34
35pub struct ReadOnlyFileModule {
41 tracked_files: Arc<TrackedFilesManager>,
42 file_tree: Arc<FileTreeManager>,
43}
44
45impl ReadOnlyFileModule {
46 pub fn new(workspace_roots: Vec<PathBuf>, settings: SettingsManager) -> Result<Self> {
47 let tracked_files = Arc::new(TrackedFilesManager::new(workspace_roots.clone())?);
48 let file_tree = Arc::new(FileTreeManager::new(workspace_roots, settings)?);
49 Ok(Self {
50 tracked_files,
51 file_tree,
52 })
53 }
54
55 pub fn tracked_files(&self) -> &Arc<TrackedFilesManager> {
56 &self.tracked_files
57 }
58}
59
60impl Module for ReadOnlyFileModule {
61 fn prompt_components(&self) -> Vec<Arc<dyn PromptComponent>> {
62 vec![]
63 }
64
65 fn context_components(&self) -> Vec<Arc<dyn ContextComponent>> {
66 vec![
67 self.file_tree.clone() as Arc<dyn ContextComponent>,
68 self.tracked_files.clone() as Arc<dyn ContextComponent>,
69 ]
70 }
71
72 fn tools(&self) -> Vec<Arc<dyn ToolExecutor>> {
73 vec![self.tracked_files.clone() as Arc<dyn ToolExecutor>]
74 }
75
76 fn slash_commands(&self) -> Vec<Arc<dyn SlashCommand>> {
77 vec![Arc::new(FileInjectSlashCommand {
78 tracked_files: self.tracked_files.clone(),
79 file_tree: self.file_tree.clone(),
80 })]
81 }
82
83 fn settings_namespace(&self) -> Option<&'static str> {
84 Some(File::NAMESPACE)
85 }
86
87 fn settings_json_schema(&self) -> Option<schemars::schema::RootSchema> {
88 Some(schemars::schema_for!(File))
89 }
90}
91
92pub struct FileTreeManager {
94 resolver: Resolver,
95 settings: SettingsManager,
96}
97
98impl FileTreeManager {
99 pub fn new(workspace_roots: Vec<PathBuf>, settings: SettingsManager) -> Result<Self> {
100 let resolver = Resolver::new(workspace_roots)?;
101 Ok(Self { resolver, settings })
102 }
103
104 pub(crate) fn list_files(&self) -> Vec<PathBuf> {
105 let mut all_files = Vec::new();
106
107 for workspace in &self.resolver.roots() {
108 let Some(real_root) = self.resolver.root(workspace) else {
109 continue;
110 };
111
112 let root_for_filter = real_root.clone();
113 let root_is_git_repo = real_root.join(".git").exists();
114
115 for result in WalkBuilder::new(&real_root)
116 .hidden(false)
117 .filter_entry(move |entry| {
118 if entry.file_name().to_string_lossy() == ".git" {
119 return false;
120 }
121 if root_is_git_repo && entry.file_type().map_or(false, |ft| ft.is_dir()) {
122 let is_root = entry.path() == root_for_filter;
123 if !is_root && entry.path().join(".git").exists() {
124 return false;
125 }
126 }
127 true
128 })
129 .build()
130 {
131 let entry = match result {
132 Ok(e) => e,
133 Err(e) => {
134 warn!(
135 ?e,
136 "Failed to read directory entry during file tree traversal"
137 );
138 continue;
139 }
140 };
141 let path = entry.path();
142
143 if !path.is_file() {
144 continue;
145 }
146
147 let resolved = match self.resolver.canonicalize(path) {
148 Ok(r) => r,
149 Err(e) => {
150 warn!(?e, "Failed to canonicalize path: {:?}", path);
151 continue;
152 }
153 };
154
155 all_files.push(resolved.virtual_path);
156 }
157 }
158
159 let file_config: File = self.settings.get_module_config(File::NAMESPACE);
160 let max_bytes = file_config.auto_context_bytes;
161 Self::truncate_by_bytes(all_files, max_bytes)
162 }
163
164 fn truncate_by_bytes(files: Vec<PathBuf>, max_bytes: usize) -> Vec<PathBuf> {
165 let mut result = Vec::new();
166 let mut current_bytes = 0;
167
168 for file in files {
169 let file_bytes = file.to_string_lossy().len() + 1;
170 if current_bytes + file_bytes > max_bytes {
171 break;
172 }
173 current_bytes += file_bytes;
174 result.push(file);
175 }
176
177 result
178 }
179}
180
181#[async_trait::async_trait(?Send)]
182impl ContextComponent for FileTreeManager {
183 fn id(&self) -> ContextComponentId {
184 FILE_TREE_ID
185 }
186
187 async fn build_context_section(&self) -> Option<String> {
188 let files = self.list_files();
189 if files.is_empty() {
190 return None;
191 }
192
193 let mut output = String::from("Project Files:\n");
194 output.push_str(&build_file_tree(&files));
195 Some(output)
196 }
197}
198
199struct TrackedFilesInner {
200 ai_tracked: BTreeSet<PathBuf>,
201 user_pinned: BTreeSet<PathBuf>,
202}
203
204pub struct TrackedFilesManager {
206 inner: Arc<RwLock<TrackedFilesInner>>,
207 pub(crate) file_manager: FileAccessManager,
208}
209
210impl TrackedFilesManager {
211 pub fn tool_name() -> ToolName {
212 ToolName::new("set_tracked_files")
213 }
214
215 pub fn new(workspace_roots: Vec<PathBuf>) -> Result<Self> {
216 let file_manager = FileAccessManager::new(workspace_roots)?;
217 Ok(Self {
218 inner: Arc::new(RwLock::new(TrackedFilesInner {
219 ai_tracked: BTreeSet::new(),
220 user_pinned: BTreeSet::new(),
221 })),
222 file_manager,
223 })
224 }
225
226 pub fn get_tracked_files(&self) -> Vec<PathBuf> {
227 let inner = self.inner.read().expect("lock poisoned");
228 inner
229 .ai_tracked
230 .union(&inner.user_pinned)
231 .cloned()
232 .collect()
233 }
234
235 pub fn clear(&self) {
236 self.inner
237 .write()
238 .expect("lock poisoned")
239 .ai_tracked
240 .clear();
241 }
242
243 pub fn set_files(&self, files: Vec<PathBuf>) {
244 let mut inner = self.inner.write().expect("lock poisoned");
245 inner.ai_tracked.clear();
246 for file in files {
247 if !inner.user_pinned.contains(&file) {
248 inner.ai_tracked.insert(file);
249 }
250 }
251 }
252
253 pub fn pin_files(&self, files: Vec<PathBuf>) {
254 let mut inner = self.inner.write().expect("lock poisoned");
255 for file in files {
256 inner.ai_tracked.remove(&file);
257 inner.user_pinned.insert(file);
258 }
259 }
260
261 pub fn unpin_all(&self) {
262 self.inner
263 .write()
264 .expect("lock poisoned")
265 .user_pinned
266 .clear();
267 }
268
269 pub fn get_pinned_files(&self) -> Vec<PathBuf> {
270 self.inner
271 .read()
272 .expect("lock poisoned")
273 .user_pinned
274 .iter()
275 .cloned()
276 .collect()
277 }
278
279 async fn read_file_contents(&self) -> Vec<(PathBuf, String)> {
280 let all_files: BTreeSet<PathBuf> = {
281 let inner = self.inner.read().expect("lock poisoned");
282 inner
283 .ai_tracked
284 .union(&inner.user_pinned)
285 .cloned()
286 .collect()
287 };
288 let mut results = Vec::new();
289
290 for path in all_files {
291 let path_str = path.to_string_lossy();
292 match self.file_manager.read_file(&path_str).await {
293 Ok(content) => results.push((path, content)),
294 Err(e) => warn!(?e, "Failed to read tracked file: {:?}", path),
295 }
296 }
297
298 results
299 }
300}
301
302#[async_trait::async_trait(?Send)]
303impl ContextComponent for TrackedFilesManager {
304 fn id(&self) -> ContextComponentId {
305 TRACKED_FILES_ID
306 }
307
308 async fn build_context_section(&self) -> Option<String> {
309 let contents = self.read_file_contents().await;
310 if contents.is_empty() {
311 return None;
312 }
313
314 let mut output = String::from("Tracked Files:\n");
315 for (path, content) in contents {
316 output.push_str(&format!("\n=== {} ===\n{}", path.display(), content));
317 }
318 Some(output)
319 }
320}
321
322#[async_trait::async_trait(?Send)]
323impl ToolExecutor for TrackedFilesManager {
324 fn name(&self) -> String {
325 "set_tracked_files".to_string()
326 }
327
328 fn description(&self) -> String {
329 "Set the complete list of files to track for inclusion in all future messages. Each call REPLACES ALL previously tracked files — include every file you need in a single call. Do NOT make multiple calls per turn; only the last call takes effect, wasting earlier calls. Pass an empty array to clear all tracked files. Minimize tracked files to conserve context.".to_string()
330 }
331
332 fn input_schema(&self) -> Value {
333 json!({
334 "type": "object",
335 "properties": {
336 "file_paths": {
337 "type": "array",
338 "items": {
339 "type": "string"
340 },
341 "description": "Array of file paths to track. Empty array clears all tracked files."
342 }
343 },
344 "required": ["file_paths"]
345 })
346 }
347
348 fn category(&self) -> ToolCategory {
349 ToolCategory::Execution
350 }
351
352 async fn process(&self, request: &ToolRequest) -> Result<Box<dyn ToolCallHandle>> {
353 let mut file_paths_value = request
354 .arguments
355 .get("file_paths")
356 .ok_or_else(|| anyhow::anyhow!("Missing required parameter: file_paths"))?
357 .clone();
358
359 let file_paths_arr: Vec<String> = loop {
360 match file_paths_value {
361 Value::Array(arr) => {
362 break arr
363 .into_iter()
364 .filter_map(|v| v.as_str().map(String::from))
365 .collect()
366 }
367 Value::String(s) => {
368 file_paths_value = serde_json::from_str::<Value>(&s)
369 .map_err(|_| anyhow::anyhow!("file_paths must be an array of strings"))?;
370 }
371 _ => bail!("file_paths must be an array of strings"),
372 }
373 };
374
375 let mut valid_paths = Vec::new();
376 let mut invalid_files = Vec::new();
377
378 for path_str in file_paths_arr {
379 if self.file_manager.file_exists(&path_str).await? {
380 valid_paths.push(PathBuf::from(&path_str));
381 } else {
382 invalid_files.push(path_str);
383 }
384 }
385
386 if !invalid_files.is_empty() {
387 return Err(anyhow::anyhow!(
388 "The following files do not exist: {:?}",
389 invalid_files
390 ));
391 }
392
393 Ok(Box::new(SetTrackedFilesHandle {
394 file_paths: valid_paths,
395 tool_use_id: request.tool_use_id.clone(),
396 inner: self.inner.clone(),
397 }))
398 }
399}
400
401struct SetTrackedFilesHandle {
402 file_paths: Vec<PathBuf>,
403 tool_use_id: String,
404 inner: Arc<RwLock<TrackedFilesInner>>,
405}
406
407#[async_trait::async_trait(?Send)]
408impl ToolCallHandle for SetTrackedFilesHandle {
409 fn tool_request(&self) -> ToolRequestEvent {
410 let file_path_strings: Vec<String> = self
411 .file_paths
412 .iter()
413 .map(|p| p.to_string_lossy().to_string())
414 .collect();
415 ToolRequestEvent {
416 tool_call_id: self.tool_use_id.clone(),
417 tool_name: "set_tracked_files".to_string(),
418 tool_type: ToolRequestType::ReadFiles {
419 file_paths: file_path_strings,
420 },
421 }
422 }
423
424 async fn execute(self: Box<Self>) -> ToolOutput {
425 let mut inner = self.inner.write().expect("lock poisoned");
426 inner.ai_tracked.clear();
427 for path in &self.file_paths {
428 if !inner.user_pinned.contains(path) {
429 inner.ai_tracked.insert(path.clone());
430 }
431 }
432 drop(inner);
433
434 let file_path_strings: Vec<String> = self
435 .file_paths
436 .iter()
437 .map(|p| p.to_string_lossy().to_string())
438 .collect();
439
440 ToolOutput::Result {
441 content: json!({
442 "action": "set_tracked_files",
443 "tracked_files": file_path_strings
444 })
445 .to_string(),
446 is_error: false,
447 continuation: ContinuationPreference::Continue,
448 ui_result: ToolExecutionResult::Other {
449 result: json!({
450 "action": "set_tracked_files",
451 "tracked_files": file_path_strings
452 }),
453 },
454 }
455 }
456}
457
458#[derive(Default)]
459struct TrieNode {
460 children: BTreeMap<String, TrieNode>,
461 is_file: bool,
462}
463
464impl TrieNode {
465 fn insert_path(&mut self, components: &[&str]) {
466 if components.is_empty() {
467 return;
468 }
469
470 let is_file = components.len() == 1;
471 let child = self.children.entry(components[0].to_string()).or_default();
472
473 if is_file {
474 child.is_file = true;
475 } else {
476 child.insert_path(&components[1..]);
477 }
478 }
479
480 fn render(&self, output: &mut String, depth: usize) {
481 let indent = " ".repeat(depth);
482
483 for (name, child) in &self.children {
484 output.push_str(&indent);
485 output.push_str(name);
486
487 if !child.is_file {
488 output.push('/');
489 }
490 output.push('\n');
491
492 child.render(output, depth + 1);
493 }
494 }
495}
496
497struct FileInjectSlashCommand {
499 tracked_files: Arc<TrackedFilesManager>,
500 file_tree: Arc<FileTreeManager>,
501}
502
503impl FileInjectSlashCommand {
504 async fn pin_single_file(&self, path_str: &str) -> Vec<ChatMessage> {
505 let exists = self.tracked_files.file_manager.file_exists(path_str).await;
506 match exists {
507 Ok(false) => return vec![ChatMessage::error(format!("File not found: {path_str}"))],
508 Err(e) => return vec![ChatMessage::error(format!("Error checking file: {e:?}"))],
509 Ok(true) => {}
510 }
511 self.tracked_files.pin_files(vec![PathBuf::from(path_str)]);
512 vec![ChatMessage::system(format!("Pinned: {path_str}"))]
513 }
514}
515
516#[async_trait::async_trait(?Send)]
517impl SlashCommand for FileInjectSlashCommand {
518 fn name(&self) -> &'static str {
519 "@"
520 }
521
522 fn description(&self) -> &'static str {
523 "Pin files into context (AI cannot remove). /@ <path>, /@ all, /@ clear, /@ list"
524 }
525
526 fn usage(&self) -> &'static str {
527 "/@ <file_path> | /@ all | /@ clear | /@ list"
528 }
529
530 async fn execute(&self, _state: &mut ActorState, args: &[&str]) -> Vec<ChatMessage> {
531 let Some(subcommand) = args.first() else {
532 return vec![ChatMessage::system(
533 "Usage: /@ <file_path> | /@ all | /@ clear | /@ list".to_string(),
534 )];
535 };
536
537 match *subcommand {
538 "all" => {
539 let files = self.file_tree.list_files();
540 let count = files.len();
541 self.tracked_files.pin_files(files);
542 vec![ChatMessage::system(format!(
543 "Pinned {count} files from file tree."
544 ))]
545 }
546 "clear" => {
547 self.tracked_files.unpin_all();
548 vec![ChatMessage::system("All pinned files cleared.".to_string())]
549 }
550 "list" => {
551 let pinned = self.tracked_files.get_pinned_files();
552 if pinned.is_empty() {
553 return vec![ChatMessage::system("No pinned files.".to_string())];
554 }
555 let mut msg = format!("Pinned files ({}):\n", pinned.len());
556 for path in &pinned {
557 msg.push_str(&format!(" {}\n", path.display()));
558 }
559 vec![ChatMessage::system(msg)]
560 }
561 path => self.pin_single_file(path).await,
562 }
563 }
564}
565
566fn build_file_tree(files: &[PathBuf]) -> String {
567 if files.is_empty() {
568 return String::new();
569 }
570
571 let mut root = TrieNode::default();
572
573 for file_path in files {
574 let path_str = file_path.to_string_lossy();
575 let components: Vec<&str> = path_str.split('/').filter(|s| !s.is_empty()).collect();
576 root.insert_path(&components);
577 }
578
579 let mut result = String::new();
580 root.render(&mut result, 0);
581 result
582}