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/.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 diff --git a/Cargo.toml b/Cargo.toml index 23a7a55..dde8516 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,3 +4,22 @@ version = "0.0.10" 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"] } + +[[bin]] +name = "lis" +path = "cli/main.rs" diff --git a/README.md b/README.md index 01a857c..8a20fe9 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,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). diff --git a/cli/args.rs b/cli/args.rs new file mode 100644 index 0000000..15751d8 --- /dev/null +++ b/cli/args.rs @@ -0,0 +1,85 @@ +use clap::{Parser, ValueEnum}; +use lis::entry::SortBy; +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)] +pub enum Format { + Plain, + Csv, + 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 new file mode 100644 index 0000000..4cb1117 --- /dev/null +++ b/cli/display.rs @@ -0,0 +1,130 @@ +use humansize::{DECIMAL, format_size}; +use lis::entry::Entry; +use lscolors::LsColors; +use terminal_size::{Width, terminal_size}; +use unicode_width::UnicodeWidthStr; + +pub fn print_plain(entries: &[Entry], lscolors: &LsColors, show_icon: bool) { + use std::io::IsTerminal; + if !std::io::stdout().is_terminal() { + print_one_column(entries); + return; + } + + 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 = 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; + + 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 + ); + } +} + +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}"; + } + 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}", + } +} + +#[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}"); + } +} + diff --git a/cli/main.rs b/cli/main.rs new file mode 100644 index 0000000..858978e --- /dev/null +++ b/cli/main.rs @@ -0,0 +1,45 @@ +mod args; +mod display; + +use args::{Args, Format}; +use clap::Parser; +use lis::Lis; +use lis::entry::{Entry, sort_entries}; +use lscolors::LsColors; + +fn main() { + let args = Args::parse(); + let lscolors = LsColors::from_env().unwrap_or_default(); + + let mut entries = Lis::new(args.path.clone()) + .recursive(args.recursive) + .all(args.all) + .whole_all(args.whole_all) + .list(); + + sort_entries(&mut entries, args.sort.clone(), args.reverse); + output_entries(&entries, &args, &lscolors); +} + +fn output_entries(entries: &[Entry], args: &Args, lscolors: &LsColors) { + match args.format { + Format::Plain => { + if args.long { + display::print_long(entries, lscolors, args.icon); + } else { + display::print_plain(entries, lscolors, args.icon); + } + } + 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(); +} 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..e7feec8 --- /dev/null +++ b/examples/complex.rs @@ -0,0 +1,24 @@ +use lis::Lis; +use lis::entry::{SortBy, sort_entries}; +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} Path", "Mode", "Size", "Modified"); + println!("{:-<60}", ""); + + for entry in entries.iter().take(10) { + println!( + "{:<10} {:>10} {:<20} {}", + entry.mode, entry.size, entry.modified, entry.name + ); + } +} diff --git a/src/entry.rs b/src/entry.rs new file mode 100644 index 0000000..7e7ad80 --- /dev/null +++ b/src/entry.rs @@ -0,0 +1,215 @@ +use chrono::{DateTime, Local}; +use clap::ValueEnum; +use nix::sys::stat::Mode; +use serde::{Deserialize, Serialize}; +use std::fs; +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)] +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(); + let is_symlink = metadata.file_type().is_symlink(); + + Some(Entry { + name, + path: path.to_path_buf(), + is_dir, + 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: path + .extension() + .and_then(|e| e.to_str()) + .unwrap_or("") + .to_string(), + }) + } +} + +/// 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 { + 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 } + }); +} + +/// 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()) + .into(); + 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)); + + #[cfg(unix)] + { + let m = Mode::from_bits_truncate(mode as nix::sys::stat::mode_t); + s.push_str(&get_permissions_string(m)); + } + 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); + 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 +} + +#[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 new file mode 100644 index 0000000..97823d7 --- /dev/null +++ b/src/git.rs @@ -0,0 +1,44 @@ +use git2::{Repository, StatusOptions}; +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) { + let mut opts = StatusOptions::new(); + opts.include_untracked(true); + 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()); + } + } + } + } + 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 new file mode 100644 index 0000000..12f97c1 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,139 @@ +//! `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; + +use entry::Entry; +use git::get_git_statuses; +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, + all: bool, + whole_all: bool, +} + +impl Lis { + /// Creates a new `Lis` instance for the given path. + pub fn new(path: PathBuf) -> Self { + Self { + path, + recursive: false, + all: false, + whole_all: false, + } + } + + /// 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(); + let walker = self.create_walker(); + + for dir_entry in walker.flatten() { + if let Some(entry) = self.process_dir_entry(&dir_entry, &git_statuses) { + entries.push(entry); + } + } + 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); + builder.git_ignore(!self.whole_all); + builder.max_depth(if self.recursive { None } else { Some(1) }); + builder.build() + } + + /// Processes a single directory entry and converts it to an `Entry` struct. + 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) + } + + /// 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) { + 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()) + } + } +} + +#[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/src/main.rs b/src/main.rs deleted file mode 100644 index e7a11a9..0000000 --- a/src/main.rs +++ /dev/null @@ -1,3 +0,0 @@ -fn main() { - println!("Hello, world!"); -} 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); +}