Skip to main content

command_stream/commands/
ls.rs

1//! Virtual `ls` command implementation
2
3use crate::commands::CommandContext;
4use crate::utils::{trace_lazy, CommandResult};
5use std::fs;
6use std::path::Path;
7
8/// Execute the ls command
9///
10/// Lists directory contents.
11pub async fn ls(ctx: CommandContext) -> CommandResult {
12    // Parse flags
13    let mut show_all = false;
14    let mut long_format = false;
15    let mut paths = Vec::new();
16
17    for arg in &ctx.args {
18        if arg == "-a" || arg == "--all" {
19            show_all = true;
20        } else if arg == "-l" {
21            long_format = true;
22        } else if arg == "-la" || arg == "-al" {
23            show_all = true;
24            long_format = true;
25        } else if arg.starts_with('-') {
26            if arg.contains('a') {
27                show_all = true;
28            }
29            if arg.contains('l') {
30                long_format = true;
31            }
32        } else {
33            paths.push(arg.clone());
34        }
35    }
36
37    // Default to current directory
38    if paths.is_empty() {
39        paths.push(".".to_string());
40    }
41
42    let cwd = ctx.get_cwd();
43    let mut outputs = Vec::new();
44
45    for path_str in paths {
46        let resolved_path = if Path::new(&path_str).is_absolute() {
47            Path::new(&path_str).to_path_buf()
48        } else {
49            cwd.join(&path_str)
50        };
51
52        trace_lazy("VirtualCommand", || {
53            format!("ls: listing {:?}", resolved_path)
54        });
55
56        if !resolved_path.exists() {
57            return CommandResult::error(format!(
58                "ls: cannot access '{}': No such file or directory\n",
59                path_str
60            ));
61        }
62
63        if resolved_path.is_file() {
64            outputs.push(format_entry(&resolved_path, long_format));
65        } else {
66            match fs::read_dir(&resolved_path) {
67                Ok(entries) => {
68                    let mut entry_strs = Vec::new();
69
70                    // `flatten()` drops unreadable entries, matching the
71                    // behaviour of `ls`, which lists what it can read.
72                    for entry in entries.flatten() {
73                        let name = entry.file_name().to_string_lossy().to_string();
74
75                        // Skip hidden files unless -a is specified
76                        if !show_all && name.starts_with('.') {
77                            continue;
78                        }
79
80                        if long_format {
81                            entry_strs.push(format_entry(&entry.path(), true));
82                        } else {
83                            entry_strs.push(name);
84                        }
85                    }
86
87                    entry_strs.sort();
88                    outputs.push(entry_strs.join("\n"));
89                }
90                Err(e) => {
91                    return CommandResult::error(format!(
92                        "ls: cannot open '{}': {}\n",
93                        path_str, e
94                    ));
95                }
96            }
97        }
98    }
99
100    let output = outputs.join("\n");
101    if output.is_empty() {
102        CommandResult::success_empty()
103    } else {
104        CommandResult::success(format!("{}\n", output))
105    }
106}
107
108fn format_entry(path: &Path, long_format: bool) -> String {
109    let name = path
110        .file_name()
111        .map(|n| n.to_string_lossy().to_string())
112        .unwrap_or_else(|| path.display().to_string());
113
114    if !long_format {
115        return name;
116    }
117
118    // Long format: permissions, links, owner, group, size, date, name
119    let metadata = match fs::metadata(path) {
120        Ok(m) => m,
121        Err(_) => return name,
122    };
123
124    let size = metadata.len();
125
126    // Simplified permissions
127    let perms = if metadata.is_dir() {
128        "drwxr-xr-x"
129    } else {
130        "-rw-r--r--"
131    };
132
133    format!("{} {:>8} {}", perms, size, name)
134}
135
136#[cfg(test)]
137mod tests {
138    use super::*;
139    use tempfile::tempdir;
140
141    #[tokio::test]
142    async fn test_ls_current_dir() {
143        let ctx = CommandContext::new(vec![]);
144        let result = ls(ctx).await;
145        assert!(result.is_success());
146    }
147
148    #[tokio::test]
149    async fn test_ls_with_path() {
150        let temp = tempdir().unwrap();
151        fs::write(temp.path().join("file.txt"), "test").unwrap();
152
153        let ctx = CommandContext::new(vec![temp.path().to_string_lossy().to_string()]);
154        let result = ls(ctx).await;
155
156        assert!(result.is_success());
157        assert!(result.stdout.contains("file.txt"));
158    }
159
160    #[tokio::test]
161    async fn test_ls_hidden_files() {
162        let temp = tempdir().unwrap();
163        fs::write(temp.path().join(".hidden"), "test").unwrap();
164        fs::write(temp.path().join("visible"), "test").unwrap();
165
166        // Without -a
167        let ctx = CommandContext::new(vec![temp.path().to_string_lossy().to_string()]);
168        let result = ls(ctx).await;
169        assert!(!result.stdout.contains(".hidden"));
170        assert!(result.stdout.contains("visible"));
171
172        // With -a
173        let ctx = CommandContext::new(vec![
174            "-a".to_string(),
175            temp.path().to_string_lossy().to_string(),
176        ]);
177        let result = ls(ctx).await;
178        assert!(result.stdout.contains(".hidden"));
179        assert!(result.stdout.contains("visible"));
180    }
181}