Skip to main content

st/formatters/
ls.rs

1// -----------------------------------------------------------------------------
2// ๐Ÿ—‚๏ธ LS MODE - The Classic Unix Experience with Smart-Tree Magic!
3// -----------------------------------------------------------------------------
4// This formatter replicates the beloved `ls -Alh` command that every Unix user
5// knows and loves. We take that familiar format and supercharge it with
6// smart-tree's intelligence and beautiful formatting.
7//
8// Output format matches: drwxrwxr-x 1 hue hue 1.2K Jul  9 14:56 filename
9// - Permissions (like drwxrwxr-x)
10// - Link count
11// - Owner and group
12// - Human-readable file size (1.2K, 45M, 2.3G)
13// - Last modified date and time
14// - Filename with proper coloring and emojis (optional)
15//
16// Hue gets the comfort of familiar ls output, Trish gets beautiful formatting,
17// and Aye gets to show off some Rust file system wizardry! ๐ŸŽญ
18// -----------------------------------------------------------------------------
19
20use super::Formatter;
21use crate::emoji_mapper;
22use crate::scanner::{FileNode, TreeStats};
23use anyhow::Result;
24use chrono::{DateTime, Local};
25use std::fs;
26use std::io::Write;
27use std::path::Path;
28
29#[cfg(unix)]
30use std::os::unix::fs::{MetadataExt, PermissionsExt};
31
32/// LS Formatter - Unix ls -Alh output with smart-tree enhancements
33///
34/// This formatter provides the classic Unix `ls -Alh` experience:
35/// - Long format with detailed file information
36/// - Human-readable file sizes  
37/// - All files including hidden ones
38/// - Familiar permissions display
39/// - Proper date/time formatting
40///
41/// Perfect for users who want smart-tree's power with familiar ls output!
42pub struct LsFormatter {
43    /// Whether to show emojis alongside filenames (default: true)
44    show_emojis: bool,
45    /// Whether to use colors in output (default: true)  
46    use_colors: bool,
47}
48
49impl Default for LsFormatter {
50    fn default() -> Self {
51        Self::new(true, true)
52    }
53}
54
55impl LsFormatter {
56    /// Create a new LS formatter
57    ///
58    /// # Arguments
59    /// * `show_emojis` - Whether to include emojis in the output (Trish loves these!)
60    /// * `use_colors` - Whether to colorize the output for better readability
61    pub fn new(show_emojis: bool, use_colors: bool) -> Self {
62        Self {
63            show_emojis,
64            use_colors,
65        }
66    }
67
68    /// Format file permissions in the classic Unix style (e.g., drwxrwxr-x)
69    ///
70    /// This creates the familiar 10-character permission string that every
71    /// Unix user recognizes. First character is file type, then 3 groups of
72    /// 3 characters each for owner, group, and other permissions.
73    /// On Windows, we show a simplified version.
74    fn format_permissions(&self, node: &FileNode) -> String {
75        let metadata = match fs::metadata(&node.path) {
76            Ok(meta) => meta,
77            Err(_) => return "?---------".to_string(), // Permission denied or file missing
78        };
79
80        let file_type = if metadata.is_dir() {
81            'd'
82        } else if metadata.is_symlink() {
83            'l'
84        } else {
85            '-'
86        };
87
88        #[cfg(unix)]
89        {
90            let mode = metadata.permissions().mode();
91
92            // Extract permission bits (owner, group, other)
93            let owner_perms = format!(
94                "{}{}{}",
95                if mode & 0o400 != 0 { 'r' } else { '-' },
96                if mode & 0o200 != 0 { 'w' } else { '-' },
97                if mode & 0o100 != 0 { 'x' } else { '-' }
98            );
99
100            let group_perms = format!(
101                "{}{}{}",
102                if mode & 0o040 != 0 { 'r' } else { '-' },
103                if mode & 0o020 != 0 { 'w' } else { '-' },
104                if mode & 0o010 != 0 { 'x' } else { '-' }
105            );
106
107            let other_perms = format!(
108                "{}{}{}",
109                if mode & 0o004 != 0 { 'r' } else { '-' },
110                if mode & 0o002 != 0 { 'w' } else { '-' },
111                if mode & 0o001 != 0 { 'x' } else { '-' }
112            );
113
114            format!("{}{}{}{}", file_type, owner_perms, group_perms, other_perms)
115        }
116
117        #[cfg(windows)]
118        {
119            // On Windows, show simplified permissions
120            let readonly = metadata.permissions().readonly();
121            if readonly {
122                format!("{}r--r--r--", file_type)
123            } else {
124                format!("{}rw-rw-rw-", file_type)
125            }
126        }
127
128        #[cfg(not(any(unix, windows)))]
129        {
130            // For other platforms, show a generic format
131            format!("{}rwxrwxrwx", file_type)
132        }
133    }
134
135    /// Format file size in human-readable format (like ls -h)
136    ///
137    /// Converts bytes to human-readable units (B, K, M, G, T)
138    /// Uses binary units (1024) like traditional ls command
139    fn format_size(&self, size: u64) -> String {
140        const UNITS: &[&str] = &["B", "K", "M", "G", "T"];
141
142        if size == 0 {
143            return "0".to_string();
144        }
145
146        let mut size_f = size as f64;
147        let mut unit_index = 0;
148
149        while size_f >= 1024.0 && unit_index < UNITS.len() - 1 {
150            size_f /= 1024.0;
151            unit_index += 1;
152        }
153
154        if unit_index == 0 {
155            format!("{}", size)
156        } else if size_f >= 10.0 {
157            format!("{:.0}{}", size_f, UNITS[unit_index])
158        } else {
159            format!("{:.1}{}", size_f, UNITS[unit_index])
160        }
161    }
162
163    /// Get the appropriate emoji for a file node
164    ///
165    /// This adds visual flair to the output, making it easier to quickly
166    /// identify file types. Uses the centralized emoji mapper for rich file type representation!
167    fn get_emoji(&self, node: &FileNode) -> &'static str {
168        if !self.show_emojis {
169            return "";
170        }
171
172        emoji_mapper::get_file_emoji(node, false)
173    }
174
175    /// Format the filename with optional emoji and coloring
176    /// Ensures consistent spacing by padding emoji field to 2 characters
177    fn format_filename(&self, node: &FileNode) -> String {
178        let emoji = self.get_emoji(node);
179        let filename = node
180            .path
181            .file_name()
182            .unwrap_or_else(|| node.path.as_os_str())
183            .to_string_lossy();
184
185        // Format emoji with consistent spacing
186        let emoji_field = if emoji.is_empty() {
187            String::new()
188        } else {
189            // Always add a space after emoji for consistent alignment
190            format!("{} ", emoji)
191        };
192
193        if self.use_colors {
194            if node.is_dir {
195                // Blue color for directories (ANSI color code 34)
196                format!("{}\x1b[34m{}\x1b[0m", emoji_field, filename)
197            } else if node.path.extension().and_then(|s| s.to_str()) == Some("rs") {
198                // Orange color for Rust files (Hue's favorite!)
199                format!("{}\x1b[38;5;208m{}\x1b[0m", emoji_field, filename)
200            } else {
201                // Default color for regular files
202                format!("{}{}", emoji_field, filename)
203            }
204        } else if emoji_field.is_empty() {
205            filename.to_string()
206        } else {
207            format!("{}{}", emoji_field, filename)
208        }
209    }
210
211    /// Get owner and group information
212    ///
213    /// On Unix systems, this attempts to resolve uid/gid to actual names.
214    /// Falls back to numeric IDs if resolution fails.
215    fn get_owner_group(&self, node: &FileNode) -> (String, String) {
216        #[cfg(unix)]
217        {
218            use std::ffi::CStr;
219
220            // Get username from uid
221            let owner = unsafe {
222                let passwd = libc::getpwuid(node.uid);
223                if passwd.is_null() {
224                    // User not found, use numeric ID
225                    node.uid.to_string()
226                } else {
227                    // Convert username to String
228                    CStr::from_ptr((*passwd).pw_name)
229                        .to_string_lossy()
230                        .to_string()
231                }
232            };
233
234            // Get group name from gid
235            let group = unsafe {
236                let grp = libc::getgrgid(node.gid);
237                if grp.is_null() {
238                    // Group not found, use numeric ID
239                    node.gid.to_string()
240                } else {
241                    // Convert group name to String
242                    CStr::from_ptr((*grp).gr_name).to_string_lossy().to_string()
243                }
244            };
245
246            (owner, group)
247        }
248
249        #[cfg(not(unix))]
250        {
251            // On non-Unix systems, just show the numeric IDs
252            (node.uid.to_string(), node.gid.to_string())
253        }
254    }
255
256    /// Get hard link count (simplified)
257    fn get_link_count(&self, node: &FileNode) -> u64 {
258        #[cfg(unix)]
259        {
260            match fs::metadata(&node.path) {
261                Ok(meta) => meta.nlink(),
262                Err(_) => 1, // Default to 1 if we can't read metadata
263            }
264        }
265
266        #[cfg(not(unix))]
267        {
268            // On non-Unix systems, always return 1 for files, 2 for directories
269            // This is a reasonable approximation
270            if node.is_dir {
271                2
272            } else {
273                1
274            }
275        }
276    }
277}
278
279impl Formatter for LsFormatter {
280    fn format(
281        &self,
282        writer: &mut dyn Write,
283        nodes: &[FileNode],
284        _stats: &TreeStats,
285        root_path: &Path,
286    ) -> Result<()> {
287        // Check if this appears to be a filtered result set (from --find or other filters)
288        // Heuristic: if nodes don't include all direct children of root, it's likely filtered
289        let direct_child_count = nodes
290            .iter()
291            .filter(|n| n.path != root_path && n.path.parent() == Some(root_path))
292            .count();
293        let total_non_root = nodes.iter().filter(|n| n.path != root_path).count();
294        let is_filtered = total_non_root > 0
295            && (direct_child_count == 0 || total_non_root > direct_child_count * 2);
296
297        let display_nodes: Vec<&FileNode> = if is_filtered {
298            // For filtered results, show all matching nodes with full paths
299            nodes
300                .iter()
301                .filter(|node| node.path != root_path) // Still exclude the root
302                .collect()
303        } else {
304            // Normal ls behavior: only show direct children of root_path
305            nodes
306                .iter()
307                .filter(|node| {
308                    if node.path == root_path {
309                        return false; // Don't show the root directory itself
310                    }
311                    // Only show direct children (depth 1 from root)
312                    node.path.parent() == Some(root_path)
313                })
314                .collect()
315        };
316
317        // If no files/directories to display, show a message
318        if display_nodes.is_empty() {
319            writeln!(writer, "No matching files or directories found")?;
320            if is_filtered {
321                writeln!(writer)?;
322                writeln!(
323                    writer,
324                    "๐Ÿ’ก Tip: Try using --everything to search in ignored directories like .cache"
325                )?;
326                writeln!(
327                    writer,
328                    "๐Ÿ’ก Tip: Use -d 10 or higher to search deeper (default is 5 levels)"
329                )?;
330                writeln!(
331                    writer,
332                    "๐Ÿ’ก Tip: Hidden directories need -a flag, ignored ones need --everything"
333                )?;
334            }
335            return Ok(());
336        }
337
338        // Note: Nodes are already sorted by the scanner based on user's --sort preference
339        // We don't re-sort here to preserve the requested sort order
340
341        // Format each file/directory in ls -Alh style
342        for node in display_nodes {
343            let permissions = self.format_permissions(node);
344            let link_count = self.get_link_count(node);
345            let (owner, group) = self.get_owner_group(node);
346            let size = self.format_size(node.size);
347
348            // Format the modification time
349            let modified_time = match fs::metadata(&node.path) {
350                Ok(meta) => match meta.modified() {
351                    Ok(time) => {
352                        let datetime: DateTime<Local> = time.into();
353                        datetime.format("%b %d %H:%M").to_string()
354                    }
355                    Err(_) => "??? ?? ??:??".to_string(),
356                },
357                Err(_) => "??? ?? ??:??".to_string(),
358            };
359
360            // Determine filename display strategy:
361            // - When filtering results (search/pattern match): Show relative path for context
362            // - Otherwise: Show only the filename for cleaner output
363            let filename = if is_filtered {
364                // Format with relative path to help identify match locations
365                let emoji = self.get_emoji(node);
366                // Format emoji with consistent spacing
367                let emoji_field = if emoji.is_empty() {
368                    String::new()
369                } else {
370                    // Always add a space after emoji for consistent alignment
371                    format!("{} ", emoji)
372                };
373
374                // Get relative path from root_path
375                let relative_path = node
376                    .path
377                    .strip_prefix(root_path)
378                    .unwrap_or(&node.path)
379                    .display();
380
381                // Apply directory coloring if colors are enabled
382                if self.use_colors && node.is_dir {
383                    // Blue color (ANSI 34) for directories
384                    format!("{}\x1b[34m{}\x1b[0m", emoji_field, relative_path)
385                } else {
386                    // Default formatting for files or when colors are disabled
387                    format!("{}{}", emoji_field, relative_path)
388                }
389            } else {
390                self.format_filename(node)
391            };
392
393            // Write the ls -Alh formatted line
394            writeln!(
395                writer,
396                "{:<10} {:>1} {:<4} {:<4} {:>6} {} {}",
397                permissions, link_count, owner, group, size, modified_time, filename
398            )?;
399        }
400
401        Ok(())
402    }
403}
404
405// -----------------------------------------------------------------------------
406// ๐ŸŽญ Tests - Because Trish insists on quality assurance!
407// -----------------------------------------------------------------------------
408
409#[cfg(test)]
410mod tests {
411    use super::*;
412    use crate::scanner::{FileCategory, FileType, FilesystemType};
413    use std::path::PathBuf;
414    use std::time::SystemTime;
415
416    #[test]
417    fn test_format_size() {
418        let formatter = LsFormatter::new(false, false);
419
420        assert_eq!(formatter.format_size(0), "0");
421        assert_eq!(formatter.format_size(500), "500");
422        assert_eq!(formatter.format_size(1024), "1.0K");
423        assert_eq!(formatter.format_size(1536), "1.5K");
424        assert_eq!(formatter.format_size(1048576), "1.0M");
425        assert_eq!(formatter.format_size(1073741824), "1.0G");
426    }
427
428    #[test]
429    fn test_emoji_selection() {
430        let formatter = LsFormatter::new(true, false);
431
432        // Test directory emojis
433        let empty_dir = FileNode {
434            path: PathBuf::from("/test"),
435            file_type: FileType::Directory,
436            size: 0,
437            is_dir: true,
438            depth: 0,
439            permissions: 0o755,
440            modified: SystemTime::now(),
441            uid: 1000,
442            gid: 1000,
443            is_symlink: false,
444            is_hidden: false,
445            permission_denied: false,
446            is_ignored: false,
447            category: FileCategory::Unknown,
448            search_matches: None,
449            filesystem_type: FilesystemType::Unknown,
450            git_branch: None,
451            traversal_context: None,
452            interest: None,
453            security_findings: Vec::new(),
454            change_status: None,
455            content_hash: None,
456        };
457        assert_eq!(formatter.get_emoji(&empty_dir), "๐Ÿ“‚");
458
459        // Test file emojis
460        let empty_file = FileNode {
461            path: PathBuf::from("/test.txt"),
462            file_type: FileType::RegularFile,
463            size: 0,
464            is_dir: false,
465            depth: 0,
466            permissions: 0o644,
467            modified: SystemTime::now(),
468            uid: 1000,
469            gid: 1000,
470            is_symlink: false,
471            is_hidden: false,
472            permission_denied: false,
473            is_ignored: false,
474            category: FileCategory::Unknown,
475            search_matches: None,
476            filesystem_type: FilesystemType::Unknown,
477            git_branch: None,
478            traversal_context: None,
479            interest: None,
480            security_findings: Vec::new(),
481            change_status: None,
482            content_hash: None,
483        };
484        assert_eq!(formatter.get_emoji(&empty_file), "๐Ÿชน");
485    }
486
487    #[test]
488    fn test_permissions_format() {
489        let formatter = LsFormatter::new(false, false);
490
491        // This is a basic test - in real usage, format_permissions
492        // reads actual file metadata
493        let test_node = FileNode {
494            path: PathBuf::from("/test"),
495            file_type: FileType::Directory,
496            size: 0,
497            is_dir: true,
498            depth: 0,
499            permissions: 0o755,
500            modified: SystemTime::now(),
501            uid: 1000,
502            gid: 1000,
503            is_symlink: false,
504            is_hidden: false,
505            permission_denied: false,
506            is_ignored: false,
507            category: FileCategory::Unknown,
508            search_matches: None,
509            filesystem_type: FilesystemType::Unknown,
510            git_branch: None,
511            traversal_context: None,
512            interest: None,
513            security_findings: Vec::new(),
514            change_status: None,
515            content_hash: None,
516        };
517
518        let perms = formatter.format_permissions(&test_node);
519        // Should start with 'd' for directory or '?' if we can't read it
520        assert!(perms.starts_with('d') || perms.starts_with('?'));
521        assert_eq!(perms.len(), 10); // Always 10 characters
522    }
523}