diff --git a/src/archive/rar.rs b/src/archive/rar.rs index 7fa3d9b25..069162eb5 100644 --- a/src/archive/rar.rs +++ b/src/archive/rar.rs @@ -7,7 +7,7 @@ use unrar::Archive; use crate::{ error::{Error, Result}, info, - list::FileInArchive, + list::{FileInArchive, FileType}, utils::BytesFmt, }; @@ -55,7 +55,10 @@ pub fn list_archive( let is_dir = item.is_directory(); let path = item.filename; - Ok(FileInArchive { path, is_dir }) + Ok(FileInArchive { + path, + file_type: if is_dir { FileType::Directory } else { FileType::File }, + }) })) } diff --git a/src/archive/sevenz.rs b/src/archive/sevenz.rs index 1f7118083..319645c51 100644 --- a/src/archive/sevenz.rs +++ b/src/archive/sevenz.rs @@ -16,7 +16,7 @@ use crate::{ Result, error::{Error, FinalError}, info, - list::FileInArchive, + list::{FileInArchive, FileType}, utils::{ BytesFmt, FileVisibilityPolicy, PathFmt, cd_into_same_dir_as, ensure_parent_dir_exists, is_same_file_as_output, }, @@ -88,7 +88,11 @@ where let entry_extract_fn = |entry: &ArchiveEntry, _: &mut dyn Read, _: &PathBuf| { files.push(Ok(FileInArchive { path: entry.name().into(), - is_dir: entry.is_directory(), + file_type: if entry.is_directory() { + FileType::Directory + } else { + FileType::File + }, })); Ok(true) }; diff --git a/src/archive/tar.rs b/src/archive/tar.rs index 68d51d23a..11ce30343 100644 --- a/src/archive/tar.rs +++ b/src/archive/tar.rs @@ -17,7 +17,7 @@ use crate::{ Result, error::FinalError, info, - list::FileInArchive, + list::{FileInArchive, FileType as ListFileType}, utils::{ self, BytesFmt, FileType, FileVisibilityPolicy, PathFmt, canonicalize, create_symlink, is_same_file_as_output, read_file_type, set_permission_mode, @@ -103,13 +103,28 @@ pub fn list_archive(mut archive: tar::Archive) -> Result>().into_iter()) } +fn get_file_type(header: &tar::Header, file: &tar::Entry) -> Result { + Ok(match header.entry_type() { + tar::EntryType::Directory => ListFileType::Directory, + tar::EntryType::Symlink => file + .link_name()? + .map(|t| ListFileType::Symlink { target: t.into_owned() }) + .unwrap_or(ListFileType::File), + tar::EntryType::Link => file + .link_name()? + .map(|t| ListFileType::Hardlink { target: t.into_owned() }) + .unwrap_or(ListFileType::File), + _ => ListFileType::File, + }) +} + /// Compresses the archives given by `input_filenames` into the file given previously to `writer`. pub fn build_archive( explicit_paths: &[PathBuf], diff --git a/src/archive/zip.rs b/src/archive/zip.rs index 5a60cc913..0bd889813 100644 --- a/src/archive/zip.rs +++ b/src/archive/zip.rs @@ -19,7 +19,7 @@ use crate::{ Result, error::FinalError, info, info_accessible, - list::FileInArchive, + list::{FileInArchive, FileType as ListFileType}, utils::{ BytesFmt, FileType, FileVisibilityPolicy, PathFmt, canonicalize, cd_into_same_dir_as, create_symlink, ensure_parent_dir_exists, get_invalid_utf8_paths, is_same_file_as_output, pretty_format_list_of_paths, @@ -119,15 +119,31 @@ where None => archive.by_index(idx), }; - let file = match zip_result { + let mut file = match zip_result { Ok(f) => f, Err(e) => return Err(e.into()), }; let path = file.enclosed_name().unwrap_or_else(|| file.mangled_name()).to_owned(); - let is_dir = file.is_dir(); - Ok(FileInArchive { path, is_dir }) + let file_type = if file.is_dir() { + ListFileType::Directory + } else if let Some(target) = file + .unix_mode() + .filter(|mode| mode & 0o170000 == 0o120000) + .and_then(|_| { + let mut s = Vec::new(); + file.read_to_end(&mut s) + .ok() + .map(|_| PathBuf::from(String::from_utf8_lossy(&s).into_owned())) + }) + { + ListFileType::Symlink { target } + } else { + ListFileType::File + }; + + Ok(FileInArchive { path, file_type }) }) } diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 0a259c289..5016477fb 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -251,10 +251,13 @@ pub fn run(args: CliArgs, question_policy: QuestionPolicy, file_visibility_polic // Ensure we were not told to list the content of a non-archive compressed file check::check_for_non_archive_formats(&files, &formats)?; - let list_options = ListOptions { tree }; + let list_options = ListOptions { + tree, + quiet: args.quiet, + }; for (i, (archive_path, formats)) in files.iter().zip(formats).enumerate() { - if i > 0 { + if i > 0 && !args.quiet { println!(); } let formats = extension::flatten_compression_formats(&formats); diff --git a/src/list.rs b/src/list.rs index 5f8154eba..db549fdef 100644 --- a/src/list.rs +++ b/src/list.rs @@ -14,6 +14,17 @@ use crate::{Result, accessible::is_running_in_accessible_mode, utils::PathFmt}; pub struct ListOptions { /// Whether to show a tree view pub tree: bool, + + /// Whether to suppress extra output like symlink targets (for scripting) + pub quiet: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum FileType { + File, + Directory, + Symlink { target: PathBuf }, + Hardlink { target: PathBuf }, } /// Represents a single file in an archive, used in `list::list_files()` @@ -22,8 +33,8 @@ pub struct FileInArchive { /// The file path pub path: PathBuf, - /// Whether this file is a directory - pub is_dir: bool, + /// The type of file + pub file_type: FileType, } /// Actually print the files @@ -34,15 +45,18 @@ pub fn list_files( list_options: ListOptions, ) -> Result<()> { let mut out = BufWriter::new(stdout().lock()); - let _ = writeln!(out, "Archive: {}", PathFmt(archive)); + + if !list_options.quiet { + let _ = writeln!(out, "Archive: {}", PathFmt(archive)); + } if list_options.tree { let tree = files.into_iter().collect::>()?; tree.print(&mut out); } else { for file in files { - let FileInArchive { path, is_dir } = file?; - print_entry(&mut out, path.display(), is_dir); + let FileInArchive { path, file_type } = file?; + print_entry(&mut out, path.display(), &file_type, list_options.quiet); } } Ok(()) @@ -50,31 +64,57 @@ pub fn list_files( /// Print an entry and highlight directories, either by coloring them /// if that's supported or by adding a trailing / -fn print_entry(out: &mut impl Write, name: impl fmt::Display, is_dir: bool) { +fn print_entry(out: &mut impl Write, name: impl fmt::Display, file_type: &FileType, quiet: bool) { use crate::utils::colors::*; - if !is_dir { - // Not a directory -> just print the file name - let _ = writeln!(out, "{name}"); - return; - } + match file_type { + FileType::File => { + let _ = writeln!(out, "{name}"); + } + FileType::Symlink { target } | FileType::Hardlink { target } => { + if quiet { + // In quiet mode, just print the name (like a regular file) + // This allows scripts to process the list without parsing arrows + let _ = writeln!(out, "{}{name}{}", *CYAN, *ALL_RESET); + return; + } - // Handle directory display - let name_str = name.to_string(); - let display_name = name_str.strip_suffix('/').unwrap_or(&name_str); + let suffix = if matches!(file_type, FileType::Hardlink { .. }) { + " (hardlink)" + } else { + "" + }; - let output = if BLUE.is_empty() { - // Colors are deactivated, print final / to mark directories - format!("{display_name}/") - } else if is_running_in_accessible_mode() { - // Accessible mode: use colors but print final / for screen readers - format!("{}{}{}/{}", *BLUE, *STYLE_BOLD, display_name, *ALL_RESET) - } else { - // Normal mode: use colors without trailing slash - format!("{}{}{}{}", *BLUE, *STYLE_BOLD, display_name, *ALL_RESET) - }; + if is_running_in_accessible_mode() { + let _ = writeln!(out, "{name} -> {}{suffix}", target.display()); + } else { + let _ = writeln!( + out, + "{c}{name}{r} {c}-> {c}{target}{suffix}{r}", + c = *CYAN, + r = *ALL_RESET, + target = target.display() + ); + } + } + FileType::Directory => { + let name_str = name.to_string(); + let display_name = name_str.strip_suffix('/').unwrap_or(&name_str); + + let output = if BLUE.is_empty() { + // Colors are deactivated, print final / to mark directories + format!("{display_name}/") + } else if is_running_in_accessible_mode() { + // Accessible mode: use colors but print final / for screen readers + format!("{}{}{}/{}", *BLUE, *STYLE_BOLD, display_name, *ALL_RESET) + } else { + // Normal mode: use colors without trailing slash + format!("{}{}{}{}", *BLUE, *STYLE_BOLD, display_name, *ALL_RESET) + }; - let _ = writeln!(out, "{output}"); + let _ = writeln!(out, "{output}"); + } + } } /// Since archives store files as a list of entries -> without direct @@ -91,7 +131,7 @@ mod tree { use bstr::{ByteSlice, ByteVec}; use linked_hash_map::LinkedHashMap; - use super::FileInArchive; + use super::{FileInArchive, FileType}; use crate::{utils::NoQuotePathFmt, warning}; /// Directory tree @@ -151,11 +191,17 @@ mod tree { }; let _ = write!(out, "{prefix}{final_part}"); - let is_dir = match self.file { - Some(FileInArchive { is_dir, .. }) => is_dir, - None => true, + let file_type = match &self.file { + Some(FileInArchive { file_type, .. }) => file_type.clone(), + // If we don't have a file entry but have children, it's an implicit directory + None => FileType::Directory, }; - super::print_entry(out, as ByteVec>::from_os_str_lossy(name).as_bstr(), is_dir); + super::print_entry( + out, + as ByteVec>::from_os_str_lossy(name).as_bstr(), + &file_type, + false, // Always show targets in tree view, regardless of --quiet flag + ); // Construct prefix for children, adding either a line if this isn't // the last entry in the parent dir or empty space if it is.