From a32f141424ddbef223a9c5080fd120ecbec138df Mon Sep 17 00:00:00 2001 From: Haruaki Tamada Date: Tue, 21 Apr 2026 07:43:02 +0900 Subject: [PATCH 01/13] Implement initial lis command with specified features --- Cargo.toml | 15 +++ src/main.rs | 360 +++++++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 374 insertions(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index 1c8b8a8..1421cb5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,3 +4,18 @@ version = "0.1.0" edition = "2024" [dependencies] +clap = { version = "4.5", features = ["derive"] } +chrono = "0.4" +humansize = "2.1" +uzers = "0.12" +git2 = "0.19" +ignore = "0.4" +lscolors = "0.19" +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +serde_yaml = "0.9" +csv = "1.3" +terminal_size = "0.4" +unicode-width = "0.2" +colored = "2.1" +nix = { version = "0.29", features = ["fs", "user"] } diff --git a/src/main.rs b/src/main.rs index e7a11a9..c1583ec 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,3 +1,361 @@ +use chrono::{DateTime, Local}; +use clap::{Parser, ValueEnum}; +use git2::{Repository, StatusOptions}; +use humansize::{format_size, DECIMAL}; +use ignore::WalkBuilder; +use lscolors::LsColors; +use nix::sys::stat::Mode; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::fs; +use std::os::unix::fs::{MetadataExt, PermissionsExt}; +use std::path::{Path, PathBuf}; +use terminal_size::{terminal_size, Width}; +use unicode_width::UnicodeWidthStr; +use uzers::{get_group_by_gid, get_user_by_uid}; + +#[derive(Parser, Debug)] +#[command(author, version, about, long_about = None)] +struct Args { + /// Directory to list + #[arg(default_value = ".")] + path: PathBuf, + + /// Sort by + #[arg(long, value_enum, default_value_t = SortBy::Name)] + sort: SortBy, + + /// Reverse sort order + #[arg(short, long)] + reverse: bool, + + /// Long format + #[arg(short, long)] + long: bool, + + /// All entries, respecting .gitignore + #[arg(short, long)] + all: bool, + + /// All entries, ignoring .gitignore + #[arg(short = 'A', long)] + whole_all: bool, + + /// Display icons + #[arg(long)] + icon: bool, + + /// Output format + #[arg(long, value_enum, default_value_t = Format::Plain)] + format: Format, + + /// Recursive listing + #[arg(short = 'R', long)] + recursive: bool, +} + +#[derive(ValueEnum, Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +enum SortBy { + Name, + Time, + Size, + Extension, +} + +#[derive(ValueEnum, Clone, Debug, PartialEq, Eq)] +enum Format { + Plain, + Csv, + Json, + Yaml, +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +struct Entry { + name: String, + path: PathBuf, + is_dir: bool, + mode: String, + nlink: u64, + owner: String, + group: String, + size: u64, + modified: String, + git_status: String, + extension: String, +} + +fn get_git_statuses(path: &Path) -> HashMap { + let mut statuses = HashMap::new(); + if let Ok(repo) = Repository::discover(path) { + let mut opts = StatusOptions::new(); + opts.include_untracked(true); + if let Ok(repo_statuses) = repo.statuses(Some(&mut opts)) { + if let Some(workdir) = repo.workdir() { + let workdir = fs::canonicalize(workdir).unwrap_or_else(|_| workdir.to_path_buf()); + for entry in repo_statuses.iter() { + if let Some(path_str) = entry.path() { + let full_path = workdir.join(path_str); + let status = entry.status(); + let status_str = if status.is_index_new() || status.is_wt_new() { + "A" + } else if status.is_index_modified() || status.is_wt_modified() { + "M" + } else if status.is_index_deleted() || status.is_wt_deleted() { + "D" + } else if status.is_index_renamed() || status.is_wt_renamed() { + "R" + } else if status.is_index_typechange() || status.is_wt_typechange() { + "T" + } else { + " " + }; + statuses.insert(full_path, status_str.to_string()); + } + } + } + } + } + statuses +} + +fn get_mode_string(mode: u32, is_dir: bool, is_symlink: bool) -> String { + let mut s = String::with_capacity(10); + + if is_dir { + s.push('d'); + } else if is_symlink { + s.push('l'); + } else { + s.push('-'); + } + + #[cfg(unix)] + { + let m = Mode::from_bits_truncate(mode as nix::sys::stat::mode_t); + s.push(if m.contains(Mode::S_IRUSR) { 'r' } else { '-' }); + s.push(if m.contains(Mode::S_IWUSR) { 'w' } else { '-' }); + s.push(if m.contains(Mode::S_IXUSR) { 'x' } else { '-' }); + s.push(if m.contains(Mode::S_IRGRP) { 'r' } else { '-' }); + s.push(if m.contains(Mode::S_IWGRP) { 'w' } else { '-' }); + s.push(if m.contains(Mode::S_IXGRP) { 'x' } else { '-' }); + s.push(if m.contains(Mode::S_IROTH) { 'r' } else { '-' }); + s.push(if m.contains(Mode::S_IWOTH) { 'w' } else { '-' }); + s.push(if m.contains(Mode::S_IXOTH) { 'x' } else { '-' }); + } + + s +} + +fn get_icon(_name: &str, is_dir: bool, ext: &str) -> &'static str { + if is_dir { + return "\u{f115}"; // ๐Ÿ“ + } + match ext { + "rs" => "\u{e7a8}", // ๐Ÿฆ€ + "md" => "\u{f48a}", // ๐Ÿ“ + "toml" => "\u{f013}", // โš™๏ธ + "json" => "\u{f1c0}", // ๐Ÿ—„๏ธ + "yaml" | "yml" => "\u{f1c0}", + "png" | "jpg" | "jpeg" | "gif" => "\u{f1c5}", // ๐Ÿ–ผ๏ธ + "txt" => "\u{f15c}", // ๐Ÿ“„ + _ => "\u{f15b}", // ๐Ÿ“„ + } +} + fn main() { - println!("Hello, world!"); + let args = Args::parse(); + let lscolors = LsColors::from_env().unwrap_or_default(); + + let mut entries = Vec::new(); + let git_statuses = get_git_statuses(&args.path); + + let mut builder = WalkBuilder::new(&args.path); + builder.hidden(!args.all && !args.whole_all); + builder.git_ignore(!args.whole_all); + builder.max_depth(if args.recursive { None } else { Some(1) }); + + let walker = builder.build(); + + for result in walker { + match result { + Ok(dir_entry) => { + let path = dir_entry.path(); + + // For name display: + // If not recursive, use file_name. + // If recursive, use path relative to args.path. + let name = if args.recursive { + match path.strip_prefix(&args.path) { + Ok(p) => { + let s = p.to_string_lossy().into_owned(); + if s.is_empty() { + args.path.to_string_lossy().into_owned() + } else { + s + } + } + Err(_) => dir_entry.file_name().to_string_lossy().into_owned(), + } + } else { + if path == args.path { + continue; + } + dir_entry.file_name().to_string_lossy().into_owned() + }; + + let metadata = match fs::symlink_metadata(path) { + Ok(m) => m, + Err(_) => continue, + }; + + let is_dir = metadata.is_dir(); + let is_symlink = metadata.file_type().is_symlink(); + let mode = get_mode_string(metadata.permissions().mode(), is_dir, is_symlink); + let nlink = metadata.nlink(); + let owner = get_user_by_uid(metadata.uid()) + .map(|u| u.name().to_string_lossy().into_owned()) + .unwrap_or_else(|| metadata.uid().to_string()); + let group = get_group_by_gid(metadata.gid()) + .map(|g| g.name().to_string_lossy().into_owned()) + .unwrap_or_else(|| metadata.gid().to_string()); + let size = metadata.len(); + let modified: DateTime = metadata.modified().unwrap_or_else(|_| std::time::SystemTime::now()).into(); + let modified_str = modified.format("%Y-%m-%d %H:%M").to_string(); + + let abs_path = fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()); + let git_status = git_statuses.get(&abs_path).cloned().unwrap_or_else(|| " ".to_string()); + let extension = path.extension().and_then(|e| e.to_str()).unwrap_or("").to_string(); + + entries.push(Entry { + name, + path: path.to_path_buf(), + is_dir, + mode, + nlink, + owner, + group, + size, + modified: modified_str, + git_status, + extension, + }); + } + Err(_) => {} + } + } + + // Sorting + entries.sort_by(|a, b| { + let cmp = match args.sort { + SortBy::Name => a.name.cmp(&b.name), + SortBy::Time => a.modified.cmp(&b.modified), + SortBy::Size => a.size.cmp(&b.size), + SortBy::Extension => a.extension.cmp(&b.extension), + }; + if args.reverse { + cmp.reverse() + } else { + cmp + } + }); + + match args.format { + Format::Plain => { + if args.long { + print_long(&entries, &lscolors, args.icon); + } else { + print_plain(&entries, &lscolors, args.icon); + } + } + Format::Csv => { + let mut wtr = csv::Writer::from_writer(std::io::stdout()); + for entry in entries { + wtr.serialize(entry).unwrap(); + } + wtr.flush().unwrap(); + } + Format::Json => { + println!("{}", serde_json::to_string_pretty(&entries).unwrap()); + } + Format::Yaml => { + println!("{}", serde_yaml::to_string(&entries).unwrap()); + } + } +} + +fn print_plain(entries: &[Entry], lscolors: &LsColors, show_icon: bool) { + use std::io::IsTerminal; + if !std::io::stdout().is_terminal() { + for entry in entries { + println!("{}", entry.name); + } + return; + } + + let names: Vec = entries + .iter() + .map(|e| { + let s = if show_icon { + format!("{} {}", get_icon(&e.name, e.is_dir, &e.extension), e.name) + } else { + e.name.clone() + }; + if let Some(style) = lscolors.style_for_path(&e.path) { + let colored_s = style.to_nu_ansi_term_style().paint(s); + format!("{}", colored_s) + } else { + s + } + }) + .collect(); + + if names.is_empty() { + return; + } + + let term_width = terminal_size().map(|(Width(w), _)| w as usize).unwrap_or(80); + let max_width = entries + .iter() + .map(|e| { + let icon_width = if show_icon { 3 } else { 0 }; + UnicodeWidthStr::width(e.name.as_str()) + icon_width + }) + .max() + .unwrap_or(0) + + 2; + + let cols = (term_width / max_width).max(1); + let rows = (names.len() as f64 / cols as f64).ceil() as usize; + + for r in 0..rows { + for c in 0..cols { + let i = c * rows + r; + if i < names.len() { + print!("{:3} {:<8} {:<8} {:>8} {} {} {}", + e.mode, e.nlink, e.owner, e.group, size_str, e.modified, e.git_status, name_str + ); + } } From 0da2cdb7e689f2b789f3cb8647b8042fe8221f98 Mon Sep 17 00:00:00 2001 From: Haruaki Tamada Date: Tue, 21 Apr 2026 07:44:22 +0900 Subject: [PATCH 02/13] Refactor code to separate concerns into modules (args, display, entry, git) --- src/args.rs | 60 ++++++++++ src/display.rs | 96 ++++++++++++++++ src/entry.rs | 84 ++++++++++++++ src/git.rs | 38 +++++++ src/main.rs | 293 ++++--------------------------------------------- 5 files changed, 297 insertions(+), 274 deletions(-) create mode 100644 src/args.rs create mode 100644 src/display.rs create mode 100644 src/entry.rs create mode 100644 src/git.rs diff --git a/src/args.rs b/src/args.rs new file mode 100644 index 0000000..12b9318 --- /dev/null +++ b/src/args.rs @@ -0,0 +1,60 @@ +use clap::{Parser, ValueEnum}; +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; + +#[derive(Parser, Debug)] +#[command(author, version, about, long_about = None)] +pub struct Args { + /// Directory to list + #[arg(default_value = ".")] + pub path: PathBuf, + + /// Sort by + #[arg(long, value_enum, default_value_t = SortBy::Name)] + pub sort: SortBy, + + /// Reverse sort order + #[arg(short, long)] + pub reverse: bool, + + /// Long format + #[arg(short, long)] + pub long: bool, + + /// All entries, respecting .gitignore + #[arg(short, long)] + pub all: bool, + + /// All entries, ignoring .gitignore + #[arg(short = 'A', long)] + pub whole_all: bool, + + /// Display icons + #[arg(long)] + pub icon: bool, + + /// Output format + #[arg(long, value_enum, default_value_t = Format::Plain)] + pub format: Format, + + /// Recursive listing + #[arg(short = 'R', long)] + pub recursive: bool, +} + +#[derive(ValueEnum, Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum SortBy { + Name, + Time, + Size, + Extension, +} + +#[derive(ValueEnum, Clone, Debug, PartialEq, Eq)] +pub enum Format { + Plain, + Csv, + Json, + Yaml, +} diff --git a/src/display.rs b/src/display.rs new file mode 100644 index 0000000..8230d29 --- /dev/null +++ b/src/display.rs @@ -0,0 +1,96 @@ +use crate::entry::Entry; +use lscolors::LsColors; +use terminal_size::{terminal_size, Width}; +use unicode_width::UnicodeWidthStr; +use humansize::{format_size, DECIMAL}; + +pub fn print_plain(entries: &[Entry], lscolors: &LsColors, show_icon: bool) { + use std::io::IsTerminal; + if !std::io::stdout().is_terminal() { + for entry in entries { + println!("{}", entry.name); + } + return; + } + + let names: Vec = entries + .iter() + .map(|e| { + let s = if show_icon { + format!("{} {}", get_icon(&e.name, e.is_dir, &e.extension), e.name) + } else { + e.name.clone() + }; + if let Some(style) = lscolors.style_for_path(&e.path) { + let colored_s = style.to_nu_ansi_term_style().paint(s); + format!("{}", colored_s) + } else { + s + } + }) + .collect(); + + if names.is_empty() { + return; + } + + let term_width = terminal_size().map(|(Width(w), _)| w as usize).unwrap_or(80); + let max_width = entries + .iter() + .map(|e| { + let icon_width = if show_icon { 3 } else { 0 }; + UnicodeWidthStr::width(e.name.as_str()) + icon_width + }) + .max() + .unwrap_or(0) + + 2; + + let cols = (term_width / max_width).max(1); + let rows = (names.len() as f64 / cols as f64).ceil() as usize; + + for r in 0..rows { + for c in 0..cols { + let i = c * rows + r; + if i < names.len() { + print!("{:3} {:<8} {:<8} {:>8} {} {} {}", + e.mode, e.nlink, e.owner, e.group, size_str, e.modified, e.git_status, name_str + ); + } +} + +pub fn get_icon(_name: &str, is_dir: bool, ext: &str) -> &'static str { + if is_dir { + return "\u{f115}"; // ๐Ÿ“ + } + match ext { + "rs" => "\u{e7a8}", // ๐Ÿฆ€ + "md" => "\u{f48a}", // ๐Ÿ“ + "toml" => "\u{f013}", // โš™๏ธ + "json" => "\u{f1c0}", // ๐Ÿ—„๏ธ + "yaml" | "yml" => "\u{f1c0}", + "png" | "jpg" | "jpeg" | "gif" => "\u{f1c5}", // ๐Ÿ–ผ๏ธ + "txt" => "\u{f15c}", // ๐Ÿ“„ + _ => "\u{f15b}", // ๐Ÿ“„ + } +} diff --git a/src/entry.rs b/src/entry.rs new file mode 100644 index 0000000..051c57d --- /dev/null +++ b/src/entry.rs @@ -0,0 +1,84 @@ +use std::path::{Path, PathBuf}; +use serde::{Deserialize, Serialize}; +use std::fs; +use std::os::unix::fs::{MetadataExt, PermissionsExt}; +use chrono::{DateTime, Local}; +use uzers::{get_group_by_gid, get_user_by_uid}; +use nix::sys::stat::Mode; + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct Entry { + pub name: String, + pub path: PathBuf, + pub is_dir: bool, + pub mode: String, + pub nlink: u64, + pub owner: String, + pub group: String, + pub size: u64, + pub modified: String, + pub git_status: String, + pub extension: String, +} + +impl Entry { + pub fn new(path: &Path, name: String, git_status: String) -> Option { + let metadata = fs::symlink_metadata(path).ok()?; + let is_dir = metadata.is_dir(); + let is_symlink = metadata.file_type().is_symlink(); + let mode = get_mode_string(metadata.permissions().mode(), is_dir, is_symlink); + let nlink = metadata.nlink(); + let owner = get_user_by_uid(metadata.uid()) + .map(|u| u.name().to_string_lossy().into_owned()) + .unwrap_or_else(|| metadata.uid().to_string()); + let group = get_group_by_gid(metadata.gid()) + .map(|g| g.name().to_string_lossy().into_owned()) + .unwrap_or_else(|| metadata.gid().to_string()); + let size = metadata.len(); + let modified: DateTime = metadata.modified().unwrap_or_else(|_| std::time::SystemTime::now()).into(); + let modified_str = modified.format("%Y-%m-%d %H:%M").to_string(); + let extension = path.extension().and_then(|e| e.to_str()).unwrap_or("").to_string(); + + Some(Entry { + name, + path: path.to_path_buf(), + is_dir, + mode, + nlink, + owner, + group, + size, + modified: modified_str, + git_status, + extension, + }) + } +} + +fn get_mode_string(mode: u32, is_dir: bool, is_symlink: bool) -> String { + let mut s = String::with_capacity(10); + + if is_dir { + s.push('d'); + } else if is_symlink { + s.push('l'); + } else { + s.push('-'); + } + + #[cfg(unix)] + { + let m = Mode::from_bits_truncate(mode as nix::sys::stat::mode_t); + s.push(if m.contains(Mode::S_IRUSR) { 'r' } else { '-' }); + s.push(if m.contains(Mode::S_IWUSR) { 'w' } else { '-' }); + s.push(if m.contains(Mode::S_IXUSR) { 'x' } else { '-' }); + s.push(if m.contains(Mode::S_IRGRP) { 'r' } else { '-' }); + s.push(if m.contains(Mode::S_IWGRP) { 'w' } else { '-' }); + s.push(if m.contains(Mode::S_IXGRP) { 'x' } else { '-' }); + s.push(if m.contains(Mode::S_IROTH) { 'r' } else { '-' }); + s.push(if m.contains(Mode::S_IWOTH) { 'w' } else { '-' }); + s.push(if m.contains(Mode::S_IXOTH) { 'x' } else { '-' }); + } + + s +} diff --git a/src/git.rs b/src/git.rs new file mode 100644 index 0000000..35c4633 --- /dev/null +++ b/src/git.rs @@ -0,0 +1,38 @@ +use git2::{Repository, StatusOptions}; +use std::collections::HashMap; +use std::fs; +use std::path::{Path, PathBuf}; + +pub fn get_git_statuses(path: &Path) -> HashMap { + let mut statuses = HashMap::new(); + if let Ok(repo) = Repository::discover(path) { + let mut opts = StatusOptions::new(); + opts.include_untracked(true); + if let Ok(repo_statuses) = repo.statuses(Some(&mut opts)) { + if let Some(workdir) = repo.workdir() { + let workdir = fs::canonicalize(workdir).unwrap_or_else(|_| workdir.to_path_buf()); + for entry in repo_statuses.iter() { + if let Some(path_str) = entry.path() { + let full_path = workdir.join(path_str); + let status = entry.status(); + let status_str = if status.is_index_new() || status.is_wt_new() { + "A" + } else if status.is_index_modified() || status.is_wt_modified() { + "M" + } else if status.is_index_deleted() || status.is_wt_deleted() { + "D" + } else if status.is_index_renamed() || status.is_wt_renamed() { + "R" + } else if status.is_index_typechange() || status.is_wt_typechange() { + "T" + } else { + " " + }; + statuses.insert(full_path, status_str.to_string()); + } + } + } + } + } + statuses +} diff --git a/src/main.rs b/src/main.rs index c1583ec..0963ea7 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,168 +1,15 @@ -use chrono::{DateTime, Local}; -use clap::{Parser, ValueEnum}; -use git2::{Repository, StatusOptions}; -use humansize::{format_size, DECIMAL}; +mod args; +mod display; +mod entry; +mod git; + +use args::{Args, Format, SortBy}; +use clap::Parser; +use entry::Entry; +use git::get_git_statuses; use ignore::WalkBuilder; use lscolors::LsColors; -use nix::sys::stat::Mode; -use serde::{Deserialize, Serialize}; -use std::collections::HashMap; use std::fs; -use std::os::unix::fs::{MetadataExt, PermissionsExt}; -use std::path::{Path, PathBuf}; -use terminal_size::{terminal_size, Width}; -use unicode_width::UnicodeWidthStr; -use uzers::{get_group_by_gid, get_user_by_uid}; - -#[derive(Parser, Debug)] -#[command(author, version, about, long_about = None)] -struct Args { - /// Directory to list - #[arg(default_value = ".")] - path: PathBuf, - - /// Sort by - #[arg(long, value_enum, default_value_t = SortBy::Name)] - sort: SortBy, - - /// Reverse sort order - #[arg(short, long)] - reverse: bool, - - /// Long format - #[arg(short, long)] - long: bool, - - /// All entries, respecting .gitignore - #[arg(short, long)] - all: bool, - - /// All entries, ignoring .gitignore - #[arg(short = 'A', long)] - whole_all: bool, - - /// Display icons - #[arg(long)] - icon: bool, - - /// Output format - #[arg(long, value_enum, default_value_t = Format::Plain)] - format: Format, - - /// Recursive listing - #[arg(short = 'R', long)] - recursive: bool, -} - -#[derive(ValueEnum, Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "lowercase")] -enum SortBy { - Name, - Time, - Size, - Extension, -} - -#[derive(ValueEnum, Clone, Debug, PartialEq, Eq)] -enum Format { - Plain, - Csv, - Json, - Yaml, -} - -#[derive(Serialize, Deserialize, Debug, Clone)] -struct Entry { - name: String, - path: PathBuf, - is_dir: bool, - mode: String, - nlink: u64, - owner: String, - group: String, - size: u64, - modified: String, - git_status: String, - extension: String, -} - -fn get_git_statuses(path: &Path) -> HashMap { - let mut statuses = HashMap::new(); - if let Ok(repo) = Repository::discover(path) { - let mut opts = StatusOptions::new(); - opts.include_untracked(true); - if let Ok(repo_statuses) = repo.statuses(Some(&mut opts)) { - if let Some(workdir) = repo.workdir() { - let workdir = fs::canonicalize(workdir).unwrap_or_else(|_| workdir.to_path_buf()); - for entry in repo_statuses.iter() { - if let Some(path_str) = entry.path() { - let full_path = workdir.join(path_str); - let status = entry.status(); - let status_str = if status.is_index_new() || status.is_wt_new() { - "A" - } else if status.is_index_modified() || status.is_wt_modified() { - "M" - } else if status.is_index_deleted() || status.is_wt_deleted() { - "D" - } else if status.is_index_renamed() || status.is_wt_renamed() { - "R" - } else if status.is_index_typechange() || status.is_wt_typechange() { - "T" - } else { - " " - }; - statuses.insert(full_path, status_str.to_string()); - } - } - } - } - } - statuses -} - -fn get_mode_string(mode: u32, is_dir: bool, is_symlink: bool) -> String { - let mut s = String::with_capacity(10); - - if is_dir { - s.push('d'); - } else if is_symlink { - s.push('l'); - } else { - s.push('-'); - } - - #[cfg(unix)] - { - let m = Mode::from_bits_truncate(mode as nix::sys::stat::mode_t); - s.push(if m.contains(Mode::S_IRUSR) { 'r' } else { '-' }); - s.push(if m.contains(Mode::S_IWUSR) { 'w' } else { '-' }); - s.push(if m.contains(Mode::S_IXUSR) { 'x' } else { '-' }); - s.push(if m.contains(Mode::S_IRGRP) { 'r' } else { '-' }); - s.push(if m.contains(Mode::S_IWGRP) { 'w' } else { '-' }); - s.push(if m.contains(Mode::S_IXGRP) { 'x' } else { '-' }); - s.push(if m.contains(Mode::S_IROTH) { 'r' } else { '-' }); - s.push(if m.contains(Mode::S_IWOTH) { 'w' } else { '-' }); - s.push(if m.contains(Mode::S_IXOTH) { 'x' } else { '-' }); - } - - s -} - -fn get_icon(_name: &str, is_dir: bool, ext: &str) -> &'static str { - if is_dir { - return "\u{f115}"; // ๐Ÿ“ - } - match ext { - "rs" => "\u{e7a8}", // ๐Ÿฆ€ - "md" => "\u{f48a}", // ๐Ÿ“ - "toml" => "\u{f013}", // โš™๏ธ - "json" => "\u{f1c0}", // ๐Ÿ—„๏ธ - "yaml" | "yml" => "\u{f1c0}", - "png" | "jpg" | "jpeg" | "gif" => "\u{f1c5}", // ๐Ÿ–ผ๏ธ - "txt" => "\u{f15c}", // ๐Ÿ“„ - _ => "\u{f15b}", // ๐Ÿ“„ - } -} fn main() { let args = Args::parse(); @@ -182,7 +29,7 @@ fn main() { match result { Ok(dir_entry) => { let path = dir_entry.path(); - + // For name display: // If not recursive, use file_name. // If recursive, use path relative to args.path. @@ -205,42 +52,15 @@ fn main() { dir_entry.file_name().to_string_lossy().into_owned() }; - let metadata = match fs::symlink_metadata(path) { - Ok(m) => m, - Err(_) => continue, - }; - - let is_dir = metadata.is_dir(); - let is_symlink = metadata.file_type().is_symlink(); - let mode = get_mode_string(metadata.permissions().mode(), is_dir, is_symlink); - let nlink = metadata.nlink(); - let owner = get_user_by_uid(metadata.uid()) - .map(|u| u.name().to_string_lossy().into_owned()) - .unwrap_or_else(|| metadata.uid().to_string()); - let group = get_group_by_gid(metadata.gid()) - .map(|g| g.name().to_string_lossy().into_owned()) - .unwrap_or_else(|| metadata.gid().to_string()); - let size = metadata.len(); - let modified: DateTime = metadata.modified().unwrap_or_else(|_| std::time::SystemTime::now()).into(); - let modified_str = modified.format("%Y-%m-%d %H:%M").to_string(); - let abs_path = fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()); - let git_status = git_statuses.get(&abs_path).cloned().unwrap_or_else(|| " ".to_string()); - let extension = path.extension().and_then(|e| e.to_str()).unwrap_or("").to_string(); + let git_status = git_statuses + .get(&abs_path) + .cloned() + .unwrap_or_else(|| " ".to_string()); - entries.push(Entry { - name, - path: path.to_path_buf(), - is_dir, - mode, - nlink, - owner, - group, - size, - modified: modified_str, - git_status, - extension, - }); + if let Some(entry) = Entry::new(path, name, git_status) { + entries.push(entry); + } } Err(_) => {} } @@ -264,9 +84,9 @@ fn main() { match args.format { Format::Plain => { if args.long { - print_long(&entries, &lscolors, args.icon); + display::print_long(&entries, &lscolors, args.icon); } else { - print_plain(&entries, &lscolors, args.icon); + display::print_plain(&entries, &lscolors, args.icon); } } Format::Csv => { @@ -284,78 +104,3 @@ fn main() { } } } - -fn print_plain(entries: &[Entry], lscolors: &LsColors, show_icon: bool) { - use std::io::IsTerminal; - if !std::io::stdout().is_terminal() { - for entry in entries { - println!("{}", entry.name); - } - return; - } - - let names: Vec = entries - .iter() - .map(|e| { - let s = if show_icon { - format!("{} {}", get_icon(&e.name, e.is_dir, &e.extension), e.name) - } else { - e.name.clone() - }; - if let Some(style) = lscolors.style_for_path(&e.path) { - let colored_s = style.to_nu_ansi_term_style().paint(s); - format!("{}", colored_s) - } else { - s - } - }) - .collect(); - - if names.is_empty() { - return; - } - - let term_width = terminal_size().map(|(Width(w), _)| w as usize).unwrap_or(80); - let max_width = entries - .iter() - .map(|e| { - let icon_width = if show_icon { 3 } else { 0 }; - UnicodeWidthStr::width(e.name.as_str()) + icon_width - }) - .max() - .unwrap_or(0) - + 2; - - let cols = (term_width / max_width).max(1); - let rows = (names.len() as f64 / cols as f64).ceil() as usize; - - for r in 0..rows { - for c in 0..cols { - let i = c * rows + r; - if i < names.len() { - print!("{:3} {:<8} {:<8} {:>8} {} {} {}", - e.mode, e.nlink, e.owner, e.group, size_str, e.modified, e.git_status, name_str - ); - } -} From a76182ca7639cdaf64a61e5b0f9a14614f4d4713 Mon Sep 17 00:00:00 2001 From: Haruaki Tamada Date: Tue, 21 Apr 2026 07:49:31 +0900 Subject: [PATCH 03/13] Further refactor: split functions exceeding 20 lines into smaller ones --- src/display.rs | 101 ++++++++++++++++++++------------------- src/entry.rs | 84 ++++++++++++++++++--------------- src/main.rs | 125 ++++++++++++++++++++++++++++--------------------- 3 files changed, 171 insertions(+), 139 deletions(-) diff --git a/src/display.rs b/src/display.rs index 8230d29..4a090cf 100644 --- a/src/display.rs +++ b/src/display.rs @@ -7,44 +7,48 @@ use humansize::{format_size, DECIMAL}; pub fn print_plain(entries: &[Entry], lscolors: &LsColors, show_icon: bool) { use std::io::IsTerminal; if !std::io::stdout().is_terminal() { - for entry in entries { - println!("{}", entry.name); - } + print_one_column(entries); return; } - let names: Vec = entries - .iter() - .map(|e| { - let s = if show_icon { - format!("{} {}", get_icon(&e.name, e.is_dir, &e.extension), e.name) - } else { - e.name.clone() - }; - if let Some(style) = lscolors.style_for_path(&e.path) { - let colored_s = style.to_nu_ansi_term_style().paint(s); - format!("{}", colored_s) - } else { - s - } - }) - .collect(); - + let names = style_entries(entries, lscolors, show_icon); if names.is_empty() { return; } let term_width = terminal_size().map(|(Width(w), _)| w as usize).unwrap_or(80); - let max_width = entries - .iter() - .map(|e| { - let icon_width = if show_icon { 3 } else { 0 }; - UnicodeWidthStr::width(e.name.as_str()) + icon_width - }) - .max() - .unwrap_or(0) - + 2; + let max_width = get_max_width(entries, show_icon); + print_columns(&names, max_width, term_width); +} + +fn print_one_column(entries: &[Entry]) { + for entry in entries { + println!("{}", entry.name); + } +} + +fn style_entries(entries: &[Entry], lscolors: &LsColors, show_icon: bool) -> Vec { + entries.iter().map(|e| { + let s = if show_icon { + format!("{} {}", get_icon(&e.name, e.is_dir, &e.extension), e.name) + } else { + e.name.clone() + }; + lscolors.style_for_path(&e.path) + .map(|style| style.to_nu_ansi_term_style().paint(&s).to_string()) + .unwrap_or(s) + }).collect() +} + +fn get_max_width(entries: &[Entry], show_icon: bool) -> usize { + entries.iter().map(|e| { + let icon_width = if show_icon { 3 } else { 0 }; + UnicodeWidthStr::width(e.name.as_str()) + icon_width + }).max().unwrap_or(0) + 2 +} + +fn print_columns(names: &[String], max_width: usize, term_width: usize) { let cols = (term_width / max_width).max(1); let rows = (names.len() as f64 / cols as f64).ceil() as usize; @@ -62,15 +66,7 @@ pub fn print_plain(entries: &[Entry], lscolors: &LsColors, show_icon: bool) { pub fn print_long(entries: &[Entry], lscolors: &LsColors, show_icon: bool) { for e in entries { let size_str = format_size(e.size, DECIMAL); - let mut name_str = if show_icon { - format!("{} {}", get_icon(&e.name, e.is_dir, &e.extension), e.name) - } else { - e.name.clone() - }; - - if let Some(style) = lscolors.style_for_path(&e.path) { - name_str = format!("{}", style.to_nu_ansi_term_style().paint(name_str)); - } + let name_str = style_entry_name(e, lscolors, show_icon); println!( "{} {:>3} {:<8} {:<8} {:>8} {} {} {}", @@ -79,18 +75,27 @@ pub fn print_long(entries: &[Entry], lscolors: &LsColors, show_icon: bool) { } } +fn style_entry_name(e: &Entry, lscolors: &LsColors, show_icon: bool) -> String { + let s = if show_icon { + format!("{} {}", get_icon(&e.name, e.is_dir, &e.extension), e.name) + } else { + e.name.clone() + }; + lscolors.style_for_path(&e.path) + .map(|style| style.to_nu_ansi_term_style().paint(&s).to_string()) + .unwrap_or(s) +} + pub fn get_icon(_name: &str, is_dir: bool, ext: &str) -> &'static str { - if is_dir { - return "\u{f115}"; // ๐Ÿ“ - } + if is_dir { return "\u{f115}"; } match ext { - "rs" => "\u{e7a8}", // ๐Ÿฆ€ - "md" => "\u{f48a}", // ๐Ÿ“ - "toml" => "\u{f013}", // โš™๏ธ - "json" => "\u{f1c0}", // ๐Ÿ—„๏ธ + "rs" => "\u{e7a8}", + "md" => "\u{f48a}", + "toml" => "\u{f013}", + "json" => "\u{f1c0}", "yaml" | "yml" => "\u{f1c0}", - "png" | "jpg" | "jpeg" | "gif" => "\u{f1c5}", // ๐Ÿ–ผ๏ธ - "txt" => "\u{f15c}", // ๐Ÿ“„ - _ => "\u{f15b}", // ๐Ÿ“„ + "png" | "jpg" | "jpeg" | "gif" => "\u{f1c5}", + "txt" => "\u{f15c}", + _ => "\u{f15b}", } } diff --git a/src/entry.rs b/src/entry.rs index 051c57d..4949329 100644 --- a/src/entry.rs +++ b/src/entry.rs @@ -26,59 +26,69 @@ impl Entry { let metadata = fs::symlink_metadata(path).ok()?; let is_dir = metadata.is_dir(); let is_symlink = metadata.file_type().is_symlink(); - let mode = get_mode_string(metadata.permissions().mode(), is_dir, is_symlink); - let nlink = metadata.nlink(); - let owner = get_user_by_uid(metadata.uid()) - .map(|u| u.name().to_string_lossy().into_owned()) - .unwrap_or_else(|| metadata.uid().to_string()); - let group = get_group_by_gid(metadata.gid()) - .map(|g| g.name().to_string_lossy().into_owned()) - .unwrap_or_else(|| metadata.gid().to_string()); - let size = metadata.len(); - let modified: DateTime = metadata.modified().unwrap_or_else(|_| std::time::SystemTime::now()).into(); - let modified_str = modified.format("%Y-%m-%d %H:%M").to_string(); - let extension = path.extension().and_then(|e| e.to_str()).unwrap_or("").to_string(); - + Some(Entry { name, path: path.to_path_buf(), is_dir, - mode, - nlink, - owner, - group, - size, - modified: modified_str, + mode: get_mode_string(metadata.permissions().mode(), is_dir, is_symlink), + nlink: metadata.nlink(), + owner: get_owner_name(metadata.uid()), + group: get_group_name(metadata.gid()), + size: metadata.len(), + modified: get_modified_time(&metadata), git_status, - extension, + extension: path.extension().and_then(|e| e.to_str()).unwrap_or("").to_string(), }) } } +fn get_owner_name(uid: u32) -> String { + get_user_by_uid(uid) + .map(|u| u.name().to_string_lossy().into_owned()) + .unwrap_or_else(|| uid.to_string()) +} + +fn get_group_name(gid: u32) -> String { + get_group_by_gid(gid) + .map(|g| g.name().to_string_lossy().into_owned()) + .unwrap_or_else(|| gid.to_string()) +} + +fn get_modified_time(metadata: &fs::Metadata) -> String { + let modified: DateTime = metadata.modified() + .unwrap_or_else(|_| std::time::SystemTime::now()) + .into(); + modified.format("%Y-%m-%d %H:%M").to_string() +} + fn get_mode_string(mode: u32, is_dir: bool, is_symlink: bool) -> String { let mut s = String::with_capacity(10); - - if is_dir { - s.push('d'); - } else if is_symlink { - s.push('l'); - } else { - s.push('-'); - } + s.push(get_file_type_char(is_dir, is_symlink)); #[cfg(unix)] { let m = Mode::from_bits_truncate(mode as nix::sys::stat::mode_t); - s.push(if m.contains(Mode::S_IRUSR) { 'r' } else { '-' }); - s.push(if m.contains(Mode::S_IWUSR) { 'w' } else { '-' }); - s.push(if m.contains(Mode::S_IXUSR) { 'x' } else { '-' }); - s.push(if m.contains(Mode::S_IRGRP) { 'r' } else { '-' }); - s.push(if m.contains(Mode::S_IWGRP) { 'w' } else { '-' }); - s.push(if m.contains(Mode::S_IXGRP) { 'x' } else { '-' }); - s.push(if m.contains(Mode::S_IROTH) { 'r' } else { '-' }); - s.push(if m.contains(Mode::S_IWOTH) { 'w' } else { '-' }); - s.push(if m.contains(Mode::S_IXOTH) { 'x' } else { '-' }); + s.push_str(&get_permissions_string(m)); } + s +} + +fn get_file_type_char(is_dir: bool, is_symlink: bool) -> char { + if is_dir { 'd' } else if is_symlink { 'l' } else { '-' } +} +#[cfg(unix)] +fn get_permissions_string(m: Mode) -> String { + let mut s = String::with_capacity(9); + s.push(if m.contains(Mode::S_IRUSR) { 'r' } else { '-' }); + s.push(if m.contains(Mode::S_IWUSR) { 'w' } else { '-' }); + s.push(if m.contains(Mode::S_IXUSR) { 'x' } else { '-' }); + s.push(if m.contains(Mode::S_IRGRP) { 'r' } else { '-' }); + s.push(if m.contains(Mode::S_IWGRP) { 'w' } else { '-' }); + s.push(if m.contains(Mode::S_IXGRP) { 'x' } else { '-' }); + s.push(if m.contains(Mode::S_IROTH) { 'r' } else { '-' }); + s.push(if m.contains(Mode::S_IWOTH) { 'w' } else { '-' }); + s.push(if m.contains(Mode::S_IXOTH) { 'x' } else { '-' }); s } diff --git a/src/main.rs b/src/main.rs index 0963ea7..2fda3f1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -7,66 +7,83 @@ use args::{Args, Format, SortBy}; use clap::Parser; use entry::Entry; use git::get_git_statuses; -use ignore::WalkBuilder; +use ignore::{Walk, WalkBuilder}; use lscolors::LsColors; +use std::collections::HashMap; use std::fs; +use std::path::{Path, PathBuf}; fn main() { let args = Args::parse(); let lscolors = LsColors::from_env().unwrap_or_default(); + let git_statuses = get_git_statuses(&args.path); + + let mut entries = collect_entries(&args, &git_statuses); + sort_entries(&mut entries, &args); + output_entries(&entries, &args, &lscolors); +} +fn collect_entries(args: &Args, git_statuses: &HashMap) -> Vec { let mut entries = Vec::new(); - let git_statuses = get_git_statuses(&args.path); + let walker = create_walker(args); + for result in walker { + if let Ok(dir_entry) = result { + if let Some(entry) = process_dir_entry(&dir_entry, args, git_statuses) { + entries.push(entry); + } + } + } + entries +} + +fn create_walker(args: &Args) -> Walk { let mut builder = WalkBuilder::new(&args.path); builder.hidden(!args.all && !args.whole_all); builder.git_ignore(!args.whole_all); builder.max_depth(if args.recursive { None } else { Some(1) }); + builder.build() +} - let walker = builder.build(); - - for result in walker { - match result { - Ok(dir_entry) => { - let path = dir_entry.path(); +fn process_dir_entry( + dir_entry: &ignore::DirEntry, + args: &Args, + git_statuses: &HashMap, +) -> Option { + let path = dir_entry.path(); + let name = get_display_name(path, args, dir_entry)?; - // For name display: - // If not recursive, use file_name. - // If recursive, use path relative to args.path. - let name = if args.recursive { - match path.strip_prefix(&args.path) { - Ok(p) => { - let s = p.to_string_lossy().into_owned(); - if s.is_empty() { - args.path.to_string_lossy().into_owned() - } else { - s - } - } - Err(_) => dir_entry.file_name().to_string_lossy().into_owned(), - } - } else { - if path == args.path { - continue; - } - dir_entry.file_name().to_string_lossy().into_owned() - }; + let abs_path = fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()); + let git_status = git_statuses + .get(&abs_path) + .cloned() + .unwrap_or_else(|| " ".to_string()); - let abs_path = fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()); - let git_status = git_statuses - .get(&abs_path) - .cloned() - .unwrap_or_else(|| " ".to_string()); + Entry::new(path, name, git_status) +} - if let Some(entry) = Entry::new(path, name, git_status) { - entries.push(entry); - } +fn get_display_name(path: &Path, args: &Args, dir_entry: &ignore::DirEntry) -> Option { + if args.recursive { + match path.strip_prefix(&args.path) { + Ok(p) => { + let s = p.to_string_lossy().into_owned(); + Some(if s.is_empty() { + args.path.to_string_lossy().into_owned() + } else { + s + }) } - Err(_) => {} + Err(_) => Some(dir_entry.file_name().to_string_lossy().into_owned()), + } + } else { + if path == args.path { + return None; } + Some(dir_entry.file_name().to_string_lossy().into_owned()) } +} - // Sorting +fn sort_entries(entries: &mut [Entry], args: &Args) { entries.sort_by(|a, b| { let cmp = match args.sort { SortBy::Name => a.name.cmp(&b.name), @@ -80,27 +97,27 @@ fn main() { cmp } }); +} +fn output_entries(entries: &[Entry], args: &Args, lscolors: &LsColors) { match args.format { Format::Plain => { if args.long { - display::print_long(&entries, &lscolors, args.icon); + display::print_long(entries, lscolors, args.icon); } else { - display::print_plain(&entries, &lscolors, args.icon); + display::print_plain(entries, lscolors, args.icon); } } - Format::Csv => { - let mut wtr = csv::Writer::from_writer(std::io::stdout()); - for entry in entries { - wtr.serialize(entry).unwrap(); - } - wtr.flush().unwrap(); - } - Format::Json => { - println!("{}", serde_json::to_string_pretty(&entries).unwrap()); - } - Format::Yaml => { - println!("{}", serde_yaml::to_string(&entries).unwrap()); - } + Format::Csv => print_csv(entries), + Format::Json => println!("{}", serde_json::to_string_pretty(entries).unwrap()), + Format::Yaml => println!("{}", serde_yaml::to_string(entries).unwrap()), + } +} + +fn print_csv(entries: &[Entry]) { + let mut wtr = csv::Writer::from_writer(std::io::stdout()); + for entry in entries { + wtr.serialize(entry).unwrap(); } + wtr.flush().unwrap(); } From bab4cc1d235056e699f969a3c524fa30accc5110 Mon Sep 17 00:00:00 2001 From: Haruaki Tamada Date: Tue, 21 Apr 2026 08:26:29 +0900 Subject: [PATCH 04/13] Introduce lib.rs and move CLI interfaces into cli/ directory --- Cargo.toml | 4 ++++ {src => cli}/args.rs | 0 {src => cli}/display.rs | 2 +- {src => cli}/main.rs | 6 ++---- src/lib.rs | 2 ++ 5 files changed, 9 insertions(+), 5 deletions(-) rename {src => cli}/args.rs (100%) rename {src => cli}/display.rs (99%) rename {src => cli}/main.rs (98%) create mode 100644 src/lib.rs diff --git a/Cargo.toml b/Cargo.toml index 1421cb5..83995c0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,3 +19,7 @@ terminal_size = "0.4" unicode-width = "0.2" colored = "2.1" nix = { version = "0.29", features = ["fs", "user"] } + +[[bin]] +name = "lis" +path = "cli/main.rs" diff --git a/src/args.rs b/cli/args.rs similarity index 100% rename from src/args.rs rename to cli/args.rs diff --git a/src/display.rs b/cli/display.rs similarity index 99% rename from src/display.rs rename to cli/display.rs index 4a090cf..7794d92 100644 --- a/src/display.rs +++ b/cli/display.rs @@ -1,4 +1,4 @@ -use crate::entry::Entry; +use lis::entry::Entry; use lscolors::LsColors; use terminal_size::{terminal_size, Width}; use unicode_width::UnicodeWidthStr; diff --git a/src/main.rs b/cli/main.rs similarity index 98% rename from src/main.rs rename to cli/main.rs index 2fda3f1..cfc7b40 100644 --- a/src/main.rs +++ b/cli/main.rs @@ -1,12 +1,10 @@ mod args; mod display; -mod entry; -mod git; use args::{Args, Format, SortBy}; use clap::Parser; -use entry::Entry; -use git::get_git_statuses; +use lis::entry::Entry; +use lis::git::get_git_statuses; use ignore::{Walk, WalkBuilder}; use lscolors::LsColors; use std::collections::HashMap; diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..b59b064 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,2 @@ +pub mod entry; +pub mod git; From 85080ca78ed0bec2fc09f3390211a886fed4ca15 Mon Sep 17 00:00:00 2001 From: Haruaki Tamada Date: Tue, 21 Apr 2026 08:29:47 +0900 Subject: [PATCH 05/13] Expose library API via Lis struct and move orchestration logic to src/lib.rs --- cli/args.rs | 11 +----- cli/main.rs | 96 ++++++------------------------------------------- src/entry.rs | 26 ++++++++++++++ src/lib.rs | 100 +++++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 137 insertions(+), 96 deletions(-) diff --git a/cli/args.rs b/cli/args.rs index 12b9318..9443294 100644 --- a/cli/args.rs +++ b/cli/args.rs @@ -1,6 +1,6 @@ use clap::{Parser, ValueEnum}; -use serde::{Deserialize, Serialize}; use std::path::PathBuf; +use lis::entry::SortBy; #[derive(Parser, Debug)] #[command(author, version, about, long_about = None)] @@ -42,15 +42,6 @@ pub struct Args { pub recursive: bool, } -#[derive(ValueEnum, Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "lowercase")] -pub enum SortBy { - Name, - Time, - Size, - Extension, -} - #[derive(ValueEnum, Clone, Debug, PartialEq, Eq)] pub enum Format { Plain, diff --git a/cli/main.rs b/cli/main.rs index cfc7b40..486a3fa 100644 --- a/cli/main.rs +++ b/cli/main.rs @@ -1,100 +1,24 @@ mod args; mod display; -use args::{Args, Format, SortBy}; +use args::{Args, Format}; use clap::Parser; -use lis::entry::Entry; -use lis::git::get_git_statuses; -use ignore::{Walk, WalkBuilder}; +use lis::entry::{sort_entries, Entry}; +use lis::Lis; use lscolors::LsColors; -use std::collections::HashMap; -use std::fs; -use std::path::{Path, PathBuf}; fn main() { let args = Args::parse(); let lscolors = LsColors::from_env().unwrap_or_default(); - let git_statuses = get_git_statuses(&args.path); - let mut entries = collect_entries(&args, &git_statuses); - sort_entries(&mut entries, &args); - output_entries(&entries, &args, &lscolors); -} - -fn collect_entries(args: &Args, git_statuses: &HashMap) -> Vec { - let mut entries = Vec::new(); - let walker = create_walker(args); - - for result in walker { - if let Ok(dir_entry) = result { - if let Some(entry) = process_dir_entry(&dir_entry, args, git_statuses) { - entries.push(entry); - } - } - } - entries -} - -fn create_walker(args: &Args) -> Walk { - let mut builder = WalkBuilder::new(&args.path); - builder.hidden(!args.all && !args.whole_all); - builder.git_ignore(!args.whole_all); - builder.max_depth(if args.recursive { None } else { Some(1) }); - builder.build() -} - -fn process_dir_entry( - dir_entry: &ignore::DirEntry, - args: &Args, - git_statuses: &HashMap, -) -> Option { - let path = dir_entry.path(); - let name = get_display_name(path, args, dir_entry)?; + let mut entries = Lis::new(args.path.clone()) + .recursive(args.recursive) + .all(args.all) + .whole_all(args.whole_all) + .list(); - let abs_path = fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()); - let git_status = git_statuses - .get(&abs_path) - .cloned() - .unwrap_or_else(|| " ".to_string()); - - Entry::new(path, name, git_status) -} - -fn get_display_name(path: &Path, args: &Args, dir_entry: &ignore::DirEntry) -> Option { - if args.recursive { - match path.strip_prefix(&args.path) { - Ok(p) => { - let s = p.to_string_lossy().into_owned(); - Some(if s.is_empty() { - args.path.to_string_lossy().into_owned() - } else { - s - }) - } - Err(_) => Some(dir_entry.file_name().to_string_lossy().into_owned()), - } - } else { - if path == args.path { - return None; - } - Some(dir_entry.file_name().to_string_lossy().into_owned()) - } -} - -fn sort_entries(entries: &mut [Entry], args: &Args) { - entries.sort_by(|a, b| { - let cmp = match args.sort { - SortBy::Name => a.name.cmp(&b.name), - SortBy::Time => a.modified.cmp(&b.modified), - SortBy::Size => a.size.cmp(&b.size), - SortBy::Extension => a.extension.cmp(&b.extension), - }; - if args.reverse { - cmp.reverse() - } else { - cmp - } - }); + sort_entries(&mut entries, args.sort.clone(), args.reverse); + output_entries(&entries, &args, &lscolors); } fn output_entries(entries: &[Entry], args: &Args, lscolors: &LsColors) { diff --git a/src/entry.rs b/src/entry.rs index 4949329..85e4bab 100644 --- a/src/entry.rs +++ b/src/entry.rs @@ -5,6 +5,7 @@ use std::os::unix::fs::{MetadataExt, PermissionsExt}; use chrono::{DateTime, Local}; use uzers::{get_group_by_gid, get_user_by_uid}; use nix::sys::stat::Mode; +use clap::ValueEnum; #[derive(Serialize, Deserialize, Debug, Clone)] pub struct Entry { @@ -21,6 +22,15 @@ pub struct Entry { pub extension: String, } +#[derive(ValueEnum, Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum SortBy { + Name, + Time, + Size, + Extension, +} + impl Entry { pub fn new(path: &Path, name: String, git_status: String) -> Option { let metadata = fs::symlink_metadata(path).ok()?; @@ -43,6 +53,22 @@ impl Entry { } } +pub fn sort_entries(entries: &mut [Entry], sort_by: SortBy, reverse: bool) { + entries.sort_by(|a, b| { + let cmp = match sort_by { + SortBy::Name => a.name.cmp(&b.name), + SortBy::Time => a.modified.cmp(&b.modified), + SortBy::Size => a.size.cmp(&b.size), + SortBy::Extension => a.extension.cmp(&b.extension), + }; + if reverse { + cmp.reverse() + } else { + cmp + } + }); +} + fn get_owner_name(uid: u32) -> String { get_user_by_uid(uid) .map(|u| u.name().to_string_lossy().into_owned()) diff --git a/src/lib.rs b/src/lib.rs index b59b064..72307ed 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,2 +1,102 @@ pub mod entry; pub mod git; + +use entry::Entry; +use git::get_git_statuses; +use ignore::{Walk, WalkBuilder}; +use std::fs; +use std::path::{Path, PathBuf}; + +pub struct Lis { + path: PathBuf, + recursive: bool, + all: bool, + whole_all: bool, +} + +impl Lis { + pub fn new(path: PathBuf) -> Self { + Self { + path, + recursive: false, + all: false, + whole_all: false, + } + } + + pub fn recursive(mut self, recursive: bool) -> Self { + self.recursive = recursive; + self + } + + pub fn all(mut self, all: bool) -> Self { + self.all = all; + self + } + + pub fn whole_all(mut self, whole_all: bool) -> Self { + self.whole_all = whole_all; + self + } + + pub fn list(&self) -> Vec { + let git_statuses = get_git_statuses(&self.path); + let mut entries = Vec::new(); + let walker = self.create_walker(); + + for result in walker { + if let Ok(dir_entry) = result { + if let Some(entry) = self.process_dir_entry(&dir_entry, &git_statuses) { + entries.push(entry); + } + } + } + entries + } + + fn create_walker(&self) -> Walk { + let mut builder = WalkBuilder::new(&self.path); + builder.hidden(!self.all && !self.whole_all); + builder.git_ignore(!self.whole_all); + builder.max_depth(if self.recursive { None } else { Some(1) }); + builder.build() + } + + fn process_dir_entry( + &self, + dir_entry: &ignore::DirEntry, + git_statuses: &std::collections::HashMap, + ) -> Option { + let path = dir_entry.path(); + let name = self.get_display_name(path, dir_entry)?; + + let abs_path = fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()); + let git_status = git_statuses + .get(&abs_path) + .cloned() + .unwrap_or_else(|| " ".to_string()); + + Entry::new(path, name, git_status) + } + + fn get_display_name(&self, path: &Path, dir_entry: &ignore::DirEntry) -> Option { + if self.recursive { + match path.strip_prefix(&self.path) { + Ok(p) => { + let s = p.to_string_lossy().into_owned(); + Some(if s.is_empty() { + self.path.to_string_lossy().into_owned() + } else { + s + }) + } + Err(_) => Some(dir_entry.file_name().to_string_lossy().into_owned()), + } + } else { + if path == self.path { + return None; + } + Some(dir_entry.file_name().to_string_lossy().into_owned()) + } + } +} From 11672c21c6944b29937f29b0b45dc9ff7e0d0b1b Mon Sep 17 00:00:00 2001 From: Haruaki Tamada Date: Tue, 21 Apr 2026 09:25:07 +0900 Subject: [PATCH 06/13] Add basic and complex example programs to demonstrate library usage --- examples/basic.rs | 12 ++++++++++++ examples/complex.rs | 24 ++++++++++++++++++++++++ 2 files changed, 36 insertions(+) create mode 100644 examples/basic.rs create mode 100644 examples/complex.rs diff --git a/examples/basic.rs b/examples/basic.rs new file mode 100644 index 0000000..47370c7 --- /dev/null +++ b/examples/basic.rs @@ -0,0 +1,12 @@ +use lis::Lis; +use std::path::PathBuf; + +fn main() { + // Basic usage: list the current directory + let entries = Lis::new(PathBuf::from(".")).list(); + + println!("Listing current directory:"); + for entry in entries { + println!("{:<10} {}", entry.mode, entry.name); + } +} diff --git a/examples/complex.rs b/examples/complex.rs new file mode 100644 index 0000000..c53107e --- /dev/null +++ b/examples/complex.rs @@ -0,0 +1,24 @@ +use lis::entry::{sort_entries, SortBy}; +use lis::Lis; +use std::path::PathBuf; + +fn main() { + // Advanced usage: recursive, including all files, sorted by size descending + let mut entries = Lis::new(PathBuf::from(".")) + .recursive(true) + .all(true) + .list(); + + // Sort by size, reverse + sort_entries(&mut entries, SortBy::Size, true); + + println!("{:<10} {:>10} {:<20} {}", "Mode", "Size", "Modified", "Path"); + println!("{:-<60}", ""); + + for entry in entries.iter().take(10) { + println!( + "{:<10} {:>10} {:<20} {}", + entry.mode, entry.size, entry.modified, entry.name + ); + } +} From 1015094d37b286c35d914ba78427df9cab4e293a Mon Sep 17 00:00:00 2001 From: Haruaki Tamada Date: Tue, 21 Apr 2026 09:26:03 +0900 Subject: [PATCH 07/13] Update README and add cli/README.md with CLI documentation --- README.md | 38 +++++++++++++++++++++++++++++++++++++ cli/README.md | 52 +++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 90 insertions(+) create mode 100644 cli/README.md diff --git a/README.md b/README.md index 96e8ece..e8e30b4 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,44 @@ It is not intended for production use. ## ๐Ÿ—ฃ๏ธ Overview +`lis` provides a library and a CLI tool for listing directory entries with advanced features like Git status, icons, and multiple output formats. + +## ๐Ÿš€ Features + +- List entries in specified directories. +- Support for sorting (name, time, size, extension) and reverse order. +- Long format with detailed information, including **Git status**. +- Support for hidden files, respecting `.gitignore`. +- Display icons for each entry based on file type and extension. +- Multiple output formats: `plain`, `csv`, `json`, and `yaml`. +- Support for `LS_COLORS` environment variable. + +## ๐Ÿ“– Library Usage + +Add `lis` to your `Cargo.toml`. Then use it as follows: + +```rust +use lis::Lis; +use std::path::PathBuf; + +fn main() { + let entries = Lis::new(PathBuf::from(".")) + .recursive(true) + .all(true) + .list(); + + for entry in entries { + println!("{:<10} {}", entry.mode, entry.name); + } +} +``` + +Check the [examples](examples/) directory for more details. + +## ๐Ÿ’ป CLI Usage + +See [cli/README.md](cli/README.md) for more details. + ## โ„น๏ธ About ### ๐Ÿ‘จโ€๐Ÿ’ผโ€‹ Developers ๐Ÿ‘ฉโ€๐Ÿ’ผ diff --git a/cli/README.md b/cli/README.md new file mode 100644 index 0000000..96ef475 --- /dev/null +++ b/cli/README.md @@ -0,0 +1,52 @@ +# ๐Ÿ’ป lis CLI Tool + +`lis` is a command-line interface for listing directory entries with advanced features. + +## ๐Ÿ› ๏ธ Installation + +```bash +cargo install --path . +``` + +## ๐Ÿš€ Usage + +```bash +lis [OPTIONS] [PATH] +``` + +### โš™๏ธ Options + +- `-l`, `--long`: Display in long format with details. +- `--sort `: Sort entries by `name` (default), `time`, `size`, or `extension`. +- `--reverse`: Reverse the sort order. +- `-a`, `--all`: List all entries, respecting `.gitignore`. +- `-A`, `--whole-all`: List all entries, ignoring `.gitignore`. +- `--icon`: Display icons for each entry. +- `--format `: Specify output format: `plain` (default), `csv`, `json`, or `yaml`. +- `-R`, `--recursive`: List entries recursively. + +### ๐ŸŒŸ Examples + +- **List current directory in long format with icons:** + ```bash + lis -l --icon + ``` + +- **List current directory recursively in JSON format:** + ```bash + lis -R --format json + ``` + +- **List all entries (including hidden) sorted by size:** + ```bash + lis -a --sort size + ``` + +- **List hidden files ignoring `.gitignore`:** + ```bash + lis -A + ``` + +## ๐Ÿ“– Specifications + +The detailed specification of `lis` can be found in [.github/assets/spec.md](../.github/assets/spec.md). From 06f801a6c3d5e1ff8611455d284bb97aef0b7b0d Mon Sep 17 00:00:00 2001 From: Haruaki Tamada Date: Tue, 21 Apr 2026 09:48:23 +0900 Subject: [PATCH 08/13] Add documentation comments to all source files --- src/entry.rs | 25 +++++++++++++++++++++++++ src/git.rs | 35 +++++++++++++++++++++-------------- src/lib.rs | 13 +++++++++++++ 3 files changed, 59 insertions(+), 14 deletions(-) diff --git a/src/entry.rs b/src/entry.rs index 85e4bab..a4e1f56 100644 --- a/src/entry.rs +++ b/src/entry.rs @@ -7,31 +7,49 @@ use uzers::{get_group_by_gid, get_user_by_uid}; use nix::sys::stat::Mode; use clap::ValueEnum; +/// Represents a single directory entry with its metadata. #[derive(Serialize, Deserialize, Debug, Clone)] pub struct Entry { + /// The display name of the entry. pub name: String, + /// The full path to the entry. pub path: PathBuf, + /// Whether the entry is a directory. pub is_dir: bool, + /// The file mode string (e.g., "-rw-r--r--"). pub mode: String, + /// The number of hard links to the entry. pub nlink: u64, + /// The name of the owner. pub owner: String, + /// The name of the group. pub group: String, + /// The size of the entry in bytes. pub size: u64, + /// The last modification time string. pub modified: String, + /// The Git status of the entry (e.g., "M", "A", or " "). pub git_status: String, + /// The file extension. pub extension: String, } +/// Available sorting options for directory entries. #[derive(ValueEnum, Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum SortBy { + /// Sort by name (ascending). Name, + /// Sort by last modification time (ascending). Time, + /// Sort by size in bytes (ascending). Size, + /// Sort by file extension (ascending). Extension, } impl Entry { + /// Creates a new `Entry` instance by reading metadata from the filesystem. pub fn new(path: &Path, name: String, git_status: String) -> Option { let metadata = fs::symlink_metadata(path).ok()?; let is_dir = metadata.is_dir(); @@ -53,6 +71,7 @@ impl Entry { } } +/// Sorts the given slice of entries based on the specified criteria. pub fn sort_entries(entries: &mut [Entry], sort_by: SortBy, reverse: bool) { entries.sort_by(|a, b| { let cmp = match sort_by { @@ -69,18 +88,21 @@ pub fn sort_entries(entries: &mut [Entry], sort_by: SortBy, reverse: bool) { }); } +/// Retrieves the owner name for the given UID. fn get_owner_name(uid: u32) -> String { get_user_by_uid(uid) .map(|u| u.name().to_string_lossy().into_owned()) .unwrap_or_else(|| uid.to_string()) } +/// Retrieves the group name for the given GID. fn get_group_name(gid: u32) -> String { get_group_by_gid(gid) .map(|g| g.name().to_string_lossy().into_owned()) .unwrap_or_else(|| gid.to_string()) } +/// Retrieves the last modification time of the given metadata as a formatted string. fn get_modified_time(metadata: &fs::Metadata) -> String { let modified: DateTime = metadata.modified() .unwrap_or_else(|_| std::time::SystemTime::now()) @@ -88,6 +110,7 @@ fn get_modified_time(metadata: &fs::Metadata) -> String { modified.format("%Y-%m-%d %H:%M").to_string() } +/// Constructs a file mode string for the given metadata. fn get_mode_string(mode: u32, is_dir: bool, is_symlink: bool) -> String { let mut s = String::with_capacity(10); s.push(get_file_type_char(is_dir, is_symlink)); @@ -100,10 +123,12 @@ fn get_mode_string(mode: u32, is_dir: bool, is_symlink: bool) -> String { s } +/// Returns the file type character ('d', 'l', or '-') for the entry. fn get_file_type_char(is_dir: bool, is_symlink: bool) -> char { if is_dir { 'd' } else if is_symlink { 'l' } else { '-' } } +/// Constructs the permissions part of the mode string. #[cfg(unix)] fn get_permissions_string(m: Mode) -> String { let mut s = String::with_capacity(9); diff --git a/src/git.rs b/src/git.rs index 35c4633..3aac013 100644 --- a/src/git.rs +++ b/src/git.rs @@ -3,6 +3,9 @@ use std::collections::HashMap; use std::fs; use std::path::{Path, PathBuf}; +/// Retrieves the Git status for each file in the repository containing the given path. +/// +/// Returns a map from absolute file paths to their status strings. pub fn get_git_statuses(path: &Path) -> HashMap { let mut statuses = HashMap::new(); if let Ok(repo) = Repository::discover(path) { @@ -14,20 +17,7 @@ pub fn get_git_statuses(path: &Path) -> HashMap { for entry in repo_statuses.iter() { if let Some(path_str) = entry.path() { let full_path = workdir.join(path_str); - let status = entry.status(); - let status_str = if status.is_index_new() || status.is_wt_new() { - "A" - } else if status.is_index_modified() || status.is_wt_modified() { - "M" - } else if status.is_index_deleted() || status.is_wt_deleted() { - "D" - } else if status.is_index_renamed() || status.is_wt_renamed() { - "R" - } else if status.is_index_typechange() || status.is_wt_typechange() { - "T" - } else { - " " - }; + let status_str = get_status_char(entry.status()); statuses.insert(full_path, status_str.to_string()); } } @@ -36,3 +26,20 @@ pub fn get_git_statuses(path: &Path) -> HashMap { } statuses } + +/// Converts a Git status to a single character string. +fn get_status_char(status: git2::Status) -> &'static str { + if status.is_index_new() || status.is_wt_new() { + "A" + } else if status.is_index_modified() || status.is_wt_modified() { + "M" + } else if status.is_index_deleted() || status.is_wt_deleted() { + "D" + } else if status.is_index_renamed() || status.is_wt_renamed() { + "R" + } else if status.is_index_typechange() || status.is_wt_typechange() { + "T" + } else { + " " + } +} diff --git a/src/lib.rs b/src/lib.rs index 72307ed..8b3c230 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,3 +1,7 @@ +//! `lis` is a library and CLI tool for listing directory entries. +//! +//! It provides features like Git status, icons, and multiple output formats. + pub mod entry; pub mod git; @@ -7,6 +11,7 @@ use ignore::{Walk, WalkBuilder}; use std::fs; use std::path::{Path, PathBuf}; +/// The main entry point for listing directory contents. pub struct Lis { path: PathBuf, recursive: bool, @@ -15,6 +20,7 @@ pub struct Lis { } impl Lis { + /// Creates a new `Lis` instance for the given path. pub fn new(path: PathBuf) -> Self { Self { path, @@ -24,21 +30,25 @@ impl Lis { } } + /// Sets whether to list entries recursively. pub fn recursive(mut self, recursive: bool) -> Self { self.recursive = recursive; self } + /// Sets whether to list hidden entries while respecting `.gitignore`. pub fn all(mut self, all: bool) -> Self { self.all = all; self } + /// Sets whether to list all entries, ignoring `.gitignore`. pub fn whole_all(mut self, whole_all: bool) -> Self { self.whole_all = whole_all; self } + /// Lists the entries in the directory according to the configured options. pub fn list(&self) -> Vec { let git_statuses = get_git_statuses(&self.path); let mut entries = Vec::new(); @@ -54,6 +64,7 @@ impl Lis { entries } + /// Creates a walker based on the current configuration. fn create_walker(&self) -> Walk { let mut builder = WalkBuilder::new(&self.path); builder.hidden(!self.all && !self.whole_all); @@ -62,6 +73,7 @@ impl Lis { builder.build() } + /// Processes a single directory entry and converts it to an `Entry` struct. fn process_dir_entry( &self, dir_entry: &ignore::DirEntry, @@ -79,6 +91,7 @@ impl Lis { Entry::new(path, name, git_status) } + /// Determines the display name for a directory entry based on whether listing is recursive. fn get_display_name(&self, path: &Path, dir_entry: &ignore::DirEntry) -> Option { if self.recursive { match path.strip_prefix(&self.path) { From d8d873f98a81967fc9c7c47e7a79bac6f328c251 Mon Sep 17 00:00:00 2001 From: Haruaki Tamada Date: Tue, 21 Apr 2026 10:18:59 +0900 Subject: [PATCH 09/13] Fix clippy warnings and add unit/integration tests --- cli/args.rs | 2 +- cli/display.rs | 52 +++++++++++++-------- cli/main.rs | 2 +- examples/complex.rs | 4 +- src/entry.rs | 95 +++++++++++++++++++++++++++++++++------ src/git.rs | 17 ++++--- src/lib.rs | 34 +++++++++++--- tests/integration_test.rs | 42 +++++++++++++++++ 8 files changed, 198 insertions(+), 50 deletions(-) create mode 100644 tests/integration_test.rs diff --git a/cli/args.rs b/cli/args.rs index 9443294..9ca332a 100644 --- a/cli/args.rs +++ b/cli/args.rs @@ -1,6 +1,6 @@ use clap::{Parser, ValueEnum}; -use std::path::PathBuf; use lis::entry::SortBy; +use std::path::PathBuf; #[derive(Parser, Debug)] #[command(author, version, about, long_about = None)] diff --git a/cli/display.rs b/cli/display.rs index 7794d92..74a9195 100644 --- a/cli/display.rs +++ b/cli/display.rs @@ -1,8 +1,8 @@ +use humansize::{DECIMAL, format_size}; use lis::entry::Entry; use lscolors::LsColors; -use terminal_size::{terminal_size, Width}; +use terminal_size::{Width, terminal_size}; use unicode_width::UnicodeWidthStr; -use humansize::{format_size, DECIMAL}; pub fn print_plain(entries: &[Entry], lscolors: &LsColors, show_icon: bool) { use std::io::IsTerminal; @@ -16,7 +16,9 @@ pub fn print_plain(entries: &[Entry], lscolors: &LsColors, show_icon: bool) { return; } - let term_width = terminal_size().map(|(Width(w), _)| w as usize).unwrap_or(80); + let term_width = terminal_size() + .map(|(Width(w), _)| w as usize) + .unwrap_or(80); let max_width = get_max_width(entries, show_icon); print_columns(&names, max_width, term_width); @@ -29,23 +31,32 @@ fn print_one_column(entries: &[Entry]) { } fn style_entries(entries: &[Entry], lscolors: &LsColors, show_icon: bool) -> Vec { - entries.iter().map(|e| { - let s = if show_icon { - format!("{} {}", get_icon(&e.name, e.is_dir, &e.extension), e.name) - } else { - e.name.clone() - }; - lscolors.style_for_path(&e.path) - .map(|style| style.to_nu_ansi_term_style().paint(&s).to_string()) - .unwrap_or(s) - }).collect() + entries + .iter() + .map(|e| { + let s = if show_icon { + format!("{} {}", get_icon(&e.name, e.is_dir, &e.extension), e.name) + } else { + e.name.clone() + }; + lscolors + .style_for_path(&e.path) + .map(|style| style.to_nu_ansi_term_style().paint(&s).to_string()) + .unwrap_or(s) + }) + .collect() } fn get_max_width(entries: &[Entry], show_icon: bool) -> usize { - entries.iter().map(|e| { - let icon_width = if show_icon { 3 } else { 0 }; - UnicodeWidthStr::width(e.name.as_str()) + icon_width - }).max().unwrap_or(0) + 2 + entries + .iter() + .map(|e| { + let icon_width = if show_icon { 3 } else { 0 }; + UnicodeWidthStr::width(e.name.as_str()) + icon_width + }) + .max() + .unwrap_or(0) + + 2 } fn print_columns(names: &[String], max_width: usize, term_width: usize) { @@ -81,13 +92,16 @@ fn style_entry_name(e: &Entry, lscolors: &LsColors, show_icon: bool) -> String { } else { e.name.clone() }; - lscolors.style_for_path(&e.path) + lscolors + .style_for_path(&e.path) .map(|style| style.to_nu_ansi_term_style().paint(&s).to_string()) .unwrap_or(s) } pub fn get_icon(_name: &str, is_dir: bool, ext: &str) -> &'static str { - if is_dir { return "\u{f115}"; } + if is_dir { + return "\u{f115}"; + } match ext { "rs" => "\u{e7a8}", "md" => "\u{f48a}", diff --git a/cli/main.rs b/cli/main.rs index 486a3fa..858978e 100644 --- a/cli/main.rs +++ b/cli/main.rs @@ -3,8 +3,8 @@ mod display; use args::{Args, Format}; use clap::Parser; -use lis::entry::{sort_entries, Entry}; use lis::Lis; +use lis::entry::{Entry, sort_entries}; use lscolors::LsColors; fn main() { diff --git a/examples/complex.rs b/examples/complex.rs index c53107e..e7feec8 100644 --- a/examples/complex.rs +++ b/examples/complex.rs @@ -1,5 +1,5 @@ -use lis::entry::{sort_entries, SortBy}; use lis::Lis; +use lis::entry::{SortBy, sort_entries}; use std::path::PathBuf; fn main() { @@ -12,7 +12,7 @@ fn main() { // Sort by size, reverse sort_entries(&mut entries, SortBy::Size, true); - println!("{:<10} {:>10} {:<20} {}", "Mode", "Size", "Modified", "Path"); + println!("{:<10} {:>10} {:<20} Path", "Mode", "Size", "Modified"); println!("{:-<60}", ""); for entry in entries.iter().take(10) { diff --git a/src/entry.rs b/src/entry.rs index a4e1f56..ae497e6 100644 --- a/src/entry.rs +++ b/src/entry.rs @@ -1,11 +1,11 @@ -use std::path::{Path, PathBuf}; +use chrono::{DateTime, Local}; +use clap::ValueEnum; +use nix::sys::stat::Mode; use serde::{Deserialize, Serialize}; use std::fs; use std::os::unix::fs::{MetadataExt, PermissionsExt}; -use chrono::{DateTime, Local}; +use std::path::{Path, PathBuf}; use uzers::{get_group_by_gid, get_user_by_uid}; -use nix::sys::stat::Mode; -use clap::ValueEnum; /// Represents a single directory entry with its metadata. #[derive(Serialize, Deserialize, Debug, Clone)] @@ -54,7 +54,7 @@ impl Entry { let metadata = fs::symlink_metadata(path).ok()?; let is_dir = metadata.is_dir(); let is_symlink = metadata.file_type().is_symlink(); - + Some(Entry { name, path: path.to_path_buf(), @@ -66,7 +66,11 @@ impl Entry { size: metadata.len(), modified: get_modified_time(&metadata), git_status, - extension: path.extension().and_then(|e| e.to_str()).unwrap_or("").to_string(), + extension: path + .extension() + .and_then(|e| e.to_str()) + .unwrap_or("") + .to_string(), }) } } @@ -80,11 +84,7 @@ pub fn sort_entries(entries: &mut [Entry], sort_by: SortBy, reverse: bool) { SortBy::Size => a.size.cmp(&b.size), SortBy::Extension => a.extension.cmp(&b.extension), }; - if reverse { - cmp.reverse() - } else { - cmp - } + if reverse { cmp.reverse() } else { cmp } }); } @@ -104,7 +104,8 @@ fn get_group_name(gid: u32) -> String { /// Retrieves the last modification time of the given metadata as a formatted string. fn get_modified_time(metadata: &fs::Metadata) -> String { - let modified: DateTime = metadata.modified() + let modified: DateTime = metadata + .modified() .unwrap_or_else(|_| std::time::SystemTime::now()) .into(); modified.format("%Y-%m-%d %H:%M").to_string() @@ -125,7 +126,13 @@ fn get_mode_string(mode: u32, is_dir: bool, is_symlink: bool) -> String { /// Returns the file type character ('d', 'l', or '-') for the entry. fn get_file_type_char(is_dir: bool, is_symlink: bool) -> char { - if is_dir { 'd' } else if is_symlink { 'l' } else { '-' } + if is_dir { + 'd' + } else if is_symlink { + 'l' + } else { + '-' + } } /// Constructs the permissions part of the mode string. @@ -143,3 +150,65 @@ fn get_permissions_string(m: Mode) -> String { s.push(if m.contains(Mode::S_IXOTH) { 'x' } else { '-' }); s } + +#[cfg(test)] +mod tests { + use super::*; + use std::path::PathBuf; + + fn create_test_entry(name: &str, size: u64, modified: &str, extension: &str) -> Entry { + Entry { + name: name.to_string(), + path: PathBuf::from(name), + is_dir: false, + mode: "-rw-r--r--".to_string(), + nlink: 1, + owner: "user".to_string(), + group: "group".to_string(), + size, + modified: modified.to_string(), + git_status: " ".to_string(), + extension: extension.to_string(), + } + } + + #[test] + fn test_sort_entries_by_name() { + let mut entries = vec![ + create_test_entry("b", 20, "2023-01-01 10:00", "txt"), + create_test_entry("a", 10, "2023-01-01 11:00", "rs"), + create_test_entry("c", 30, "2023-01-01 09:00", "md"), + ]; + + sort_entries(&mut entries, SortBy::Name, false); + assert_eq!(entries[0].name, "a"); + assert_eq!(entries[1].name, "b"); + assert_eq!(entries[2].name, "c"); + + sort_entries(&mut entries, SortBy::Name, true); + assert_eq!(entries[0].name, "c"); + assert_eq!(entries[1].name, "b"); + assert_eq!(entries[2].name, "a"); + } + + #[test] + fn test_sort_entries_by_size() { + let mut entries = vec![ + create_test_entry("b", 20, "2023-01-01 10:00", "txt"), + create_test_entry("a", 10, "2023-01-01 11:00", "rs"), + create_test_entry("c", 30, "2023-01-01 09:00", "md"), + ]; + + sort_entries(&mut entries, SortBy::Size, false); + assert_eq!(entries[0].size, 10); + assert_eq!(entries[1].size, 20); + assert_eq!(entries[2].size, 30); + } + + #[test] + fn test_get_file_type_char() { + assert_eq!(get_file_type_char(true, false), 'd'); + assert_eq!(get_file_type_char(false, true), 'l'); + assert_eq!(get_file_type_char(false, false), '-'); + } +} diff --git a/src/git.rs b/src/git.rs index 3aac013..97823d7 100644 --- a/src/git.rs +++ b/src/git.rs @@ -11,15 +11,14 @@ pub fn get_git_statuses(path: &Path) -> HashMap { if let Ok(repo) = Repository::discover(path) { let mut opts = StatusOptions::new(); opts.include_untracked(true); - if let Ok(repo_statuses) = repo.statuses(Some(&mut opts)) { - if let Some(workdir) = repo.workdir() { - let workdir = fs::canonicalize(workdir).unwrap_or_else(|_| workdir.to_path_buf()); - for entry in repo_statuses.iter() { - if let Some(path_str) = entry.path() { - let full_path = workdir.join(path_str); - let status_str = get_status_char(entry.status()); - statuses.insert(full_path, status_str.to_string()); - } + if let (Ok(repo_statuses), Some(workdir)) = (repo.statuses(Some(&mut opts)), repo.workdir()) + { + let workdir = fs::canonicalize(workdir).unwrap_or_else(|_| workdir.to_path_buf()); + for entry in repo_statuses.iter() { + if let Some(path_str) = entry.path() { + let full_path = workdir.join(path_str); + let status_str = get_status_char(entry.status()); + statuses.insert(full_path, status_str.to_string()); } } } diff --git a/src/lib.rs b/src/lib.rs index 8b3c230..12f97c1 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -54,11 +54,9 @@ impl Lis { let mut entries = Vec::new(); let walker = self.create_walker(); - for result in walker { - if let Ok(dir_entry) = result { - if let Some(entry) = self.process_dir_entry(&dir_entry, &git_statuses) { - entries.push(entry); - } + for dir_entry in walker.flatten() { + if let Some(entry) = self.process_dir_entry(&dir_entry, &git_statuses) { + entries.push(entry); } } entries @@ -113,3 +111,29 @@ impl Lis { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_lis_new() { + let path = PathBuf::from("."); + let lis = Lis::new(path.clone()); + assert_eq!(lis.path, path); + assert!(!lis.recursive); + assert!(!lis.all); + assert!(!lis.whole_all); + } + + #[test] + fn test_lis_builder() { + let lis = Lis::new(PathBuf::from(".")) + .recursive(true) + .all(true) + .whole_all(true); + assert!(lis.recursive); + assert!(lis.all); + assert!(lis.whole_all); + } +} diff --git a/tests/integration_test.rs b/tests/integration_test.rs new file mode 100644 index 0000000..de8d1b3 --- /dev/null +++ b/tests/integration_test.rs @@ -0,0 +1,42 @@ +use lis::Lis; +use std::path::PathBuf; + +#[test] +fn test_list_current_dir() { + let lis = Lis::new(PathBuf::from(".")); + let entries = lis.list(); + + // Check that we have some entries (at least Cargo.toml, src, etc.) + assert!(!entries.is_empty()); + + // Check if Cargo.toml is in the list + let has_cargo_toml = entries.iter().any(|e| e.name == "Cargo.toml"); + assert!(has_cargo_toml); +} + +#[test] +fn test_list_src_dir() { + let lis = Lis::new(PathBuf::from("src")); + let entries = lis.list(); + + assert!(!entries.is_empty()); + + // Check if lib.rs is in the list + let has_lib_rs = entries.iter().any(|e| e.name == "lib.rs"); + assert!(has_lib_rs); +} + +#[test] +fn test_recursive_list() { + let lis = Lis::new(PathBuf::from(".")).recursive(true); + let entries = lis.list(); + + assert!(!entries.is_empty()); + + // In a recursive list, we should see paths like "src/lib.rs" or "cli/main.rs" + // The implementation of get_display_name for recursive: + // Some(if s.is_empty() { self.path.to_string_lossy().into_owned() } else { s }) + + let has_src_lib_rs = entries.iter().any(|e| e.name == "src/lib.rs" || e.name == "src/lib.rs".replace("/", std::path::MAIN_SEPARATOR.to_string().as_str())); + assert!(has_src_lib_rs); +} From 6caa80788f1a76072341a4b5094b20f59ce0d2d5 Mon Sep 17 00:00:00 2001 From: Haruaki Tamada Date: Tue, 21 Apr 2026 10:30:25 +0900 Subject: [PATCH 10/13] Add unit tests for CLI arguments and display --- cli/args.rs | 31 +++++++++++++++++++++++++++++++ cli/display.rs | 15 +++++++++++++++ 2 files changed, 46 insertions(+) diff --git a/cli/args.rs b/cli/args.rs index 9ca332a..0bf265f 100644 --- a/cli/args.rs +++ b/cli/args.rs @@ -49,3 +49,34 @@ pub enum Format { Json, Yaml, } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_args_default() { + let args = Args::try_parse_from(&["lis"]).unwrap(); + assert_eq!(args.path, PathBuf::from(".")); + assert_eq!(args.sort, SortBy::Name); + assert!(!args.reverse); + assert!(!args.long); + assert!(!args.all); + assert!(!args.whole_all); + assert_eq!(args.format, Format::Plain); + } + + #[test] + fn test_args_custom() { + let args = Args::try_parse_from(&["lis", "src", "--sort", "size", "-r", "-l", "-a", "--icon", "--format", "json", "-R"]).unwrap(); + assert_eq!(args.path, PathBuf::from("src")); + assert_eq!(args.sort, SortBy::Size); + assert!(args.reverse); + assert!(args.long); + assert!(args.all); + assert!(args.icon); + assert_eq!(args.format, Format::Json); + assert!(args.recursive); + } +} + diff --git a/cli/display.rs b/cli/display.rs index 74a9195..4cb1117 100644 --- a/cli/display.rs +++ b/cli/display.rs @@ -113,3 +113,18 @@ pub fn get_icon(_name: &str, is_dir: bool, ext: &str) -> &'static str { _ => "\u{f15b}", } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_get_icon() { + assert_eq!(get_icon("main.rs", false, "rs"), "\u{e7a8}"); + assert_eq!(get_icon("README.md", false, "md"), "\u{f48a}"); + assert_eq!(get_icon("Cargo.toml", false, "toml"), "\u{f013}"); + assert_eq!(get_icon("src", true, ""), "\u{f115}"); + assert_eq!(get_icon("unknown", false, "unknown"), "\u{f15b}"); + } +} + From d0c6451ce18f235d3934db89da5225b3dc2da6f3 Mon Sep 17 00:00:00 2001 From: Haruaki Tamada Date: Tue, 21 Apr 2026 10:32:28 +0900 Subject: [PATCH 11/13] Fix clippy warnings in CLI tests --- cli/args.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/cli/args.rs b/cli/args.rs index 0bf265f..15751d8 100644 --- a/cli/args.rs +++ b/cli/args.rs @@ -56,7 +56,7 @@ mod tests { #[test] fn test_args_default() { - let args = Args::try_parse_from(&["lis"]).unwrap(); + let args = Args::try_parse_from(["lis"]).unwrap(); assert_eq!(args.path, PathBuf::from(".")); assert_eq!(args.sort, SortBy::Name); assert!(!args.reverse); @@ -68,7 +68,10 @@ mod tests { #[test] fn test_args_custom() { - let args = Args::try_parse_from(&["lis", "src", "--sort", "size", "-r", "-l", "-a", "--icon", "--format", "json", "-R"]).unwrap(); + let args = Args::try_parse_from([ + "lis", "src", "--sort", "size", "-r", "-l", "-a", "--icon", "--format", "json", "-R", + ]) + .unwrap(); assert_eq!(args.path, PathBuf::from("src")); assert_eq!(args.sort, SortBy::Size); assert!(args.reverse); From a786ee12f2b2db81682622b43d7a734e27150ea1 Mon Sep 17 00:00:00 2001 From: Haruaki Tamada Date: Fri, 8 May 2026 12:43:21 +0900 Subject: [PATCH 12/13] fix: update checkout action version in update-version workflow --- .github/workflows/publish.yaml | 37 ++++++++++----------------- .github/workflows/update-version.yaml | 2 +- 2 files changed, 14 insertions(+), 25 deletions(-) diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml index a458f86..11580d0 100644 --- a/.github/workflows/publish.yaml +++ b/.github/workflows/publish.yaml @@ -26,16 +26,22 @@ jobs: - name: Git Tag name id: vars run: | + # ๅพŒใ‹ใ‚‰ๅ‚็…งใ™ใ‚‹ใŸใ‚ใซใ€ใ‚ฟใ‚ฐๅใจใ‚ขใƒ—ใƒชใ‚ฑใƒผใ‚ทใƒงใƒณๅใ‚’ๅ‡บๅŠ›ใ—ใฆใŠใใ€‚ + # ใ‚ฟใ‚ฐๅใฏใ€ใƒ–ใƒฉใƒณใƒๅใ‹ใ‚‰ 'releases/v' ใ‚’ๅ–ใ‚Š้™คใ„ใŸใ‚‚ใฎใ€‚ + # ใ‚ขใƒ—ใƒชใ‚ฑใƒผใ‚ทใƒงใƒณๅใฏใ€ใƒชใƒใ‚ธใƒˆใƒชๅใ‹ใ‚‰ใ‚ชใƒผใƒŠใƒผๅใ‚’ๅ–ใ‚Š้™คใ„ใŸใ‚‚ใฎใ€‚ + # $GITHUB_OUTPUT ใซๅ‡บๅŠ›ใ™ใ‚‹ใ“ใจใงใ€ๅพŒ็ถšใฎใ‚ธใƒงใƒ–ใ‹ใ‚‰ๅ‚็…งใงใใ‚‹ใ‚ˆใ†ใซใชใ‚‹ใ€‚ echo "tag=${GITHUB_HEAD_REF##*/v}" >> $GITHUB_OUTPUT echo "appname=${GITHUB_REPOSITORY#*/}" >> $GITHUB_OUTPUT - name: Set git identity run: | + # git tag ใ‚’ push ใ™ใ‚‹ใฎใงใ€git ใฎใƒฆใƒผใ‚ถๅใจใƒกใƒผใƒซใ‚ขใƒ‰ใƒฌใ‚นใ‚’่จญๅฎšใ—ใฆใŠใใ€‚ git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" - name: Create and Push tag run: | + # ใ‚ฟใ‚ฐใ‚’ไฝœๆˆใ—ใฆใ€ใƒชใƒขใƒผใƒˆใซ push ใ™ใ‚‹ใ€‚ git tag v${{ steps.vars.outputs.tag }} git push origin v${{ steps.vars.outputs.tag }} @@ -43,19 +49,10 @@ jobs: env: GH_TOKEN: ${{ github.token }} run: | + # gh ใ‚ณใƒžใƒณใƒ‰ใ‚’ไฝฟใฃใฆใ€ใƒชใƒชใƒผใ‚นใ‚’ไฝœๆˆใ™ใ‚‹ใ€‚ + # draft ใจใ—ใฆไฝœๆˆใ™ใ‚‹ใ€‚ๅพŒ็ถšใฎใ‚ธใƒงใƒ–ใงใ€ใƒ‰ใƒฉใƒ•ใƒˆใ‚’ false ใซใ™ใ‚‹ใ€‚ gh release create v${{ steps.vars.outputs.tag }} --verify-tag --generate-notes --title "Release v${{ steps.vars.outputs.tag }}" --draft - # - name: Create release - # id: create_release - # uses: actions/create-release@v1.0.0 - # env: - # GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - # with: - # tag_name: v${{ steps.vars.outputs.tag }} - # release_name: Release v${{ steps.vars.outputs.tag }} - # draft: true - # prerelease: false - publish: runs-on: ${{ matrix.os }} needs: setup @@ -76,18 +73,6 @@ jobs: os_name: linux artifact_name: ${{ needs.setup.outputs.appname }} ext: '' - # - os: ubuntu-latest - # architecture: arm64 - # os_name: windows - # target: aarch64-pc-windows-gnullvm - # artifact_name: ${{ needs.setup.outputs.appname }}-cli.exe - # ext: '.exe' - # - os: ubuntu-latest - # architecture: amd64 - # os_name: windows - # target: x86_64-pc-windows-gnu - # artifact_name: ${{ needs.setup.outputs.appname }}-cli.exe - # ext: '.exe' - os: macos-latest target: aarch64-apple-darwin architecture: arm64 @@ -117,10 +102,12 @@ jobs: rustup update stable rustup target add ${{ matrix.target }} cargo install cargo-zigbuild + # zigbuild ใ‚’ไฝฟใฃใฆใ€ใ‚ฏใƒญใ‚นใ‚ณใƒณใƒ‘ใ‚คใƒซใ™ใ‚‹ใ€‚ cargo zigbuild --release --target ${{ matrix.target }} - name: Create distribution files. run: | + # ้…ๅธƒ็”จใฎใƒ•ใ‚กใ‚คใƒซใ‚’ไฝœๆˆใ™ใ‚‹ใ€‚ ASSET_NAME=${{ needs.setup.outputs.appname }}-${{ needs.setup.outputs.tag}}_${{ matrix.architecture }}_${{ matrix.os_name }} mkdir -p dist/$ASSET_NAME cp target/${{ matrix.target }}/release/${{ needs.setup.outputs.appname }}${{ matrix.ext }} dist/$ASSET_NAME/ @@ -131,6 +118,7 @@ jobs: env: GH_TOKEN: ${{ github.token }} run: | + # gh ใ‚ณใƒžใƒณใƒ‰ใ‚’ไฝฟใฃใฆใ€ใƒชใƒชใƒผใ‚นใซใ‚ขใ‚ปใƒƒใƒˆใ‚’ใ‚ขใƒƒใƒ—ใƒญใƒผใƒ‰ใ™ใ‚‹ใ€‚ ASSET_NAME=${{ needs.setup.outputs.appname }}-${{ needs.setup.outputs.tag}}_${{ matrix.architecture }}_${{ matrix.os_name }} gh release upload v${{ needs.setup.outputs.tag }} dist/${ASSET_NAME}.tar.gz @@ -138,7 +126,7 @@ jobs: runs-on: ubuntu-latest permissions: contents: write - needs: publish + needs: publish # publish ใ‚ธใƒงใƒ–ใŒๅฎŒไบ†ใ—ใŸๅพŒใซๅฎŸ่กŒใ•ใ‚Œใ‚‹ใ€‚ steps: - name: Checkout uses: actions/checkout@v6 @@ -150,4 +138,5 @@ jobs: env: GH_TOKEN: ${{ github.token }} run: | + # gh ใ‚ณใƒžใƒณใƒ‰ใ‚’ไฝฟใฃใฆใ€ใƒ‰ใƒฉใƒ•ใƒˆใ‚’ false ใซใ™ใ‚‹๏ผˆrelease ใŒ immutable ใงใ‚ใ‚Œใฐใ€ใ“ใ‚Œไปฅ้™ๅค‰ๆ›ดใงใใชใ„๏ผ‰ใ€‚ gh release edit v${{ needs.publish.outputs.tag }} --draft=false diff --git a/.github/workflows/update-version.yaml b/.github/workflows/update-version.yaml index e9ecb17..86b2ce3 100644 --- a/.github/workflows/update-version.yaml +++ b/.github/workflows/update-version.yaml @@ -16,7 +16,7 @@ jobs: echo "tag=${GITHUB_REF##**/v}" >> $GITHUB_OUTPUT - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: ref: ${{ steps.vars.outputs.branch }} fetch-depth: 0 From 48616964a84b13faa2fa15a65a9816ca86a740b2 Mon Sep 17 00:00:00 2001 From: Haruaki Tamada Date: Sat, 9 May 2026 08:16:05 +0900 Subject: [PATCH 13/13] fix: comment out windows-latest in build workflow and move unix-specific imports in entry.rs --- .github/workflows/build.yaml | 2 +- src/entry.rs | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index f14584a..b653e0e 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -11,7 +11,7 @@ jobs: os: - ubuntu-latest - macOS-latest - - windows-latest + # - windows-latest steps: - name: setup rust run: | diff --git a/src/entry.rs b/src/entry.rs index ae497e6..7e7ad80 100644 --- a/src/entry.rs +++ b/src/entry.rs @@ -3,9 +3,10 @@ use clap::ValueEnum; use nix::sys::stat::Mode; use serde::{Deserialize, Serialize}; use std::fs; -use std::os::unix::fs::{MetadataExt, PermissionsExt}; use std::path::{Path, PathBuf}; use uzers::{get_group_by_gid, get_user_by_uid}; +#[cfg(unix)] +use std::os::unix::fs::{MetadataExt, PermissionsExt}; /// Represents a single directory entry with its metadata. #[derive(Serialize, Deserialize, Debug, Clone)]