use std::ffi::OsString;
use std::path::PathBuf;
use crate::sort::SortKey;
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct ListOptions {
pub recursive: bool,
pub sort_key: SortKey,
pub reverse: bool,
pub unrestricted: u8,
pub directory: bool,
}
#[derive(Debug, PartialEq, Eq)]
pub enum Action {
List {
paths: Vec<PathBuf>,
options: ListOptions,
},
Help,
Version,
}
#[derive(Debug, PartialEq, Eq)]
pub struct ArgError {
pub message: String,
}
pub const HELP: &str = "\
Usage: freshl [OPTION]... [PATH]...
A modern replacement for `ls`. One opinionated layout: type, mode, links,
owner, group, size (raw bytes grouped in clusters of six), mtime as ISO 8601
UTC, optional git status, name. Hidden files are always listed.
Options:
-R Recurse into directories (depth-first).
-S Sort by size, smallest first (largest at the bottom).
-t Sort by mtime, oldest first (newest at the bottom).
-r Toggle the within-group order; repeat to undo (`-rrrr` is a
no-op). Directories still group first.
-u Recurse into gitignored directories (with -R). Repeat (-uu) to
also recurse into hidden directories.
-d List directories themselves, not their contents. Suppresses -R.
-h, --help Print this help.
--version Print version.
-- Treat following arguments as paths.
Short flags may be bundled (e.g. -Rt, -Sr, -Ruu).
";
#[must_use]
pub fn version_line() -> String {
format!("{} {}", env!("CARGO_PKG_NAME"), env!("CARGO_PKG_VERSION"))
}
pub fn parse<I>(raw: I) -> Result<Action, ArgError>
where
I: IntoIterator<Item = OsString>,
{
let mut paths: Vec<PathBuf> = Vec::new();
let mut options = ListOptions::default();
let mut positional_only = false;
for arg in raw {
if positional_only {
paths.push(PathBuf::from(arg));
continue;
}
let bytes = arg.as_encoded_bytes();
if bytes == b"--" {
positional_only = true;
} else if bytes == b"--help" {
return Ok(Action::Help);
} else if bytes == b"--version" {
return Ok(Action::Version);
} else if let Some(cluster) = bytes.strip_prefix(b"-")
&& let Some(&first) = cluster.first()
&& first != b'-'
{
if cluster.contains(&b'h') {
return Ok(Action::Help);
}
for &b in cluster {
match b {
b'R' => options.recursive = true,
b'S' => options.sort_key = SortKey::Size,
b't' => options.sort_key = SortKey::Time,
b'r' => options.reverse = !options.reverse,
b'u' => options.unrestricted = options.unrestricted.saturating_add(1).min(2),
b'd' => options.directory = true,
_ => {
return Err(ArgError {
message: format!("unknown option: {}", arg.to_string_lossy()),
});
}
}
}
} else if bytes.starts_with(b"-") && bytes != b"-" {
return Err(ArgError {
message: format!("unknown option: {}", arg.to_string_lossy()),
});
} else {
paths.push(PathBuf::from(arg));
}
}
Ok(Action::List { paths, options })
}
#[cfg(test)]
mod tests {
use super::{Action, ListOptions, parse, version_line};
use crate::sort::SortKey;
use std::ffi::OsString;
use std::path::PathBuf;
fn args(items: &[&str]) -> Vec<OsString> {
items.iter().map(OsString::from).collect()
}
fn list(paths: Vec<PathBuf>, options: ListOptions) -> Action {
Action::List { paths, options }
}
#[test]
fn empty_means_list_with_no_paths() {
assert_eq!(parse(args(&[])), Ok(list(vec![], ListOptions::default())));
}
#[test]
fn single_positional_path() {
assert_eq!(
parse(args(&["src"])),
Ok(list(vec![PathBuf::from("src")], ListOptions::default()))
);
}
#[test]
fn multiple_positional_paths() {
assert_eq!(
parse(args(&["a", "b", "c"])),
Ok(list(
vec![PathBuf::from("a"), PathBuf::from("b"), PathBuf::from("c"),],
ListOptions::default()
))
);
}
#[test]
fn help_short_flag() {
assert_eq!(parse(args(&["-h"])), Ok(Action::Help));
}
#[test]
fn help_long_flag() {
assert_eq!(parse(args(&["--help", "ignored"])), Ok(Action::Help));
}
#[test]
fn version_long_flag() {
assert_eq!(parse(args(&["--version", "ignored"])), Ok(Action::Version));
}
#[test]
fn double_dash_treats_following_as_paths() {
assert_eq!(
parse(args(&["--", "--help", "-foo"])),
Ok(list(
vec![PathBuf::from("--help"), PathBuf::from("-foo"),],
ListOptions::default()
))
);
}
#[test]
fn unknown_long_flag_errors() {
let err = parse(args(&["--what"])).unwrap_err();
assert!(err.message.contains("--what"));
}
#[test]
fn single_dash_is_a_path() {
assert_eq!(
parse(args(&["-"])),
Ok(list(vec![PathBuf::from("-")], ListOptions::default()))
);
}
#[test]
fn version_line_includes_name_and_version() {
let v = version_line();
assert!(v.starts_with("freshl "));
assert!(v.len() > "freshl ".len());
}
#[test]
fn help_text_mentions_usage() {
assert!(super::HELP.contains("Usage: freshl"));
}
fn list_with(opts: ListOptions) -> Action {
Action::List {
paths: vec![],
options: opts,
}
}
#[test]
fn recursive_short_flag_sets_recursive() {
assert_eq!(
parse(args(&["-R"])),
Ok(list_with(ListOptions {
recursive: true,
..ListOptions::default()
}))
);
}
#[test]
fn size_short_flag_sets_sort_key() {
assert_eq!(
parse(args(&["-S"])),
Ok(list_with(ListOptions {
sort_key: SortKey::Size,
..ListOptions::default()
}))
);
}
#[test]
fn time_short_flag_sets_sort_key() {
assert_eq!(
parse(args(&["-t"])),
Ok(list_with(ListOptions {
sort_key: SortKey::Time,
..ListOptions::default()
}))
);
}
#[test]
fn reverse_short_flag_sets_reverse() {
assert_eq!(
parse(args(&["-r"])),
Ok(list_with(ListOptions {
reverse: true,
..ListOptions::default()
}))
);
}
#[test]
fn double_r_cancels_reverse() {
assert_eq!(parse(args(&["-rr"])), Ok(list_with(ListOptions::default())));
}
#[test]
fn triple_r_leaves_reverse_set() {
assert_eq!(
parse(args(&["-rrr"])),
Ok(list_with(ListOptions {
reverse: true,
..ListOptions::default()
}))
);
}
#[test]
fn quadruple_r_is_a_noop() {
assert_eq!(
parse(args(&["-rrrr"])),
Ok(list_with(ListOptions::default()))
);
}
#[test]
fn separate_r_args_toggle_independently() {
assert_eq!(
parse(args(&["-r", "-r"])),
Ok(list_with(ListOptions::default()))
);
}
#[test]
fn rt_cluster_sets_reverse_with_time_key() {
assert_eq!(
parse(args(&["-rt"])),
Ok(list_with(ListOptions {
sort_key: SortKey::Time,
reverse: true,
..ListOptions::default()
}))
);
}
#[test]
fn bundled_cluster_sets_multiple_flags() {
assert_eq!(
parse(args(&["-Rt"])),
Ok(list_with(ListOptions {
recursive: true,
sort_key: SortKey::Time,
..ListOptions::default()
}))
);
}
#[test]
fn bundled_cluster_with_reverse_size_and_recursive() {
assert_eq!(
parse(args(&["-rSR"])),
Ok(list_with(ListOptions {
recursive: true,
sort_key: SortKey::Size,
reverse: true,
..ListOptions::default()
}))
);
}
#[test]
fn unknown_letter_in_cluster_errors_with_cluster_in_message() {
let err = parse(args(&["-RX"])).unwrap_err();
assert!(err.message.contains("-RX"), "got: {}", err.message);
}
#[test]
fn single_u_sets_unrestricted_to_one() {
assert_eq!(
parse(args(&["-u"])),
Ok(list_with(ListOptions {
unrestricted: 1,
..ListOptions::default()
}))
);
}
#[test]
fn double_u_sets_unrestricted_to_two() {
assert_eq!(
parse(args(&["-uu"])),
Ok(list_with(ListOptions {
unrestricted: 2,
..ListOptions::default()
}))
);
}
#[test]
fn triple_u_saturates_at_two() {
assert_eq!(
parse(args(&["-uuu"])),
Ok(list_with(ListOptions {
unrestricted: 2,
..ListOptions::default()
}))
);
}
#[test]
fn separate_u_flags_accumulate_to_two() {
assert_eq!(
parse(args(&["-u", "-u"])),
Ok(list_with(ListOptions {
unrestricted: 2,
..ListOptions::default()
}))
);
}
#[test]
fn h_in_cluster_short_circuits_to_help() {
assert_eq!(parse(args(&["-Rh"])), Ok(Action::Help));
}
#[test]
fn directory_short_flag_sets_directory() {
assert_eq!(
parse(args(&["-d"])),
Ok(list_with(ListOptions {
directory: true,
..ListOptions::default()
}))
);
}
#[test]
fn directory_bundles_with_other_flags() {
assert_eq!(
parse(args(&["-dR"])),
Ok(list_with(ListOptions {
directory: true,
recursive: true,
..ListOptions::default()
}))
);
}
#[test]
fn paths_after_flags_still_collected() {
assert_eq!(
parse(args(&["-R", "src", "docs"])).unwrap(),
Action::List {
paths: vec![PathBuf::from("src"), PathBuf::from("docs")],
options: ListOptions {
recursive: true,
..ListOptions::default()
},
}
);
}
}