Skip to main content

st/formatters/
smart.rs

1//
2// -----------------------------------------------------------------------------
3//  SMART FORMATTER: Surface What Matters
4//
5//  This formatter transforms a wall of files into actionable intelligence.
6//  Instead of "here's everything", it says "here's what you need to know."
7//
8//  Output is grouped by interest level:
9//  - SECURITY: Critical findings that need immediate attention
10//  - IMPORTANT: Key files, recent changes, high-interest items
11//  - CHANGES: What's different since last scan
12//  - NOTABLE: Worth knowing but not urgent
13//  - BACKGROUND: Summary count (not listed individually)
14//
15//  "Don't list everything. Surface what matters." - The Smart Tree Philosophy
16// -----------------------------------------------------------------------------
17//
18
19use 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
29/// Smart Formatter - Groups output by interest level
30pub struct SmartFormatter {
31    /// Use colors in output
32    use_color: bool,
33    /// Use emoji in output
34    use_emoji: bool,
35    /// Show background files individually (default: just count)
36    show_background: bool,
37    /// Path display mode
38    path_mode: PathDisplayMode,
39    /// Minimum interest level to show individually
40    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    /// Set whether to show background files individually
55    pub fn with_show_background(mut self, show: bool) -> Self {
56        self.show_background = show;
57        self
58    }
59
60    /// Set path display mode
61    pub fn with_path_mode(mut self, mode: PathDisplayMode) -> Self {
62        self.path_mode = mode;
63        self
64    }
65
66    /// Set minimum interest level to display
67    pub fn with_min_level(mut self, level: InterestLevel) -> Self {
68        self.min_level = level;
69        self
70    }
71
72    /// Format a path for display
73    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    /// Get color code for interest level
88    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", // Bold red
94            InterestLevel::Important => "\x1b[1;33m", // Bold yellow
95            InterestLevel::Notable => "\x1b[36m",    // Cyan
96            InterestLevel::Background => "\x1b[90m", // Gray
97            InterestLevel::Boring => "\x1b[90m",     // Gray
98        }
99    }
100
101    /// Get color code for risk level
102    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", // Bold red
108            RiskLevel::High => "\x1b[31m",       // Red
109            RiskLevel::Medium => "\x1b[33m",     // Yellow
110            RiskLevel::Low => "\x1b[36m",        // Cyan
111        }
112    }
113
114    /// Reset color
115    fn reset(&self) -> &'static str {
116        if self.use_color {
117            "\x1b[0m"
118        } else {
119            ""
120        }
121    }
122
123    /// Get emoji for section
124    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    /// Format time ago string
140    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    /// Detect project type from nodes
159    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        // Check for git branch in any node
176        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    /// Group nodes by interest level
185    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    /// Collect all security findings from nodes
202    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        // Sort by risk level (critical first)
210        findings.sort_by(|a, b| b.1.risk_level.cmp(&a.1.risk_level));
211        findings
212    }
213
214    /// Collect changed files
215    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(); // We won't have deleted in nodes, but for completeness
219
220        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        // === HEADER: Project info ===
246        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        // === SECURITY SECTION ===
266        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        // === CHANGES SECTION ===
303        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            // Show added files
314            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            // Show modified files
326            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        // === IMPORTANT SECTION ===
353        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        // === NOTABLE SECTION ===
411        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        // === BACKGROUND SUMMARY ===
441        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}