1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
21pub enum MapFormat {
22 #[default]
23 Tree,
24 Flat,
25 Json,
26}
27
28#[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#[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#[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#[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#[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
85pub 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 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 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 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 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 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 node.files.sort_by(|a, b| a.name.cmp(&b.name));
157 }
158
159 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 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 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 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 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 issues.sort_by(|a, b| a.file.cmp(&b.file).then(a.line.cmp(&b.line)));
277
278 issues
279 }
280}
281
282pub 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 println!("{}/", node.path);
296 println!("{}", renderer.separator(60));
297 println!();
298
299 for file in &node.files {
301 render_file_tree(file, &renderer, "");
302 }
303
304 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 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 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 if let Some(ref purpose) = file.purpose {
343 println!("{} {}", indent, style(purpose).dim());
344 }
345
346 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 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
397pub 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}