Skip to main content

st/formatters/
context.rs

1//! Context Mode - Provides intelligent context for AI conversations
2//! Integrates with MEM|8 memories, git status, and recent changes
3//!
4//! "Context is consciousness" - Omni
5
6use super::Formatter;
7use crate::mem8::ConversationMemory;
8use crate::scanner::{FileNode, TreeStats};
9use anyhow::Result;
10use std::io::Write;
11use std::path::Path;
12use std::time::{SystemTime, UNIX_EPOCH};
13
14/// Maximum number of nodes to process before switching to summary mode
15const MAX_NODES_FOR_ITERATION: usize = 100_000;
16
17/// Maximum number of nodes to check when searching for key/recent files
18const MAX_NODES_TO_CHECK: usize = 10_000;
19
20pub struct ContextFormatter {
21    show_git: bool,
22    show_memories: bool,
23}
24
25impl Default for ContextFormatter {
26    fn default() -> Self {
27        Self {
28            show_git: true,
29            show_memories: true,
30        }
31    }
32}
33
34impl ContextFormatter {
35    pub fn new() -> Self {
36        Self::default()
37    }
38
39    /// Get git context for the path
40    fn get_git_context(&self, path: &Path) -> Option<String> {
41        if !self.show_git {
42            return None;
43        }
44
45        // Try to get git info using gix
46        if let Ok(repo) = gix::discover(path) {
47            let mut git_info = Vec::new();
48
49            // Get branch
50            if let Ok(head) = repo.head_ref() {
51                if let Some(reference) = head {
52                    let branch = reference.name().as_bstr().to_string();
53                    git_info.push(format!(
54                        "Branch: {}",
55                        branch.strip_prefix("refs/heads/").unwrap_or(&branch)
56                    ));
57                }
58            }
59
60            // Get last commit
61            if let Ok(commit) = repo.head_commit() {
62                let id = commit.id().to_string();
63                let msg = commit
64                    .message_raw_sloppy()
65                    .to_string()
66                    .lines()
67                    .next()
68                    .unwrap_or("No message")
69                    .to_string();
70                git_info.push(format!("Last: {} - {}", &id[..8], msg));
71            }
72
73            if !git_info.is_empty() {
74                return Some(git_info.join("\n"));
75            }
76        }
77
78        None
79    }
80
81    /// Search for related memories in MEM|8
82    fn get_memory_context(&self, path: &Path) -> Option<String> {
83        if !self.show_memories {
84            return None;
85        }
86
87        // Get project name from path
88        let project_name = path.file_name()?.to_str()?;
89
90        // Initialize conversation memory
91        let memory = ConversationMemory::new().ok()?;
92
93        // List conversations and find related ones
94        let conversations = memory.list_conversations().ok()?;
95        let related: Vec<_> = conversations
96            .iter()
97            .filter(|c| c.file_name.contains(project_name))
98            .take(3)
99            .collect();
100
101        if !related.is_empty() {
102            let mut output = vec!["🧠 Related memories:".to_string()];
103            for conv in related {
104                output.push(format!(
105                    "  • {} ({} messages)",
106                    conv.file_name, conv.message_count
107                ));
108            }
109            return Some(output.join("\n"));
110        }
111
112        None
113    }
114}
115
116impl Formatter for ContextFormatter {
117    fn format(
118        &self,
119        writer: &mut dyn Write,
120        nodes: &[FileNode],
121        stats: &TreeStats,
122        root_path: &Path,
123    ) -> Result<()> {
124        // Safety check: Warn if there are too many nodes to process efficiently
125        let node_count = nodes.len();
126        let should_skip_iteration = node_count > MAX_NODES_FOR_ITERATION;
127
128        if should_skip_iteration {
129            eprintln!(
130                "⚠️  Warning: Large directory ({} files). Context mode will use summary data only.",
131                node_count
132            );
133            eprintln!("   Consider using --max-depth to limit the scan, or use --mode summary-ai instead.");
134        }
135
136        writeln!(writer, "=== Smart Tree Context ===")?;
137        writeln!(writer)?;
138
139        // Project identification
140        writeln!(writer, "📁 Project: {}", root_path.display())?;
141
142        // Git context
143        if let Some(git_info) = self.get_git_context(root_path) {
144            writeln!(writer, "\n📍 Git Status:")?;
145            writeln!(writer, "{}", git_info)?;
146        }
147
148        // Directory structure (compressed)
149        writeln!(writer, "\n🌳 Structure:")?;
150        writeln!(writer, "SUMMARY_AI_V1:")?;
151        writeln!(writer, "PATH:{}", root_path.display())?;
152        writeln!(
153            writer,
154            "STATS:F{:x}D{:x}S{:x}",
155            stats.total_files, stats.total_dirs, stats.total_size
156        )?;
157
158        // Count files by extension - but only if node count is reasonable
159        if !should_skip_iteration {
160            let mut ext_counts = std::collections::HashMap::new();
161            for node in nodes {
162                if !node.is_dir {
163                    if let Some(ext) = node.path.extension() {
164                        let ext_str = ext.to_string_lossy().to_string();
165                        *ext_counts.entry(ext_str).or_insert(0) += 1;
166                    }
167                }
168            }
169
170            let mut exts: Vec<_> = ext_counts.iter().collect();
171            exts.sort_by(|a, b| b.1.cmp(a.1));
172
173            let ext_str: Vec<_> = exts
174                .iter()
175                .take(10)
176                .map(|(ext, count)| format!("{}:{}", ext, count))
177                .collect();
178            if !ext_str.is_empty() {
179                writeln!(writer, "EXT:{}", ext_str.join(","))?;
180            }
181
182            // Find and show key files
183            let key_files = find_key_files(nodes);
184            if !key_files.is_empty() {
185                writeln!(writer, "KEY:{}", key_files.join(","))?;
186            }
187
188            // Recent changes
189            let recent = find_recent_files(nodes, 86400); // Last 24 hours
190            if !recent.is_empty() {
191                writeln!(writer, "\n⏰ Recent changes:")?;
192                for file in recent.iter().take(5) {
193                    writeln!(writer, "  • {}", file)?;
194                }
195            }
196        } else {
197            // For very large directories, just note that detailed analysis is skipped
198            writeln!(
199                writer,
200                "\n⚠️  Detailed file analysis skipped due to large directory size"
201            )?;
202            writeln!(
203                writer,
204                "   Total files: {}, Total dirs: {}",
205                stats.total_files, stats.total_dirs
206            )?;
207        }
208
209        // Memory context
210        if let Some(memories) = self.get_memory_context(root_path) {
211            writeln!(writer, "\n{}", memories)?;
212        }
213
214        writeln!(writer, "\n=== End Context ===")?;
215
216        Ok(())
217    }
218}
219
220// Helper functions
221fn find_key_files(nodes: &[FileNode]) -> Vec<String> {
222    let important = [
223        "Cargo.toml",
224        "package.json",
225        "README.md",
226        "CLAUDE.md",
227        "pyproject.toml",
228        "go.mod",
229        "Makefile",
230        ".env",
231    ];
232
233    let mut found = Vec::new();
234    // Limit iteration for very large directories
235    let max_to_check = nodes.len().min(MAX_NODES_TO_CHECK);
236
237    for node in nodes.iter().take(max_to_check) {
238        if let Some(file_name) = node.path.file_name() {
239            let name = file_name.to_string_lossy();
240            if important.contains(&name.as_ref()) && !found.contains(&name.to_string()) {
241                found.push(name.to_string());
242                if found.len() >= 10 {
243                    break;
244                }
245            }
246        }
247    }
248    found
249}
250
251fn find_recent_files(nodes: &[FileNode], seconds: u64) -> Vec<String> {
252    let now = SystemTime::now()
253        .duration_since(UNIX_EPOCH)
254        .unwrap_or_default()
255        .as_secs();
256
257    let mut recent = Vec::new();
258    // Limit iteration for very large directories
259    let max_to_check = nodes.len().min(MAX_NODES_TO_CHECK);
260
261    for node in nodes.iter().take(max_to_check) {
262        if !node.is_dir {
263            if let Ok(duration) = node.modified.duration_since(UNIX_EPOCH) {
264                let file_time = duration.as_secs();
265                let age = now.saturating_sub(file_time);
266                if age < seconds {
267                    recent.push(node.path.display().to_string());
268                    if recent.len() >= 10 {
269                        break;
270                    }
271                }
272            }
273        }
274    }
275    recent
276}