Skip to main content

tycode_core/file/
read_only.rs

1//! Read-only file access module.
2//!
3//! Provides context components for file tree display and tracked file contents,
4//! plus the set_tracked_files tool for managing which files appear in context.
5
6use 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
35/// Module providing read-only file access capabilities.
36///
37/// Bundles:
38/// - FileTreeManager: Shows project file structure in context
39/// - TrackedFilesManager: Displays tracked file contents in context and exposes set_tracked_files tool
40pub 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
92/// Manages file tree state and renders project structure to context.
93pub 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
204/// Manages tracked files state and provides both context rendering and tool execution.
205pub 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
497/// Slash command for injecting files into context that the AI cannot remove.
498struct 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}