diff --git a/Cargo.lock b/Cargo.lock index bf540c37..7a256472 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -749,6 +749,7 @@ dependencies = [ "proc-mounts", "rayon", "serde", + "serde_json", "serde_norway", "terminal_size", "timeago", diff --git a/Cargo.toml b/Cargo.toml index 994b6f0a..076c070b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -112,6 +112,7 @@ timeago = { version = "0.6.0", default-features = false } unicode-width = "0.2" ansi-width = "0.1.0" serde = { version = "1.0.219", features = ["derive"] } +serde_json = "1.0" dirs = "6.0.0" serde_norway = "0.9" backtrace = "0.3" diff --git a/README.md b/README.md index fcff9377..8cc9fa8c 100644 --- a/README.md +++ b/README.md @@ -90,6 +90,7 @@ If you want to pass arguments this way, use e.g. `nix run github:fxrdhan/lsr -- - **-R**, **--recurse**: recurse into directories - **-T**, **--tree**: recurse into directories as a tree - **--code[=MODE]**: print lines-of-code summary by language (Odin, Rust, C/C++, Python, Go, etc.) +- **--json**: output file listing and metadata as structured JSON - **-x**, **--across**: sort the grid across, rather than downwards - **-F**, **--classify[=(when)]**: display type indicator by file names (always, auto, never) - **--colo[u]r=(when)**: when to use terminal colours (always, auto, never) diff --git a/man/eza.1.md b/man/eza.1.md index 4375a68d..c66d777c 100644 --- a/man/eza.1.md +++ b/man/eza.1.md @@ -88,6 +88,9 @@ When used without a value, defaults to ‘`automatic`’. : The given paths (or the current directory) are walked recursively, honouring a git repository’s `.gitignore` when one is present, and each recognised language (including Odin, Rust, C/C++, Python, Go, and 100+ others) is reported with its file, line, code, comment, and blank counts, plus a bar visualising its share of the code. Valid modes are ‘`lines`’, ‘`percent`’, and ‘`both`’ (the default). +`--json` +: Output file listing and metadata as structured JSON for easy parsing and scripting. + `--follow-symlinks` : Drill down into symbolic links that point to directories. diff --git a/man/lsr.1.md b/man/lsr.1.md index 63c1f930..dec096b7 100644 --- a/man/lsr.1.md +++ b/man/lsr.1.md @@ -92,6 +92,9 @@ When used without a value, defaults to ‘`automatic`’. : The given paths (or the current directory) are walked recursively, honouring a git repository’s `.gitignore` when one is present, and each recognised language (including Odin, Rust, C/C++, Python, Go, and 100+ others) is reported with its file, line, code, comment, and blank counts, plus a bar visualising its share of the code. Valid modes are ‘`lines`’, ‘`percent`’, and ‘`both`’ (the default). +`--json` +: Output file listing and metadata as structured JSON for easy parsing and scripting. + `--follow-symlinks` : Drill down into symbolic links that point to directories. diff --git a/src/fs/dir.rs b/src/fs/dir.rs index 4d7fbc1b..1a2293ea 100644 --- a/src/fs/dir.rs +++ b/src/fs/dir.rs @@ -24,6 +24,7 @@ use crate::fs::File; /// This object gets passed to the Files themselves, in order for them to /// check the existence of surrounding files, then highlight themselves /// accordingly. (See `File#get_source_files`) +#[derive(Debug)] pub struct Dir { /// A vector of the files that have been read from this directory. contents: Vec, diff --git a/src/fs/feature/xattr.rs b/src/fs/feature/xattr.rs index 01466292..4c445272 100644 --- a/src/fs/feature/xattr.rs +++ b/src/fs/feature/xattr.rs @@ -583,6 +583,10 @@ const ATTRIBUTE_DISPLAYS: &[AttributeDisplay] = &[ attribute: "com.apple.macl", display: display_macl, }, + AttributeDisplay { + attribute: "com.apple.ResourceFork", + display: display_resourcefork, + }, ]; #[cfg(not(target_os = "macos"))] @@ -608,6 +612,43 @@ fn display_lastuseddate(attribute: &Attribute) -> Option { }) } +// Decode Classic Mac OS Resource Fork headers +#[cfg(target_os = "macos")] +fn display_resourcefork(attribute: &Attribute) -> Option { + let value = attribute.value.as_deref()?; + if value.len() < 16 { + return None; + } + let map_offset = u32::from_be_bytes(value[4..8].try_into().ok()?) as usize; + let map_len = u32::from_be_bytes(value[12..16].try_into().ok()?) as usize; + if value.len() < map_offset.checked_add(map_len)? || map_len < 28 { + return None; + } + let map = &value[map_offset..map_offset + map_len]; + let type_list_offset = u16::from_be_bytes(map[24..26].try_into().ok()?) as usize; + if map.len() < type_list_offset.checked_add(2)? { + return None; + } + let type_list = &map[type_list_offset..]; + let num_types_minus_1 = u16::from_be_bytes(type_list[0..2].try_into().ok()?); + let num_types = num_types_minus_1 as usize + 1; + if type_list.len() < 2 + num_types * 8 { + return None; + } + let mut resources = Vec::new(); + for i in 0..num_types { + let entry = &type_list[2 + i * 8..2 + (i + 1) * 8]; + let type_code = &entry[0..4]; + let count = u16::from_be_bytes(entry[4..6].try_into().ok()?) + 1; + let type_str = match std::str::from_utf8(type_code) { + Ok(s) => s.trim().to_string(), + Err(_) => format!("{type_code:02x?}"), + }; + resources.push(format!("{type_str}: {count}")); + } + Some(format!("[{}]", resources.join(", "))) +} + // com.apple.macl is a two byte flag followed by a uuid for the application #[cfg(target_os = "macos")] fn format_macl(value: &[u8]) -> String { diff --git a/src/fs/file.rs b/src/fs/file.rs index 66b4e78a..ccfe4bc9 100644 --- a/src/fs/file.rs +++ b/src/fs/file.rs @@ -1041,6 +1041,26 @@ impl<'dir> File<'dir> { pub fn flags(&self) -> f::Flags { f::Flags(0) } + + #[cfg(unix)] + pub fn permissions_plus(&self, xattrs: bool) -> Option { + self.permissions().map(|p| f::PermissionsPlus { + file_type: self.type_char(), + permissions: p, + xattrs, + }) + } + + #[allow(clippy::unnecessary_wraps)] // Needs to match Unix function + #[cfg(windows)] + pub fn permissions_plus(&self, xattrs: bool) -> Option { + Some(f::PermissionsPlus { + file_type: self.type_char(), + #[cfg(windows)] + attributes: self.attributes()?, + xattrs, + }) + } } impl<'a> AsRef> for File<'a> { diff --git a/src/main.rs b/src/main.rs index 6e7c914e..84adcdfb 100644 --- a/src/main.rs +++ b/src/main.rs @@ -24,7 +24,9 @@ use crate::fs::filter::{FileFilterFlags::OnlyFiles, GitIgnore}; use crate::fs::{Dir, File}; use crate::options::stdin::FilesInput; use crate::options::{Options, Vars, vars}; -use crate::output::{Mode, View, code, details, escape, file_name, grid, grid_details, lines}; +use crate::output::{ + Mode, View, code, details, escape, file_name, grid, grid_details, json, lines, +}; use crate::theme::Theme; use log::*; @@ -265,6 +267,14 @@ fn git_repos(options: &Options, args: &[&OsStr]) -> bool { .. }, .. + }) + | Mode::Json(json::Options { + details: + Some(details::Options { + table: Some(ref table), + .. + }), + .. }) => table.columns.subdir_git_repos || table.columns.subdir_git_repos_no_stat, _ => false, }; @@ -367,6 +377,21 @@ impl Exa<'_> { let no_files = files.is_empty(); let is_only_dir = dirs.len() == 1 && no_files; + // Separate json mode as there is special cases for multi directories cases + if let Mode::Json(opts) = &self.options.view.mode { + let r = json::Render::new( + self.git.as_ref(), + self.options.filter.dot_filter, + opts, + self.options.filter.git_ignore == GitIgnore::CheckAndIgnore, + self.git_repos, + &self.options, + ); + + r.render(files, dirs, &mut self.writer)?; + return Ok(exit_status); + } + self.options.filter.filter_argument_files(&mut files); self.print_files(None, files)?; @@ -625,6 +650,8 @@ impl Exa<'_> { // The code summary never lists files; it’s handled up front in // `run` before we ever get here. (Mode::Code(_), _) => unreachable!("--code is handled in Exa::run"), + + (Mode::Json(_), _) => unreachable!("--json is handled in Exa::run"), }; result?; diff --git a/src/options/config.rs b/src/options/config.rs index e11a4269..a2397947 100644 --- a/src/options/config.rs +++ b/src/options/config.rs @@ -578,6 +578,7 @@ pub struct UiStylesOverride { pub filenames: Option>, pub extensions: Option>, + pub directorynames: Option>, } impl FromOverride for UiStyles { @@ -613,6 +614,7 @@ impl FromOverride for UiStyles { filenames: FromOverride::from(value.filenames, default.filenames), extensions: FromOverride::from(value.extensions, default.extensions), + directorynames: FromOverride::from(value.directorynames, default.directorynames), } } } diff --git a/src/options/mod.rs b/src/options/mod.rs index 77ee8fac..bccb8f3b 100644 --- a/src/options/mod.rs +++ b/src/options/mod.rs @@ -79,7 +79,7 @@ use clap::ArgMatches; use crate::fs::dir_action::DirAction; use crate::fs::filter::{FileFilter, GitIgnore}; use crate::options::stdin::FilesInput; -use crate::output::{Mode, View, details, grid_details}; +use crate::output::{Mode, View, details, grid_details, json}; use crate::theme::Options as ThemeOptions; mod dir_action; @@ -144,6 +144,14 @@ impl Options { .. }, .. + }) + | Mode::Json(json::Options { + details: + Some(details::Options { + table: Some(ref table), + .. + }), + .. }) => table.columns.git, _ => false, } diff --git a/src/options/parser.rs b/src/options/parser.rs index dd3384c7..745f711b 100644 --- a/src/options/parser.rs +++ b/src/options/parser.rs @@ -75,6 +75,7 @@ pub fn get_command() -> clap::Command { .arg(arg!(--"follow-symlinks" "drill down into symbolic links that point to directories")) .arg(arg!(-w --width "set screen width in columns") .value_parser(value_parser!(usize))) + .arg(arg!(--json "display as a json object")) .next_help_heading("DISPLAY OPTIONS") .arg(arg!(-F --classify [WHEN] "display type indicator by file names") diff --git a/src/options/view.rs b/src/options/view.rs index be9b4418..3ecca227 100644 --- a/src/options/view.rs +++ b/src/options/view.rs @@ -20,7 +20,7 @@ use crate::output::table::{ Columns, FlagsFormat, GroupFormat, Options as TableOptions, SizeFormat, TimeTypes, UserFormat, }; use crate::output::time::TimeFormat; -use crate::output::{Mode, TerminalWidth, View, code, details, grid}; +use crate::output::{Mode, TerminalWidth, View, code, details, grid, json}; use super::parser::{ColorScaleArgs, TimeArgs}; @@ -75,6 +75,12 @@ impl Mode { let oneline = matches.get_flag("oneline"); let grid = matches.get_flag("grid"); let tree = matches.get_flag("tree"); + let json = matches.get_flag("json"); + + if json { + let json = json::Options::deduce(matches, vars, long)?; + return Ok(Self::Json(json)); + } if !long && strict { Self::strict_check_long_flags(matches)?; @@ -165,6 +171,18 @@ impl grid::Options { } } +impl json::Options { + fn deduce(matches: &ArgMatches, vars: &V, long: bool) -> Result { + let details = if long { + Some(details::Options::deduce_json(matches, vars)?) + } else { + None + }; + + Ok(json::Options { details }) + } +} + impl details::Options { fn deduce_tree(matches: &ArgMatches, vars: &V) -> Self { details::Options { @@ -178,6 +196,18 @@ impl details::Options { } } + fn deduce_json(matches: &ArgMatches, vars: &V) -> Result { + Ok(details::Options { + table: Some(TableOptions::deduce(matches, vars)?), + header: false, + xattr: xattr::ENABLED && matches.get_flag("extended"), + secattr: xattr::ENABLED && matches.get_flag("security-context"), + mounts: matches.get_flag("mounts"), + color_scale: ColorScaleOptions::default(), + follow_links: matches.get_flag("follow-symlinks"), + }) + } + fn deduce_long( matches: &ArgMatches, vars: &V, @@ -1407,4 +1437,44 @@ mod tests { let view = View::deduce(&matches, &MockVars::default(), false).unwrap(); assert!(!view.total_entries); } + + #[test] + fn test_deduce_json_short() { + let matches = mock_cli(vec!["--json"]); + let mode = Mode::deduce(&matches, &MockVars::default(), false, false).unwrap(); + match mode { + Mode::Json(opts) => { + assert!(opts.details.is_none()); + } + _ => panic!("Expected Mode::Json"), + } + } + + #[test] + fn test_deduce_json_long() { + let matches = mock_cli(vec!["--long", "--json"]); + let mode = Mode::deduce(&matches, &MockVars::default(), false, false).unwrap(); + match mode { + Mode::Json(opts) => { + assert!(opts.details.is_some()); + } + _ => panic!("Expected Mode::Json with details"), + } + } + + #[test] + fn test_deduce_json_columns() { + let matches = mock_cli(vec!["--long", "--octal-permissions", "--bytes", "--json"]); + let mode = Mode::deduce(&matches, &MockVars::default(), false, false).unwrap(); + match mode { + Mode::Json(opts) => { + let details = opts.details.expect("details must be Some"); + let table = details.table.expect("table must be Some"); + #[cfg(unix)] + assert!(table.columns.octal); + assert_eq!(table.size_format, SizeFormat::JustBytes); + } + _ => panic!("Expected Mode::Json"), + } + } } diff --git a/src/output/details.rs b/src/output/details.rs index d9ee4a47..24ff8df2 100644 --- a/src/output/details.rs +++ b/src/output/details.rs @@ -250,19 +250,6 @@ impl<'a> Render<'a> { } } - /// Whether to show the extended attribute hint - pub fn show_xattr_hint(&self, file: &File<'_>) -> bool { - // Do not show the hint '@' if the only extended attribute is the security - // attribute and the security attribute column is active. - let xattr_count = file.extended_attributes().len(); - let selinux_ctx_shown = self.opts.secattr - && match file.security_context().context { - SecurityContextType::SELinux(_) => true, - SecurityContextType::None => false, - }; - xattr_count > 1 || (xattr_count == 1 && !selinux_ctx_shown) - } - /// Adds files to the table, possibly recursively. This is easily /// parallelisable, and uses a pool of threads. fn add_files_to_table<'dir>( @@ -308,9 +295,13 @@ impl<'a> Render<'a> { &[] }; - let table_row = table - .as_ref() - .map(|t| t.row_for_file(file, self.show_xattr_hint(file), color_scale_info)); + let table_row = table.as_ref().map(|t| { + t.row_for_file( + file, + show_xattr_hint(self.opts.secattr, file), + color_scale_info, + ) + }); let mut dir = None; let follow_links = self.opts.follow_links; @@ -555,3 +546,16 @@ impl Iterator for Iter { }) } } + +/// Whether to show the extended attribute hint +pub fn show_xattr_hint(secattr: bool, file: &File<'_>) -> bool { + // Do not show the hint '@' if the only extended attribute is the security + // attribute and the security attribute column is active. + let xattr_count = file.extended_attributes().len(); + let selinux_ctx_shown = secattr + && match file.security_context().context { + SecurityContextType::SELinux(_) => true, + SecurityContextType::None => false, + }; + xattr_count > 1 || (xattr_count == 1 && !selinux_ctx_shown) +} diff --git a/src/output/file_name.rs b/src/output/file_name.rs index b9154b91..741cc2b3 100644 --- a/src/output/file_name.rs +++ b/src/output/file_name.rs @@ -417,6 +417,12 @@ impl FileName<'_, '_, C> { // This is a filesystem mounted on the directory, output its details bits.push(Style::default().paint(" [")); bits.push(Style::default().paint(mount_details.source.clone())); + if !mount_details.dest.as_os_str().is_empty() + && mount_details.dest.as_path() != Path::new("/") + { + bits.push(Style::default().paint(" on ")); + bits.push(Style::default().paint(mount_details.dest.display().to_string())); + } bits.push(Style::default().paint(" (")); bits.push(Style::default().paint(mount_details.fstype.clone())); bits.push(Style::default().paint(")]")); diff --git a/src/output/grid_details.rs b/src/output/grid_details.rs index c0c47655..0b6e1d6b 100644 --- a/src/output/grid_details.rs +++ b/src/output/grid_details.rs @@ -18,7 +18,7 @@ use crate::fs::{Dir, File}; use crate::options::parser::CodeContent; use crate::output::cell::TextCell; use crate::output::color_scale::ColorScaleInformation; -use crate::output::details::{Options as DetailsOptions, Render as DetailsRender}; +use crate::output::details::{Options as DetailsOptions, Render as DetailsRender, show_xattr_hint}; use crate::output::file_name::Options as FileStyle; use crate::output::table::{Options as TableOptions, Table}; use crate::theme::Theme; @@ -92,28 +92,6 @@ pub struct Render<'a> { } impl<'a> Render<'a> { - /// Create a temporary Details render that gets used for the columns of - /// the grid-details render that’s being generated. - /// - /// This includes an empty files vector because the files get added to - /// the table in *this* file, not in details: we only want to insert every - /// *n* files into each column’s table, not all of them. - fn details_for_column(&self) -> DetailsRender<'a> { - #[rustfmt::skip] - return DetailsRender { - dir: self.dir, - files: Vec::new(), - theme: self.theme, - file_style: self.file_style, - opts: self.details, - recurse: None, - filter: self.filter, - git_ignoring: self.git_ignoring, - git: self.git, - git_repos: self.git_repos, - }; - } - // This doesn’t take an IgnoreCache even though the details one does // because grid-details has no tree view. @@ -124,8 +102,6 @@ impl<'a> Render<'a> { .as_ref() .expect("Details table options not given!"); - let drender = self.details_for_column(); - let color_scale_info = ColorScaleInformation::from_color_scale( self.details.color_scale, &self.files, @@ -144,7 +120,11 @@ impl<'a> Render<'a> { .files .iter() .map(|file| { - let row = table.row_for_file(file, drender.show_xattr_hint(file), color_scale_info); + let row = table.row_for_file( + file, + show_xattr_hint(self.details.secattr, file), + color_scale_info, + ); table.add_widths(&row); row }) diff --git a/src/output/icons.rs b/src/output/icons.rs index 9d974075..b1a731e4 100644 --- a/src/output/icons.rs +++ b/src/output/icons.rs @@ -381,6 +381,7 @@ const FILENAME_ICONS: Map<&'static str, char> = phf_map! { "id_ed25519" => Icons::PRIVATE_KEY, // 󰌆 "id_ed25519_sk" => Icons::PRIVATE_KEY, // 󰌆 "id_rsa" => Icons::PRIVATE_KEY, // 󰌆 + "Icon\r" => Icons::OS_APPLE, //  "index.theme" => '\u{ee72}', //  "inputrc" => Icons::CONFIG, // 󱁻 "Jenkinsfile" => '\u{e66e}', //  diff --git a/src/output/json.rs b/src/output/json.rs new file mode 100644 index 00000000..7f984037 --- /dev/null +++ b/src/output/json.rs @@ -0,0 +1,426 @@ +// SPDX-FileCopyrightText: 2024 Christina Sørensen +// SPDX-FileCopyrightText: 2026 fxrdhan +// SPDX-License-Identifier: EUPL-1.2 +// +// SPDX-FileCopyrightText: 2023-2026 Christina Sørensen, eza contributors +// SPDX-FileCopyrightText: 2014 Benjamin Sago +// SPDX-License-Identifier: MIT +use std::io::{self, Write}; +use std::path::{Component, PathBuf}; + +use log::debug; + +#[cfg(unix)] +use crate::output::render::{GroupRender, OctalPermissionsRender, UserRender}; + +use crate::fs::dir_action::DirAction; +use crate::fs::feature::git::GitCache; +use crate::fs::fields as f; +use crate::fs::filter::FileFilter; +use crate::fs::{self, Dir, DotFilter, File}; +use crate::loc::count_roots; +use crate::options::parser::CodeContent; +use crate::output::View; +use crate::output::details::{self, show_xattr_hint}; +use crate::output::render::{LanguageRender, LocRender, PermissionsPlusRender, TimeRender}; +use crate::output::table::{Column, ENVIRONMENT, Environment, Options as TableOptions}; + +#[derive(PartialEq, Eq, Debug)] +pub struct Options { + /// Options for the --long option itself + pub details: Option, +} + +pub struct Render<'a> { + git: Option<&'a GitCache>, + + deref_links: bool, + total_size: bool, + + dots: DotFilter, + opts: &'a Options, + + git_ignoring: bool, + git_repos: bool, + + file_filter: &'a FileFilter, + dir_action: &'a DirAction, + view: &'a View, + + environment: &'a Environment, +} + +impl<'a> Render<'a> { + pub fn new( + git: Option<&'a GitCache>, + + dots: DotFilter, + opts: &'a Options, + + git_ignoring: bool, + git_repos: bool, + + options: &'a crate::options::Options, + ) -> Self { + let environment = &*ENVIRONMENT; + + Self { + git, + deref_links: options.view.deref_links, + total_size: options.view.total_size, + dots, + opts, + git_ignoring, + git_repos, + environment, + file_filter: &options.filter, + dir_action: &options.dir_action, + view: &options.view, + } + } + + pub fn render( + &self, + files: Vec>, + mut dirs: Vec, + w: &mut W, + ) -> io::Result<()> { + match ( + files.len(), + dirs.len(), + self.dir_action.recurse_options().is_some(), + ) { + (0, 1, false) => { + // Safe unwrap as we verify before that the len is at least one. + let dir = dirs.get_mut(0).unwrap(); + self.render_directory(dir, w) + } + (_, 0, _) => self.render_files(files, w), + (0, _, true) => self.render_recursive_directories(&mut dirs, false, w), + (0, _, _) => self.render_directories(dirs, w), + (_, _, recurse) => self.render_files_directories(files, dirs, recurse, w), + }?; + Ok(()) + } + + fn render_files(&self, files: Vec>, w: &mut W) -> io::Result<()> { + match &self.opts.details { + None => { + let fnames: Vec = files.iter().map(|f| self.render_file(f, None)).collect(); + write!(w, "[{}]", fnames.join(","))?; + } + Some(details) => { + let code_loc = match &details.table { + Some(t) => { + if matches!( + t.columns.loc, + Some(CodeContent::Percent | CodeContent::Both) + ) { + let roots: Vec = + files.iter().map(|f| f.path.clone()).collect(); + let report = count_roots(&roots); + + Some(report.total().code) + } else { + None + } + } + None => None, + }; + + let fnames: Vec = files + .iter() + .map(|f| { + let fname_json = serde_json::to_string(&f.name) + .unwrap_or_else(|_| format!("\"{}\"", f.name)); + format!("{fname_json}:{{{}}}", self.render_file(f, code_loc)) + }) + .collect(); + write!(w, "{{{}}}", fnames.join(","))?; + } + } + Ok(()) + } + + fn render_directory(&self, dir: &'a mut Dir, w: &mut W) -> io::Result<()> { + let dir = dir.read()?; + let files: Vec> = dir + .files( + self.dots, + self.git, + self.git_ignoring, + self.deref_links, + self.total_size, + ) + .collect(); + + self.render_files(files, w)?; + + Ok(()) + } + + fn render_recursive_directories( + &self, + dirs: &'a mut Vec, + sub_dir: bool, + w: &mut W, + ) -> io::Result<()> { + write!(w, "{{")?; + let mut first = true; + for dir in dirs { + if first { + first = false; + } else { + write!(w, ",")?; + } + if sub_dir { + let key = serde_json::to_string(&dir.path.display().to_string()) + .unwrap_or_else(|_| format!("\"{}\"", dir.path.display())); + write!(w, "{key}:{{")?; + } else { + let name = dir + .path + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_else(|| dir.path.display().to_string()); + let key = serde_json::to_string(&name).unwrap_or_else(|_| format!("\"{}\"", name)); + write!(w, "{key}:{{")?; + } + let dir_r = dir.read()?; + let mut files: Vec> = dir_r + .files( + self.dots, + self.git, + self.git_ignoring, + self.deref_links, + self.total_size, + ) + .collect(); + + self.file_filter.filter_child_files(true, &mut files); + self.file_filter.sort_files(&mut files); + let recurse_opts = self.dir_action.recurse_options().unwrap(); + let depth: usize = dir_r + .path + .components() + .filter(|&c| c != Component::CurDir) + .count() + + 1; + + let follow_links = self.view.follow_links; + if !recurse_opts.tree && !recurse_opts.is_too_deep(depth) { + let mut child_dirs = files + .iter() + .filter(|f| { + (if follow_links { + f.points_to_directory() + } else { + f.is_directory() + }) && !f.is_all_all + }) + .map(fs::File::to_dir) + .collect::>(); + + write!(w, "\"files\":")?; + self.render_files(files, w)?; + write!(w, ", \"directories\":")?; + self.render_recursive_directories(&mut child_dirs, false, w)?; + } else { + write!(w, "\"files\":")?; + self.render_files(files, w)?; + } + write!(w, "}}")?; + } + write!(w, "}}")?; + Ok(()) + } + + fn render_directories(&self, dirs: Vec, w: &mut W) -> io::Result<()> { + write!(w, "{{")?; + let mut first = true; + for mut dir in dirs { + if first { + first = false; + } else { + write!(w, ",")?; + } + let key = serde_json::to_string(&dir.path.display().to_string()) + .unwrap_or_else(|_| format!("\"{}\"", dir.path.display())); + write!(w, "{key}:")?; + self.render_directory(&mut dir, w)?; + } + write!(w, "}}")?; + Ok(()) + } + + fn render_files_directories( + &self, + files: Vec>, + mut dirs: Vec, + recurse: bool, + w: &mut W, + ) -> io::Result<()> { + write!(w, "{{\"files\":")?; + self.render_files(files, w)?; + write!(w, ", \"directories\":")?; + if recurse { + self.render_recursive_directories(&mut dirs, false, w)?; + } else { + self.render_directories(dirs, w)?; + } + write!(w, "}}")?; + Ok(()) + } + + fn render_file(&self, f: &File<'a>, code_loc: Option) -> String { + match &self.opts.details { + None => serde_json::to_string(&f.name).unwrap_or_else(|_| format!("\"{}\"", f.name)), + Some(o) => self.render_file_long(f, o, code_loc), + } + } + + fn render_file_long( + &self, + f: &File<'a>, + o: &details::Options, + code_loc: Option, + ) -> String { + if let Some(table_opts) = &o.table { + let columns = table_opts + .columns + .collect(self.git.is_some(), self.git_repos); + + let fobj = JsonFileObject::create_for_file( + f, + table_opts, + columns, + self.environment, + show_xattr_hint(self.opts.details.as_ref().is_some_and(|d| d.secattr), f), + self.git, + code_loc, + ); + fobj.render() + } else { + String::new() + } + } +} + +struct JsonFileObject<'a> { + /// Reusing the table column to map everything we want to be displayed + internal: Vec<(Column, String)>, + + options: &'a TableOptions, + + pub git: Option<&'a GitCache>, + + code_loc: Option, +} + +impl<'a> JsonFileObject<'a> { + /// Render a json object with the columns in the map + fn render(self) -> String { + self.internal + .iter() + .map(|(c, v)| { + let header = serde_json::to_string(c.header()) + .unwrap_or_else(|_| format!("\"{}\"", c.header())); + format!("{header}: {v}") + }) + .collect::>() + .join(",") + } + + fn create_for_file( + f: &File<'a>, + options: &'a TableOptions, + columns: Vec, + env: &Environment, + xattrs: bool, + git: Option<&'a GitCache>, + code_loc: Option, + ) -> Self { + let mut res = Self { + internal: vec![], + options, + git, + code_loc, + }; + + columns + .iter() + .for_each(|c| res.add_column(f, c, env, xattrs)); + + res + } + + fn add_column(&mut self, f: &File, c: &Column, env: &Environment, xattrs: bool) { + let column_opt = self.get_column(f, c, env, xattrs); + + if let Some(column) = column_opt { + let escaped_val = + serde_json::to_string(&column).unwrap_or_else(|_| format!("\"{}\"", column)); + self.internal.push((*c, escaped_val)); + } + } + + fn get_column(&self, f: &File, c: &Column, env: &Environment, xattrs: bool) -> Option { + match c { + Column::Permissions => f.permissions_plus(xattrs).render_json(), + Column::Timestamp(time_type) => time_type + .get_corresponding_time(f) + .render_json(env.time_offset, self.options.time_format.clone()), + Column::FileSize => f.size().render_json(self.options.size_format, &env.numeric), + #[cfg(unix)] + Column::User => f + .user() + .render_json(&*env.lock_users(), self.options.user_format), + Column::GitStatus => Some(self.git_status(f).render_json()), + #[cfg(unix)] + Column::Blocksize => f + .blocksize() + .render_json(self.options.size_format, &env.numeric), + Column::FileFlags => f.flags().render_json(self.options.flags_format), + #[cfg(unix)] + Column::Group => f.group().render_json( + &*env.lock_users(), + self.options.user_format, + self.options.group_format, + f.user(), + ), + #[cfg(unix)] + Column::Inode => Some(f.inode().render_json()), + #[cfg(unix)] + Column::HardLinks => Some(f.links().render_json(&env.numeric)), + #[cfg(unix)] + Column::Octal => f + .permissions() + .map(|p| f::OctalPermissions { permissions: p }) + .render_json(), + #[cfg(unix)] + Column::SecurityContext => f.security_context().render_json(), + + Column::Language => f.language().render_json(), + Column::Loc(code_content) => { + f.loc() + .render_json(*code_content, self.code_loc, &env.numeric) + } + Column::SubdirGitRepo(status) => self.subdir_git_repo(f, *status).render_json(), + } + } + + fn subdir_git_repo(&self, file: &File<'_>, status: bool) -> f::SubdirGitRepo { + debug!("Getting subdir repo status for path {:?}", file.path); + + if file.is_directory() { + return f::SubdirGitRepo::from_path(&file.path, status); + } + f::SubdirGitRepo::default() + } + + fn git_status(&self, file: &File<'_>) -> f::Git { + self.git + .map(|g| g.get(&file.path, file.is_directory())) + .unwrap_or_default() + } +} diff --git a/src/output/mod.rs b/src/output/mod.rs index d60eb714..ac49fefd 100644 --- a/src/output/mod.rs +++ b/src/output/mod.rs @@ -15,6 +15,7 @@ pub mod file_name; pub mod grid; pub mod grid_details; pub mod icons; +pub mod json; pub mod lines; pub mod render; pub mod table; @@ -47,6 +48,7 @@ pub enum Mode { /// The `--code` lines-of-code summary, which lists languages rather than /// files. Code(code::Options), + Json(json::Options), } /// The width of the terminal requested by the user. diff --git a/src/output/render/blocks.rs b/src/output/render/blocks.rs index 73d4afeb..6e881bd7 100644 --- a/src/output/render/blocks.rs +++ b/src/output/render/blocks.rs @@ -69,6 +69,42 @@ impl f::Blocksize { .into(), } } + + pub fn render_json(self, size_format: SizeFormat, numerics: &NumericLocale) -> Option { + use unit_prefix::NumberPrefix; + + let size = match self { + Self::Some(s) => s, + Self::None => return None, + }; + + let result = match size_format { + SizeFormat::DecimalBytes => NumberPrefix::decimal(size as f64), + SizeFormat::BinaryBytes => NumberPrefix::binary(size as f64), + SizeFormat::JustBytes => { + // But format the number directly using the locale. + let string = numerics.format_int(size); + + return Some(string); + } + }; + + let (prefix, n) = match result { + NumberPrefix::Standalone(b) => { + return Some(numerics.format_int(b)); + } + NumberPrefix::Prefixed(p, n) => (p, n), + }; + + let symbol = prefix.symbol(); + let number = if n < 10_f64 { + numerics.format_float(n, 1) + } else { + numerics.format_int(n.round() as isize) + }; + + Some(number + symbol) + } } #[rustfmt::skip] @@ -218,4 +254,47 @@ pub mod test { ) ); } + + #[test] + fn directory_json() { + let directory = f::Blocksize::None; + let expected = None; + assert_eq!( + expected, + directory.render_json(SizeFormat::JustBytes, &NumericLocale::english()) + ); + } + + #[test] + fn file_decimal_json() { + let directory = f::Blocksize::Some(2_100_000); + let expected = Some("2.1M".to_string()); + + assert_eq!( + expected, + directory.render_json(SizeFormat::DecimalBytes, &NumericLocale::english()) + ); + } + + #[test] + fn file_binary_json() { + let directory = f::Blocksize::Some(1_048_576); + let expected = Some("1.0Mi".to_string()); + + assert_eq!( + expected, + directory.render_json(SizeFormat::BinaryBytes, &NumericLocale::english()) + ); + } + + #[test] + fn file_bytes_json() { + let directory = f::Blocksize::Some(1_048_576); + let expected = Some("1,048,576".to_string()); + + assert_eq!( + expected, + directory.render_json(SizeFormat::JustBytes, &NumericLocale::english()) + ); + } } diff --git a/src/output/render/filetype.rs b/src/output/render/filetype.rs index e72270d0..77ac14d5 100644 --- a/src/output/render/filetype.rs +++ b/src/output/render/filetype.rs @@ -22,6 +22,20 @@ impl f::Type { Self::Special => colours.special().paint("?"), }; } + + pub fn render_json(self) -> &'static str { + #[rustfmt::skip] + return match self { + Self::File => ".", + Self::Directory => "d", + Self::Pipe => "|", + Self::Link => "l", + Self::BlockDevice => "b", + Self::CharDevice => "c", + Self::Socket => "s", + Self::Special => "?", + }; + } } pub trait Colours { @@ -34,3 +48,20 @@ pub trait Colours { fn socket(&self) -> Style; fn special(&self) -> Style; } + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn test_filetype_render_json() { + assert_eq!(f::Type::File.render_json(), "."); + assert_eq!(f::Type::Directory.render_json(), "d"); + assert_eq!(f::Type::Pipe.render_json(), "|"); + assert_eq!(f::Type::Link.render_json(), "l"); + assert_eq!(f::Type::BlockDevice.render_json(), "b"); + assert_eq!(f::Type::CharDevice.render_json(), "c"); + assert_eq!(f::Type::Socket.render_json(), "s"); + assert_eq!(f::Type::Special.render_json(), "?"); + } +} diff --git a/src/output/render/flags.rs b/src/output/render/flags.rs index 87145027..467c8f74 100644 --- a/src/output/render/flags.rs +++ b/src/output/render/flags.rs @@ -15,4 +15,9 @@ impl f::Flags { pub fn render(self, style: Style, _format: FlagsFormat) -> TextCell { TextCell::paint(style, "-".to_string()) } + + #[must_use] + pub fn render_json(self, _format: FlagsFormat) -> Option { + None + } } diff --git a/src/output/render/flags_bsd.rs b/src/output/render/flags_bsd.rs index 4edebcbe..4d893e48 100644 --- a/src/output/render/flags_bsd.rs +++ b/src/output/render/flags_bsd.rs @@ -60,4 +60,8 @@ impl f::Flags { pub fn render(self, style: Style, _format: FlagsFormat) -> TextCell { TextCell::paint(style, wrapper_flags_to_string(self.0)) } + + pub fn render_json(self, _format: FlagsFormat) -> Option { + Some(wrapper_flags_to_string(self.0)) + } } diff --git a/src/output/render/flags_windows.rs b/src/output/render/flags_windows.rs index d60a2974..9aff6e9f 100644 --- a/src/output/render/flags_windows.rs +++ b/src/output/render/flags_windows.rs @@ -141,4 +141,12 @@ impl f::Flags { }, ) } + + pub fn render_json(self, format: FlagsFormat) -> Option { + Some(if format == FlagsFormat::Short { + flags_to_windows_string(self.0) + } else { + flags_to_bsd_string(self.0) + }) + } } diff --git a/src/output/render/git.rs b/src/output/render/git.rs index 019de38c..995bb1b2 100644 --- a/src/output/render/git.rs +++ b/src/output/render/git.rs @@ -16,6 +16,10 @@ impl f::Git { contents: vec![self.staged.render(colours), self.unstaged.render(colours)].into(), } } + + pub fn render_json(self) -> String { + self.staged.render_json().to_owned() + self.unstaged.render_json() + } } impl f::GitStatus { @@ -32,6 +36,20 @@ impl f::GitStatus { Self::Conflicted => colours.conflicted().paint("U"), }; } + + fn render_json(self) -> &'static str { + #[rustfmt::skip] + return match self { + Self::NotModified => "-", + Self::New => "N", + Self::Modified => "M", + Self::Deleted => "D", + Self::Renamed => "R", + Self::TypeChange => "T", + Self::Ignored => "I", + Self::Conflicted => "U", + }; + } } pub trait Colours { @@ -75,6 +93,15 @@ impl f::SubdirGitRepo { } } } + + pub fn render_json(self) -> Option { + let branch_name = self.branch.unwrap_or("-".to_string()); + if let Some(status) = self.status { + Some(format!("{} {}", status.render_json(), branch_name)) + } else { + Some(branch_name) + } + } } impl f::SubdirGitRepoStatus { @@ -85,6 +112,14 @@ impl f::SubdirGitRepoStatus { Self::GitDirty => colours.git_dirty().paint("+"), } } + + pub fn render_json(self) -> &'static str { + match self { + Self::NoRepo => "-", + Self::GitClean => "|", + Self::GitDirty => "+", + } + } } pub trait RepoColours { @@ -162,4 +197,28 @@ pub mod test { assert_eq!(expected, stati.render(&TestColours)); } + + #[test] + fn git_blank_json() { + let stati = f::Git { + staged: f::GitStatus::NotModified, + unstaged: f::GitStatus::NotModified, + }; + + let expected = "--".to_string(); + + assert_eq!(expected, stati.render_json()); + } + + #[test] + fn git_new_changed_json() { + let stati = f::Git { + staged: f::GitStatus::New, + unstaged: f::GitStatus::Modified, + }; + + let expected = "NM".to_string(); + + assert_eq!(expected, stati.render_json()); + } } diff --git a/src/output/render/groups.rs b/src/output/render/groups.rs index 018eacf7..876c2b39 100644 --- a/src/output/render/groups.rs +++ b/src/output/render/groups.rs @@ -21,6 +21,14 @@ pub trait Render { group_format: GroupFormat, file_user: Option, ) -> TextCell; + + fn render_json( + self, + users: &U, + user_format: UserFormat, + group_format: GroupFormat, + file_user: Option, + ) -> Option; } impl Render for Option { @@ -71,6 +79,35 @@ impl Render for Option { TextCell::paint(style, group_name) } + + fn render_json( + self, + users: &U, + user_format: UserFormat, + group_format: GroupFormat, + file_user: Option, + ) -> Option { + let g = self?; + let group = match users.get_group_by_gid(g.0) { + Some(g) => (*g).clone(), + None => return Some(g.0.to_string()), + }; + + let mut group_name = match user_format { + UserFormat::Name => group.name().to_string_lossy().into(), + UserFormat::Numeric => group.gid().to_string(), + }; + + if let GroupFormat::Smart = group_format + && let Some(file_uid) = file_user + && let Some(file_user) = users.get_user_by_uid(file_uid.0) + && file_user.name().to_string_lossy() == group.name().to_string_lossy() + { + group_name = ":".to_string(); + } + + Some(group_name) + } } pub trait Colours { @@ -286,4 +323,126 @@ pub mod test { ) ); } + + #[test] + fn named_json() { + let mut users = MockUsers::with_current_uid(1000); + users.add_group(Group::new(100, "folk")); + + let group = Some(f::Group(100)); + let file_user = Some(f::User(1000)); + let expected = Some("folk".to_string()); + assert_eq!( + expected, + group.render_json(&users, UserFormat::Name, GroupFormat::Regular, file_user) + ); + + let expected = Some("100".to_string()); + assert_eq!( + expected, + group.render_json(&users, UserFormat::Numeric, GroupFormat::Regular, file_user) + ); + } + + #[test] + fn unnamed_json() { + let users = MockUsers::with_current_uid(1000); + + let group = Some(f::Group(100)); + let file_user = Some(f::User(1000)); + let expected = Some("100".to_string()); + assert_eq!( + expected, + group.render_json(&users, UserFormat::Name, GroupFormat::Regular, file_user) + ); + assert_eq!( + expected, + group.render_json(&users, UserFormat::Numeric, GroupFormat::Regular, file_user) + ); + } + + #[test] + fn primary_json() { + let mut users = MockUsers::with_current_uid(2); + users.add_user(User::new(2, "eve", 100)); + users.add_group(Group::new(100, "folk")); + + let group = Some(f::Group(100)); + let file_user = Some(f::User(2)); + let expected = Some("folk".to_string()); + assert_eq!( + expected, + group.render_json(&users, UserFormat::Name, GroupFormat::Regular, file_user) + ); + } + + #[test] + fn secondary_json() { + let mut users = MockUsers::with_current_uid(2); + users.add_user(User::new(2, "eve", 666)); + + let test_group = Group::new(100, "folk").add_member("eve"); + users.add_group(test_group); + + let group = Some(f::Group(100)); + let file_user = Some(f::User(2)); + let expected = Some("folk".to_string()); + assert_eq!( + expected, + group.render_json(&users, UserFormat::Name, GroupFormat::Regular, file_user) + ); + } + + #[test] + fn overflow_json() { + let group = Some(f::Group(2_147_483_648)); + let file_user = Some(f::User(1000)); + let expected = Some("2147483648".to_string()); + assert_eq!( + expected, + group.render_json( + &MockUsers::with_current_uid(0), + UserFormat::Numeric, + GroupFormat::Regular, + file_user + ) + ); + } + + #[test] + fn smart_json() { + let mut users = MockUsers::with_current_uid(1000); + users.add_user(User::new(1000, "user", 100)); + users.add_user(User::new(1001, "http", 101)); + users.add_group(Group::new(100, "user")); + users.add_group(Group::new(101, "http")); + + let user_group = Some(f::Group(100)); + let user_file = Some(f::User(1000)); + let expected = Some(":".to_string()); + assert_eq!( + expected, + user_group.render_json(&users, UserFormat::Name, GroupFormat::Smart, user_file) + ); + + let expected = Some(":".to_string()); + assert_eq!( + expected, + user_group.render_json(&users, UserFormat::Numeric, GroupFormat::Smart, user_file) + ); + + let http_group = Some(f::Group(101)); + let expected = Some("http".to_string()); + assert_eq!( + expected, + http_group.render_json(&users, UserFormat::Name, GroupFormat::Smart, user_file) + ); + + let http_file = Some(f::User(1001)); + let expected = Some(":".to_string()); + assert_eq!( + expected, + http_group.render_json(&users, UserFormat::Name, GroupFormat::Smart, http_file) + ); + } } diff --git a/src/output/render/inode.rs b/src/output/render/inode.rs index c925f7cc..38be588b 100644 --- a/src/output/render/inode.rs +++ b/src/output/render/inode.rs @@ -14,6 +14,10 @@ impl f::Inode { pub fn render(self, style: Style) -> TextCell { TextCell::paint(style, self.0.to_string()) } + + pub fn render_json(self) -> String { + self.0.to_string() + } } #[cfg(test)] @@ -29,4 +33,11 @@ pub mod test { let expected = TextCell::paint_str(Cyan.underline(), "1414213"); assert_eq!(expected, io.render(Cyan.underline())); } + + #[test] + fn blocklessness_json() { + let io = f::Inode(1_414_213); + let expected = "1414213".to_string(); + assert_eq!(expected, io.render_json()); + } } diff --git a/src/output/render/language.rs b/src/output/render/language.rs new file mode 100644 index 00000000..f9fe6c30 --- /dev/null +++ b/src/output/render/language.rs @@ -0,0 +1,28 @@ +// SPDX-FileCopyrightText: 2026 Christina Sørensen +// SPDX-FileCopyrightText: 2026 fxrdhan +// SPDX-License-Identifier: EUPL-1.2 +// +// SPDX-FileCopyrightText: 2023-2026 Christina Sørensen, eza contributors +// SPDX-FileCopyrightText: 2014 Benjamin Sago +// SPDX-License-Identifier: MIT +use nu_ansi_term::Style; + +use crate::{loc::Language, output::cell::TextCell}; + +pub trait Render { + fn render(self, style: Style) -> TextCell; + fn render_json(self) -> Option; +} + +impl Render for Option<&Language> { + fn render(self, style: Style) -> TextCell { + match self { + Some(lang) => TextCell::paint(style, lang.name.to_string()), + None => TextCell::paint(style, "-".to_string()), + } + } + + fn render_json(self) -> Option { + self.map(|lang| lang.name.to_string()) + } +} diff --git a/src/output/render/links.rs b/src/output/render/links.rs index 8a0fbd0a..79191049 100644 --- a/src/output/render/links.rs +++ b/src/output/render/links.rs @@ -24,6 +24,10 @@ impl f::Links { TextCell::paint(style, numeric.format_int(self.count)) } + + pub fn render_json(&self, numeric: &NumericLocale) -> String { + numeric.format_int(self.count) + } } #[allow(unused)] @@ -113,4 +117,46 @@ pub mod test { stati.render(&TestColours, &locale::Numeric::english()) ); } + + #[test] + #[cfg(unix)] + fn regular_file_json() { + let stati = f::Links { + count: 1, + multiple: false, + }; + + assert_eq!( + "1".to_string(), + stati.render_json(&locale::Numeric::english()) + ); + } + + #[test] + #[cfg(unix)] + fn regular_directory_json() { + let stati = f::Links { + count: 3005, + multiple: false, + }; + + assert_eq!( + "3,005".to_string(), + stati.render_json(&locale::Numeric::english()) + ); + } + + #[test] + #[cfg(unix)] + fn popular_file_json() { + let stati = f::Links { + count: 3005, + multiple: true, + }; + + assert_eq!( + "3,005".to_string(), + stati.render_json(&locale::Numeric::english()) + ); + } } diff --git a/src/output/render/loc.rs b/src/output/render/loc.rs new file mode 100644 index 00000000..f079d6f2 --- /dev/null +++ b/src/output/render/loc.rs @@ -0,0 +1,74 @@ +// SPDX-FileCopyrightText: 2026 Christina Sørensen +// SPDX-FileCopyrightText: 2026 fxrdhan +// SPDX-License-Identifier: EUPL-1.2 +// +// SPDX-FileCopyrightText: 2023-2026 Christina Sørensen, eza contributors +// SPDX-FileCopyrightText: 2014 Benjamin Sago +// SPDX-License-Identifier: MIT +use locale::Numeric as NumericLocale; +use nu_ansi_term::Style; + +use crate::{loc::LocCounts, options::parser::CodeContent, output::cell::TextCell}; + +pub trait Render { + fn render( + self, + style: Style, + placeholder_style: Style, + content: CodeContent, + loc_total: Option, + numeric_format: &NumericLocale, + ) -> TextCell; + fn render_json( + self, + content: CodeContent, + loc_total: Option, + numeric_format: &NumericLocale, + ) -> Option; +} + +impl Render for Option { + fn render( + self, + style: Style, + placeholder_style: Style, + content: CodeContent, + loc_total: Option, + numeric_format: &NumericLocale, + ) -> TextCell { + let Some(counts) = self else { + return TextCell::paint(placeholder_style, "-".to_string()); + }; + // Quantities take the same colour as file sizes, so the Code column + // reads consistently next to Size. + match content { + CodeContent::Percent => match loc_total { + Some(total) if total > 0 => { + let pct = (counts.code as f64) * 100.0 / (total as f64); + TextCell::paint(style, format!("{pct:.1}%")) + } + _ => TextCell::paint(placeholder_style, "-".to_string()), + }, + _ => TextCell::paint(style, numeric_format.format_int(counts.code)), + } + } + + fn render_json( + self, + content: CodeContent, + loc_total: Option, + numeric_format: &NumericLocale, + ) -> Option { + let counts = self?; + match content { + CodeContent::Percent => match loc_total { + Some(total) if total > 0 => { + let pct = (counts.code as f64) * 100.0 / (total as f64); + Some(format!("{pct:.1}%")) + } + _ => None, + }, + _ => Some(numeric_format.format_int(counts.code)), + } + } +} diff --git a/src/output/render/mod.rs b/src/output/render/mod.rs index 0e478348..a6c72bef 100644 --- a/src/output/render/mod.rs +++ b/src/output/render/mod.rs @@ -79,3 +79,9 @@ mod flags_windows; target_os = "windows" )))] mod flags; + +mod language; +pub use self::language::Render as LanguageRender; + +mod loc; +pub use self::loc::Render as LocRender; diff --git a/src/output/render/octal.rs b/src/output/render/octal.rs index 155b3f69..cf3f42a4 100644 --- a/src/output/render/octal.rs +++ b/src/output/render/octal.rs @@ -11,6 +11,7 @@ use crate::output::cell::TextCell; pub trait Render { fn render(&self, style: Style) -> TextCell; + fn render_json(&self) -> Option; } impl Render for Option { @@ -48,6 +49,35 @@ impl Render for Option { None => TextCell::paint(style, "----".into()), } } + + fn render_json(&self) -> Option { + self.map(|p| { + let perm = &p.permissions; + #[rustfmt::skip] + let octal_sticky = f::OctalPermissions::bits_to_octal( + perm.setuid, + perm.setgid, + perm.sticky, + ); + let octal_owner = f::OctalPermissions::bits_to_octal( + perm.user_read, + perm.user_write, + perm.user_execute, + ); + let octal_group = f::OctalPermissions::bits_to_octal( + perm.group_read, + perm.group_write, + perm.group_execute, + ); + let octal_other = f::OctalPermissions::bits_to_octal( + perm.other_read, + perm.other_write, + perm.other_execute, + ); + + format!("{octal_sticky}{octal_owner}{octal_group}{octal_other}") + }) + } } impl f::OctalPermissions { @@ -201,4 +231,142 @@ pub mod test { let expected = TextCell::paint_str(Purple.bold(), "1777"); assert_eq!(expected, octal.render(Purple.bold())); } + + #[test] + fn normal_folder_json() { + let bits = f::Permissions { + user_read: true, + user_write: true, + user_execute: true, + setuid: false, + group_read: true, + group_write: false, + group_execute: true, + setgid: false, + other_read: true, + other_write: false, + other_execute: true, + sticky: false, + }; + + let octal = Some(f::OctalPermissions { permissions: bits }); + + let expected = Some("0755".to_string()); + assert_eq!(expected, octal.render_json()); + } + + #[test] + fn normal_file_json() { + let bits = f::Permissions { + user_read: true, + user_write: true, + user_execute: false, + setuid: false, + group_read: true, + group_write: false, + group_execute: false, + setgid: false, + other_read: true, + other_write: false, + other_execute: false, + sticky: false, + }; + + let octal = Some(f::OctalPermissions { permissions: bits }); + + let expected = Some("0644".to_string()); + assert_eq!(expected, octal.render_json()); + } + + #[test] + fn secret_file_json() { + let bits = f::Permissions { + user_read: true, + user_write: true, + user_execute: false, + setuid: false, + group_read: false, + group_write: false, + group_execute: false, + setgid: false, + other_read: false, + other_write: false, + other_execute: false, + sticky: false, + }; + + let octal = Some(f::OctalPermissions { permissions: bits }); + + let expected = Some("0600".to_string()); + assert_eq!(expected, octal.render_json()); + } + + #[test] + fn sticky1_json() { + let bits = f::Permissions { + user_read: true, + user_write: true, + user_execute: true, + setuid: true, + group_read: true, + group_write: true, + group_execute: true, + setgid: false, + other_read: true, + other_write: true, + other_execute: true, + sticky: false, + }; + + let octal = Some(f::OctalPermissions { permissions: bits }); + + let expected = Some("4777".to_string()); + assert_eq!(expected, octal.render_json()); + } + + #[test] + fn sticky2_json() { + let bits = f::Permissions { + user_read: true, + user_write: true, + user_execute: true, + setuid: false, + group_read: true, + group_write: true, + group_execute: true, + setgid: true, + other_read: true, + other_write: true, + other_execute: true, + sticky: false, + }; + + let octal = Some(f::OctalPermissions { permissions: bits }); + + let expected = Some("2777".to_string()); + assert_eq!(expected, octal.render_json()); + } + + #[test] + fn sticky3_json() { + let bits = f::Permissions { + user_read: true, + user_write: true, + user_execute: true, + setuid: false, + group_read: true, + group_write: true, + group_execute: true, + setgid: false, + other_read: true, + other_write: true, + other_execute: true, + sticky: true, + }; + + let octal = Some(f::OctalPermissions { permissions: bits }); + + let expected = Some("1777".to_string()); + assert_eq!(expected, octal.render_json()); + } } diff --git a/src/output/render/permissions.rs b/src/output/render/permissions.rs index 4ef2e611..fa003169 100644 --- a/src/output/render/permissions.rs +++ b/src/output/render/permissions.rs @@ -12,6 +12,7 @@ use nu_ansi_term::Style; pub trait PermissionsPlusRender { fn render(&self, colours: &C) -> TextCell; + fn render_json(&self) -> Option; } pub trait Colours { diff --git a/src/output/render/permissions_unix.rs b/src/output/render/permissions_unix.rs index 60c25e48..d48ab8ad 100644 --- a/src/output/render/permissions_unix.rs +++ b/src/output/render/permissions_unix.rs @@ -39,10 +39,25 @@ impl PermissionsPlusRender for Option { } } } + + fn render_json(&self) -> Option { + self.map(|p| { + let mut chars = vec![p.file_type.render_json()]; + let permissions = p.permissions; + chars.extend(Some(permissions).render_json(p.file_type.is_regular_file())); + + if p.xattrs { + chars.push("@"); + } + + chars.join("") + }) + } } pub trait RenderPermissions { fn render(&self, colours: &C, is_regular_file: bool) -> Vec>; + fn render_json(&self, is_regular_file: bool) -> Vec<&'static str>; } impl RenderPermissions for Option { @@ -72,6 +87,50 @@ impl RenderPermissions for Option { None => std::iter::repeat_n(colours.dash().paint("-"), 9).collect(), } } + + fn render_json(&self, is_regular_file: bool) -> Vec<&'static str> { + let bit = |bit, chr: &'static str| { + if bit { chr } else { "-" } + }; + + match self { + Some(p) => { + let user_exec = match (p.user_execute, p.setuid, is_regular_file) { + (false, false, _) => "-", + (true, false, _) => "x", + (false, true, _) => "S", + (true, true, _) => "s", + }; + + let group_exec = match (p.group_execute, p.setgid) { + (false, false) => "-", + (true, false) => "x", + (false, true) => "S", + (true, true) => "s", + }; + + let other_exec = match (p.other_execute, p.sticky) { + (false, false) => "-", + (true, false) => "x", + (false, true) => "T", + (true, true) => "t", + }; + + vec![ + bit(p.user_read, "r"), + bit(p.user_write, "w"), + user_exec, + bit(p.group_read, "r"), + bit(p.group_write, "w"), + group_exec, + bit(p.other_read, "r"), + bit(p.other_write, "w"), + other_exec, + ] + } + None => std::iter::repeat_n("-", 9).collect::>(), + } + } } impl f::Permissions { @@ -270,4 +329,94 @@ pub mod test { assert_eq!(expected, bits.render(&TestColours, true).into()); } + + #[test] + #[cfg(unix)] + fn negate_json() { + let bits = Some(f::Permissions { + user_read: false, + user_write: false, + user_execute: false, + setuid: false, + group_read: false, + group_write: false, + group_execute: false, + setgid: false, + other_read: false, + other_write: false, + other_execute: false, + sticky: false, + }); + + let expected = vec!["-", "-", "-", "-", "-", "-", "-", "-", "-"]; + + assert_eq!(expected, bits.render_json(false)); + } + + #[test] + #[cfg(unix)] + fn affirm_json() { + let bits = Some(f::Permissions { + user_read: true, + user_write: true, + user_execute: true, + setuid: false, + group_read: true, + group_write: true, + group_execute: true, + setgid: false, + other_read: true, + other_write: true, + other_execute: true, + sticky: false, + }); + + let expected = vec!["r", "w", "x", "r", "w", "x", "r", "w", "x"]; + + assert_eq!(expected, bits.render_json(true)); + } + + #[test] + fn specials_json() { + let bits = Some(f::Permissions { + user_read: false, + user_write: false, + user_execute: true, + setuid: true, + group_read: false, + group_write: false, + group_execute: true, + setgid: true, + other_read: false, + other_write: false, + other_execute: true, + sticky: true, + }); + + let expected = vec!["-", "-", "s", "-", "-", "s", "-", "-", "t"]; + + assert_eq!(expected, bits.render_json(true)); + } + + #[test] + fn extra_specials_json() { + let bits = Some(f::Permissions { + user_read: false, + user_write: false, + user_execute: false, + setuid: true, + group_read: false, + group_write: false, + group_execute: false, + setgid: true, + other_read: false, + other_write: false, + other_execute: false, + sticky: true, + }); + + let expected = vec!["-", "-", "S", "-", "-", "S", "-", "-", "T"]; + + assert_eq!(expected, bits.render_json(true)); + } } diff --git a/src/output/render/permissions_windows.rs b/src/output/render/permissions_windows.rs index 9beea81c..6e7ea5a8 100644 --- a/src/output/render/permissions_windows.rs +++ b/src/output/render/permissions_windows.rs @@ -30,6 +30,15 @@ impl PermissionsPlusRender for Option { }, } } + + fn render_json(&self) -> Option { + self.map(|p| { + let mut chars = vec![p.attributes.render_type_json()]; + chars.extend(p.attributes.render_json()); + + chars.join("") + }) + } } impl f::Attributes { @@ -50,6 +59,19 @@ impl f::Attributes { ] } + pub fn render_json(self) -> Vec<&'static str> { + let bit = |bit, chr: &'static str| { + if bit { chr } else { "-" } + }; + + vec![ + bit(self.archive, "a"), + bit(self.readonly, "r"), + bit(self.hidden, "h"), + bit(self.system, "s"), + ] + } + pub fn render_type(self, colours: &C) -> ANSIString<'static> { if self.reparse_point { return colours.pipe().paint("l"); @@ -58,4 +80,13 @@ impl f::Attributes { } colours.dash().paint("-") } + + pub fn render_type_json(self) -> &'static str { + if self.reparse_point { + return "l"; + } else if self.directory { + return "d"; + } + "-" + } } diff --git a/src/output/render/securityctx.rs b/src/output/render/securityctx.rs index 38d33cba..7e98a21c 100644 --- a/src/output/render/securityctx.rs +++ b/src/output/render/securityctx.rs @@ -36,6 +36,24 @@ impl f::SecurityContext<'_> { } } } + + pub fn render_json(&self) -> Option { + match &self.context { + f::SecurityContextType::None => None, + f::SecurityContextType::SELinux(context) => { + let mut chars = Vec::with_capacity(7); + + for (i, part) in context.split(':').enumerate() { + if i > 0 { + chars.push(":".to_string()); + } + chars.push(String::from(part)); + } + + Some(chars.join("")) + } + } + } } #[rustfmt::skip] diff --git a/src/output/render/size.rs b/src/output/render/size.rs index d5ba39a9..5aeb5965 100644 --- a/src/output/render/size.rs +++ b/src/output/render/size.rs @@ -103,6 +103,38 @@ impl f::Size { .into(), } } + + pub fn render_json(self, size_format: SizeFormat, numerics: &NumericLocale) -> Option { + use unit_prefix::NumberPrefix; + + let size = match self { + Self::Some(s) => s, + Self::None => return None, + Self::DeviceIDs(ref ids) => return Some(ids.render_json()), + }; + + let result = match size_format { + SizeFormat::DecimalBytes => NumberPrefix::decimal(size as f64), + SizeFormat::BinaryBytes => NumberPrefix::binary(size as f64), + SizeFormat::JustBytes => return Some(numerics.format_int(size)), + }; + + let (prefix, n) = match result { + NumberPrefix::Standalone(b) => return Some(numerics.format_int(b)), + NumberPrefix::Prefixed(p, n) => (p, n), + }; + + let (prefix, n) = carry_to_next_prefix(prefix, n); + + let symbol = prefix.symbol(); + let number = if n < 10_f64 { + numerics.format_float(n, 1) + } else { + numerics.format_int(n.round() as isize) + }; + + Some(number + symbol) + } } /// Steps up to the next unit prefix when rounding for display would otherwise @@ -162,6 +194,10 @@ impl f::DeviceIDs { .into(), } } + + fn render_json(self) -> String { + [self.major.to_string(), self.minor.to_string()].join(",") + } } pub trait Colours { @@ -464,4 +500,61 @@ pub mod test { (Prefix::Kibi, 512.0) ); } + + #[test] + fn directory_json() { + let directory = f::Size::None; + let expected = None; + assert_eq!( + expected, + directory.render_json(SizeFormat::JustBytes, &NumericLocale::english()) + ); + } + + #[test] + fn file_decimal_json() { + let directory = f::Size::Some(2_100_000); + let expected = Some("2.1M".to_string()); + + assert_eq!( + expected, + directory.render_json(SizeFormat::DecimalBytes, &NumericLocale::english()) + ); + } + + #[test] + fn file_binary_json() { + let directory = f::Size::Some(1_048_576); + let expected = Some("1.0Mi".to_string()); + + assert_eq!( + expected, + directory.render_json(SizeFormat::BinaryBytes, &NumericLocale::english()) + ); + } + + #[test] + fn file_bytes_json() { + let directory = f::Size::Some(1_048_576); + let expected = Some("1,048,576".to_string()); + + assert_eq!( + expected, + directory.render_json(SizeFormat::JustBytes, &NumericLocale::english()) + ); + } + + #[test] + fn device_ids_json() { + let directory = f::Size::DeviceIDs(f::DeviceIDs { + major: 10, + minor: 80, + }); + let expected = Some("10,80".to_string()); + + assert_eq!( + expected, + directory.render_json(SizeFormat::JustBytes, &NumericLocale::english()) + ); + } } diff --git a/src/output/render/times.rs b/src/output/render/times.rs index 88b7c1ef..cf2e8457 100644 --- a/src/output/render/times.rs +++ b/src/output/render/times.rs @@ -12,6 +12,7 @@ use nu_ansi_term::Style; pub trait Render { fn render(self, style: Style, time_offset: FixedOffset, time_format: TimeFormat) -> TextCell; + fn render_json(self, time_offset: FixedOffset, time_format: TimeFormat) -> Option; } impl Render for Option { @@ -27,4 +28,13 @@ impl Render for Option { TextCell::paint(style, datestamp) } + + fn render_json(self, time_offset: FixedOffset, time_format: TimeFormat) -> Option { + self.map(|time| { + time_format.format(&DateTime::::from_naive_utc_and_offset( + time, + time_offset, + )) + }) + } } diff --git a/src/output/render/users.rs b/src/output/render/users.rs index 436df002..76786652 100644 --- a/src/output/render/users.rs +++ b/src/output/render/users.rs @@ -13,6 +13,7 @@ use crate::output::table::UserFormat; pub trait Render { fn render(self, colours: &C, users: &U, format: UserFormat) -> TextCell; + fn render_json(self, users: &U, format: UserFormat) -> Option; } impl Render for Option { @@ -38,6 +39,16 @@ impl Render for Option { }; TextCell::paint(style, user_name) } + + fn render_json(self, users: &U, format: UserFormat) -> Option { + let uid = self?.0; + + Some(match (format, users.get_user_by_uid(uid)) { + (_, None) => uid.to_string(), + (UserFormat::Numeric, _) => uid.to_string(), + (UserFormat::Name, Some(user)) => user.name().to_string_lossy().into(), + }) + } } pub trait Colours { @@ -137,4 +148,61 @@ pub mod test { ) ); } + + #[test] + fn named_json() { + let mut users = MockUsers::with_current_uid(1000); + users.add_user(User::new(1000, "enoch", 100)); + + let user = Some(f::User(1000)); + let expected = Some("enoch".to_string()); + #[rustfmt::skip] + assert_eq!(expected, user.render_json(&users, UserFormat::Name)); + + let expected = Some("1000".to_string()); + #[rustfmt::skip] + assert_eq!(expected, user.render_json(&users, UserFormat::Numeric)); + } + + #[test] + fn unnamed_json() { + let users = MockUsers::with_current_uid(1000); + + let user = Some(f::User(1000)); + let expected = Some("1000".to_string()); + #[rustfmt::skip] + assert_eq!(expected, user.render_json(&users, UserFormat::Name)); + #[rustfmt::skip] + assert_eq!(expected, user.render_json(&users, UserFormat::Numeric)); + } + + #[test] + fn different_named_json() { + let mut users = MockUsers::with_current_uid(0); + users.add_user(User::new(1000, "enoch", 100)); + + let user = Some(f::User(1000)); + let expected = Some("enoch".to_string()); + assert_eq!(expected, user.render_json(&users, UserFormat::Name)); + } + + #[test] + fn different_unnamed_json() { + let user = Some(f::User(1000)); + let expected = Some("1000".to_string()); + assert_eq!( + expected, + user.render_json(&MockUsers::with_current_uid(0), UserFormat::Numeric) + ); + } + + #[test] + fn overflow_json() { + let user = Some(f::User(2_147_483_648)); + let expected = Some("2147483648".to_string()); + assert_eq!( + expected, + user.render_json(&MockUsers::with_current_uid(0), UserFormat::Numeric) + ); + } } diff --git a/src/output/table.rs b/src/output/table.rs index 7b5c25ac..45fd6315 100644 --- a/src/output/table.rs +++ b/src/output/table.rs @@ -25,7 +25,7 @@ use crate::output::cell::TextCell; use crate::output::color_scale::ColorScaleInformation; #[cfg(unix)] use crate::output::render::{GroupRender, OctalPermissionsRender, UserRender}; -use crate::output::render::{PermissionsPlusRender, TimeRender}; +use crate::output::render::{LanguageRender, LocRender, PermissionsPlusRender, TimeRender}; use crate::output::time::TimeFormat; use crate::theme::Theme; @@ -393,14 +393,14 @@ impl Default for TimeTypes { /// Any environment field should be able to be mocked up for test runs. pub struct Environment { /// The computer’s current time offset, determined from time zone. - time_offset: FixedOffset, + pub time_offset: FixedOffset, /// Localisation rules for formatting numbers. - numeric: locale::Numeric, + pub numeric: locale::Numeric, /// Mapping cache of user IDs to usernames. #[cfg(unix)] - users: Mutex, + pub users: Mutex, } impl Environment { @@ -427,7 +427,7 @@ impl Environment { } } -static ENVIRONMENT: LazyLock = LazyLock::new(Environment::load_all); +pub static ENVIRONMENT: LazyLock = LazyLock::new(Environment::load_all); pub struct Table<'a> { columns: Vec, @@ -515,7 +515,7 @@ impl<'a> Table<'a> { let cells = self .columns .iter() - .map(|c| self.display(file, *c, xattrs, color_scale_info)) + .map(|&c| self.display(file, c, xattrs, color_scale_info)) .collect(); Row { cells } @@ -525,26 +525,6 @@ impl<'a> Table<'a> { self.widths.add_widths(row); } - #[cfg(unix)] - fn permissions_plus(&self, file: &File<'_>, xattrs: bool) -> Option { - file.permissions().map(|p| f::PermissionsPlus { - file_type: file.type_char(), - permissions: p, - xattrs, - }) - } - - #[allow(clippy::unnecessary_wraps)] // Needs to match Unix function - #[cfg(windows)] - fn permissions_plus(&self, file: &File<'_>, xattrs: bool) -> Option { - Some(f::PermissionsPlus { - file_type: file.type_char(), - #[cfg(windows)] - attributes: file.attributes()?, - xattrs, - }) - } - #[cfg(unix)] fn octal_permissions(&self, file: &File<'_>) -> Option { file.permissions() @@ -559,15 +539,28 @@ impl<'a> Table<'a> { color_scale_info: Option, ) -> TextCell { match column { - Column::Permissions => self.permissions_plus(file, xattrs).render(self.theme), + Column::Permissions => file.permissions_plus(xattrs).render(self.theme), Column::FileSize => file.size().render( self.theme, self.size_format, &self.env.numeric, color_scale_info, ), - Column::Language => self.language(file), - Column::Loc(content) => self.loc(file, content), + Column::Language => file + .language() + .render(self.theme.ui.date.unwrap_or_default()), + Column::Loc(content) => file.loc().render( + self.theme + .ui + .size + .unwrap_or_default() + .number_byte + .unwrap_or_default(), + self.theme.ui.punctuation.unwrap_or_default(), + content, + self.loc_total, + &self.env.numeric, + ), #[cfg(unix)] Column::HardLinks => file.links().render(self.theme, &self.env.numeric), #[cfg(unix)] @@ -619,52 +612,6 @@ impl<'a> Table<'a> { } } - /// The language column: the recognised language’s name, or a dash. - fn language(&self, file: &File<'_>) -> TextCell { - match file.language() { - Some(lang) => TextCell::paint( - self.theme.ui.date.unwrap_or_default(), - lang.name.to_string(), - ), - None => self.loc_placeholder(), - } - } - - /// A lines-of-code column, rendered as a raw code-line count or as a - /// percentage of the whole tree’s code, depending on `content`. - fn loc(&self, file: &File<'_>, content: CodeContent) -> TextCell { - let Some(counts) = file.loc() else { - return self.loc_placeholder(); - }; - // Quantities take the same colour as file sizes, so the Code column - // reads consistently next to Size. - let style = self - .theme - .ui - .size - .unwrap_or_default() - .number_byte - .unwrap_or_default(); - match content { - CodeContent::Percent => match self.loc_total { - Some(total) if total > 0 => { - let pct = (counts.code as f64) * 100.0 / (total as f64); - TextCell::paint(style, format!("{pct:.1}%")) - } - _ => self.loc_placeholder(), - }, - _ => TextCell::paint(style, self.env.numeric.format_int(counts.code)), - } - } - - /// The placeholder shown for files with no language or no count. - fn loc_placeholder(&self) -> TextCell { - TextCell::paint( - self.theme.ui.punctuation.unwrap_or_default(), - "-".to_string(), - ) - } - fn git_status(&self, file: &File<'_>) -> f::Git { debug!("Getting Git status for file {:?}", file.path); diff --git a/src/theme/default_theme.rs b/src/theme/default_theme.rs index 4cc57c82..bca06d13 100644 --- a/src/theme/default_theme.rs +++ b/src/theme/default_theme.rs @@ -141,6 +141,7 @@ impl Default for UiStyles { filenames: None, extensions: None, + directorynames: None, } } } diff --git a/src/theme/mod.rs b/src/theme/mod.rs index 6064e844..a98377c7 100644 --- a/src/theme/mod.rs +++ b/src/theme/mod.rs @@ -479,17 +479,27 @@ impl FileNameColours for Theme { .unwrap_or(self.ui.filekinds.unwrap_or_default().normal()) } - fn style_override(&self, file: &File<'_>) -> Option { - if let Some(ref name_overrides) = self.ui.filenames - && let Some(file_override) = name_overrides.get(&file.name) { + fn style_override(&self, file: &File<'_>) -> Option { + if file.is_directory() { + if let Some(ref dir_overrides) = self.ui.directorynames + && let Some(dir_override) = dir_overrides.get(&file.name) + { + return Some(*dir_override); + } + } else { + if let Some(ref name_overrides) = self.ui.filenames + && let Some(file_override) = name_overrides.get(&file.name) + { return Some(*file_override); } - if let Some(ref ext_overrides) = self.ui.extensions - && let Some(ext) = file.ext.clone() - && let Some(file_override) = ext_overrides.get(&ext) { - return Some(*file_override); - } + if let Some(ref ext_overrides) = self.ui.extensions + && let Some(ext) = file.ext.clone() + && let Some(file_override) = ext_overrides.get(&ext) + { + return Some(*file_override); + } + } None } diff --git a/src/theme/ui_styles.rs b/src/theme/ui_styles.rs index ec981297..bf200d93 100644 --- a/src/theme/ui_styles.rs +++ b/src/theme/ui_styles.rs @@ -55,6 +55,7 @@ pub struct UiStyles { pub filenames: Option>, pub extensions: Option>, + pub directorynames: Option>, } // Macro to generate .unwrap_or_default getters for each field to cut down boilerplate macro_rules! field_accessors { @@ -503,6 +504,7 @@ impl UiStyles { filenames: None, extensions: None, + directorynames: None, } } } diff --git a/tests/adversarial_batch5_tests.rs b/tests/adversarial_batch5_tests.rs new file mode 100644 index 00000000..8037c642 --- /dev/null +++ b/tests/adversarial_batch5_tests.rs @@ -0,0 +1,560 @@ +// SPDX-FileCopyrightText: 2026 fxrdhan +// SPDX-License-Identifier: EUPL-1.2 + +use std::fs::{self, File as StdFile}; +use std::io::Write; +use std::path::PathBuf; +use std::process::Command; +use std::time::{SystemTime, UNIX_EPOCH}; + +struct TempTestDir { + path: PathBuf, +} + +impl TempTestDir { + fn new(prefix: &str) -> Self { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let path = std::env::temp_dir().join(format!( + "lsr_adv_b5_{prefix}_{}_{}", + std::process::id(), + nanos + )); + let _ = fs::remove_dir_all(&path); + fs::create_dir_all(&path).expect("Failed to create temp dir"); + Self { path } + } + + fn create_file(&self, name: &str, content: &[u8]) -> PathBuf { + let p = self.path.join(name); + if let Some(parent) = p.parent() { + fs::create_dir_all(parent).unwrap(); + } + let mut f = StdFile::create(&p).unwrap(); + f.write_all(content).unwrap(); + p + } + + fn create_dir(&self, name: &str) -> PathBuf { + let p = self.path.join(name); + fs::create_dir_all(&p).unwrap(); + p + } +} + +impl Drop for TempTestDir { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.path); + } +} + +#[test] +fn test_m1_json_cli_short_single_directory() { + let bin_path = env!("CARGO_BIN_EXE_lsr"); + let temp = TempTestDir::new("json_short"); + temp.create_file("alpha.txt", b"a"); + temp.create_file("beta.rs", b"b"); + temp.create_dir("gamma_dir"); + + let output = Command::new(bin_path) + .args(["--json", temp.path.to_str().unwrap()]) + .output() + .expect("Failed to run lsr"); + + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + let val: serde_json::Value = serde_json::from_str(&stdout) + .unwrap_or_else(|e| panic!("Invalid JSON: {e}, stdout: {stdout}")); + + let arr = val.as_array().expect("Expected JSON array"); + let items: Vec<&str> = arr.iter().map(|v| v.as_str().unwrap()).collect(); + assert!(items.contains(&"alpha.txt")); + assert!(items.contains(&"beta.rs")); + assert!(items.contains(&"gamma_dir")); +} + +#[test] +fn test_m1_json_cli_short_empty_directory() { + let bin_path = env!("CARGO_BIN_EXE_lsr"); + let temp = TempTestDir::new("json_empty"); + + let output = Command::new(bin_path) + .args(["--json", temp.path.to_str().unwrap()]) + .output() + .expect("Failed to run lsr"); + + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + let val: serde_json::Value = serde_json::from_str(&stdout) + .unwrap_or_else(|e| panic!("Invalid JSON: {e}, stdout: {stdout}")); + + let arr = val.as_array().expect("Expected JSON array"); + assert!(arr.is_empty()); +} + +#[test] +fn test_m1_json_cli_short_single_file() { + let bin_path = env!("CARGO_BIN_EXE_lsr"); + let temp = TempTestDir::new("json_file"); + let file_path = temp.create_file("solo.txt", b"solo"); + + let output = Command::new(bin_path) + .args(["--json", file_path.to_str().unwrap()]) + .output() + .expect("Failed to run lsr"); + + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + let val: serde_json::Value = serde_json::from_str(&stdout) + .unwrap_or_else(|e| panic!("Invalid JSON: {e}, stdout: {stdout}")); + + let arr = val.as_array().expect("Expected JSON array"); + assert_eq!(arr.len(), 1); + assert_eq!(arr[0].as_str().unwrap(), "solo.txt"); +} + +#[test] +fn test_m1_json_cli_short_multi_directories() { + let bin_path = env!("CARGO_BIN_EXE_lsr"); + let temp = TempTestDir::new("json_multidir"); + let dir_a = temp.create_dir("dirA"); + let dir_b = temp.create_dir("dirB"); + temp.create_file("dirA/file_a.txt", b"a"); + temp.create_file("dirB/file_b.txt", b"b"); + + let output = Command::new(bin_path) + .args(["--json", dir_a.to_str().unwrap(), dir_b.to_str().unwrap()]) + .output() + .expect("Failed to run lsr"); + + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + let val: serde_json::Value = serde_json::from_str(&stdout) + .unwrap_or_else(|e| panic!("Invalid JSON: {e}, stdout: {stdout}")); + + let obj = val.as_object().expect("Expected JSON map for multi dirs"); + assert!(obj.contains_key(dir_a.to_str().unwrap())); + assert!(obj.contains_key(dir_b.to_str().unwrap())); + + let arr_a = obj + .get(dir_a.to_str().unwrap()) + .unwrap() + .as_array() + .expect("dirA must be array"); + assert_eq!(arr_a[0].as_str().unwrap(), "file_a.txt"); +} + +#[test] +fn test_m1_json_cli_short_mixed_files_and_directories() { + let bin_path = env!("CARGO_BIN_EXE_lsr"); + let temp = TempTestDir::new("json_mixed"); + let f1 = temp.create_file("top.txt", b"top"); + let dir1 = temp.create_dir("subfolder"); + temp.create_file("subfolder/inner.txt", b"inner"); + + let output = Command::new(bin_path) + .args(["--json", f1.to_str().unwrap(), dir1.to_str().unwrap()]) + .output() + .expect("Failed to run lsr"); + + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + let val: serde_json::Value = serde_json::from_str(&stdout) + .unwrap_or_else(|e| panic!("Invalid JSON: {e}, stdout: {stdout}")); + + let obj = val.as_object().expect("Expected JSON object for mixed"); + assert!(obj.contains_key("files")); + assert!(obj.contains_key("directories")); +} + +#[test] +fn test_m1_json_cli_long_metadata_schema() { + let bin_path = env!("CARGO_BIN_EXE_lsr"); + let temp = TempTestDir::new("json_long"); + temp.create_file("test.txt", b"content of test file"); + + let output = Command::new(bin_path) + .args([ + "-l", + "--octal-permissions", + "--json", + temp.path.to_str().unwrap(), + ]) + .output() + .expect("Failed to run lsr"); + + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + let val: serde_json::Value = serde_json::from_str(&stdout) + .unwrap_or_else(|e| panic!("Invalid JSON: {e}, stdout: {stdout}")); + let obj = val.as_object().expect("Expected JSON map"); + + let file_meta = obj.get("test.txt").expect("test.txt must exist in map"); + let meta_obj = file_meta.as_object().expect("Metadata must be an object"); + + assert!(meta_obj.contains_key("Permissions")); + assert!(meta_obj.contains_key("Size")); + #[cfg(unix)] + { + assert!(meta_obj.contains_key("Octal")); + assert_eq!(meta_obj.get("Octal").unwrap().as_str().unwrap(), "0644"); + } +} + +#[test] +fn test_m1_json_cli_long_empty_directory() { + let bin_path = env!("CARGO_BIN_EXE_lsr"); + let temp = TempTestDir::new("json_long_empty"); + + let output = Command::new(bin_path) + .args(["-l", "--json", temp.path.to_str().unwrap()]) + .output() + .expect("Failed to run lsr"); + + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + let val: serde_json::Value = serde_json::from_str(&stdout) + .unwrap_or_else(|e| panic!("Invalid JSON: {e}, stdout: {stdout}")); + let obj = val.as_object().expect("Expected JSON map"); + assert!(obj.is_empty()); +} + +#[test] +fn test_m1_json_cli_all_hidden_files() { + let bin_path = env!("CARGO_BIN_EXE_lsr"); + let temp = TempTestDir::new("json_hidden"); + temp.create_file(".secret.txt", b"secret"); + temp.create_file("visible.txt", b"visible"); + + let output = Command::new(bin_path) + .args(["-a", "--json", temp.path.to_str().unwrap()]) + .output() + .expect("Failed to run lsr"); + + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + let val: serde_json::Value = serde_json::from_str(&stdout) + .unwrap_or_else(|e| panic!("Invalid JSON: {e}, stdout: {stdout}")); + let arr = val.as_array().unwrap(); + let items: Vec<&str> = arr.iter().map(|v| v.as_str().unwrap()).collect(); + assert!(items.contains(&".secret.txt")); + assert!(items.contains(&"visible.txt")); +} + +#[test] +fn test_m1_json_cli_bytes_and_binary_units() { + let bin_path = env!("CARGO_BIN_EXE_lsr"); + let temp = TempTestDir::new("json_units"); + temp.create_file("large.bin", &vec![0u8; 1024 * 1024]); + + // --bytes mode + let out_bytes = Command::new(bin_path) + .args(["-l", "--bytes", "--json", temp.path.to_str().unwrap()]) + .output() + .expect("Failed to run lsr"); + assert!(out_bytes.status.success()); + let val_bytes: serde_json::Value = + serde_json::from_str(&String::from_utf8_lossy(&out_bytes.stdout)).unwrap(); + let size_bytes = val_bytes + .get("large.bin") + .unwrap() + .get("Size") + .unwrap() + .as_str() + .unwrap(); + assert_eq!(size_bytes, "1,048,576"); + + // --binary mode + let out_binary = Command::new(bin_path) + .args(["-l", "--binary", "--json", temp.path.to_str().unwrap()]) + .output() + .expect("Failed to run lsr"); + assert!(out_binary.status.success()); + let val_binary: serde_json::Value = + serde_json::from_str(&String::from_utf8_lossy(&out_binary.stdout)).unwrap(); + let size_binary = val_binary + .get("large.bin") + .unwrap() + .get("Size") + .unwrap() + .as_str() + .unwrap(); + assert_eq!(size_binary, "1.0Mi"); +} + +#[test] +fn test_m1_json_cli_time_styles() { + let bin_path = env!("CARGO_BIN_EXE_lsr"); + let temp = TempTestDir::new("json_time"); + temp.create_file("stamp.txt", b"timestamp test"); + + let output = Command::new(bin_path) + .args([ + "-l", + "--time-style=iso", + "--json", + temp.path.to_str().unwrap(), + ]) + .output() + .expect("Failed to run lsr"); + + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + let val: serde_json::Value = serde_json::from_str(&stdout).unwrap(); + let mod_time = val + .get("stamp.txt") + .unwrap() + .get("Date Modified") + .unwrap() + .as_str() + .unwrap(); + // ISO format: YYYY-MM-DD HH:MM + assert!(mod_time.contains('-')); + assert!(mod_time.contains(':')); +} + +#[test] +fn test_m1_json_cli_recursive_tree() { + let bin_path = env!("CARGO_BIN_EXE_lsr"); + let temp = TempTestDir::new("json_tree"); + temp.create_file("root_file.txt", b"root"); + temp.create_file("sub/nested_file.txt", b"nested"); + + let output = Command::new(bin_path) + .args(["-R", "--json", temp.path.to_str().unwrap()]) + .output() + .expect("Failed to run lsr"); + + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + let val: serde_json::Value = serde_json::from_str(&stdout) + .unwrap_or_else(|e| panic!("Invalid JSON: {e}, stdout: {stdout}")); + + assert!(val.is_object()); + let top_dir = temp.path.file_name().unwrap().to_str().unwrap(); + let top_obj = val.get(top_dir).expect("Must contain top directory"); + assert!(top_obj.get("files").is_some()); + assert!(top_obj.get("directories").is_some()); +} + +#[test] +fn test_m1_json_cli_recursive_long_tree() { + let bin_path = env!("CARGO_BIN_EXE_lsr"); + let temp = TempTestDir::new("json_long_tree"); + temp.create_file("root_file.txt", b"root"); + temp.create_file("sub/nested_file.txt", b"nested"); + + let output = Command::new(bin_path) + .args(["-l", "-R", "--json", temp.path.to_str().unwrap()]) + .output() + .expect("Failed to run lsr"); + + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + let val: serde_json::Value = serde_json::from_str(&stdout) + .unwrap_or_else(|e| panic!("Invalid JSON: {e}, stdout: {stdout}")); + + assert!(val.is_object()); + let top_dir = temp.path.file_name().unwrap().to_str().unwrap(); + let top_obj = val.get(top_dir).expect("Must contain top directory"); + let files_obj = top_obj + .get("files") + .expect("Must contain files object") + .as_object() + .unwrap(); + assert!(files_obj.contains_key("root_file.txt")); + assert!(files_obj.get("root_file.txt").unwrap().is_object()); +} + +#[test] +fn test_m1_json_cli_special_characters_escaping() { + let bin_path = env!("CARGO_BIN_EXE_lsr"); + let temp = TempTestDir::new("json_escaping"); + temp.create_file("file with spaces.txt", b"1"); + temp.create_file("file\"with\"quotes.txt", b"2"); + temp.create_file("emoji_🚀_tag.txt", b"3"); + temp.create_file("unicode_日本語_test.txt", b"4"); + + let output = Command::new(bin_path) + .args(["--json", temp.path.to_str().unwrap()]) + .output() + .expect("Failed to run lsr"); + + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + let val: serde_json::Value = serde_json::from_str(&stdout) + .unwrap_or_else(|e| panic!("JSON parse failed for escaped chars: {e}\nOutput:\n{stdout}")); + + let arr = val.as_array().unwrap(); + let items: Vec<&str> = arr.iter().map(|v| v.as_str().unwrap()).collect(); + assert!(items.contains(&"file with spaces.txt")); + assert!(items.contains(&"file\"with\"quotes.txt")); + assert!(items.contains(&"emoji_🚀_tag.txt")); + assert!(items.contains(&"unicode_日本語_test.txt")); +} + +#[test] +fn test_m1_json_cli_no_ansi_escapes() { + let bin_path = env!("CARGO_BIN_EXE_lsr"); + let temp = TempTestDir::new("json_no_ansi"); + temp.create_file("plain.txt", b"plain"); + + let output = Command::new(bin_path) + .args([ + "-l", + "--color=always", + "--json", + temp.path.to_str().unwrap(), + ]) + .output() + .expect("Failed to run lsr"); + + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + !stdout.contains("\x1B["), + "JSON output must never contain ANSI escape codes" + ); + let _: serde_json::Value = serde_json::from_str(&stdout).unwrap(); +} + +#[test] +#[cfg(unix)] +fn test_m1_json_cli_symlinks() { + let bin_path = env!("CARGO_BIN_EXE_lsr"); + let temp = TempTestDir::new("json_symlink"); + let target = temp.create_file("target.txt", b"target"); + let link_path = temp.path.join("link.txt"); + std::os::unix::fs::symlink(&target, &link_path).unwrap(); + + let output = Command::new(bin_path) + .args(["-l", "--json", temp.path.to_str().unwrap()]) + .output() + .expect("Failed to run lsr"); + + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + let val: serde_json::Value = serde_json::from_str(&stdout).unwrap(); + let link_meta = val.get("link.txt").unwrap().as_object().unwrap(); + let perms = link_meta.get("Permissions").unwrap().as_str().unwrap(); + assert!( + perms.starts_with('l'), + "Symlink permission string must start with 'l', got {perms}" + ); +} + +#[test] +#[cfg(feature = "git")] +fn test_m1_json_cli_git_status() { + let bin_path = env!("CARGO_BIN_EXE_lsr"); + let temp = TempTestDir::new("json_git"); + let repo = git2::Repository::init(&temp.path).expect("Failed to init git repo"); + + let file_path = temp.create_file("tracked.txt", b"initial"); + let mut index = repo.index().unwrap(); + index.add_path(std::path::Path::new("tracked.txt")).unwrap(); + index.write().unwrap(); + + // Now modify the file + let mut f = StdFile::create(&file_path).unwrap(); + f.write_all(b"modified").unwrap(); + + let output = Command::new(bin_path) + .args(["-l", "--git", "--json", temp.path.to_str().unwrap()]) + .output() + .expect("Failed to run lsr"); + + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + let val: serde_json::Value = serde_json::from_str(&stdout).unwrap(); + let git_status = val + .get("tracked.txt") + .unwrap() + .get("Git") + .unwrap() + .as_str() + .unwrap(); + assert!(git_status == "NM" || git_status == "-M" || git_status == "N-"); +} + +#[test] +fn test_m2_resourcefork_xattr_decoding_unit() { + #[cfg(target_os = "macos")] + { + use lsr::fs::feature::xattr::Attribute; + + let mut data = vec![0u8; 64]; + // Header + data[0..4].copy_from_slice(&256u32.to_be_bytes()); // data offset + data[4..8].copy_from_slice(&16u32.to_be_bytes()); // map offset + data[8..12].copy_from_slice(&0u32.to_be_bytes()); // data len + data[12..16].copy_from_slice(&48u32.to_be_bytes()); // map len + + // Map at 16 + // Type list offset at map + 24 = index 40 + data[40..42].copy_from_slice(&28u16.to_be_bytes()); + + // Type list at 16 + 28 = 44 + data[44..46].copy_from_slice(&0u16.to_be_bytes()); // 1 type (0 + 1) + data[46..50].copy_from_slice(b"icns"); + data[50..52].copy_from_slice(&0u16.to_be_bytes()); // count 1 (0 + 1) + data[52..54].copy_from_slice(&0u16.to_be_bytes()); + + let attr = Attribute { + name: "com.apple.ResourceFork".to_string(), + value: Some(data), + }; + + let formatted = format!("{attr}"); + assert!( + formatted.contains("<[icns: 1]>"), + "Formatted output: {formatted}" + ); + } +} + +#[test] +fn test_m3_mounts_cli_display() { + let bin_path = env!("CARGO_BIN_EXE_lsr"); + let output = Command::new(bin_path) + .args(["-l", "-M", "/"]) + .output() + .expect("Failed to run lsr"); + + assert!(output.status.success()); +} + +#[test] +fn test_m4_directorynames_theme_override() { + let yaml = r#" +directorynames: + special_dir: + filename: + foreground: Red +"#; + let temp = TempTestDir::new("theme_dir"); + let theme_file = temp.create_file("theme.yml", yaml.as_bytes()); + let cfg = lsr::options::config::ThemeConfig::from_path(theme_file); + let theme = cfg.to_theme().expect("Failed to parse theme"); + assert!(theme.directorynames.is_some()); + let dir_styles = theme.directorynames.unwrap(); + assert!(dir_styles.contains_key("special_dir")); +} + +#[test] +fn test_m5_icons_apple_and_configs() { + let bin_path = env!("CARGO_BIN_EXE_lsr"); + let temp = TempTestDir::new("icons_test"); + temp.create_file("hyprland.conf", b""); + + let output = Command::new(bin_path) + .args(["--icons=always", temp.path.to_str().unwrap()]) + .output() + .expect("Failed to run lsr"); + + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("hyprland.conf")); +}