Swadhin Biswas feat: add --git flag, overhaul CI/CD, rewrite website, prepare for crates.io · 3/7/2026
d0f56db
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
use std::collections::HashMap;
use std::fs;
use std::os::unix::fs::{FileTypeExt, MetadataExt, PermissionsExt};
use std::path::{Path, PathBuf};
use std::time::SystemTime;
use crate::cli::Args;
use crate::git::{self, GitRepo, GitStatus};
/// Represents a single file/directory entry with all metadata needed for display.
#[derive(Debug, Clone)]
#[allow(dead_code)]
pub struct FileEntry {
pub name: String,
pub path: PathBuf,
pub is_dir: bool,
pub is_symlink: bool,
pub is_executable: bool,
pub is_hidden: bool,
pub is_pipe: bool,
pub is_socket: bool,
pub is_block_device: bool,
pub is_char_device: bool,
pub size: u64,
pub modified: Option<SystemTime>,
pub accessed: Option<SystemTime>,
pub created: Option<SystemTime>,
pub mode: u32,
pub nlink: u64,
pub uid: u32,
pub gid: u32,
pub blocks: u64,
pub inode: u64,
pub symlink_target: Option<String>,
pub extension: String,
pub git_status: Option<GitStatus>,
}
impl FileEntry {
/// Create a FileEntry from a path, reading metadata.
pub fn from_path(path: &Path, follow_symlinks: bool) -> std::io::Result<Self> {
let symlink_meta = fs::symlink_metadata(path)?;
let is_symlink = symlink_meta.file_type().is_symlink();
let meta = if follow_symlinks && is_symlink {
fs::metadata(path).unwrap_or_else(|_| symlink_meta.clone())
} else {
symlink_meta.clone()
};
let file_type = meta.file_type();
let name = path
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| path.to_string_lossy().into_owned());
let is_dir = file_type.is_dir();
let is_executable = !is_dir && (meta.permissions().mode() & 0o111 != 0);
let is_hidden = name.starts_with('.');
let is_pipe = file_type.is_fifo();
let is_socket = file_type.is_socket();
let is_block_device = file_type.is_block_device();
let is_char_device = file_type.is_char_device();
let symlink_target = if is_symlink {
fs::read_link(path)
.ok()
.map(|t| t.to_string_lossy().into_owned())
} else {
None
};
let extension = path
.extension()
.map(|e| e.to_string_lossy().into_owned())
.unwrap_or_default();
Ok(FileEntry {
name,
path: path.to_path_buf(),
is_dir,
is_symlink,
is_executable,
is_hidden,
is_pipe,
is_socket,
is_block_device,
is_char_device,
size: meta.len(),
modified: meta.modified().ok(),
accessed: meta.accessed().ok(),
created: meta.created().ok(),
mode: meta.mode(),
nlink: meta.nlink(),
uid: meta.uid(),
gid: meta.gid(),
blocks: meta.blocks(),
inode: meta.ino(),
symlink_target,
extension,
git_status: None,
})
}
/// Create synthetic . and .. entries for a directory
pub fn dot_entries(dir: &Path) -> Vec<FileEntry> {
let mut entries = Vec::with_capacity(2);
// "."
if let Ok(mut entry) = FileEntry::from_path(dir, false) {
entry.name = ".".to_string();
entries.push(entry);
}
// ".."
let parent = dir.parent().unwrap_or(dir);
if let Ok(mut entry) = FileEntry::from_path(parent, false) {
entry.name = "..".to_string();
entries.push(entry);
}
entries
}
}
/// Read directory entries, filtered according to the arguments.
pub fn read_directory(dir: &Path, args: &Args) -> std::io::Result<Vec<FileEntry>> {
let mut entries = Vec::new();
// Add . and .. if requested
if args.show_dot_dirs() {
entries.extend(FileEntry::dot_entries(dir));
}
let follow_symlinks = args.dereference;
let rd = fs::read_dir(dir)?;
for entry_result in rd {
let entry = match entry_result {
Ok(e) => e,
Err(_) => continue,
};
let name = entry.file_name().to_string_lossy().into_owned();
// Skip hidden files unless requested
if name.starts_with('.') && !args.show_hidden() {
continue;
}
// Skip backup files if requested
if args.ignore_backups && name.ends_with('~') {
continue;
}
// Skip files matching ignore pattern
if let Some(ref pattern) = args.ignore_pattern {
if matches_glob_simple(&name, pattern) {
continue;
}
}
// Skip files matching hide pattern (unless -a or -A)
if !args.show_hidden() {
if let Some(ref pattern) = args.hide_pattern {
if matches_glob_simple(&name, pattern) {
continue;
}
}
}
let path = entry.path();
match FileEntry::from_path(&path, follow_symlinks) {
Ok(fe) => entries.push(fe),
Err(_) => {
// If we can't read metadata, create a minimal entry
entries.push(FileEntry {
name,
path,
is_dir: false,
is_symlink: false,
is_executable: false,
is_hidden: true,
is_pipe: false,
is_socket: false,
is_block_device: false,
is_char_device: false,
size: 0,
modified: None,
accessed: None,
created: None,
mode: 0,
nlink: 0,
uid: 0,
gid: 0,
blocks: 0,
inode: 0,
symlink_target: None,
extension: String::new(),
git_status: None,
});
}
}
}
Ok(entries)
}
/// Apply git status information to a list of file entries.
pub fn apply_git_status(entries: &mut [FileEntry], git_repo: &GitRepo) {
for entry in entries.iter_mut() {
entry.git_status = Some(git_repo.status_for(&entry.path));
}
}
/// Load git status for a directory if --git is enabled.
#[allow(dead_code)]
pub fn load_git_for_dir(dir: &Path, show_git: bool) -> Option<GitRepo> {
if !show_git {
return None;
}
git::load_git_status(dir)
}
pub fn sort_entries(entries: &mut [FileEntry], args: &Args) {
if args.no_sort() {
return;
}
// Determine the primary sort
if args.sort_size {
entries.sort_by(|a, b| b.size.cmp(&a.size));
} else if args.sort_time {
entries.sort_by(|a, b| {
let ta = a.modified.unwrap_or(SystemTime::UNIX_EPOCH);
let tb = b.modified.unwrap_or(SystemTime::UNIX_EPOCH);
tb.cmp(&ta)
});
} else if args.sort_extension {
entries.sort_by(|a, b| {
let ea = a.extension.to_lowercase();
let eb = b.extension.to_lowercase();
ea.cmp(&eb)
.then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase()))
});
} else if args.version_sort {
entries.sort_by(|a, b| version_compare(&a.name, &b.name));
} else {
// Default: alphabetical (case-insensitive)
entries.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
}
// Group directories first if requested
if args.group_directories_first {
entries.sort_by(|a, b| b.is_dir.cmp(&a.is_dir));
}
// Reverse if requested
if args.reverse {
entries.reverse();
}
}
/// Simple glob matching (supports * and ? wildcards)
fn matches_glob_simple(name: &str, pattern: &str) -> bool {
let mut name_chars = name.chars().peekable();
let mut pat_chars = pattern.chars().peekable();
fn matches_inner(
name: &mut std::iter::Peekable<std::str::Chars>,
pat: &mut std::iter::Peekable<std::str::Chars>,
) -> bool {
loop {
match (pat.peek().copied(), name.peek().copied()) {
(None, None) => return true,
(None, Some(_)) => return false,
(Some('*'), _) => {
pat.next();
if pat.peek().is_none() {
return true;
}
// Try matching * against zero or more chars
loop {
let mut pat_clone = pat.clone();
let mut name_clone = name.clone();
if matches_inner(&mut name_clone, &mut pat_clone) {
return true;
}
if name.next().is_none() {
return false;
}
}
}
(Some('?'), Some(_)) => {
pat.next();
name.next();
}
(Some(p), Some(n)) if p == n => {
pat.next();
name.next();
}
_ => return false,
}
}
}
matches_inner(&mut name_chars, &mut pat_chars)
}
/// Natural/version sort comparison
fn version_compare(a: &str, b: &str) -> std::cmp::Ordering {
let mut ai = a.chars().peekable();
let mut bi = b.chars().peekable();
loop {
match (ai.peek(), bi.peek()) {
(None, None) => return std::cmp::Ordering::Equal,
(None, Some(_)) => return std::cmp::Ordering::Less,
(Some(_), None) => return std::cmp::Ordering::Greater,
(Some(&ac), Some(&bc)) => {
if ac.is_ascii_digit() && bc.is_ascii_digit() {
// Compare numeric segments
let mut an = String::new();
let mut bn = String::new();
while let Some(&c) = ai.peek() {
if c.is_ascii_digit() {
an.push(c);
ai.next();
} else {
break;
}
}
while let Some(&c) = bi.peek() {
if c.is_ascii_digit() {
bn.push(c);
bi.next();
} else {
break;
}
}
let na: u64 = an.parse().unwrap_or(0);
let nb: u64 = bn.parse().unwrap_or(0);
match na.cmp(&nb) {
std::cmp::Ordering::Equal => continue,
other => return other,
}
} else {
let al = ac.to_lowercase().next().unwrap_or(ac);
let bl = bc.to_lowercase().next().unwrap_or(bc);
match al.cmp(&bl) {
std::cmp::Ordering::Equal => {
// If lowercase-equal, compare original case
match ac.cmp(&bc) {
std::cmp::Ordering::Equal => {
ai.next();
bi.next();
}
other => {
ai.next();
bi.next();
return other;
}
}
}
other => return other,
}
}
}
}
}
}
/// Gather entries for a single path argument.
/// If `-d` is set, lists the directory itself rather than its contents.
#[allow(dead_code)]
pub fn gather_entries(path: &Path, args: &Args) -> std::io::Result<Vec<FileEntry>> {
let follow = args.dereference || args.dereference_command_line;
if args.directory {
let entry = FileEntry::from_path(path, follow)?;
return Ok(vec![entry]);
}
if path.is_dir() {
read_directory(path, args)
} else {
let entry = FileEntry::from_path(path, follow)?;
Ok(vec![entry])
}
}
/// Map of user IDs to user names (cached).
pub fn get_username(uid: u32, cache: &mut HashMap<u32, String>) -> String {
if let Some(name) = cache.get(&uid) {
return name.clone();
}
let name = uzers::get_user_by_uid(uid)
.map(|u| u.name().to_string_lossy().into_owned())
.unwrap_or_else(|| uid.to_string());
cache.insert(uid, name.clone());
name
}
/// Map of group IDs to group names (cached).
pub fn get_groupname(gid: u32, cache: &mut HashMap<u32, String>) -> String {
if let Some(name) = cache.get(&gid) {
return name.clone();
}
let name = uzers::get_group_by_gid(gid)
.map(|g| g.name().to_string_lossy().into_owned())
.unwrap_or_else(|| gid.to_string());
cache.insert(gid, name.clone());
name
}