1use crate::formatters::{Formatter, PathDisplayMode};
20use crate::scanner::{FileNode, TreeStats};
21use crate::scanner_interest::InterestLevel;
22use crate::security_scan::RiskLevel;
23use anyhow::Result;
24use std::collections::HashMap;
25use std::io::Write;
26use std::path::Path;
27use std::time::SystemTime;
28
29pub struct SmartFormatter {
31 use_color: bool,
33 use_emoji: bool,
35 show_background: bool,
37 path_mode: PathDisplayMode,
39 min_level: InterestLevel,
41}
42
43impl SmartFormatter {
44 pub fn new(use_color: bool, use_emoji: bool) -> Self {
45 Self {
46 use_color,
47 use_emoji,
48 show_background: false,
49 path_mode: PathDisplayMode::Relative,
50 min_level: InterestLevel::Background,
51 }
52 }
53
54 pub fn with_show_background(mut self, show: bool) -> Self {
56 self.show_background = show;
57 self
58 }
59
60 pub fn with_path_mode(mut self, mode: PathDisplayMode) -> Self {
62 self.path_mode = mode;
63 self
64 }
65
66 pub fn with_min_level(mut self, level: InterestLevel) -> Self {
68 self.min_level = level;
69 self
70 }
71
72 fn format_path<'a>(&self, path: &'a Path, root: &Path) -> std::borrow::Cow<'a, str> {
74 match self.path_mode {
75 PathDisplayMode::Off => path
76 .file_name()
77 .map(|n| n.to_string_lossy())
78 .unwrap_or_else(|| path.to_string_lossy()),
79 PathDisplayMode::Relative => path
80 .strip_prefix(root)
81 .map(|p| p.to_string_lossy())
82 .unwrap_or_else(|_| path.to_string_lossy()),
83 PathDisplayMode::Full => path.to_string_lossy(),
84 }
85 }
86
87 fn level_color(&self, level: InterestLevel) -> &'static str {
89 if !self.use_color {
90 return "";
91 }
92 match level {
93 InterestLevel::Critical => "\x1b[1;31m", InterestLevel::Important => "\x1b[1;33m", InterestLevel::Notable => "\x1b[36m", InterestLevel::Background => "\x1b[90m", InterestLevel::Boring => "\x1b[90m", }
99 }
100
101 fn risk_color(&self, risk: RiskLevel) -> &'static str {
103 if !self.use_color {
104 return "";
105 }
106 match risk {
107 RiskLevel::Critical => "\x1b[1;31m", RiskLevel::High => "\x1b[31m", RiskLevel::Medium => "\x1b[33m", RiskLevel::Low => "\x1b[36m", }
112 }
113
114 fn reset(&self) -> &'static str {
116 if self.use_color {
117 "\x1b[0m"
118 } else {
119 ""
120 }
121 }
122
123 fn section_emoji(&self, section: &str) -> &'static str {
125 if !self.use_emoji {
126 return "";
127 }
128 match section {
129 "security" => "⚠️ ",
130 "important" => "🔥 ",
131 "changes" => "📝 ",
132 "notable" => "📌 ",
133 "background" => "📦 ",
134 "project" => "🌳 ",
135 _ => "",
136 }
137 }
138
139 fn time_ago(&self, modified: SystemTime) -> String {
141 let now = SystemTime::now();
142 let duration = now.duration_since(modified).unwrap_or_default();
143 let secs = duration.as_secs();
144
145 if secs < 60 {
146 "just now".to_string()
147 } else if secs < 3600 {
148 format!("{}m ago", secs / 60)
149 } else if secs < 86400 {
150 format!("{}h ago", secs / 3600)
151 } else if secs < 604800 {
152 format!("{}d ago", secs / 86400)
153 } else {
154 format!("{}w ago", secs / 604800)
155 }
156 }
157
158 fn detect_project_type(&self, nodes: &[FileNode]) -> Option<(&'static str, Option<String>)> {
160 for node in nodes {
161 if node.is_dir {
162 continue;
163 }
164 let name = node.path.file_name()?.to_str()?;
165 match name.to_lowercase().as_str() {
166 "cargo.toml" => return Some(("Rust", node.git_branch.clone())),
167 "package.json" => return Some(("Node.js", node.git_branch.clone())),
168 "pyproject.toml" | "setup.py" => return Some(("Python", node.git_branch.clone())),
169 "go.mod" => return Some(("Go", node.git_branch.clone())),
170 "gemfile" => return Some(("Ruby", node.git_branch.clone())),
171 "pom.xml" | "build.gradle" => return Some(("Java", node.git_branch.clone())),
172 _ => continue,
173 }
174 }
175 for node in nodes {
177 if let Some(ref branch) = node.git_branch {
178 return Some(("Project", Some(branch.clone())));
179 }
180 }
181 None
182 }
183
184 fn group_by_interest<'a>(&self, nodes: &'a [FileNode]) -> HashMap<InterestLevel, Vec<&'a FileNode>> {
186 let mut groups: HashMap<InterestLevel, Vec<&'a FileNode>> = HashMap::new();
187
188 for node in nodes {
189 let level = node
190 .interest
191 .as_ref()
192 .map(|i| i.level)
193 .unwrap_or(InterestLevel::Background);
194
195 groups.entry(level).or_default().push(node);
196 }
197
198 groups
199 }
200
201 fn collect_security_findings<'a>(&self, nodes: &'a [FileNode]) -> Vec<(&'a FileNode, &'a crate::security_scan::SecurityFinding)> {
203 let mut findings = Vec::new();
204 for node in nodes {
205 for finding in &node.security_findings {
206 findings.push((node, finding));
207 }
208 }
209 findings.sort_by(|a, b| b.1.risk_level.cmp(&a.1.risk_level));
211 findings
212 }
213
214 fn collect_changes<'a>(&self, nodes: &'a [FileNode]) -> (Vec<&'a FileNode>, Vec<&'a FileNode>, Vec<&'a FileNode>) {
216 let mut added = Vec::new();
217 let mut modified = Vec::new();
218 let mut deleted = Vec::new(); for node in nodes {
221 if let Some(ref change) = node.change_status {
222 match change {
223 crate::scanner_interest::ChangeType::Added => added.push(node),
224 crate::scanner_interest::ChangeType::Modified
225 | crate::scanner_interest::ChangeType::PermissionChanged
226 | crate::scanner_interest::ChangeType::TypeChanged
227 | crate::scanner_interest::ChangeType::Renamed => modified.push(node),
228 crate::scanner_interest::ChangeType::Deleted => deleted.push(node),
229 }
230 }
231 }
232
233 (added, modified, deleted)
234 }
235}
236
237impl Formatter for SmartFormatter {
238 fn format(
239 &self,
240 writer: &mut dyn Write,
241 nodes: &[FileNode],
242 stats: &TreeStats,
243 root_path: &Path,
244 ) -> Result<()> {
245 let project_name = root_path
247 .file_name()
248 .map(|n| n.to_string_lossy().to_string())
249 .unwrap_or_else(|| ".".to_string());
250
251 let (project_type, git_branch) = self.detect_project_type(nodes).unwrap_or(("", None));
252
253 write!(writer, "{}", self.section_emoji("project"))?;
254 write!(writer, "{}{}{}", self.level_color(InterestLevel::Important), project_name, self.reset())?;
255
256 if !project_type.is_empty() {
257 write!(writer, " ({})", project_type)?;
258 }
259 if let Some(branch) = git_branch {
260 write!(writer, " [{}]", branch)?;
261 }
262 writeln!(writer)?;
263 writeln!(writer)?;
264
265 let security_findings = self.collect_security_findings(nodes);
267 if !security_findings.is_empty() {
268 writeln!(
269 writer,
270 "{}{}SECURITY ({} finding{}){}",
271 self.section_emoji("security"),
272 self.level_color(InterestLevel::Critical),
273 security_findings.len(),
274 if security_findings.len() == 1 { "" } else { "s" },
275 self.reset()
276 )?;
277
278 for (node, finding) in security_findings.iter().take(10) {
279 let path = self.format_path(&node.path, root_path);
280 writeln!(
281 writer,
282 " {}{}: {}{}",
283 self.risk_color(finding.risk_level),
284 path,
285 finding.description,
286 self.reset()
287 )?;
288 }
289
290 if security_findings.len() > 10 {
291 writeln!(
292 writer,
293 " {}... and {} more{}",
294 self.level_color(InterestLevel::Background),
295 security_findings.len() - 10,
296 self.reset()
297 )?;
298 }
299 writeln!(writer)?;
300 }
301
302 let (added, modified, _deleted) = self.collect_changes(nodes);
304 if !added.is_empty() || !modified.is_empty() {
305 writeln!(
306 writer,
307 "{}{}CHANGES{}",
308 self.section_emoji("changes"),
309 self.level_color(InterestLevel::Notable),
310 self.reset()
311 )?;
312
313 for node in added.iter().take(5) {
315 let path = self.format_path(&node.path, root_path);
316 writeln!(
317 writer,
318 " {}+ {}{}",
319 self.level_color(InterestLevel::Notable),
320 path,
321 self.reset()
322 )?;
323 }
324
325 for node in modified.iter().take(5) {
327 let path = self.format_path(&node.path, root_path);
328 let time = self.time_ago(node.modified);
329 writeln!(
330 writer,
331 " {}~ {} [{}]{}",
332 self.level_color(InterestLevel::Notable),
333 path,
334 time,
335 self.reset()
336 )?;
337 }
338
339 let total_changes = added.len() + modified.len();
340 if total_changes > 10 {
341 writeln!(
342 writer,
343 " {}... and {} more changes{}",
344 self.level_color(InterestLevel::Background),
345 total_changes - 10,
346 self.reset()
347 )?;
348 }
349 writeln!(writer)?;
350 }
351
352 let groups = self.group_by_interest(nodes);
354
355 if let Some(critical) = groups.get(&InterestLevel::Critical) {
356 if !critical.is_empty() {
357 writeln!(
358 writer,
359 "{}{}CRITICAL{}",
360 self.section_emoji("important"),
361 self.level_color(InterestLevel::Critical),
362 self.reset()
363 )?;
364
365 for node in critical.iter().take(10) {
366 let path = self.format_path(&node.path, root_path);
367 let time = self.time_ago(node.modified);
368 let score = node.interest.as_ref().map(|i| i.score).unwrap_or(0.0);
369 writeln!(
370 writer,
371 " {} [{}] {:.0}%",
372 path,
373 time,
374 score * 100.0
375 )?;
376 }
377 writeln!(writer)?;
378 }
379 }
380
381 if let Some(important) = groups.get(&InterestLevel::Important) {
382 if !important.is_empty() {
383 writeln!(
384 writer,
385 "{}{}IMPORTANT{}",
386 self.section_emoji("important"),
387 self.level_color(InterestLevel::Important),
388 self.reset()
389 )?;
390
391 for node in important.iter().take(10) {
392 let path = self.format_path(&node.path, root_path);
393 let time = self.time_ago(node.modified);
394 writeln!(writer, " {} [{}]", path, time)?;
395 }
396
397 if important.len() > 10 {
398 writeln!(
399 writer,
400 " {}... and {} more{}",
401 self.level_color(InterestLevel::Background),
402 important.len() - 10,
403 self.reset()
404 )?;
405 }
406 writeln!(writer)?;
407 }
408 }
409
410 if let Some(notable) = groups.get(&InterestLevel::Notable) {
412 if !notable.is_empty() && self.min_level <= InterestLevel::Notable {
413 writeln!(
414 writer,
415 "{}{}NOTABLE ({}){}",
416 self.section_emoji("notable"),
417 self.level_color(InterestLevel::Notable),
418 notable.len(),
419 self.reset()
420 )?;
421
422 for node in notable.iter().take(5) {
423 let path = self.format_path(&node.path, root_path);
424 writeln!(writer, " {}", path)?;
425 }
426
427 if notable.len() > 5 {
428 writeln!(
429 writer,
430 " {}... and {} more{}",
431 self.level_color(InterestLevel::Background),
432 notable.len() - 5,
433 self.reset()
434 )?;
435 }
436 writeln!(writer)?;
437 }
438 }
439
440 let background_count = groups
442 .get(&InterestLevel::Background)
443 .map(|v| v.len())
444 .unwrap_or(0)
445 + groups
446 .get(&InterestLevel::Boring)
447 .map(|v| v.len())
448 .unwrap_or(0);
449
450 if background_count > 0 {
451 writeln!(
452 writer,
453 "{}{}BACKGROUND: {} files, {} dirs ({}){}",
454 self.section_emoji("background"),
455 self.level_color(InterestLevel::Background),
456 stats.total_files,
457 stats.total_dirs,
458 humansize::format_size(stats.total_size, humansize::BINARY),
459 self.reset()
460 )?;
461 }
462
463 Ok(())
464 }
465}
466
467#[cfg(test)]
468mod tests {
469 use super::*;
470 use crate::scanner::{FileCategory, FileType, FilesystemType};
471 use std::path::PathBuf;
472
473 fn make_test_node(path: &str, is_dir: bool) -> FileNode {
474 FileNode {
475 path: PathBuf::from(path),
476 is_dir,
477 size: 1000,
478 permissions: 0o644,
479 uid: 1000,
480 gid: 1000,
481 modified: SystemTime::now(),
482 is_symlink: false,
483 is_hidden: false,
484 permission_denied: false,
485 is_ignored: false,
486 depth: path.matches('/').count(),
487 file_type: if is_dir {
488 FileType::Directory
489 } else {
490 FileType::RegularFile
491 },
492 category: FileCategory::Unknown,
493 search_matches: None,
494 filesystem_type: FilesystemType::Unknown,
495 git_branch: None,
496 traversal_context: None,
497 interest: None,
498 security_findings: Vec::new(),
499 change_status: None,
500 content_hash: None,
501 }
502 }
503
504 #[test]
505 fn test_smart_formatter_basic() {
506 let formatter = SmartFormatter::new(false, false);
507 let nodes = vec![
508 make_test_node("src", true),
509 make_test_node("src/main.rs", false),
510 make_test_node("Cargo.toml", false),
511 ];
512
513 let stats = TreeStats {
514 total_files: 2,
515 total_dirs: 1,
516 total_size: 2000,
517 file_types: std::collections::HashMap::new(),
518 largest_files: vec![],
519 newest_files: vec![],
520 oldest_files: vec![],
521 };
522
523 let mut output = Vec::new();
524 formatter
525 .format(&mut output, &nodes, &stats, Path::new("/project"))
526 .unwrap();
527
528 let output_str = String::from_utf8(output).unwrap();
529 assert!(output_str.contains("BACKGROUND"));
530 }
531
532 #[test]
533 fn test_time_ago() {
534 let formatter = SmartFormatter::new(false, false);
535
536 let now = SystemTime::now();
537 assert_eq!(formatter.time_ago(now), "just now");
538
539 let hour_ago = now - std::time::Duration::from_secs(3600);
540 assert_eq!(formatter.time_ago(hour_ago), "1h ago");
541
542 let day_ago = now - std::time::Duration::from_secs(86400);
543 assert_eq!(formatter.time_ago(day_ago), "1d ago");
544 }
545}