acp/commands/
context.rs

1//! @acp:module "Context Command"
2//! @acp:summary "RFC-0015: Operation-specific context for AI agents"
3//! @acp:domain cli
4//! @acp:layer handler
5//!
6//! Provides context tailored for specific operations:
7//! - create: Naming conventions, import style, directory patterns
8//! - modify: Constraints, importers, affected files
9//! - debug: Error context, related symbols
10//! - explore: Directory structure, domain overview
11
12use std::path::PathBuf;
13
14use anyhow::Result;
15use serde::Serialize;
16
17use crate::cache::{Cache, FileEntry, FileNamingConvention};
18use crate::query::Query;
19
20/// Options for the context command
21#[derive(Debug, Clone)]
22pub struct ContextOptions {
23    /// Cache file path
24    pub cache: PathBuf,
25    /// Output as JSON
26    pub json: bool,
27    /// Verbose output with additional details
28    pub verbose: bool,
29}
30
31impl Default for ContextOptions {
32    fn default() -> Self {
33        Self {
34            cache: PathBuf::from(".acp/acp.cache.json"),
35            json: false,
36            verbose: false,
37        }
38    }
39}
40
41/// Context subcommand for different operations
42#[derive(Debug, Clone)]
43pub enum ContextOperation {
44    /// Context for creating a new file
45    Create {
46        /// Directory where the file will be created
47        directory: String,
48    },
49    /// Context for modifying an existing file
50    Modify {
51        /// File path to modify
52        file: String,
53    },
54    /// Context for debugging an issue
55    Debug {
56        /// File or symbol with the issue
57        target: String,
58    },
59    /// Context for exploring the codebase
60    Explore {
61        /// Optional domain to focus on
62        domain: Option<String>,
63    },
64}
65
66/// Context output for create operation
67#[derive(Debug, Clone, Serialize)]
68pub struct CreateContext {
69    /// Target directory
70    pub directory: String,
71    /// Detected language for this directory
72    pub language: Option<String>,
73    /// File naming conventions in this directory
74    pub naming: Option<FileNamingConvention>,
75    /// Import style preferences
76    pub import_style: Option<ImportStyle>,
77    /// Similar files in the directory
78    pub similar_files: Vec<String>,
79    /// Recommended file pattern
80    pub recommended_pattern: Option<String>,
81}
82
83/// Context output for modify operation
84#[derive(Debug, Clone, Serialize)]
85pub struct ModifyContext {
86    /// Target file path
87    pub file: String,
88    /// Files that import this file (will be affected by changes)
89    pub importers: Vec<String>,
90    /// Number of importers
91    pub importer_count: usize,
92    /// Constraints on this file
93    pub constraints: Option<FileConstraint>,
94    /// Symbols in this file
95    pub symbols: Vec<String>,
96    /// Domain this file belongs to
97    pub domain: Option<String>,
98}
99
100/// Context output for debug operation
101#[derive(Debug, Clone, Serialize)]
102pub struct DebugContext {
103    /// Target file or symbol
104    pub target: String,
105    /// Related files (dependencies)
106    pub related_files: Vec<String>,
107    /// Symbols in the target
108    pub symbols: Vec<SymbolInfo>,
109    /// Potential hotpaths through this code
110    pub hotpaths: Vec<String>,
111}
112
113/// Context output for explore operation
114#[derive(Debug, Clone, Serialize)]
115pub struct ExploreContext {
116    /// Domain being explored (if specified)
117    pub domain: Option<String>,
118    /// Project statistics
119    pub stats: ProjectStats,
120    /// Domain list
121    pub domains: Vec<DomainInfo>,
122    /// Recent/important files
123    pub key_files: Vec<String>,
124}
125
126/// Import style preferences
127#[derive(Debug, Clone, Serialize)]
128pub struct ImportStyle {
129    /// Module system (esm, commonjs)
130    pub module_system: String,
131    /// Path style (relative, absolute, alias)
132    pub path_style: String,
133    /// Whether index exports are used
134    pub index_exports: bool,
135}
136
137/// File-level constraints
138#[derive(Debug, Clone, Serialize)]
139pub struct FileConstraint {
140    /// Mutation level
141    pub level: String,
142    /// Constraint reason
143    pub reason: Option<String>,
144}
145
146/// Symbol information
147#[derive(Debug, Clone, Serialize)]
148pub struct SymbolInfo {
149    /// Symbol name
150    pub name: String,
151    /// Symbol type
152    pub symbol_type: String,
153    /// Purpose if available
154    pub purpose: Option<String>,
155}
156
157/// Domain information
158#[derive(Debug, Clone, Serialize)]
159pub struct DomainInfo {
160    /// Domain name
161    pub name: String,
162    /// Number of files
163    pub file_count: usize,
164    /// Number of symbols
165    pub symbol_count: usize,
166}
167
168/// Project statistics summary
169#[derive(Debug, Clone, Serialize)]
170pub struct ProjectStats {
171    /// Total files
172    pub files: usize,
173    /// Total symbols
174    pub symbols: usize,
175    /// Total lines
176    pub lines: usize,
177    /// Primary language
178    pub primary_language: Option<String>,
179    /// Annotation coverage
180    pub coverage: f64,
181}
182
183/// Execute the context command
184pub fn execute_context(options: ContextOptions, operation: ContextOperation) -> Result<()> {
185    let cache = Cache::from_json(&options.cache)?;
186
187    match operation {
188        ContextOperation::Create { directory } => {
189            execute_create_context(&cache, &directory, &options)
190        }
191        ContextOperation::Modify { file } => execute_modify_context(&cache, &file, &options),
192        ContextOperation::Debug { target } => execute_debug_context(&cache, &target, &options),
193        ContextOperation::Explore { domain } => {
194            execute_explore_context(&cache, domain.as_deref(), &options)
195        }
196    }
197}
198
199/// Execute context for create operation (T2.7)
200fn execute_create_context(cache: &Cache, directory: &str, options: &ContextOptions) -> Result<()> {
201    // Find naming conventions for this directory (direct access - not an Option)
202    let naming = cache
203        .conventions
204        .file_naming
205        .iter()
206        .find(|n| n.directory == directory)
207        .cloned()
208        .or_else(|| {
209            // Try to find a parent directory convention
210            cache
211                .conventions
212                .file_naming
213                .iter()
214                .filter(|n| directory.starts_with(&n.directory))
215                .max_by_key(|n| n.directory.len())
216                .cloned()
217        });
218
219    // Detect primary language in directory
220    let language = detect_directory_language(cache, directory);
221
222    // Get import style from conventions
223    let import_style = cache.conventions.imports.as_ref().map(|i| ImportStyle {
224        module_system: i
225            .module_system
226            .as_ref()
227            .map(|m| format!("{:?}", m).to_lowercase())
228            .unwrap_or_else(|| "esm".to_string()),
229        path_style: i
230            .path_style
231            .as_ref()
232            .map(|p| format!("{:?}", p).to_lowercase())
233            .unwrap_or_else(|| "relative".to_string()),
234        index_exports: i.index_exports,
235    });
236
237    // Find similar files in the directory
238    let similar_files: Vec<String> = cache
239        .files
240        .keys()
241        .filter(|p| {
242            let parent = std::path::Path::new(p)
243                .parent()
244                .map(|p| p.to_string_lossy().to_string())
245                .unwrap_or_default();
246            parent == directory
247        })
248        .take(5)
249        .cloned()
250        .collect();
251
252    let recommended_pattern = naming.as_ref().map(|n| n.pattern.clone());
253
254    let context = CreateContext {
255        directory: directory.to_string(),
256        language,
257        naming,
258        import_style,
259        similar_files,
260        recommended_pattern,
261    };
262
263    output_context(&context, options)
264}
265
266/// Execute context for modify operation (T2.8)
267fn execute_modify_context(cache: &Cache, file: &str, options: &ContextOptions) -> Result<()> {
268    let file_entry = cache.files.get(file);
269
270    // Get importers from the file entry
271    let importers = file_entry
272        .map(|f| f.imported_by.clone())
273        .unwrap_or_default();
274    let importer_count = importers.len();
275
276    // Get file constraints
277    let constraints = cache.constraints.as_ref().and_then(|c| {
278        c.by_file.get(file).and_then(|fc| {
279            fc.mutation.as_ref().map(|m| FileConstraint {
280                level: format!("{:?}", m.level).to_lowercase(),
281                reason: m.reason.clone(),
282            })
283        })
284    });
285
286    // Get symbols in this file
287    let symbols = file_entry.map(|f| f.exports.clone()).unwrap_or_default();
288
289    // Get domain (domains is a HashMap<String, DomainEntry>)
290    let domain = cache
291        .domains
292        .iter()
293        .find(|(_, d)| d.files.contains(&file.to_string()))
294        .map(|(name, _)| name.clone());
295
296    let context = ModifyContext {
297        file: file.to_string(),
298        importers,
299        importer_count,
300        constraints,
301        symbols,
302        domain,
303    };
304
305    output_context(&context, options)
306}
307
308/// Execute context for debug operation
309fn execute_debug_context(cache: &Cache, target: &str, options: &ContextOptions) -> Result<()> {
310    // Target could be a file or symbol
311    let (file_path, symbols_info) = if cache.files.contains_key(target) {
312        // It's a file
313        let file = cache.files.get(target).unwrap();
314        let symbols: Vec<SymbolInfo> = file
315            .exports
316            .iter()
317            .filter_map(|name| cache.symbols.get(name))
318            .map(|s| SymbolInfo {
319                name: s.name.clone(),
320                symbol_type: format!("{:?}", s.symbol_type).to_lowercase(),
321                purpose: s.purpose.clone(),
322            })
323            .collect();
324        (target.to_string(), symbols)
325    } else if let Some(symbol) = cache.symbols.get(target) {
326        // It's a symbol
327        (
328            symbol.file.clone(),
329            vec![SymbolInfo {
330                name: symbol.name.clone(),
331                symbol_type: format!("{:?}", symbol.symbol_type).to_lowercase(),
332                purpose: symbol.purpose.clone(),
333            }],
334        )
335    } else {
336        // Not found
337        return Err(anyhow::anyhow!(
338            "Target not found: {}. Provide a file path or symbol name.",
339            target
340        ));
341    };
342
343    // Get related files (imports)
344    let related_files = cache
345        .files
346        .get(&file_path)
347        .map(|f| f.imports.clone())
348        .unwrap_or_default();
349
350    // Get hotpaths using Query API
351    let q = Query::new(cache);
352    let hotpaths: Vec<String> = q
353        .hotpaths()
354        .filter(|hp| hp.contains(&file_path) || hp.contains(target))
355        .take(3)
356        .map(String::from)
357        .collect();
358
359    let context = DebugContext {
360        target: target.to_string(),
361        related_files,
362        symbols: symbols_info,
363        hotpaths,
364    };
365
366    output_context(&context, options)
367}
368
369/// Execute context for explore operation
370fn execute_explore_context(
371    cache: &Cache,
372    domain_filter: Option<&str>,
373    options: &ContextOptions,
374) -> Result<()> {
375    let stats = ProjectStats {
376        files: cache.stats.files,
377        symbols: cache.stats.symbols,
378        lines: cache.stats.lines,
379        primary_language: cache.stats.primary_language.clone(),
380        coverage: cache.stats.annotation_coverage,
381    };
382
383    // domains is HashMap<String, DomainEntry>
384    let domains: Vec<DomainInfo> = cache
385        .domains
386        .iter()
387        .filter(|(name, _)| domain_filter.is_none_or(|f| name.contains(f)))
388        .map(|(name, d)| DomainInfo {
389            name: name.clone(),
390            file_count: d.files.len(),
391            symbol_count: d.symbols.len(),
392        })
393        .collect();
394
395    // Get key files (entry points, high-importer files)
396    let mut key_files: Vec<(&String, &FileEntry)> = cache.files.iter().collect();
397    key_files.sort_by(|a, b| b.1.imported_by.len().cmp(&a.1.imported_by.len()));
398    let key_files: Vec<String> = key_files
399        .iter()
400        .take(10)
401        .map(|(k, _)| (*k).clone())
402        .collect();
403
404    let context = ExploreContext {
405        domain: domain_filter.map(String::from),
406        stats,
407        domains,
408        key_files,
409    };
410
411    output_context(&context, options)
412}
413
414/// Output context in the appropriate format
415fn output_context<T: Serialize + std::fmt::Debug>(
416    context: &T,
417    _options: &ContextOptions,
418) -> Result<()> {
419    // Always output JSON for now - machine-readable format
420    println!("{}", serde_json::to_string_pretty(context)?);
421    Ok(())
422}
423
424/// Detect the primary language in a directory
425fn detect_directory_language(cache: &Cache, directory: &str) -> Option<String> {
426    use std::collections::HashMap;
427
428    let mut lang_counts: HashMap<String, usize> = HashMap::new();
429
430    for (path, file) in &cache.files {
431        let parent = std::path::Path::new(path)
432            .parent()
433            .map(|p| p.to_string_lossy().to_string())
434            .unwrap_or_default();
435
436        if parent == directory || parent.starts_with(&format!("{}/", directory)) {
437            let lang = format!("{:?}", file.language).to_lowercase();
438            *lang_counts.entry(lang).or_insert(0) += 1;
439        }
440    }
441
442    lang_counts
443        .into_iter()
444        .max_by_key(|(_, count)| *count)
445        .map(|(lang, _)| lang)
446}
447
448#[cfg(test)]
449mod tests {
450    use super::*;
451
452    #[test]
453    fn test_import_style_default() {
454        let style = ImportStyle {
455            module_system: "esm".to_string(),
456            path_style: "relative".to_string(),
457            index_exports: false,
458        };
459        assert_eq!(style.module_system, "esm");
460    }
461
462    #[test]
463    fn test_context_options_default() {
464        let opts = ContextOptions::default();
465        assert_eq!(opts.cache, PathBuf::from(".acp/acp.cache.json"));
466        assert!(!opts.json);
467    }
468}