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}