acp/commands/
map.rs

1//! @acp:module "Map Command"
2//! @acp:summary "Display directory/file structure with annotations (RFC-001)"
3//! @acp:domain cli
4//! @acp:layer service
5//!
6//! Implements `acp map <path>` command for hierarchical codebase navigation.
7
8use std::collections::HashMap;
9use std::path::Path;
10
11use console::style;
12use serde::Serialize;
13
14use crate::cache::{Cache, FileEntry};
15use crate::error::Result;
16
17use super::output::{constraint_level_str, TreeRenderer};
18
19/// Output format for map command
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
21pub enum MapFormat {
22    #[default]
23    Tree,
24    Flat,
25    Json,
26}
27
28/// Options for the map command
29#[derive(Debug, Clone)]
30pub struct MapOptions {
31    pub depth: usize,
32    pub show_inline: bool,
33    pub format: MapFormat,
34}
35
36impl Default for MapOptions {
37    fn default() -> Self {
38        Self {
39            depth: 3,
40            show_inline: false,
41            format: MapFormat::Tree,
42        }
43    }
44}
45
46/// A file node in the map tree
47#[derive(Debug, Clone, Serialize)]
48pub struct FileNode {
49    pub name: String,
50    pub path: String,
51    pub constraint_level: Option<String>,
52    pub purpose: Option<String>,
53    pub symbols: Vec<SymbolNode>,
54    pub inline_issues: Vec<InlineIssue>,
55}
56
57/// A symbol within a file
58#[derive(Debug, Clone, Serialize)]
59pub struct SymbolNode {
60    pub name: String,
61    pub symbol_type: String,
62    pub line: usize,
63    pub is_frozen: bool,
64}
65
66/// An inline issue (hack, todo, fixme)
67#[derive(Debug, Clone, Serialize)]
68pub struct InlineIssue {
69    pub file: String,
70    pub line: usize,
71    pub issue_type: String,
72    pub message: String,
73    pub expires: Option<String>,
74}
75
76/// Directory node in the map tree
77#[derive(Debug, Clone, Serialize)]
78pub struct DirectoryNode {
79    pub name: String,
80    pub path: String,
81    pub files: Vec<FileNode>,
82    pub subdirs: Vec<DirectoryNode>,
83}
84
85/// Builder for constructing the map tree from cache
86pub struct MapBuilder<'a> {
87    cache: &'a Cache,
88    options: MapOptions,
89}
90
91impl<'a> MapBuilder<'a> {
92    pub fn new(cache: &'a Cache, options: MapOptions) -> Self {
93        Self { cache, options }
94    }
95
96    /// Build the directory tree for a given path
97    pub fn build(&self, root_path: &Path) -> Result<DirectoryNode> {
98        let root_str = root_path.to_string_lossy().to_string();
99        let normalized_root = self.normalize_path(&root_str);
100
101        // Group files by directory
102        let mut dir_files: HashMap<String, Vec<&FileEntry>> = HashMap::new();
103
104        for (path, file) in &self.cache.files {
105            let normalized = self.normalize_path(path);
106
107            // Check if file is under the root path
108            if normalized.starts_with(&normalized_root)
109                || normalized_root.is_empty()
110                || normalized_root == "."
111            {
112                let dir = self.get_directory(&normalized);
113                dir_files.entry(dir).or_default().push(file);
114            }
115        }
116
117        // Build the tree recursively
118        self.build_directory_node(&normalized_root, &dir_files, 0)
119    }
120
121    fn normalize_path(&self, path: &str) -> String {
122        path.trim_start_matches("./").replace('\\', "/").to_string()
123    }
124
125    fn get_directory(&self, path: &str) -> String {
126        Path::new(path)
127            .parent()
128            .map(|p| p.to_string_lossy().to_string())
129            .unwrap_or_default()
130    }
131
132    fn build_directory_node(
133        &self,
134        dir_path: &str,
135        dir_files: &HashMap<String, Vec<&FileEntry>>,
136        depth: usize,
137    ) -> Result<DirectoryNode> {
138        let name = Path::new(dir_path)
139            .file_name()
140            .map(|n| n.to_string_lossy().to_string())
141            .unwrap_or_else(|| dir_path.to_string());
142
143        let mut node = DirectoryNode {
144            name,
145            path: dir_path.to_string(),
146            files: vec![],
147            subdirs: vec![],
148        };
149
150        // Add files in this directory
151        if let Some(files) = dir_files.get(dir_path) {
152            for file in files {
153                node.files.push(self.build_file_node(file));
154            }
155            // Sort files by name
156            node.files.sort_by(|a, b| a.name.cmp(&b.name));
157        }
158
159        // Add subdirectories if within depth limit
160        if depth < self.options.depth {
161            let mut subdirs: Vec<String> = dir_files
162                .keys()
163                .filter(|d| {
164                    if dir_path.is_empty() {
165                        !d.contains('/')
166                    } else {
167                        d.starts_with(&format!("{}/", dir_path))
168                            && d[dir_path.len() + 1..].split('/').count() == 1
169                    }
170                })
171                .cloned()
172                .collect();
173            subdirs.sort();
174
175            for subdir in subdirs {
176                if let Ok(subnode) = self.build_directory_node(&subdir, dir_files, depth + 1) {
177                    if !subnode.files.is_empty() || !subnode.subdirs.is_empty() {
178                        node.subdirs.push(subnode);
179                    }
180                }
181            }
182        }
183
184        Ok(node)
185    }
186
187    fn build_file_node(&self, file: &FileEntry) -> FileNode {
188        let name = Path::new(&file.path)
189            .file_name()
190            .map(|n| n.to_string_lossy().to_string())
191            .unwrap_or_else(|| file.path.clone());
192
193        // Get constraint level from cache
194        let constraint_level = self.cache.constraints.as_ref().and_then(|c| {
195            c.by_file.get(&file.path).and_then(|constraints| {
196                constraints
197                    .mutation
198                    .as_ref()
199                    .map(|m| constraint_level_str(&m.level).to_string())
200            })
201        });
202
203        // Build symbol list
204        let symbols: Vec<SymbolNode> = file
205            .exports
206            .iter()
207            .filter_map(|sym_name| {
208                self.cache.symbols.get(sym_name).map(|sym| {
209                    let is_frozen = sym
210                        .constraints
211                        .as_ref()
212                        .map(|c| c.level == "frozen")
213                        .unwrap_or(false);
214                    SymbolNode {
215                        name: sym.name.clone(),
216                        symbol_type: format!("{:?}", sym.symbol_type).to_lowercase(),
217                        line: sym.lines[0],
218                        is_frozen,
219                    }
220                })
221            })
222            .collect();
223
224        // Build inline issues
225        let inline_issues: Vec<InlineIssue> = if self.options.show_inline {
226            file.inline
227                .iter()
228                .map(|ann| InlineIssue {
229                    file: file.path.clone(),
230                    line: ann.line,
231                    issue_type: ann.annotation_type.clone(),
232                    message: ann.directive.clone(),
233                    expires: ann.expires.clone(),
234                })
235                .collect()
236        } else {
237            vec![]
238        };
239
240        FileNode {
241            name,
242            path: file.path.clone(),
243            constraint_level,
244            purpose: file.purpose.clone(),
245            symbols,
246            inline_issues,
247        }
248    }
249
250    /// Collect all inline issues across the tree
251    pub fn collect_issues(&self, root_path: &Path) -> Vec<InlineIssue> {
252        let root_str = root_path.to_string_lossy().to_string();
253        let normalized_root = self.normalize_path(&root_str);
254
255        let mut issues = vec![];
256
257        for (path, file) in &self.cache.files {
258            let normalized = self.normalize_path(path);
259            if normalized.starts_with(&normalized_root)
260                || normalized_root.is_empty()
261                || normalized_root == "."
262            {
263                for ann in &file.inline {
264                    issues.push(InlineIssue {
265                        file: file.path.clone(),
266                        line: ann.line,
267                        issue_type: ann.annotation_type.clone(),
268                        message: ann.directive.clone(),
269                        expires: ann.expires.clone(),
270                    });
271                }
272            }
273        }
274
275        // Sort by file and line
276        issues.sort_by(|a, b| a.file.cmp(&b.file).then(a.line.cmp(&b.line)));
277
278        issues
279    }
280}
281
282/// Render the map tree to stdout
283pub fn render_map(node: &DirectoryNode, options: &MapOptions, all_issues: &[InlineIssue]) {
284    match options.format {
285        MapFormat::Tree => render_tree(node, options, all_issues),
286        MapFormat::Flat => render_flat(node),
287        MapFormat::Json => render_json(node, all_issues),
288    }
289}
290
291fn render_tree(node: &DirectoryNode, options: &MapOptions, all_issues: &[InlineIssue]) {
292    let renderer = TreeRenderer::default();
293
294    // Print directory header
295    println!("{}/", node.path);
296    println!("{}", renderer.separator(60));
297    println!();
298
299    // Print files
300    for file in &node.files {
301        render_file_tree(file, &renderer, "");
302    }
303
304    // Print subdirectories
305    for subdir in &node.subdirs {
306        println!();
307        println!("{}/", subdir.path);
308        for file in &subdir.files {
309            render_file_tree(file, &renderer, "  ");
310        }
311    }
312
313    // Print active issues if enabled
314    if options.show_inline && !all_issues.is_empty() {
315        println!();
316        println!("{}:", style("Active Issues").bold());
317        for issue in all_issues {
318            let expires_str = issue
319                .expires
320                .as_ref()
321                .map(|e| format!(" expires {}", e))
322                .unwrap_or_default();
323            println!(
324                "  {}:{} - @acp:{}{}",
325                issue.file, issue.line, issue.issue_type, expires_str
326            );
327        }
328    }
329}
330
331fn render_file_tree(file: &FileNode, renderer: &TreeRenderer, indent: &str) {
332    // File header with constraint level
333    let constraint_str = file
334        .constraint_level
335        .as_ref()
336        .map(|l| format!(" ({})", l))
337        .unwrap_or_default();
338
339    println!("{}{}{}", indent, style(&file.name).bold(), constraint_str);
340
341    // Purpose
342    if let Some(ref purpose) = file.purpose {
343        println!("{}  {}", indent, style(purpose).dim());
344    }
345
346    // Symbols
347    let symbol_count = file.symbols.len();
348    for (i, sym) in file.symbols.iter().enumerate() {
349        let is_last = i == symbol_count - 1;
350        let branch = if is_last {
351            renderer.last_branch()
352        } else {
353            renderer.branch()
354        };
355
356        let frozen_marker = if sym.is_frozen { " [frozen]" } else { "" };
357        println!(
358            "{}  {} {} ({}:{}){}",
359            indent, branch, sym.name, sym.symbol_type, sym.line, frozen_marker
360        );
361    }
362}
363
364fn render_flat(node: &DirectoryNode) {
365    // Flat list of all files with their constraint levels
366    render_flat_recursive(node, 0);
367}
368
369fn render_flat_recursive(node: &DirectoryNode, depth: usize) {
370    let indent = "  ".repeat(depth);
371
372    for file in &node.files {
373        let constraint_str = file
374            .constraint_level
375            .as_ref()
376            .map(|l| format!(" [{}]", l))
377            .unwrap_or_default();
378        println!("{}{}{}", indent, file.path, constraint_str);
379    }
380
381    for subdir in &node.subdirs {
382        render_flat_recursive(subdir, depth);
383    }
384}
385
386fn render_json(node: &DirectoryNode, issues: &[InlineIssue]) {
387    #[derive(Serialize)]
388    struct MapOutput<'a> {
389        tree: &'a DirectoryNode,
390        issues: &'a [InlineIssue],
391    }
392
393    let output = MapOutput { tree: node, issues };
394    println!("{}", serde_json::to_string_pretty(&output).unwrap());
395}
396
397/// Execute the map command
398pub fn execute_map(cache: &Cache, path: &Path, options: MapOptions) -> Result<()> {
399    let builder = MapBuilder::new(cache, options.clone());
400    let tree = builder.build(path)?;
401    let issues = if options.show_inline {
402        builder.collect_issues(path)
403    } else {
404        vec![]
405    };
406
407    render_map(&tree, &options, &issues);
408    Ok(())
409}
410
411#[cfg(test)]
412mod tests {
413    use super::*;
414
415    #[test]
416    fn test_map_options_default() {
417        let opts = MapOptions::default();
418        assert_eq!(opts.depth, 3);
419        assert!(!opts.show_inline);
420        assert_eq!(opts.format, MapFormat::Tree);
421    }
422}