From bf24f711bfb670cd54e8846bcdbcaff729d12b51 Mon Sep 17 00:00:00 2001 From: Rabindra Dhakal Date: Sun, 21 Sep 2025 12:40:36 +0545 Subject: [PATCH 01/12] feat(crate): init soar-utils crate --- Cargo.lock | 26 +- Cargo.toml | 1 + crates/soar-utils/Cargo.toml | 18 ++ crates/soar-utils/src/bytes.rs | 282 +++++++++++++++++ crates/soar-utils/src/error.rs | 318 +++++++++++++++++++ crates/soar-utils/src/fs.rs | 242 +++++++++++++++ crates/soar-utils/src/hash.rs | 169 +++++++++++ crates/soar-utils/src/lib.rs | 6 + crates/soar-utils/src/path.rs | 538 +++++++++++++++++++++++++++++++++ crates/soar-utils/src/user.rs | 77 +++++ 10 files changed, 1675 insertions(+), 2 deletions(-) create mode 100644 crates/soar-utils/Cargo.toml create mode 100644 crates/soar-utils/src/bytes.rs create mode 100644 crates/soar-utils/src/error.rs create mode 100644 crates/soar-utils/src/fs.rs create mode 100644 crates/soar-utils/src/hash.rs create mode 100644 crates/soar-utils/src/lib.rs create mode 100644 crates/soar-utils/src/path.rs create mode 100644 crates/soar-utils/src/user.rs diff --git a/Cargo.lock b/Cargo.lock index 8c6a5f7d8..5fc267f15 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -576,7 +576,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "778e2ac28f6c47af28e4907f13ffd1e1ddbd400980a9abd7c8df189bf578a5ad" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] [[package]] @@ -1899,7 +1899,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] [[package]] @@ -2166,6 +2166,15 @@ dependencies = [ "zstd", ] +[[package]] +name = "soar-utils" +version = "0.1.0" +dependencies = [ + "blake3", + "nix", + "tempfile", +] + [[package]] name = "socket2" version = "0.5.10" @@ -2303,6 +2312,19 @@ dependencies = [ "xattr", ] +[[package]] +name = "tempfile" +version = "3.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84fa4d11fadde498443cca10fd3ac23c951f0dc59e080e9f4b93d4df4e4eea53" +dependencies = [ + "fastrand", + "getrandom 0.3.3", + "once_cell", + "rustix", + "windows-sys 0.60.2", +] + [[package]] name = "thiserror" version = "1.0.69" diff --git a/Cargo.toml b/Cargo.toml index d3f030a9d..fe478c00c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,6 @@ [workspace] members = [ + "crates/soar-utils", "soar-cli", "soar-core" ] diff --git a/crates/soar-utils/Cargo.toml b/crates/soar-utils/Cargo.toml new file mode 100644 index 000000000..14dfeba5d --- /dev/null +++ b/crates/soar-utils/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "soar-utils" +version = "0.1.0" +authors.workspace = true +license.workspace = true +edition.workspace = true +repository.workspace = true +keywords.workspace = true +readme.workspace = true +categories.workspace = true + +[dependencies] +blake3 = { version = "1.8.2", features = ["mmap"] } +nix = { version = "0.30.1", features = ["ioctl", "term", "user"] } + +[dev-dependencies] +tempfile = "3.10.1" + diff --git a/crates/soar-utils/src/bytes.rs b/crates/soar-utils/src/bytes.rs new file mode 100644 index 000000000..d8599520d --- /dev/null +++ b/crates/soar-utils/src/bytes.rs @@ -0,0 +1,282 @@ +use crate::error::{BytesError, BytesResult}; + +pub trait ByteFormatter { + /// Formats a number of bytes into a human-readable string. + /// + /// This method converts a byte count into a string with appropriate units (B, KiB, MiB, etc.) + /// and a specified level of precision. + /// + /// # Arguments + /// + /// * `bytes` - The number of bytes to format + /// * `precision` - The number of decimal places to display + /// + /// # Returns + /// + /// A human-readable string representation of the byte count. + /// + /// # Example + /// + /// ``` + /// use soar_utils::bytes::{ByteFormatter, StandardByteFormatter}; + /// + /// let formatter = StandardByteFormatter; + /// let bytes = 1024_u64.pow(2); + /// let formatted = formatter.format_bytes(bytes, 2); + /// + /// assert_eq!(formatted, "1.00 MiB"); + /// ``` + fn format_bytes(&self, bytes: u64, precision: usize) -> String; + + /// Parses a human-readable byte string into a number of bytes. + /// + /// This method converts a string with units (e.g., "1.00 MiB", "1KB") into a `u64` byte count. + /// It supports both binary (KiB, MiB) and decimal (KB, MB) prefixes. + /// + /// # Arguments + /// + /// * `s` - The string to parse + /// + /// # Returns + /// + /// Returns the number of bytes as a `u64`, or a [`BytesError`] if the string is invalid. + /// + /// # Errors + /// + /// * [`BytesError::ParseFailed`] if the string has an invalid format or suffix. + /// + /// # Example + /// + /// ``` + /// use soar_utils::bytes::{ByteFormatter, StandardByteFormatter}; + /// + /// let formatter = StandardByteFormatter; + /// let bytes = formatter.parse_bytes("1.00 MiB").unwrap(); + /// + /// assert_eq!(bytes, 1024_u64.pow(2)); + /// ``` + fn parse_bytes(&self, s: &str) -> BytesResult; +} + +#[derive(Default, Clone)] +pub struct StandardByteFormatter; + +impl ByteFormatter for StandardByteFormatter { + fn format_bytes(&self, bytes: u64, precision: usize) -> String { + let unit = 1024.0; + let sizes = ["B", "KiB", "MiB", "GiB", "TiB", "PiB", "EiB"]; + + let idx = (bytes as f64).log(unit).floor() as usize; + let idx = idx.min(sizes.len() - 1); + + format!( + "{:.*} {}", + precision, + bytes as f64 / unit.powi(idx.try_into().unwrap()), + sizes[idx] + ) + } + + fn parse_bytes(&self, s: &str) -> BytesResult { + let mut size = s.trim().to_uppercase(); + + // If it's a number, just return it + if let Ok(v) = size.parse::() { + return Ok(v); + }; + + let prefixes = ["", "K", "M", "G", "T", "P", "E"]; + + let base: f64 = if size.ends_with("IB") { + size.truncate(size.len() - 2); + 1024.0 + } else if size.ends_with("B") { + size.truncate(size.len() - 1); + 1000.0 + } else { + return Err(BytesError::ParseFailed { + input: s.to_string(), + reason: "Invalid suffix".to_string(), + }); + }; + + prefixes + .iter() + .enumerate() + .rev() + .find_map(|(i, p)| { + size.strip_suffix(p).and_then(|num| { + num.trim() + .parse::() + .ok() + .map(|n| n * base.powi(i.try_into().unwrap())) + .map(|n| n.round() as u64) + }) + }) + .ok_or_else(|| BytesError::ParseFailed { + input: s.to_string(), + reason: "Unrecognized size format".into(), + }) + } +} + +/// Formats a number of bytes into a human-readable string. +/// +/// This is a convenience function that creates a [`StandardByteFormatter`] and calls +/// [`ByteFormatter::format_bytes`] on it. +/// +/// See [`ByteFormatter::format_bytes`] for detailed documentation. +pub fn format_bytes(bytes: u64, precision: usize) -> String { + StandardByteFormatter.format_bytes(bytes, precision) +} + +/// Parses a human-readable byte string into a number of bytes. +/// +/// This is a convenience function that creates a [`StandardByteFormatter`] and calls +/// [`ByteFormatter::parse_bytes`] on it. +/// +/// See [`ByteFormatter::parse_bytes`] for detailed documentation. +pub fn parse_bytes(s: &str) -> BytesResult { + StandardByteFormatter.parse_bytes(s) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_format_bytes_with_precisions() { + assert_eq!(format_bytes(1111, 0), "1 KiB"); + + let formatter = StandardByteFormatter; + assert_eq!(formatter.format_bytes(0, 0), "0 B"); + assert_eq!(formatter.format_bytes(0, 3), "0.000 B"); + assert_eq!(formatter.format_bytes(1023, 0), "1023 B"); + assert_eq!(formatter.format_bytes(1023, 2), "1023.00 B"); + + assert_eq!(formatter.format_bytes(1024, 0), "1 KiB"); + assert_eq!(formatter.format_bytes(1024, 1), "1.0 KiB"); + assert_eq!(formatter.format_bytes(1536, 2), "1.50 KiB"); + assert_eq!(formatter.format_bytes(2047, 3), "1.999 KiB"); + assert_eq!(formatter.format_bytes(2048, 4), "2.0000 KiB"); + + assert_eq!(formatter.format_bytes(1024_u64.pow(2), 0), "1 MiB"); + assert_eq!( + formatter.format_bytes(3 * 1024_u64.pow(2) / 2, 2), + "1.50 MiB" + ); + assert_eq!( + formatter.format_bytes(2 * 1024_u64.pow(2) - 1, 3), + "2.000 MiB" + ); + + assert_eq!(formatter.format_bytes(1024_u64.pow(3), 2), "1.00 GiB"); + assert_eq!( + formatter.format_bytes(5 * 1024_u64.pow(3) / 2, 1), + "2.5 GiB" + ); + + assert_eq!(formatter.format_bytes(1024_u64.pow(4), 3), "1.000 TiB"); + assert_eq!( + formatter.format_bytes(3 * 1024_u64.pow(4) / 2, 2), + "1.50 TiB" + ); + + assert_eq!(formatter.format_bytes(1024_u64.pow(5), 0), "1 PiB"); + assert_eq!( + formatter.format_bytes(1024_u64.pow(5) + 512 * 1024_u64.pow(4), 2), + "1.50 PiB" + ); + + assert_eq!(formatter.format_bytes(1024_u64.pow(6), 1), "1.0 EiB"); + assert_eq!( + formatter.format_bytes(1024_u64.pow(6) + 512 * 1024_u64.pow(5), 3), + "1.500 EiB" + ); + } + + #[test] + fn test_parse_bytes() { + assert_eq!(parse_bytes("111").unwrap(), 111); + + let formatter = StandardByteFormatter; + assert_eq!(formatter.parse_bytes("42").unwrap(), 42); + assert_eq!(formatter.parse_bytes(" 120 ").unwrap(), 120); + + assert_eq!(formatter.parse_bytes("0B").unwrap(), 0); + assert_eq!(formatter.parse_bytes("1B").unwrap(), 1); + assert_eq!(formatter.parse_bytes("1023B").unwrap(), 1023); + + assert_eq!(formatter.parse_bytes("1KiB").unwrap(), 1024); + assert_eq!(formatter.parse_bytes("1.50KiB").unwrap(), 3 * 1024 / 2); + assert_eq!(formatter.parse_bytes("1KB").unwrap(), 1000); + assert_eq!(formatter.parse_bytes("1.50KB").unwrap(), 3 * 1000 / 2); + + assert_eq!(formatter.parse_bytes("1MiB").unwrap(), 1024_u64.pow(2)); + assert_eq!( + formatter.parse_bytes("1.50MiB").unwrap(), + 3 * 1024_u64.pow(2) / 2 + ); + assert_eq!(formatter.parse_bytes("1MB").unwrap(), 1000_u64.pow(2)); + assert_eq!( + formatter.parse_bytes("1.50MB").unwrap(), + 3 * 1000_u64.pow(2) / 2 + ); + + assert_eq!(formatter.parse_bytes("1GiB").unwrap(), 1024_u64.pow(3)); + assert_eq!( + formatter.parse_bytes("1.50GiB").unwrap(), + 3 * 1024_u64.pow(3) / 2 + ); + assert_eq!(formatter.parse_bytes("1GB").unwrap(), 1000_u64.pow(3)); + assert_eq!( + formatter.parse_bytes("1.50GB").unwrap(), + 3 * 1000_u64.pow(3) / 2 + ); + + assert_eq!(formatter.parse_bytes("1TiB").unwrap(), 1024_u64.pow(4)); + assert_eq!( + formatter.parse_bytes("1.50TiB").unwrap(), + 3 * 1024_u64.pow(4) / 2 + ); + assert_eq!(formatter.parse_bytes("1TB").unwrap(), 1000_u64.pow(4)); + assert_eq!( + formatter.parse_bytes("1.50TB").unwrap(), + 3 * 1000_u64.pow(4) / 2 + ); + + assert_eq!(formatter.parse_bytes("1PiB").unwrap(), 1024_u64.pow(5)); + assert_eq!( + formatter.parse_bytes("1.50PiB").unwrap(), + 3 * 1024_u64.pow(5) / 2 + ); + assert_eq!(formatter.parse_bytes("1PB").unwrap(), 1000_u64.pow(5)); + assert_eq!( + formatter.parse_bytes("1.50PB").unwrap(), + 3 * 1000_u64.pow(5) / 2 + ); + + assert_eq!(formatter.parse_bytes("1EiB").unwrap(), 1024_u64.pow(6)); + assert_eq!( + formatter.parse_bytes("1.50EiB").unwrap(), + 3 * 1024_u64.pow(6) / 2 + ); + assert_eq!(formatter.parse_bytes("1EB").unwrap(), 1000_u64.pow(6)); + assert_eq!( + formatter.parse_bytes("1.50EB").unwrap(), + 3 * 1000_u64.pow(6) / 2 + ); + } + + #[test] + fn test_fail_parse_bytes() { + let formatter = StandardByteFormatter; + assert!(formatter.parse_bytes("1.xE").is_err()); + assert!(formatter.parse_bytes("1.xEB").is_err()); + assert!(formatter.parse_bytes("1.50FB").is_err()); + assert!(formatter.parse_bytes("1LB ").is_err()); + assert!(formatter.parse_bytes(" 1.50Li").is_err()); + assert!(formatter.parse_bytes(" MiB ").is_err()); + assert!(formatter.parse_bytes("MB").is_err()); + } +} diff --git a/crates/soar-utils/src/error.rs b/crates/soar-utils/src/error.rs new file mode 100644 index 000000000..98fe42781 --- /dev/null +++ b/crates/soar-utils/src/error.rs @@ -0,0 +1,318 @@ +use std::{error::Error, fmt, path::PathBuf}; + +#[derive(Debug)] +pub enum BytesError { + ParseFailed { input: String, reason: String }, +} + +impl fmt::Display for BytesError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + BytesError::ParseFailed { input, reason } => { + write!(f, "Failed to parse `{input}` as bytes: {reason}") + } + } + } +} + +#[derive(Debug)] +pub enum HashError { + ReadFailed { + path: PathBuf, + source: std::io::Error, + }, +} + +impl fmt::Display for HashError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + HashError::ReadFailed { path, source } => { + write!(f, "Failed to read file `{}`: {source}", path.display()) + } + } + } +} + +impl Error for HashError { + fn source(&self) -> Option<&(dyn Error + 'static)> { + match self { + HashError::ReadFailed { source, .. } => Some(source), + } + } +} + +impl Error for BytesError {} + +#[derive(Debug)] +pub enum PathError { + CurrentDir { source: std::io::Error }, + + Empty, + + MissingEnvVar { var: String, input: String }, + + UnclosedVariable { input: String }, +} + +impl fmt::Display for PathError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + PathError::Empty => write!(f, "Path is empty"), + PathError::CurrentDir { source } => { + write!(f, "Failed to get current directory: {source}") + } + PathError::UnclosedVariable { input } => { + write!(f, "Unclosed variable expression starting at `{input}`") + } + PathError::MissingEnvVar { var, input } => { + write!(f, "Environment variable `{var}` not set in `{input}`") + } + } + } +} + +impl Error for PathError { + fn source(&self) -> Option<&(dyn Error + 'static)> { + match self { + PathError::CurrentDir { source } => Some(source), + _ => None, + } + } +} + +#[derive(Debug)] +pub enum FileSystemError { + File { + path: PathBuf, + action: &'static str, + source: std::io::Error, + }, + + Directory { + path: PathBuf, + action: &'static str, + source: std::io::Error, + }, + + NotADirectory { + path: PathBuf, + }, +} + +impl fmt::Display for FileSystemError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + FileSystemError::File { + path, + action, + source, + } => { + write!(f, "Failed to {action} file `{}`: {source}", path.display()) + } + FileSystemError::Directory { + path, + action, + source, + } => { + write!( + f, + "Failed to {action} directory `{}`: {source}", + path.display() + ) + } + FileSystemError::NotADirectory { path } => { + write!(f, "`{}` is not a directory", path.display()) + } + } + } +} + +impl Error for FileSystemError { + fn source(&self) -> Option<&(dyn Error + 'static)> { + match self { + FileSystemError::File { source, .. } => Some(source), + FileSystemError::Directory { source, .. } => Some(source), + _ => None, + } + } +} + +#[derive(Debug)] +pub enum UtilsError { + Bytes(BytesError), + Path(PathError), + FileSystem(FileSystemError), +} + +impl fmt::Display for UtilsError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + UtilsError::Bytes(err) => write!(f, "{err}"), + UtilsError::Path(err) => write!(f, "{err}"), + UtilsError::FileSystem(err) => write!(f, "{err}"), + } + } +} + +impl Error for UtilsError { + fn source(&self) -> Option<&(dyn Error + 'static)> { + match self { + UtilsError::Bytes(err) => Some(err), + UtilsError::Path(err) => Some(err), + UtilsError::FileSystem(err) => Some(err), + } + } +} + +impl From for UtilsError { + fn from(err: BytesError) -> Self { + UtilsError::Bytes(err) + } +} + +impl From for UtilsError { + fn from(err: PathError) -> Self { + UtilsError::Path(err) + } +} + +impl From for UtilsError { + fn from(err: FileSystemError) -> Self { + UtilsError::FileSystem(err) + } +} + +pub type BytesResult = std::result::Result; +pub type FileSystemResult = std::result::Result; +pub type HashResult = std::result::Result; +pub type PathResult = std::result::Result; + +pub type UtilsResult = std::result::Result; + +#[cfg(test)] +mod tests { + use super::*; + use std::io; + + #[test] + fn test_bytes_error_display() { + let error = BytesError::ParseFailed { + input: "test".to_string(), + reason: "invalid".to_string(), + }; + assert_eq!( + error.to_string(), + "Failed to parse `test` as bytes: invalid" + ); + } + + #[test] + fn test_hash_error_display_and_source() { + let io_error = io::Error::new(io::ErrorKind::NotFound, "file not found"); + let error = HashError::ReadFailed { + path: PathBuf::from("/test"), + source: io_error, + }; + assert_eq!( + error.to_string(), + "Failed to read file `/test`: file not found" + ); + assert!(error.source().is_some()); + } + + #[test] + fn test_path_error_display_and_source() { + let io_error = io::Error::other("some error"); + let current_dir_error = PathError::CurrentDir { source: io_error }; + assert_eq!( + current_dir_error.to_string(), + "Failed to get current directory: some error" + ); + assert!(current_dir_error.source().is_some()); + + let empty_error = PathError::Empty; + assert_eq!(empty_error.to_string(), "Path is empty"); + assert!(empty_error.source().is_none()); + + let missing_env_var_error = PathError::MissingEnvVar { + var: "VAR".to_string(), + input: "$VAR".to_string(), + }; + assert_eq!( + missing_env_var_error.to_string(), + "Environment variable `VAR` not set in `$VAR`" + ); + assert!(missing_env_var_error.source().is_none()); + + let unclosed_variable_error = PathError::UnclosedVariable { + input: "${VAR".to_string(), + }; + assert_eq!( + unclosed_variable_error.to_string(), + "Unclosed variable expression starting at `${VAR`" + ); + assert!(unclosed_variable_error.source().is_none()); + } + + #[test] + fn test_file_system_error_display_and_source() { + let io_error = io::Error::new(io::ErrorKind::PermissionDenied, "permission denied"); + let file_error = FileSystemError::File { + path: PathBuf::from("/file"), + action: "read", + source: io_error, + }; + assert_eq!( + file_error.to_string(), + "Failed to read file `/file`: permission denied" + ); + assert!(file_error.source().is_some()); + + let io_error2 = io::Error::new(io::ErrorKind::PermissionDenied, "permission denied"); + let dir_error = FileSystemError::Directory { + path: PathBuf::from("/dir"), + action: "create", + source: io_error2, + }; + assert_eq!( + dir_error.to_string(), + "Failed to create directory `/dir`: permission denied" + ); + assert!(dir_error.source().is_some()); + + let not_a_dir_error = FileSystemError::NotADirectory { + path: PathBuf::from("/path"), + }; + assert_eq!(not_a_dir_error.to_string(), "`/path` is not a directory"); + assert!(not_a_dir_error.source().is_none()); + } + + #[test] + fn test_utils_error_display_and_source_and_from() { + let bytes_error = BytesError::ParseFailed { + input: "test".to_string(), + reason: "invalid".to_string(), + }; + let utils_error_from_bytes = UtilsError::from(bytes_error); + assert_eq!( + utils_error_from_bytes.to_string(), + "Failed to parse `test` as bytes: invalid" + ); + assert!(utils_error_from_bytes.source().is_some()); + + let path_error = PathError::Empty; + let utils_error_from_path = UtilsError::from(path_error); + assert_eq!(utils_error_from_path.to_string(), "Path is empty"); + assert!(utils_error_from_path.source().is_some()); + + let fs_error = FileSystemError::NotADirectory { + path: PathBuf::from("/path"), + }; + let utils_error_from_fs = UtilsError::from(fs_error); + assert_eq!( + utils_error_from_fs.to_string(), + "`/path` is not a directory" + ); + assert!(utils_error_from_fs.source().is_some()); + } +} diff --git a/crates/soar-utils/src/fs.rs b/crates/soar-utils/src/fs.rs new file mode 100644 index 000000000..caac96b20 --- /dev/null +++ b/crates/soar-utils/src/fs.rs @@ -0,0 +1,242 @@ +use std::{fs, path::Path}; + +use crate::error::{FileSystemError, FileSystemResult}; + +pub trait FileSystemProvider { + /// Removes the specified file or directory safely. + /// + /// If the path does not exist, this function returns `Ok(())` without error. If the path + /// points to a directory, it and all of its contents are removed recursively, equivalent to + /// [`std::fs::remove_dir_all`]. If the path points to a file, it is removed with + /// [`std::fs::remove_file`]. + /// + /// # Errors + /// + /// Returns a [`FileSystemError::File`] if the removal fails for any reason other than + /// the path not existing (e.g., permission denied, path is in use, etc.). + /// + /// # Example + /// + /// ```no_run + /// use soar_utils::error::FileSystemResult; + /// use soar_utils::fs::{FileSystemProvider, StandardFileSystemProvider}; + /// + /// fn main() -> FileSystemResult<()> { + /// let fs = StandardFileSystemProvider; + /// // Remove a file or directory, ignoring if it doesn't exist + /// fs.safe_remove("/tmp/some_path")?; + /// Ok(()) + /// } + /// ``` + fn safe_remove>(&self, path: P) -> FileSystemResult<()>; + + /// Creates a directory structure if it doesn't exist. + /// + /// If the directory already exists, this function does nothing. If the directory structure + /// exists but is not a directory, this function returns an error. + /// + /// # Arguments + /// + /// * `path` - The path to create. + /// + /// # Errors + /// + /// * [`FileSystemError::Directory`] if the directory could not be created. + /// * [`FileSystemError::NotADirectory`] if the path exists but is not a directory. + /// + /// # Example + /// + /// ```no_run + /// use soar_utils::error::FileSystemResult; + /// use soar_utils::fs::{FileSystemProvider, StandardFileSystemProvider}; + /// + /// fn main() -> FileSystemResult<()> { + /// let fs = StandardFileSystemProvider; + /// let dir = "/tmp/soar-doc/internal/dir"; + /// fs.ensure_dir_exists(dir)?; + /// Ok(()) + /// } + /// ``` + fn ensure_dir_exists>(&self, path: P) -> FileSystemResult<()>; +} + +#[derive(Default, Clone)] +pub struct StandardFileSystemProvider; + +impl FileSystemProvider for StandardFileSystemProvider { + fn safe_remove>(&self, path: P) -> FileSystemResult<()> { + let path = path.as_ref(); + + if !path.exists() { + return Ok(()); + } + + let result = if path.is_dir() { + fs::remove_dir_all(path) + } else { + fs::remove_file(path) + }; + + result.map_err(|err| FileSystemError::File { + path: path.to_path_buf(), + action: "remove", + source: err, + }) + } + + fn ensure_dir_exists>(&self, path: P) -> FileSystemResult<()> { + let path = path.as_ref(); + if !path.exists() { + std::fs::create_dir_all(path).map_err(|err| FileSystemError::Directory { + path: path.to_path_buf(), + action: "create", + source: err, + })?; + } else if !path.is_dir() { + return Err(FileSystemError::NotADirectory { + path: path.to_path_buf(), + }); + } + + Ok(()) + } +} + +/// Creates a directory structure if it doesn't exist. +/// +/// This is a convenience function that creates a [`StandardFileSystemProvider`] and calls +/// [`FileSystemProvider::ensure_dir_exists`] on it. +/// +/// See [`FileSystemProvider::ensure_dir_exists`] for detailed documentation. +pub fn ensure_dir_exists>(path: P) -> FileSystemResult<()> { + StandardFileSystemProvider.ensure_dir_exists(path) +} + +/// Removes the specified file or directory safely. +/// +/// This is a convenience function that creates a [`StandardFileSystemProvider`] and calls +/// [`FileSystemProvider::safe_remove`] on it. +/// +/// See [`FileSystemProvider::safe_remove`] for detailed documentation. +pub fn safe_remove>(path: P) -> FileSystemResult<()> { + StandardFileSystemProvider.safe_remove(path) +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + #[test] + fn test_safe_remove_file() { + let dir = tempdir().unwrap(); + let file_path = dir.path().join("test_file.txt"); + fs::write(&file_path, "hello").unwrap(); + safe_remove(&file_path).unwrap(); + assert!(!file_path.exists()); + } + + #[test] + fn test_safe_remove_dir() { + let dir = tempdir().unwrap(); + let sub_dir = dir.path().join("sub"); + fs::create_dir(&sub_dir).unwrap(); + safe_remove(&sub_dir).unwrap(); + assert!(!sub_dir.exists()); + } + + #[test] + fn test_safe_remove_non_existent() { + let dir = tempdir().unwrap(); + let file_path = dir.path().join("non_existent.txt"); + safe_remove(&file_path).unwrap(); + } + + #[test] + fn test_ensure_dir_exists() { + let dir = tempdir().unwrap(); + let new_dir = dir.path().join("new_dir"); + ensure_dir_exists(&new_dir).unwrap(); + assert!(new_dir.is_dir()); + } + + #[test] + fn test_ensure_dir_exists_already_exists() { + let dir = tempdir().unwrap(); + ensure_dir_exists(dir.path()).unwrap(); + assert!(dir.path().is_dir()); + } + + #[test] + fn test_ensure_dir_exists_file_collision() { + let dir = tempdir().unwrap(); + let file_path = dir.path().join("file.txt"); + fs::write(&file_path, "hello").unwrap(); + assert!(ensure_dir_exists(&file_path).is_err()); + } + + #[test] + fn test_ensure_dir_exists_permission_denied() { + let dir = tempdir().unwrap(); + let read_only_dir = dir.path().join("read_only"); + fs::create_dir(&read_only_dir).unwrap(); + + // Set read-only permissions on the directory. + let mut perms = fs::metadata(&read_only_dir).unwrap().permissions(); + perms.set_readonly(true); + fs::set_permissions(&read_only_dir, perms).unwrap(); + + let new_dir = read_only_dir.join("new_dir"); + let result = ensure_dir_exists(&new_dir); + assert!(result.is_err()); + + // Cleanup: Set back to writable to allow tempdir to be removed. + let mut perms = fs::metadata(&read_only_dir).unwrap().permissions(); + perms.set_readonly(false); + fs::set_permissions(&read_only_dir, perms).unwrap(); + } + + #[test] + fn test_standard_safe_remove_permission_denied() { + let dir = tempdir().unwrap(); + let sub_dir = dir.path().join("read_only_dir"); + fs::create_dir(&sub_dir).unwrap(); + let file_path = sub_dir.join("file.txt"); + fs::write(&file_path, "content").unwrap(); + + // Set read-only permissions on the parent directory. + let mut perms = fs::metadata(&sub_dir).unwrap().permissions(); + perms.set_readonly(true); + fs::set_permissions(&sub_dir, perms).unwrap(); + + let result = safe_remove(&file_path); + assert!(result.is_err()); + + // Cleanup: Set back to writable to allow tempdir to be removed. + let mut perms = fs::metadata(&sub_dir).unwrap().permissions(); + perms.set_readonly(false); + fs::set_permissions(&sub_dir, perms).unwrap(); + } + + #[test] + fn test_safe_remove_dir_permission_denied() { + let dir = tempdir().unwrap(); + let sub_dir = dir.path().join("read_only_dir"); + fs::create_dir(&sub_dir).unwrap(); + let file_path = sub_dir.join("file.txt"); + fs::write(&file_path, "content").unwrap(); + + // Set read-only permissions on the parent directory. + let mut perms = fs::metadata(&sub_dir).unwrap().permissions(); + perms.set_readonly(true); + fs::set_permissions(&sub_dir, perms).unwrap(); + + let result = safe_remove(&sub_dir); + assert!(result.is_err()); + + // Cleanup: Set back to writable to allow tempdir to be removed. + let mut perms = fs::metadata(&sub_dir).unwrap().permissions(); + perms.set_readonly(false); + fs::set_permissions(&sub_dir, perms).unwrap(); + } +} diff --git a/crates/soar-utils/src/hash.rs b/crates/soar-utils/src/hash.rs new file mode 100644 index 000000000..c1dec1f50 --- /dev/null +++ b/crates/soar-utils/src/hash.rs @@ -0,0 +1,169 @@ +use std::path::Path; + +use crate::error::{HashError, HashResult}; + +pub trait HashProvider { + /// Calculates the checksum of a file. + /// + /// This method reads the contents of a file and computes a checksum, which is returned as a + /// hex-encoded string. The specific hashing algorithm depends on the implementation. The + /// default implementation uses the `blake3` crate. + /// + /// # Arguments + /// + /// * `file_path` - The path to the file to calculate the checksum for. + /// + /// # Errors + /// + /// * [`HashError::ReadFailed`] if the file cannot be read. + /// + /// # Example + /// + /// ```no_run + /// use soar_utils::error::HashResult; + /// use soar_utils::hash::{HashProvider, StandardHashProvider}; + /// + /// fn main() -> HashResult<()> { + /// let hash_provider = StandardHashProvider; + /// let checksum = hash_provider.calculate_checksum("/path/to/file")?; + /// println!("Checksum is {}", checksum); + /// Ok(()) + /// } + /// ``` + fn calculate_checksum>(&self, file_path: P) -> HashResult; + + /// Verifies the checksum of a file against an expected value. + /// + /// This method calculates the checksum of the given file and compares it case-insensitively + /// against the `expected` checksum string. + /// + /// # Arguments + /// + /// * `file_path` - The path to the file to verify the checksum for. + /// * `expected` - The expected checksum. + /// + /// # Errors + /// + /// * [`HashError::ReadFailed`] if the file cannot be read. + /// + /// # Example + /// + /// ```no_run + /// use soar_utils::error::HashResult; + /// use soar_utils::hash::{HashProvider, StandardHashProvider}; + /// + /// fn main() -> HashResult<()> { + /// let hash_provider = StandardHashProvider; + /// let result = hash_provider.verify_checksum("file.dat", "1234567890abcdef")?; + /// println!("Checksum matches: {}", result); + /// Ok(()) + /// } + /// ``` + fn verify_checksum>(&self, file_path: P, expected: &str) -> HashResult; +} + +/// The default [`HashProvider`] implementation using the `blake3` crate. +pub struct StandardHashProvider; + +impl HashProvider for StandardHashProvider { + fn calculate_checksum>(&self, file_path: P) -> HashResult { + let file_path = file_path.as_ref(); + let mut hasher = blake3::Hasher::new(); + hasher + .update_mmap(file_path) + .map_err(|err| HashError::ReadFailed { + path: file_path.to_path_buf(), + source: err, + })?; + Ok(hasher.finalize().to_hex().to_string()) + } + + fn verify_checksum>(&self, file_path: P, expected: &str) -> HashResult { + let file_path = file_path.as_ref(); + let actual = self.calculate_checksum(file_path)?; + Ok(actual.eq_ignore_ascii_case(expected)) + } +} + +/// Calculates the checksum of a file. +/// +/// This is a convenience function that creates a [`StandardHashProvider`] and calls +/// [`HashProvider::calculate_checksum`] on it. +/// +/// See [`HashProvider::calculate_checksum`] for detailed documentation. +pub fn calculate_checksum>(file_path: P) -> HashResult { + StandardHashProvider.calculate_checksum(file_path) +} + +/// Verifies the checksum of a file against an expected value. +/// +/// This is a convenience function that creates a [`StandardHashProvider`] and calls +/// [`HashProvider::verify_checksum`] on it. +/// +/// See [`HashProvider::verify_checksum`] for detailed documentation. +pub fn verify_checksum>(file_path: P, expected: &str) -> HashResult { + StandardHashProvider.verify_checksum(file_path, expected) +} + +#[cfg(test)] +mod tests { + use super::{calculate_checksum, verify_checksum}; + use std::io::Write; + use tempfile::NamedTempFile; + + #[test] + fn test_calculate_checksum() { + let mut file = NamedTempFile::new().unwrap(); + file.write_all(b"hello world\n").unwrap(); + let path = file.path(); + + let checksum = calculate_checksum(path).unwrap(); + assert_eq!( + checksum, + "dc5a4edb8240b018124052c330270696f96771a63b45250a5c17d3000e823355" + ); + } + + #[test] + fn test_verify_checksum_valid() { + let mut file = NamedTempFile::new().unwrap(); + file.write_all(b"hello world\n").unwrap(); + let path = file.path(); + + let result = verify_checksum( + path, + "dc5a4edb8240b018124052c330270696f96771a63b45250a5c17d3000e823355", + ) + .unwrap(); + assert!(result); + } + + #[test] + fn test_verify_checksum_invalid() { + let mut file = NamedTempFile::new().unwrap(); + file.write_all(b"hello world").unwrap(); + let path = file.path(); + + let result = verify_checksum(path, "invalid-checksum").unwrap(); + assert!(!result); + } + + #[test] + fn test_calculate_checksum_file_not_found() { + let result = calculate_checksum("/path/to/nonexistent/file"); + assert!(result.is_err()); + } + + #[test] + fn test_verify_checksum_file_not_found() { + let result = verify_checksum("/path/to/nonexistent/file", "any-checksum"); + assert!(result.is_err()); + } + + #[test] + fn test_calculate_checksum_on_directory() { + let dir = tempfile::tempdir().unwrap(); + let result = calculate_checksum(dir.path()); + assert!(result.is_err()); + } +} diff --git a/crates/soar-utils/src/lib.rs b/crates/soar-utils/src/lib.rs new file mode 100644 index 000000000..4dec02d3f --- /dev/null +++ b/crates/soar-utils/src/lib.rs @@ -0,0 +1,6 @@ +pub mod bytes; +pub mod error; +pub mod fs; +pub mod hash; +pub mod path; +pub mod user; diff --git a/crates/soar-utils/src/path.rs b/crates/soar-utils/src/path.rs new file mode 100644 index 000000000..92d51f09c --- /dev/null +++ b/crates/soar-utils/src/path.rs @@ -0,0 +1,538 @@ +use std::{env, path::PathBuf}; + +use crate::{ + error::{PathError, PathResult}, + user::get_username, +}; + +pub trait PathResolver { + /// Resolves a path string that may contain environment variables + /// + /// This method expands environment variables in the format `$VAR` or `${VAR}`, resolves tilde + /// (`~`) to the user's home directory when it appears at the start of the path, and converts + /// relative paths to absolute paths based on the current working directory. + /// + /// # Arguments + /// + /// * `path` - The path string that may contain environment variables and tilde expansion + /// + /// # Returns + /// + /// Returns an absolute [`PathBuf`] with all variables expanded, or a [`PathError`] if the path + /// is invalid or variables cannot be resolved. + /// + /// # Errors + /// + /// * [`PathError::Empty`] if the path is empty + /// * [`PathError::CurrentDir`] if the current directory cannot be determined + /// * [`PathError::MissingEnvVar`] if the environment variables are undefined + /// + /// # Example + /// + /// ``` + /// use soar_utils::error::PathResult; + /// use soar_utils::path::{PathResolver, SystemPathResolver}; + /// + /// fn main() -> PathResult<()> { + /// let resolver = SystemPathResolver; + /// let resolved = resolver.resolve_path("$HOME/path/to/file")?; + /// println!("Resolved path is {:#?}", resolved); + /// Ok(()) + /// } + /// ``` + fn resolve_path(&self, path: &str) -> PathResult; + + /// Returns the user's home directory + /// + /// This method first checks the `HOME` environment variables. If not set, it falls back to + /// constructing the path `/home/{username}` where username is obtained from the system. + /// + /// # Example + /// + /// ``` + /// use soar_utils::path::{PathResolver, SystemPathResolver}; + /// + /// let resolver = SystemPathResolver; + /// let home = resolver.home_dir(); + /// println!("Home dir is {:#?}", home); + /// ``` + fn home_dir(&self) -> PathBuf; + + /// Returns the user's config directory following XDG Base Directory Specification + /// + /// This method checks the `XDG_CONFIG_HOME` environment variable. If not set, it defaults to + /// `$HOME/.config` + /// + /// # Example + /// + /// ``` + /// use soar_utils::path::{PathResolver, SystemPathResolver}; + /// + /// let resolver = SystemPathResolver; + /// let config = resolver.xdg_config_home(); + /// println!("Config dir is {:#?}", config); + /// ``` + fn xdg_config_home(&self) -> PathBuf; + + /// Returns the user's data directory following XDG Base Directory Specification + /// + /// This method checks the `XDG_DATA_HOME` environment variable. If not set, it defaults to + /// `$HOME/.local/share` + /// + /// # Example + /// + /// ``` + /// use soar_utils::path::{PathResolver, SystemPathResolver}; + /// + /// let resolver = SystemPathResolver; + /// let data = resolver.xdg_data_home(); + /// println!("Data dir is {:#?}", data); + /// ``` + fn xdg_data_home(&self) -> PathBuf; + + /// Returns the user's cache directory following XDG Base Directory Specification + /// + /// This method checks the `XDG_CACHE_HOME` environment variable. If not set, it defaults to + /// `$HOME/.cache` + /// + /// # Example + /// + /// ``` + /// use soar_utils::path::{PathResolver, SystemPathResolver}; + /// + /// let resolver = SystemPathResolver; + /// let cache = resolver.xdg_cache_home(); + /// println!("Cache dir is {:#?}", cache); + /// ``` + fn xdg_cache_home(&self) -> PathBuf; +} + +/// The default [`PathResolver`] implementation using environment variables and filesystem calls. +pub struct SystemPathResolver; + +impl PathResolver for SystemPathResolver { + fn resolve_path(&self, path: &str) -> PathResult { + let path = path.trim(); + + if path.is_empty() { + return Err(PathError::Empty); + } + + let resolved = self.expand_variables(path)?; + let path_buf = PathBuf::from(resolved); + + if path_buf.is_absolute() { + Ok(path_buf) + } else { + env::current_dir() + .map(|cwd| cwd.join(path_buf)) + .map_err(|err| PathError::CurrentDir { source: err }) + } + } + + fn home_dir(&self) -> PathBuf { + env::var("HOME") + .map(PathBuf::from) + .unwrap_or_else(|_| PathBuf::from(format!("/home/{}", get_username()))) + } + + fn xdg_config_home(&self) -> PathBuf { + env::var("XDG_CONFIG_HOME") + .map(PathBuf::from) + .unwrap_or_else(|_| self.home_dir().join(".config")) + } + + fn xdg_data_home(&self) -> PathBuf { + env::var("XDG_DATA_HOME") + .map(PathBuf::from) + .unwrap_or_else(|_| self.home_dir().join(".local/share")) + } + + fn xdg_cache_home(&self) -> PathBuf { + env::var("XDG_CACHE_HOME") + .map(PathBuf::from) + .unwrap_or_else(|_| self.home_dir().join(".cache")) + } +} + +impl SystemPathResolver { + fn expand_variables(&self, path: &str) -> PathResult { + let mut result = String::with_capacity(path.len()); + let mut chars = path.chars().peekable(); + + while let Some(c) = chars.next() { + match c { + '$' => { + if chars.peek() == Some(&'{') { + chars.next(); + let var_name = self.consume_until(&mut chars, '}')?; + self.expand_env_var(&var_name, &mut result, path)?; + } else { + let var_name = self.consume_var_name(&mut chars); + if var_name.is_empty() { + result.push('$'); + } else { + self.expand_env_var(&var_name, &mut result, path)?; + } + } + } + '~' if result.is_empty() => result.push_str(&self.home_dir().to_string_lossy()), + _ => result.push(c), + } + } + + Ok(result) + } + + fn consume_until( + &self, + chars: &mut std::iter::Peekable, + delimiter: char, + ) -> PathResult { + let mut var_name = String::new(); + + for c in chars.by_ref() { + if c == delimiter { + return Ok(var_name); + } + var_name.push(c); + } + + Err(PathError::UnclosedVariable { + input: format!("${{{var_name}"), + }) + } + + fn consume_var_name(&self, chars: &mut std::iter::Peekable) -> String { + let mut var_name = String::new(); + + while let Some(&c) = chars.peek() { + if c.is_alphanumeric() || c == '_' { + var_name.push(chars.next().unwrap()); + } else { + break; + } + } + + var_name + } + + fn expand_env_var( + &self, + var_name: &str, + result: &mut String, + original: &str, + ) -> PathResult<()> { + match var_name { + "HOME" => result.push_str(&self.home_dir().to_string_lossy()), + "XDG_CONFIG_HOME" => result.push_str(&self.xdg_config_home().to_string_lossy()), + "XDG_DATA_HOME" => result.push_str(&self.xdg_data_home().to_string_lossy()), + "XDG_CACHE_HOME" => result.push_str(&self.xdg_cache_home().to_string_lossy()), + _ => { + let value = env::var(var_name).map_err(|_| PathError::MissingEnvVar { + input: original.into(), + var: var_name.into(), + })?; + result.push_str(&value); + } + } + Ok(()) + } +} + +/// Resolves a path string using the system path resolver. +/// +/// This is a convenience function that creates a [`SystemPathResolver`] and calls +/// [`PathResolver::resolve_path`] on it. +/// +/// See [`PathResolver::resolve_path`] for detailed documentation. +pub fn resolve_path(path: &str) -> PathResult { + SystemPathResolver.resolve_path(path) +} + +/// Returns the user's home directory using the system path resolver. +/// +/// This is a convenience function that creates a [`SystemPathResolver`] and calls +/// [`PathResolver::home_dir`] on it. +/// +/// See [`PathResolver::home_dir`] for detailed documentation. +pub fn home_dir() -> PathBuf { + SystemPathResolver.home_dir() +} + +/// Returns the user's config directory using the system path resolver. +/// +/// This is a convenience function that creates a [`SystemPathResolver`] and calls +/// [`PathResolver::xdg_config_home`] on it. +/// +/// See [`PathResolver::xdg_config_home`] for detailed documentation. +pub fn xdg_config_home() -> PathBuf { + SystemPathResolver.xdg_config_home() +} + +/// Returns the user's data directory using the system path resolver. +/// +/// This is a convenience function that creates a [`SystemPathResolver`] and calls +/// [`PathResolver::xdg_data_home`] on it. +/// +/// See [`PathResolver::xdg_data_home`] for detailed documentation. +pub fn xdg_data_home() -> PathBuf { + SystemPathResolver.xdg_data_home() +} + +/// Returns the user's cache directory using the system path resolver. +/// +/// This is a convenience function that creates a [`SystemPathResolver`] and calls +/// [`PathResolver::xdg_cache_home`] on it. +/// +/// See [`PathResolver::xdg_cache_home`] for detailed documentation. +pub fn xdg_cache_home() -> PathBuf { + SystemPathResolver.xdg_cache_home() +} + +#[cfg(test)] +mod tests { + use super::*; + use std::env; + + #[test] + fn test_expand_variables_simple() { + let resolver = SystemPathResolver; + env::set_var("TEST_VAR", "test_value"); + + let result = resolver.expand_variables("$TEST_VAR/path").unwrap(); + assert_eq!(result, "test_value/path"); + + env::remove_var("TEST_VAR"); + } + + #[test] + fn test_expand_variables_braces() { + let resolver = SystemPathResolver; + env::set_var("TEST_VAR_BRACES", "test_value"); + + let result = resolver + .expand_variables("${TEST_VAR_BRACES}/path") + .unwrap(); + assert_eq!(result, "test_value/path"); + + env::remove_var("TEST_VAR_BRACES"); + } + + #[test] + fn test_expand_variables_missing_braces() { + let resolver = SystemPathResolver; + env::set_var("TEST_VAR_MISSING_BRACES", "test_value"); + + let result = resolver.expand_variables("${TEST_VAR_MISSING_BRACES"); + assert!(result.is_err()); + + env::remove_var("TEST_VAR_MISSING_BRACES"); + } + + #[test] + fn test_expand_variables_missing_var() { + let resolver = SystemPathResolver; + let result = resolver.expand_variables("$THIS_VAR_DOESNT_EXIST"); + assert!(result.is_err()); + } + + #[test] + fn test_consume_var_name() { + let resolver = SystemPathResolver; + let mut chars = "VAR_NAME_123/extra".chars().peekable(); + let var_name = resolver.consume_var_name(&mut chars); + assert_eq!(var_name, "VAR_NAME_123"); + } + + #[test] + fn test_xdg_directories() { + let resolver = SystemPathResolver; + // We need to set HOME to have a predictable home directory for the test + env::set_var("HOME", "/tmp/home"); + let home = resolver.home_dir(); + assert_eq!(home, PathBuf::from("/tmp/home")); + + // Test without XDG variables set + env::remove_var("XDG_CONFIG_HOME"); + env::remove_var("XDG_DATA_HOME"); + env::remove_var("XDG_CACHE_HOME"); + + let config = resolver.xdg_config_home(); + let data = resolver.xdg_data_home(); + let cache = resolver.xdg_cache_home(); + + assert_eq!(config, home.join(".config")); + assert_eq!(data, home.join(".local/share")); + assert_eq!(cache, home.join(".cache")); + assert!(config.is_absolute()); + assert!(data.is_absolute()); + assert!(cache.is_absolute()); + + // Test with XDG variables set + env::set_var("XDG_CONFIG_HOME", "/tmp/config"); + env::set_var("XDG_DATA_HOME", "/tmp/data"); + env::set_var("XDG_CACHE_HOME", "/tmp/cache"); + + assert_eq!(resolver.xdg_config_home(), PathBuf::from("/tmp/config")); + assert_eq!(resolver.xdg_data_home(), PathBuf::from("/tmp/data")); + assert_eq!(resolver.xdg_cache_home(), PathBuf::from("/tmp/cache")); + + env::remove_var("XDG_CONFIG_HOME"); + env::remove_var("XDG_DATA_HOME"); + env::remove_var("XDG_CACHE_HOME"); + env::remove_var("HOME"); + } + + #[test] + fn test_resolve_path() { + let resolver = SystemPathResolver; + env::set_var("HOME", "/tmp/home"); + + assert!(resolver.resolve_path("").is_err()); + + // Absolute path + assert_eq!( + resolver.resolve_path("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/absolute/path").unwrap(), + PathBuf::from("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/absolute/path") + ); + + // Relative path + let expected_relative = env::current_dir().unwrap().join("relative/path"); + assert_eq!( + resolver.resolve_path("relative/path").unwrap(), + expected_relative + ); + + // Tilde path + let home = resolver.home_dir(); + assert_eq!(resolver.resolve_path("~/path").unwrap(), home.join("path")); + assert_eq!(resolver.resolve_path("~").unwrap(), home); + + // Tilde not at start + let expected_tilde_middle = env::current_dir().unwrap().join("not/at/~/start"); + assert_eq!( + resolver.resolve_path("not/at/~/start").unwrap(), + expected_tilde_middle + ); + env::remove_var("HOME"); + + // Unclosed variable + let result = resolver.resolve_path("${VAR"); + assert!(result.is_err()); + + // Missing variable + let result = resolver.resolve_path("${VAR}"); + assert!(result.is_err()); + } + + #[test] + fn test_home_dir() { + let resolver = SystemPathResolver; + + // Test with HOME set + env::set_var("HOME", "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/custom/home"); + assert_eq!(resolver.home_dir(), PathBuf::from("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/custom/home")); + + // Test with HOME unset + env::remove_var("HOME"); + let expected = PathBuf::from(format!("/home/{}", get_username())); + assert_eq!(resolver.home_dir(), expected); + } + + #[test] + fn test_expand_variables_edge_cases() { + let resolver = SystemPathResolver; + env::set_var("HOME", "/tmp/home"); + + // Dollar at the end + assert_eq!(resolver.expand_variables("path/$").unwrap(), "path/$"); + + // Dollar with invalid char + assert_eq!( + resolver.expand_variables("path/$!invalid").unwrap(), + "path/$!invalid" + ); + + // Multiple variables + env::set_var("VAR1", "val1"); + env::set_var("VAR2", "val2"); + assert_eq!( + resolver.expand_variables("$VAR1/${VAR2}").unwrap(), + "val1/val2" + ); + env::remove_var("VAR1"); + env::remove_var("VAR2"); + + // Tilde expansion + let home_str = resolver.home_dir().to_string_lossy().to_string(); + assert_eq!( + resolver.expand_variables("~/path").unwrap(), + format!("{}/path", home_str) + ); + assert_eq!(resolver.expand_variables("~").unwrap(), home_str); + assert_eq!(resolver.expand_variables("a/~/b").unwrap(), "a/~/b"); + env::remove_var("HOME"); + } + + #[test] + fn test_public_convenience_functions() { + env::set_var("HOME", "/tmp/home"); + assert_eq!(resolve_path("~").unwrap(), PathBuf::from("/tmp/home")); + assert_eq!(home_dir(), PathBuf::from("/tmp/home")); + assert_eq!(xdg_config_home(), PathBuf::from("/tmp/home/.config")); + assert_eq!(xdg_data_home(), PathBuf::from("/tmp/home/.local/share")); + assert_eq!(xdg_cache_home(), PathBuf::from("/tmp/home/.cache")); + env::remove_var("HOME"); + } + + #[test] + fn test_resolve_path_invalid_cwd() { + let resolver = SystemPathResolver; + let temp_dir = tempfile::tempdir().unwrap(); + let invalid_path = temp_dir.path().join("invalid"); + std::fs::create_dir(&invalid_path).unwrap(); + + let original_cwd = env::current_dir().unwrap(); + env::set_current_dir(&invalid_path).unwrap(); + std::fs::remove_dir(&invalid_path).unwrap(); + + let result = resolver.resolve_path("relative/path"); + assert!(result.is_err()); + + // Restore cwd + env::set_current_dir(original_cwd).unwrap(); + } + + #[test] + fn test_expand_env_var_special_vars() { + let resolver = SystemPathResolver; + env::set_var("HOME", "/tmp/home"); + + let mut result = String::new(); + resolver + .expand_env_var("HOME", &mut result, "$HOME") + .unwrap(); + assert_eq!(result, "/tmp/home"); + + result.clear(); + resolver + .expand_env_var("XDG_CONFIG_HOME", &mut result, "$XDG_CONFIG_HOME") + .unwrap(); + assert_eq!(result, "/tmp/home/.config"); + + result.clear(); + resolver + .expand_env_var("XDG_DATA_HOME", &mut result, "$XDG_DATA_HOME") + .unwrap(); + assert_eq!(result, "/tmp/home/.local/share"); + + result.clear(); + resolver + .expand_env_var("XDG_CACHE_HOME", &mut result, "$XDG_CACHE_HOME") + .unwrap(); + assert_eq!(result, "/tmp/home/.cache"); + + env::remove_var("HOME"); + } +} diff --git a/crates/soar-utils/src/user.rs b/crates/soar-utils/src/user.rs new file mode 100644 index 000000000..13447fc79 --- /dev/null +++ b/crates/soar-utils/src/user.rs @@ -0,0 +1,77 @@ +use std::env; + +use nix::unistd::{geteuid, User}; + +trait UsernameSource { + fn env_var(&self, key: &str) -> Option; + fn uid_name(&self) -> Option; +} + +struct SystemSource; + +impl UsernameSource for SystemSource { + fn env_var(&self, key: &str) -> Option { + env::var(key).ok() + } + + fn uid_name(&self) -> Option { + User::from_uid(geteuid()) + .ok() + .and_then(|u| u.map(|u| u.name)) + } +} + +fn get_username_with(src: &S) -> String { + src.env_var("USER") + .or_else(|| src.env_var("LOGNAME")) + .or_else(|| src.uid_name()) + .expect("Couldn't determine username.") +} + +/// Returns the username of the current user. +/// +/// This function first checks the `USER` and `LOGNAME` environment variables. If not set, it +/// falls back to fetching the username using the effective user ID. +/// +/// # Panics +/// +/// This function will panic if it cannot determine the username. +pub fn get_username() -> String { + get_username_with(&SystemSource) +} + +#[cfg(test)] +mod tests { + use super::*; + + struct AlwaysNone; + impl UsernameSource for AlwaysNone { + fn env_var(&self, _: &str) -> Option { + None + } + fn uid_name(&self) -> Option { + None + } + } + + #[test] + #[should_panic(expected = "Couldn't determine username.")] + fn test_fails_when_all_sources_missing() { + get_username_with(&AlwaysNone); + } + + #[test] + fn test_get_username() { + let username = get_username(); + assert!(!username.is_empty()); + } + + #[test] + fn test_get_username_missing_env_vars() { + env::remove_var("USER"); + env::remove_var("LOGNAME"); + + let username = get_username(); + assert!(!username.is_empty()); + } +} From ff7c2e5270a1123a0c591006492e4c322eec798c Mon Sep 17 00:00:00 2001 From: Rabindra Dhakal Date: Mon, 22 Sep 2025 22:33:41 +0545 Subject: [PATCH 02/12] refactor(test): prevent race condition --- crates/soar-utils/src/path.rs | 392 ++++++++++++++++++---------------- 1 file changed, 211 insertions(+), 181 deletions(-) diff --git a/crates/soar-utils/src/path.rs b/crates/soar-utils/src/path.rs index 92d51f09c..f2d352a73 100644 --- a/crates/soar-utils/src/path.rs +++ b/crates/soar-utils/src/path.rs @@ -293,197 +293,225 @@ pub fn xdg_cache_home() -> PathBuf { #[cfg(test)] mod tests { use super::*; - use std::env; + use std::{env, sync::Mutex}; - #[test] - fn test_expand_variables_simple() { - let resolver = SystemPathResolver; - env::set_var("TEST_VAR", "test_value"); + static ENV_MUTEX: Mutex<()> = Mutex::new(()); - let result = resolver.expand_variables("$TEST_VAR/path").unwrap(); - assert_eq!(result, "test_value/path"); + fn with_env_test(f: F) + where + F: FnOnce(), + { + let _guard = ENV_MUTEX.lock().unwrap(); + f() + } - env::remove_var("TEST_VAR"); + fn setup_test_env(vars: &[(&str, &str)]) { + for (var, value) in vars { + env::set_var(*var, *value); + } } - #[test] - fn test_expand_variables_braces() { - let resolver = SystemPathResolver; - env::set_var("TEST_VAR_BRACES", "test_value"); + fn cleanup_test_env(vars: &[&str]) { + for key in vars { + env::remove_var(key); + } + } - let result = resolver - .expand_variables("${TEST_VAR_BRACES}/path") - .unwrap(); - assert_eq!(result, "test_value/path"); + #[test] + fn test_expand_variables_simple() { + with_env_test(|| { + setup_test_env(&[("TEST_VAR", "test_value")]); + let resolver = SystemPathResolver; + let result = resolver.expand_variables("$TEST_VAR/path").unwrap(); + assert_eq!(result, "test_value/path"); + }); + } - env::remove_var("TEST_VAR_BRACES"); + #[test] + fn test_expand_variables_braces() { + with_env_test(|| { + setup_test_env(&[("TEST_VAR", "test_value")]); + let resolver = SystemPathResolver; + let result = resolver.expand_variables("${TEST_VAR}/path").unwrap(); + assert_eq!(result, "test_value/path"); + }); } #[test] fn test_expand_variables_missing_braces() { - let resolver = SystemPathResolver; - env::set_var("TEST_VAR_MISSING_BRACES", "test_value"); - - let result = resolver.expand_variables("${TEST_VAR_MISSING_BRACES"); - assert!(result.is_err()); - - env::remove_var("TEST_VAR_MISSING_BRACES"); + with_env_test(|| { + setup_test_env(&[("TEST_VAR", "test_value")]); + let resolver = SystemPathResolver; + let result = resolver.expand_variables("${TEST_VAR"); + assert!(result.is_err()); + }); } #[test] fn test_expand_variables_missing_var() { - let resolver = SystemPathResolver; - let result = resolver.expand_variables("$THIS_VAR_DOESNT_EXIST"); - assert!(result.is_err()); + with_env_test(|| { + let resolver = SystemPathResolver; + let result = resolver.expand_variables("$THIS_VAR_DOESNT_EXIST"); + assert!(result.is_err()); + }); } #[test] fn test_consume_var_name() { - let resolver = SystemPathResolver; - let mut chars = "VAR_NAME_123/extra".chars().peekable(); - let var_name = resolver.consume_var_name(&mut chars); - assert_eq!(var_name, "VAR_NAME_123"); + with_env_test(|| { + let resolver = SystemPathResolver; + let mut chars = "VAR_NAME_123/extra".chars().peekable(); + let var_name = resolver.consume_var_name(&mut chars); + assert_eq!(var_name, "VAR_NAME_123"); + }); } #[test] fn test_xdg_directories() { - let resolver = SystemPathResolver; - // We need to set HOME to have a predictable home directory for the test - env::set_var("HOME", "/tmp/home"); - let home = resolver.home_dir(); - assert_eq!(home, PathBuf::from("/tmp/home")); - - // Test without XDG variables set - env::remove_var("XDG_CONFIG_HOME"); - env::remove_var("XDG_DATA_HOME"); - env::remove_var("XDG_CACHE_HOME"); - - let config = resolver.xdg_config_home(); - let data = resolver.xdg_data_home(); - let cache = resolver.xdg_cache_home(); - - assert_eq!(config, home.join(".config")); - assert_eq!(data, home.join(".local/share")); - assert_eq!(cache, home.join(".cache")); - assert!(config.is_absolute()); - assert!(data.is_absolute()); - assert!(cache.is_absolute()); - - // Test with XDG variables set - env::set_var("XDG_CONFIG_HOME", "/tmp/config"); - env::set_var("XDG_DATA_HOME", "/tmp/data"); - env::set_var("XDG_CACHE_HOME", "/tmp/cache"); - - assert_eq!(resolver.xdg_config_home(), PathBuf::from("/tmp/config")); - assert_eq!(resolver.xdg_data_home(), PathBuf::from("/tmp/data")); - assert_eq!(resolver.xdg_cache_home(), PathBuf::from("/tmp/cache")); - - env::remove_var("XDG_CONFIG_HOME"); - env::remove_var("XDG_DATA_HOME"); - env::remove_var("XDG_CACHE_HOME"); - env::remove_var("HOME"); + with_env_test(|| { + setup_test_env(&[("HOME", "/tmp/home")]); + + let resolver = SystemPathResolver; + let home = resolver.home_dir(); + assert_eq!(home, PathBuf::from("/tmp/home")); + + // Test without XDG variables set + cleanup_test_env(&["XDG_CONFIG_HOME", "XDG_DATA_HOME", "XDG_CACHE_HOME"]); + + let config = resolver.xdg_config_home(); + let data = resolver.xdg_data_home(); + let cache = resolver.xdg_cache_home(); + + assert_eq!(config, home.join(".config")); + assert_eq!(data, home.join(".local/share")); + assert_eq!(cache, home.join(".cache")); + assert!(config.is_absolute()); + assert!(data.is_absolute()); + assert!(cache.is_absolute()); + + // Test with XDG variables set + setup_test_env(&[ + ("XDG_CONFIG_HOME", "/tmp/config"), + ("XDG_DATA_HOME", "/tmp/data"), + ("XDG_CACHE_HOME", "/tmp/cache"), + ]); + + assert_eq!(resolver.xdg_config_home(), PathBuf::from("/tmp/config")); + assert_eq!(resolver.xdg_data_home(), PathBuf::from("/tmp/data")); + assert_eq!(resolver.xdg_cache_home(), PathBuf::from("/tmp/cache")); + + cleanup_test_env(&["XDG_CONFIG_HOME", "XDG_DATA_HOME", "XDG_CACHE_HOME", "HOME"]); + }); } #[test] fn test_resolve_path() { - let resolver = SystemPathResolver; - env::set_var("HOME", "/tmp/home"); - - assert!(resolver.resolve_path("").is_err()); - - // Absolute path - assert_eq!( - resolver.resolve_path("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/absolute/path").unwrap(), - PathBuf::from("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/absolute/path") - ); - - // Relative path - let expected_relative = env::current_dir().unwrap().join("relative/path"); - assert_eq!( - resolver.resolve_path("relative/path").unwrap(), - expected_relative - ); - - // Tilde path - let home = resolver.home_dir(); - assert_eq!(resolver.resolve_path("~/path").unwrap(), home.join("path")); - assert_eq!(resolver.resolve_path("~").unwrap(), home); - - // Tilde not at start - let expected_tilde_middle = env::current_dir().unwrap().join("not/at/~/start"); - assert_eq!( - resolver.resolve_path("not/at/~/start").unwrap(), - expected_tilde_middle - ); - env::remove_var("HOME"); - - // Unclosed variable - let result = resolver.resolve_path("${VAR"); - assert!(result.is_err()); - - // Missing variable - let result = resolver.resolve_path("${VAR}"); - assert!(result.is_err()); + with_env_test(|| { + setup_test_env(&[("HOME", "/tmp/home")]); + let resolver = SystemPathResolver; + + assert!(resolver.resolve_path("").is_err()); + + // Absolute path + assert_eq!( + resolver.resolve_path("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/absolute/path").unwrap(), + PathBuf::from("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/absolute/path") + ); + + // Relative path + let expected_relative = env::current_dir().unwrap().join("relative/path"); + assert_eq!( + resolver.resolve_path("relative/path").unwrap(), + expected_relative + ); + + // Tilde path + let home = resolver.home_dir(); + assert_eq!(resolver.resolve_path("~/path").unwrap(), home.join("path")); + assert_eq!(resolver.resolve_path("~").unwrap(), home); + + // Tilde not at start + let expected_tilde_middle = env::current_dir().unwrap().join("not/at/~/start"); + assert_eq!( + resolver.resolve_path("not/at/~/start").unwrap(), + expected_tilde_middle + ); + + // Unclosed variable + let result = resolver.resolve_path("${VAR"); + assert!(result.is_err()); + + // Missing variable + let result = resolver.resolve_path("${VAR}"); + assert!(result.is_err()); + + cleanup_test_env(&["HOME"]); + }); } #[test] fn test_home_dir() { - let resolver = SystemPathResolver; - - // Test with HOME set - env::set_var("HOME", "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/custom/home"); - assert_eq!(resolver.home_dir(), PathBuf::from("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/custom/home")); - - // Test with HOME unset - env::remove_var("HOME"); - let expected = PathBuf::from(format!("/home/{}", get_username())); - assert_eq!(resolver.home_dir(), expected); + with_env_test(|| { + // Test with HOME set + setup_test_env(&[("HOME", "/tmp/home")]); + let resolver = SystemPathResolver; + assert_eq!(resolver.home_dir(), PathBuf::from("/tmp/home")); + + // Test with HOME unset + cleanup_test_env(&["HOME"]); + let expected = PathBuf::from(format!("/home/{}", get_username())); + assert_eq!(resolver.home_dir(), expected); + }); } #[test] fn test_expand_variables_edge_cases() { - let resolver = SystemPathResolver; - env::set_var("HOME", "/tmp/home"); - - // Dollar at the end - assert_eq!(resolver.expand_variables("path/$").unwrap(), "path/$"); - - // Dollar with invalid char - assert_eq!( - resolver.expand_variables("path/$!invalid").unwrap(), - "path/$!invalid" - ); - - // Multiple variables - env::set_var("VAR1", "val1"); - env::set_var("VAR2", "val2"); - assert_eq!( - resolver.expand_variables("$VAR1/${VAR2}").unwrap(), - "val1/val2" - ); - env::remove_var("VAR1"); - env::remove_var("VAR2"); - - // Tilde expansion - let home_str = resolver.home_dir().to_string_lossy().to_string(); - assert_eq!( - resolver.expand_variables("~/path").unwrap(), - format!("{}/path", home_str) - ); - assert_eq!(resolver.expand_variables("~").unwrap(), home_str); - assert_eq!(resolver.expand_variables("a/~/b").unwrap(), "a/~/b"); - env::remove_var("HOME"); + with_env_test(|| { + setup_test_env(&[("HOME", "/tmp/home")]); + let resolver = SystemPathResolver; + + // Dollar at the end + assert_eq!(resolver.expand_variables("path/$").unwrap(), "path/$"); + + // Dollar with invalid char + assert_eq!( + resolver.expand_variables("path/$!invalid").unwrap(), + "path/$!invalid" + ); + + // Multiple variables + setup_test_env(&[("VAR1", "val1"), ("VAR2", "val2")]); + assert_eq!( + resolver.expand_variables("$VAR1/${VAR2}").unwrap(), + "val1/val2" + ); + cleanup_test_env(&["VAR1", "VAR2"]); + + // Tilde expansion + let home_str = resolver.home_dir().to_string_lossy().to_string(); + assert_eq!( + resolver.expand_variables("~/path").unwrap(), + format!("{}/path", home_str) + ); + assert_eq!(resolver.expand_variables("~").unwrap(), home_str); + assert_eq!(resolver.expand_variables("a/~/b").unwrap(), "a/~/b"); + cleanup_test_env(&["HOME"]); + }); } #[test] - fn test_public_convenience_functions() { - env::set_var("HOME", "/tmp/home"); - assert_eq!(resolve_path("~").unwrap(), PathBuf::from("/tmp/home")); - assert_eq!(home_dir(), PathBuf::from("/tmp/home")); - assert_eq!(xdg_config_home(), PathBuf::from("/tmp/home/.config")); - assert_eq!(xdg_data_home(), PathBuf::from("/tmp/home/.local/share")); - assert_eq!(xdg_cache_home(), PathBuf::from("/tmp/home/.cache")); - env::remove_var("HOME"); + fn test_public_functions() { + with_env_test(|| { + setup_test_env(&[("HOME", "/tmp/home")]); + + assert_eq!(resolve_path("~").unwrap(), PathBuf::from("/tmp/home")); + assert_eq!(home_dir(), PathBuf::from("/tmp/home")); + assert_eq!(xdg_config_home(), PathBuf::from("/tmp/home/.config")); + assert_eq!(xdg_data_home(), PathBuf::from("/tmp/home/.local/share")); + assert_eq!(xdg_cache_home(), PathBuf::from("/tmp/home/.cache")); + + cleanup_test_env(&["HOME"]); + }); } #[test] @@ -506,33 +534,35 @@ mod tests { #[test] fn test_expand_env_var_special_vars() { - let resolver = SystemPathResolver; - env::set_var("HOME", "/tmp/home"); - - let mut result = String::new(); - resolver - .expand_env_var("HOME", &mut result, "$HOME") - .unwrap(); - assert_eq!(result, "/tmp/home"); - - result.clear(); - resolver - .expand_env_var("XDG_CONFIG_HOME", &mut result, "$XDG_CONFIG_HOME") - .unwrap(); - assert_eq!(result, "/tmp/home/.config"); - - result.clear(); - resolver - .expand_env_var("XDG_DATA_HOME", &mut result, "$XDG_DATA_HOME") - .unwrap(); - assert_eq!(result, "/tmp/home/.local/share"); - - result.clear(); - resolver - .expand_env_var("XDG_CACHE_HOME", &mut result, "$XDG_CACHE_HOME") - .unwrap(); - assert_eq!(result, "/tmp/home/.cache"); - - env::remove_var("HOME"); + with_env_test(|| { + setup_test_env(&[("HOME", "/tmp/home")]); + let resolver = SystemPathResolver; + + let mut result = String::new(); + resolver + .expand_env_var("HOME", &mut result, "$HOME") + .unwrap(); + assert_eq!(result, "/tmp/home"); + + result.clear(); + resolver + .expand_env_var("XDG_CONFIG_HOME", &mut result, "$XDG_CONFIG_HOME") + .unwrap(); + assert_eq!(result, "/tmp/home/.config"); + + result.clear(); + resolver + .expand_env_var("XDG_DATA_HOME", &mut result, "$XDG_DATA_HOME") + .unwrap(); + assert_eq!(result, "/tmp/home/.local/share"); + + result.clear(); + resolver + .expand_env_var("XDG_CACHE_HOME", &mut result, "$XDG_CACHE_HOME") + .unwrap(); + assert_eq!(result, "/tmp/home/.cache"); + + cleanup_test_env(&["HOME"]); + }); } } From b2bfcfdaa1b0e9e5b05fe94763ecb0ffffbe56f8 Mon Sep 17 00:00:00 2001 From: Rabindra Dhakal Date: Mon, 22 Sep 2025 22:44:19 +0545 Subject: [PATCH 03/12] Revert "refactor(test): prevent race condition" This reverts commit ff7c2e5270a1123a0c591006492e4c322eec798c. --- crates/soar-utils/src/path.rs | 392 ++++++++++++++++------------------ 1 file changed, 181 insertions(+), 211 deletions(-) diff --git a/crates/soar-utils/src/path.rs b/crates/soar-utils/src/path.rs index f2d352a73..92d51f09c 100644 --- a/crates/soar-utils/src/path.rs +++ b/crates/soar-utils/src/path.rs @@ -293,225 +293,197 @@ pub fn xdg_cache_home() -> PathBuf { #[cfg(test)] mod tests { use super::*; - use std::{env, sync::Mutex}; - - static ENV_MUTEX: Mutex<()> = Mutex::new(()); - - fn with_env_test(f: F) - where - F: FnOnce(), - { - let _guard = ENV_MUTEX.lock().unwrap(); - f() - } - - fn setup_test_env(vars: &[(&str, &str)]) { - for (var, value) in vars { - env::set_var(*var, *value); - } - } - - fn cleanup_test_env(vars: &[&str]) { - for key in vars { - env::remove_var(key); - } - } + use std::env; #[test] fn test_expand_variables_simple() { - with_env_test(|| { - setup_test_env(&[("TEST_VAR", "test_value")]); - let resolver = SystemPathResolver; - let result = resolver.expand_variables("$TEST_VAR/path").unwrap(); - assert_eq!(result, "test_value/path"); - }); + let resolver = SystemPathResolver; + env::set_var("TEST_VAR", "test_value"); + + let result = resolver.expand_variables("$TEST_VAR/path").unwrap(); + assert_eq!(result, "test_value/path"); + + env::remove_var("TEST_VAR"); } #[test] fn test_expand_variables_braces() { - with_env_test(|| { - setup_test_env(&[("TEST_VAR", "test_value")]); - let resolver = SystemPathResolver; - let result = resolver.expand_variables("${TEST_VAR}/path").unwrap(); - assert_eq!(result, "test_value/path"); - }); + let resolver = SystemPathResolver; + env::set_var("TEST_VAR_BRACES", "test_value"); + + let result = resolver + .expand_variables("${TEST_VAR_BRACES}/path") + .unwrap(); + assert_eq!(result, "test_value/path"); + + env::remove_var("TEST_VAR_BRACES"); } #[test] fn test_expand_variables_missing_braces() { - with_env_test(|| { - setup_test_env(&[("TEST_VAR", "test_value")]); - let resolver = SystemPathResolver; - let result = resolver.expand_variables("${TEST_VAR"); - assert!(result.is_err()); - }); + let resolver = SystemPathResolver; + env::set_var("TEST_VAR_MISSING_BRACES", "test_value"); + + let result = resolver.expand_variables("${TEST_VAR_MISSING_BRACES"); + assert!(result.is_err()); + + env::remove_var("TEST_VAR_MISSING_BRACES"); } #[test] fn test_expand_variables_missing_var() { - with_env_test(|| { - let resolver = SystemPathResolver; - let result = resolver.expand_variables("$THIS_VAR_DOESNT_EXIST"); - assert!(result.is_err()); - }); + let resolver = SystemPathResolver; + let result = resolver.expand_variables("$THIS_VAR_DOESNT_EXIST"); + assert!(result.is_err()); } #[test] fn test_consume_var_name() { - with_env_test(|| { - let resolver = SystemPathResolver; - let mut chars = "VAR_NAME_123/extra".chars().peekable(); - let var_name = resolver.consume_var_name(&mut chars); - assert_eq!(var_name, "VAR_NAME_123"); - }); + let resolver = SystemPathResolver; + let mut chars = "VAR_NAME_123/extra".chars().peekable(); + let var_name = resolver.consume_var_name(&mut chars); + assert_eq!(var_name, "VAR_NAME_123"); } #[test] fn test_xdg_directories() { - with_env_test(|| { - setup_test_env(&[("HOME", "/tmp/home")]); - - let resolver = SystemPathResolver; - let home = resolver.home_dir(); - assert_eq!(home, PathBuf::from("/tmp/home")); - - // Test without XDG variables set - cleanup_test_env(&["XDG_CONFIG_HOME", "XDG_DATA_HOME", "XDG_CACHE_HOME"]); - - let config = resolver.xdg_config_home(); - let data = resolver.xdg_data_home(); - let cache = resolver.xdg_cache_home(); - - assert_eq!(config, home.join(".config")); - assert_eq!(data, home.join(".local/share")); - assert_eq!(cache, home.join(".cache")); - assert!(config.is_absolute()); - assert!(data.is_absolute()); - assert!(cache.is_absolute()); - - // Test with XDG variables set - setup_test_env(&[ - ("XDG_CONFIG_HOME", "/tmp/config"), - ("XDG_DATA_HOME", "/tmp/data"), - ("XDG_CACHE_HOME", "/tmp/cache"), - ]); - - assert_eq!(resolver.xdg_config_home(), PathBuf::from("/tmp/config")); - assert_eq!(resolver.xdg_data_home(), PathBuf::from("/tmp/data")); - assert_eq!(resolver.xdg_cache_home(), PathBuf::from("/tmp/cache")); - - cleanup_test_env(&["XDG_CONFIG_HOME", "XDG_DATA_HOME", "XDG_CACHE_HOME", "HOME"]); - }); + let resolver = SystemPathResolver; + // We need to set HOME to have a predictable home directory for the test + env::set_var("HOME", "/tmp/home"); + let home = resolver.home_dir(); + assert_eq!(home, PathBuf::from("/tmp/home")); + + // Test without XDG variables set + env::remove_var("XDG_CONFIG_HOME"); + env::remove_var("XDG_DATA_HOME"); + env::remove_var("XDG_CACHE_HOME"); + + let config = resolver.xdg_config_home(); + let data = resolver.xdg_data_home(); + let cache = resolver.xdg_cache_home(); + + assert_eq!(config, home.join(".config")); + assert_eq!(data, home.join(".local/share")); + assert_eq!(cache, home.join(".cache")); + assert!(config.is_absolute()); + assert!(data.is_absolute()); + assert!(cache.is_absolute()); + + // Test with XDG variables set + env::set_var("XDG_CONFIG_HOME", "/tmp/config"); + env::set_var("XDG_DATA_HOME", "/tmp/data"); + env::set_var("XDG_CACHE_HOME", "/tmp/cache"); + + assert_eq!(resolver.xdg_config_home(), PathBuf::from("/tmp/config")); + assert_eq!(resolver.xdg_data_home(), PathBuf::from("/tmp/data")); + assert_eq!(resolver.xdg_cache_home(), PathBuf::from("/tmp/cache")); + + env::remove_var("XDG_CONFIG_HOME"); + env::remove_var("XDG_DATA_HOME"); + env::remove_var("XDG_CACHE_HOME"); + env::remove_var("HOME"); } #[test] fn test_resolve_path() { - with_env_test(|| { - setup_test_env(&[("HOME", "/tmp/home")]); - let resolver = SystemPathResolver; - - assert!(resolver.resolve_path("").is_err()); - - // Absolute path - assert_eq!( - resolver.resolve_path("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/absolute/path").unwrap(), - PathBuf::from("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/absolute/path") - ); - - // Relative path - let expected_relative = env::current_dir().unwrap().join("relative/path"); - assert_eq!( - resolver.resolve_path("relative/path").unwrap(), - expected_relative - ); - - // Tilde path - let home = resolver.home_dir(); - assert_eq!(resolver.resolve_path("~/path").unwrap(), home.join("path")); - assert_eq!(resolver.resolve_path("~").unwrap(), home); - - // Tilde not at start - let expected_tilde_middle = env::current_dir().unwrap().join("not/at/~/start"); - assert_eq!( - resolver.resolve_path("not/at/~/start").unwrap(), - expected_tilde_middle - ); - - // Unclosed variable - let result = resolver.resolve_path("${VAR"); - assert!(result.is_err()); - - // Missing variable - let result = resolver.resolve_path("${VAR}"); - assert!(result.is_err()); - - cleanup_test_env(&["HOME"]); - }); + let resolver = SystemPathResolver; + env::set_var("HOME", "/tmp/home"); + + assert!(resolver.resolve_path("").is_err()); + + // Absolute path + assert_eq!( + resolver.resolve_path("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/absolute/path").unwrap(), + PathBuf::from("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/absolute/path") + ); + + // Relative path + let expected_relative = env::current_dir().unwrap().join("relative/path"); + assert_eq!( + resolver.resolve_path("relative/path").unwrap(), + expected_relative + ); + + // Tilde path + let home = resolver.home_dir(); + assert_eq!(resolver.resolve_path("~/path").unwrap(), home.join("path")); + assert_eq!(resolver.resolve_path("~").unwrap(), home); + + // Tilde not at start + let expected_tilde_middle = env::current_dir().unwrap().join("not/at/~/start"); + assert_eq!( + resolver.resolve_path("not/at/~/start").unwrap(), + expected_tilde_middle + ); + env::remove_var("HOME"); + + // Unclosed variable + let result = resolver.resolve_path("${VAR"); + assert!(result.is_err()); + + // Missing variable + let result = resolver.resolve_path("${VAR}"); + assert!(result.is_err()); } #[test] fn test_home_dir() { - with_env_test(|| { - // Test with HOME set - setup_test_env(&[("HOME", "/tmp/home")]); - let resolver = SystemPathResolver; - assert_eq!(resolver.home_dir(), PathBuf::from("/tmp/home")); - - // Test with HOME unset - cleanup_test_env(&["HOME"]); - let expected = PathBuf::from(format!("/home/{}", get_username())); - assert_eq!(resolver.home_dir(), expected); - }); + let resolver = SystemPathResolver; + + // Test with HOME set + env::set_var("HOME", "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/custom/home"); + assert_eq!(resolver.home_dir(), PathBuf::from("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/custom/home")); + + // Test with HOME unset + env::remove_var("HOME"); + let expected = PathBuf::from(format!("/home/{}", get_username())); + assert_eq!(resolver.home_dir(), expected); } #[test] fn test_expand_variables_edge_cases() { - with_env_test(|| { - setup_test_env(&[("HOME", "/tmp/home")]); - let resolver = SystemPathResolver; - - // Dollar at the end - assert_eq!(resolver.expand_variables("path/$").unwrap(), "path/$"); - - // Dollar with invalid char - assert_eq!( - resolver.expand_variables("path/$!invalid").unwrap(), - "path/$!invalid" - ); - - // Multiple variables - setup_test_env(&[("VAR1", "val1"), ("VAR2", "val2")]); - assert_eq!( - resolver.expand_variables("$VAR1/${VAR2}").unwrap(), - "val1/val2" - ); - cleanup_test_env(&["VAR1", "VAR2"]); - - // Tilde expansion - let home_str = resolver.home_dir().to_string_lossy().to_string(); - assert_eq!( - resolver.expand_variables("~/path").unwrap(), - format!("{}/path", home_str) - ); - assert_eq!(resolver.expand_variables("~").unwrap(), home_str); - assert_eq!(resolver.expand_variables("a/~/b").unwrap(), "a/~/b"); - cleanup_test_env(&["HOME"]); - }); + let resolver = SystemPathResolver; + env::set_var("HOME", "/tmp/home"); + + // Dollar at the end + assert_eq!(resolver.expand_variables("path/$").unwrap(), "path/$"); + + // Dollar with invalid char + assert_eq!( + resolver.expand_variables("path/$!invalid").unwrap(), + "path/$!invalid" + ); + + // Multiple variables + env::set_var("VAR1", "val1"); + env::set_var("VAR2", "val2"); + assert_eq!( + resolver.expand_variables("$VAR1/${VAR2}").unwrap(), + "val1/val2" + ); + env::remove_var("VAR1"); + env::remove_var("VAR2"); + + // Tilde expansion + let home_str = resolver.home_dir().to_string_lossy().to_string(); + assert_eq!( + resolver.expand_variables("~/path").unwrap(), + format!("{}/path", home_str) + ); + assert_eq!(resolver.expand_variables("~").unwrap(), home_str); + assert_eq!(resolver.expand_variables("a/~/b").unwrap(), "a/~/b"); + env::remove_var("HOME"); } #[test] - fn test_public_functions() { - with_env_test(|| { - setup_test_env(&[("HOME", "/tmp/home")]); - - assert_eq!(resolve_path("~").unwrap(), PathBuf::from("/tmp/home")); - assert_eq!(home_dir(), PathBuf::from("/tmp/home")); - assert_eq!(xdg_config_home(), PathBuf::from("/tmp/home/.config")); - assert_eq!(xdg_data_home(), PathBuf::from("/tmp/home/.local/share")); - assert_eq!(xdg_cache_home(), PathBuf::from("/tmp/home/.cache")); - - cleanup_test_env(&["HOME"]); - }); + fn test_public_convenience_functions() { + env::set_var("HOME", "/tmp/home"); + assert_eq!(resolve_path("~").unwrap(), PathBuf::from("/tmp/home")); + assert_eq!(home_dir(), PathBuf::from("/tmp/home")); + assert_eq!(xdg_config_home(), PathBuf::from("/tmp/home/.config")); + assert_eq!(xdg_data_home(), PathBuf::from("/tmp/home/.local/share")); + assert_eq!(xdg_cache_home(), PathBuf::from("/tmp/home/.cache")); + env::remove_var("HOME"); } #[test] @@ -534,35 +506,33 @@ mod tests { #[test] fn test_expand_env_var_special_vars() { - with_env_test(|| { - setup_test_env(&[("HOME", "/tmp/home")]); - let resolver = SystemPathResolver; - - let mut result = String::new(); - resolver - .expand_env_var("HOME", &mut result, "$HOME") - .unwrap(); - assert_eq!(result, "/tmp/home"); - - result.clear(); - resolver - .expand_env_var("XDG_CONFIG_HOME", &mut result, "$XDG_CONFIG_HOME") - .unwrap(); - assert_eq!(result, "/tmp/home/.config"); - - result.clear(); - resolver - .expand_env_var("XDG_DATA_HOME", &mut result, "$XDG_DATA_HOME") - .unwrap(); - assert_eq!(result, "/tmp/home/.local/share"); - - result.clear(); - resolver - .expand_env_var("XDG_CACHE_HOME", &mut result, "$XDG_CACHE_HOME") - .unwrap(); - assert_eq!(result, "/tmp/home/.cache"); - - cleanup_test_env(&["HOME"]); - }); + let resolver = SystemPathResolver; + env::set_var("HOME", "/tmp/home"); + + let mut result = String::new(); + resolver + .expand_env_var("HOME", &mut result, "$HOME") + .unwrap(); + assert_eq!(result, "/tmp/home"); + + result.clear(); + resolver + .expand_env_var("XDG_CONFIG_HOME", &mut result, "$XDG_CONFIG_HOME") + .unwrap(); + assert_eq!(result, "/tmp/home/.config"); + + result.clear(); + resolver + .expand_env_var("XDG_DATA_HOME", &mut result, "$XDG_DATA_HOME") + .unwrap(); + assert_eq!(result, "/tmp/home/.local/share"); + + result.clear(); + resolver + .expand_env_var("XDG_CACHE_HOME", &mut result, "$XDG_CACHE_HOME") + .unwrap(); + assert_eq!(result, "/tmp/home/.cache"); + + env::remove_var("HOME"); } } From b024d4b20682e1d9316564ef4245acce28c96a88 Mon Sep 17 00:00:00 2001 From: Rabindra Dhakal Date: Mon, 22 Sep 2025 22:52:01 +0545 Subject: [PATCH 04/12] add serial test --- Cargo.lock | 80 +++++++++++++++++++++++++++++++++++ crates/soar-utils/Cargo.toml | 2 +- crates/soar-utils/src/path.rs | 11 ++++- 3 files changed, 90 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5fc267f15..1bcbef962 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1257,6 +1257,16 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f5e54036fe321fd421e10d732f155734c4e4afd610dd556d9a82833ab3ee0bed" +[[package]] +name = "lock_api" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96936507f153605bddfcda068dd804796c84324ed2510809e5b2a624c81da765" +dependencies = [ + "autocfg", + "scopeguard", +] + [[package]] name = "log" version = "0.4.27" @@ -1418,6 +1428,29 @@ dependencies = [ "syn", ] +[[package]] +name = "parking_lot" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70d58bf43669b5795d1576d0641cfb6fbb2057bf629506267a92807158584a13" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc838d2a56b5b1a6c25f55575dfc605fabb63bb2365f6c2353ef9159aa69e4a5" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-targets 0.52.6", +] + [[package]] name = "pbkdf2" version = "0.12.2" @@ -1949,6 +1982,21 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" +[[package]] +name = "scc" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46e6f046b7fef48e2660c57ed794263155d713de679057f2d0c169bfc6e756cc" +dependencies = [ + "sdd", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + [[package]] name = "scroll" version = "0.12.0" @@ -1969,6 +2017,12 @@ dependencies = [ "syn", ] +[[package]] +name = "sdd" +version = "3.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "490dcfcbfef26be6800d11870ff2df8774fa6e86d047e3e8c8a76b25655e41ca" + [[package]] name = "semver" version = "1.0.27" @@ -2040,6 +2094,31 @@ dependencies = [ "serde", ] +[[package]] +name = "serial_test" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b258109f244e1d6891bf1053a55d63a5cd4f8f4c30cf9a1280989f80e7a1fa9" +dependencies = [ + "futures", + "log", + "once_cell", + "parking_lot", + "scc", + "serial_test_derive", +] + +[[package]] +name = "serial_test_derive" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d69265a08751de7844521fd15003ae0a888e035773ba05695c5c759a6f89eef" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "sha1" version = "0.10.6" @@ -2172,6 +2251,7 @@ version = "0.1.0" dependencies = [ "blake3", "nix", + "serial_test", "tempfile", ] diff --git a/crates/soar-utils/Cargo.toml b/crates/soar-utils/Cargo.toml index 14dfeba5d..ef15651ad 100644 --- a/crates/soar-utils/Cargo.toml +++ b/crates/soar-utils/Cargo.toml @@ -15,4 +15,4 @@ nix = { version = "0.30.1", features = ["ioctl", "term", "user"] } [dev-dependencies] tempfile = "3.10.1" - +serial_test = "3.2.0" diff --git a/crates/soar-utils/src/path.rs b/crates/soar-utils/src/path.rs index 92d51f09c..6601dfed6 100644 --- a/crates/soar-utils/src/path.rs +++ b/crates/soar-utils/src/path.rs @@ -293,6 +293,7 @@ pub fn xdg_cache_home() -> PathBuf { #[cfg(test)] mod tests { use super::*; + use serial_test::serial; use std::env; #[test] @@ -346,6 +347,7 @@ mod tests { } #[test] + #[serial] fn test_xdg_directories() { let resolver = SystemPathResolver; // We need to set HOME to have a predictable home directory for the test @@ -385,6 +387,7 @@ mod tests { } #[test] + #[serial] fn test_resolve_path() { let resolver = SystemPathResolver; env::set_var("HOME", "/tmp/home"); @@ -427,12 +430,13 @@ mod tests { } #[test] + #[serial] fn test_home_dir() { let resolver = SystemPathResolver; // Test with HOME set - env::set_var("HOME", "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/custom/home"); - assert_eq!(resolver.home_dir(), PathBuf::from("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/custom/home")); + env::set_var("HOME", "/tmp/home"); + assert_eq!(resolver.home_dir(), PathBuf::from("/tmp/home")); // Test with HOME unset env::remove_var("HOME"); @@ -441,6 +445,7 @@ mod tests { } #[test] + #[serial] fn test_expand_variables_edge_cases() { let resolver = SystemPathResolver; env::set_var("HOME", "/tmp/home"); @@ -476,6 +481,7 @@ mod tests { } #[test] + #[serial] fn test_public_convenience_functions() { env::set_var("HOME", "/tmp/home"); assert_eq!(resolve_path("~").unwrap(), PathBuf::from("/tmp/home")); @@ -505,6 +511,7 @@ mod tests { } #[test] + #[serial] fn test_expand_env_var_special_vars() { let resolver = SystemPathResolver; env::set_var("HOME", "/tmp/home"); From 2b550c4f42811a5d0cb9a4fdc3ba5238e022e24e Mon Sep 17 00:00:00 2001 From: Rabindra Dhakal Date: Mon, 22 Sep 2025 22:56:36 +0545 Subject: [PATCH 05/12] try without serial --- Cargo.lock | 80 ----------------------------------- crates/soar-utils/Cargo.toml | 1 - crates/soar-utils/src/path.rs | 13 +++--- 3 files changed, 6 insertions(+), 88 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 1bcbef962..5fc267f15 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1257,16 +1257,6 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f5e54036fe321fd421e10d732f155734c4e4afd610dd556d9a82833ab3ee0bed" -[[package]] -name = "lock_api" -version = "0.4.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96936507f153605bddfcda068dd804796c84324ed2510809e5b2a624c81da765" -dependencies = [ - "autocfg", - "scopeguard", -] - [[package]] name = "log" version = "0.4.27" @@ -1428,29 +1418,6 @@ dependencies = [ "syn", ] -[[package]] -name = "parking_lot" -version = "0.12.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70d58bf43669b5795d1576d0641cfb6fbb2057bf629506267a92807158584a13" -dependencies = [ - "lock_api", - "parking_lot_core", -] - -[[package]] -name = "parking_lot_core" -version = "0.9.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc838d2a56b5b1a6c25f55575dfc605fabb63bb2365f6c2353ef9159aa69e4a5" -dependencies = [ - "cfg-if", - "libc", - "redox_syscall", - "smallvec", - "windows-targets 0.52.6", -] - [[package]] name = "pbkdf2" version = "0.12.2" @@ -1982,21 +1949,6 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" -[[package]] -name = "scc" -version = "2.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46e6f046b7fef48e2660c57ed794263155d713de679057f2d0c169bfc6e756cc" -dependencies = [ - "sdd", -] - -[[package]] -name = "scopeguard" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" - [[package]] name = "scroll" version = "0.12.0" @@ -2017,12 +1969,6 @@ dependencies = [ "syn", ] -[[package]] -name = "sdd" -version = "3.0.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "490dcfcbfef26be6800d11870ff2df8774fa6e86d047e3e8c8a76b25655e41ca" - [[package]] name = "semver" version = "1.0.27" @@ -2094,31 +2040,6 @@ dependencies = [ "serde", ] -[[package]] -name = "serial_test" -version = "3.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b258109f244e1d6891bf1053a55d63a5cd4f8f4c30cf9a1280989f80e7a1fa9" -dependencies = [ - "futures", - "log", - "once_cell", - "parking_lot", - "scc", - "serial_test_derive", -] - -[[package]] -name = "serial_test_derive" -version = "3.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d69265a08751de7844521fd15003ae0a888e035773ba05695c5c759a6f89eef" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "sha1" version = "0.10.6" @@ -2251,7 +2172,6 @@ version = "0.1.0" dependencies = [ "blake3", "nix", - "serial_test", "tempfile", ] diff --git a/crates/soar-utils/Cargo.toml b/crates/soar-utils/Cargo.toml index ef15651ad..382415929 100644 --- a/crates/soar-utils/Cargo.toml +++ b/crates/soar-utils/Cargo.toml @@ -15,4 +15,3 @@ nix = { version = "0.30.1", features = ["ioctl", "term", "user"] } [dev-dependencies] tempfile = "3.10.1" -serial_test = "3.2.0" diff --git a/crates/soar-utils/src/path.rs b/crates/soar-utils/src/path.rs index 6601dfed6..ad611e554 100644 --- a/crates/soar-utils/src/path.rs +++ b/crates/soar-utils/src/path.rs @@ -293,7 +293,6 @@ pub fn xdg_cache_home() -> PathBuf { #[cfg(test)] mod tests { use super::*; - use serial_test::serial; use std::env; #[test] @@ -347,7 +346,6 @@ mod tests { } #[test] - #[serial] fn test_xdg_directories() { let resolver = SystemPathResolver; // We need to set HOME to have a predictable home directory for the test @@ -387,7 +385,6 @@ mod tests { } #[test] - #[serial] fn test_resolve_path() { let resolver = SystemPathResolver; env::set_var("HOME", "/tmp/home"); @@ -430,7 +427,6 @@ mod tests { } #[test] - #[serial] fn test_home_dir() { let resolver = SystemPathResolver; @@ -445,7 +441,6 @@ mod tests { } #[test] - #[serial] fn test_expand_variables_edge_cases() { let resolver = SystemPathResolver; env::set_var("HOME", "/tmp/home"); @@ -481,9 +476,11 @@ mod tests { } #[test] - #[serial] fn test_public_convenience_functions() { env::set_var("HOME", "/tmp/home"); + env::remove_var("XDG_CONFIG_HOME"); + env::remove_var("XDG_DATA_HOME"); + env::remove_var("XDG_CACHE_HOME"); assert_eq!(resolve_path("~").unwrap(), PathBuf::from("/tmp/home")); assert_eq!(home_dir(), PathBuf::from("/tmp/home")); assert_eq!(xdg_config_home(), PathBuf::from("/tmp/home/.config")); @@ -511,10 +508,12 @@ mod tests { } #[test] - #[serial] fn test_expand_env_var_special_vars() { let resolver = SystemPathResolver; env::set_var("HOME", "/tmp/home"); + env::remove_var("XDG_CONFIG_HOME"); + env::remove_var("XDG_DATA_HOME"); + env::remove_var("XDG_CACHE_HOME"); let mut result = String::new(); resolver From 315fbc75d8207d5abed5408f677eef2320e4b9d0 Mon Sep 17 00:00:00 2001 From: Rabindra Dhakal Date: Tue, 23 Sep 2025 18:00:28 +0545 Subject: [PATCH 06/12] remove unnecessary trait system --- crates/soar-utils/src/bytes.rs | 394 ++++++++++-------------- crates/soar-utils/src/fs.rs | 182 +++++------ crates/soar-utils/src/hash.rs | 149 ++++----- crates/soar-utils/src/path.rs | 542 +++++++++++++-------------------- 4 files changed, 500 insertions(+), 767 deletions(-) diff --git a/crates/soar-utils/src/bytes.rs b/crates/soar-utils/src/bytes.rs index d8599520d..6c724163c 100644 --- a/crates/soar-utils/src/bytes.rs +++ b/crates/soar-utils/src/bytes.rs @@ -1,143 +1,110 @@ use crate::error::{BytesError, BytesResult}; -pub trait ByteFormatter { - /// Formats a number of bytes into a human-readable string. - /// - /// This method converts a byte count into a string with appropriate units (B, KiB, MiB, etc.) - /// and a specified level of precision. - /// - /// # Arguments - /// - /// * `bytes` - The number of bytes to format - /// * `precision` - The number of decimal places to display - /// - /// # Returns - /// - /// A human-readable string representation of the byte count. - /// - /// # Example - /// - /// ``` - /// use soar_utils::bytes::{ByteFormatter, StandardByteFormatter}; - /// - /// let formatter = StandardByteFormatter; - /// let bytes = 1024_u64.pow(2); - /// let formatted = formatter.format_bytes(bytes, 2); - /// - /// assert_eq!(formatted, "1.00 MiB"); - /// ``` - fn format_bytes(&self, bytes: u64, precision: usize) -> String; - - /// Parses a human-readable byte string into a number of bytes. - /// - /// This method converts a string with units (e.g., "1.00 MiB", "1KB") into a `u64` byte count. - /// It supports both binary (KiB, MiB) and decimal (KB, MB) prefixes. - /// - /// # Arguments - /// - /// * `s` - The string to parse - /// - /// # Returns - /// - /// Returns the number of bytes as a `u64`, or a [`BytesError`] if the string is invalid. - /// - /// # Errors - /// - /// * [`BytesError::ParseFailed`] if the string has an invalid format or suffix. - /// - /// # Example - /// - /// ``` - /// use soar_utils::bytes::{ByteFormatter, StandardByteFormatter}; - /// - /// let formatter = StandardByteFormatter; - /// let bytes = formatter.parse_bytes("1.00 MiB").unwrap(); - /// - /// assert_eq!(bytes, 1024_u64.pow(2)); - /// ``` - fn parse_bytes(&self, s: &str) -> BytesResult; -} - -#[derive(Default, Clone)] -pub struct StandardByteFormatter; - -impl ByteFormatter for StandardByteFormatter { - fn format_bytes(&self, bytes: u64, precision: usize) -> String { - let unit = 1024.0; - let sizes = ["B", "KiB", "MiB", "GiB", "TiB", "PiB", "EiB"]; - - let idx = (bytes as f64).log(unit).floor() as usize; - let idx = idx.min(sizes.len() - 1); - - format!( - "{:.*} {}", - precision, - bytes as f64 / unit.powi(idx.try_into().unwrap()), - sizes[idx] - ) - } - - fn parse_bytes(&self, s: &str) -> BytesResult { - let mut size = s.trim().to_uppercase(); - - // If it's a number, just return it - if let Ok(v) = size.parse::() { - return Ok(v); - }; - - let prefixes = ["", "K", "M", "G", "T", "P", "E"]; - - let base: f64 = if size.ends_with("IB") { - size.truncate(size.len() - 2); - 1024.0 - } else if size.ends_with("B") { - size.truncate(size.len() - 1); - 1000.0 - } else { - return Err(BytesError::ParseFailed { - input: s.to_string(), - reason: "Invalid suffix".to_string(), - }); - }; - - prefixes - .iter() - .enumerate() - .rev() - .find_map(|(i, p)| { - size.strip_suffix(p).and_then(|num| { - num.trim() - .parse::() - .ok() - .map(|n| n * base.powi(i.try_into().unwrap())) - .map(|n| n.round() as u64) - }) - }) - .ok_or_else(|| BytesError::ParseFailed { - input: s.to_string(), - reason: "Unrecognized size format".into(), - }) - } -} - /// Formats a number of bytes into a human-readable string. /// -/// This is a convenience function that creates a [`StandardByteFormatter`] and calls -/// [`ByteFormatter::format_bytes`] on it. +/// This method converts a byte count into a string with appropriate units (B, KiB, MiB, etc.) +/// and a specified level of precision. +/// +/// # Arguments /// -/// See [`ByteFormatter::format_bytes`] for detailed documentation. +/// * `bytes` - The number of bytes to format +/// * `precision` - The number of decimal places to display +/// +/// # Returns +/// +/// A human-readable string representation of the byte count. +/// +/// # Example +/// +/// ``` +/// use soar_utils::bytes::format_bytes; +/// +/// let bytes = 1024_u64.pow(2); +/// let formatted = format_bytes(bytes, 2); +/// +/// assert_eq!(formatted, "1.00 MiB"); +/// ``` pub fn format_bytes(bytes: u64, precision: usize) -> String { - StandardByteFormatter.format_bytes(bytes, precision) + let unit = 1024.0; + let sizes = ["B", "KiB", "MiB", "GiB", "TiB", "PiB", "EiB"]; + + let idx = (bytes as f64).log(unit).floor() as usize; + let idx = idx.min(sizes.len() - 1); + + format!( + "{:.*} {}", + precision, + bytes as f64 / unit.powi(idx.try_into().unwrap()), + sizes[idx] + ) } /// Parses a human-readable byte string into a number of bytes. /// -/// This is a convenience function that creates a [`StandardByteFormatter`] and calls -/// [`ByteFormatter::parse_bytes`] on it. +/// This method converts a string with units (e.g., "1.00 MiB", "1KB") into a `u64` byte count. +/// It supports both binary (KiB, MiB) and decimal (KB, MB) prefixes. +/// +/// # Arguments +/// +/// * `s` - The string to parse +/// +/// # Returns /// -/// See [`ByteFormatter::parse_bytes`] for detailed documentation. +/// Returns the number of bytes as a `u64`, or a [`BytesError`] if the string is invalid. +/// +/// # Errors +/// +/// * [`BytesError::ParseFailed`] if the string has an invalid format or suffix. +/// +/// # Example +/// +/// ``` +/// use soar_utils::bytes::parse_bytes; +/// +/// let bytes = parse_bytes("1.00 MiB").unwrap(); +/// +/// assert_eq!(bytes, 1024_u64.pow(2)); +/// ``` pub fn parse_bytes(s: &str) -> BytesResult { - StandardByteFormatter.parse_bytes(s) + let mut size = s.trim().to_uppercase(); + + // If it's a number, just return it + if let Ok(v) = size.parse::() { + return Ok(v); + }; + + let prefixes = ["", "K", "M", "G", "T", "P", "E"]; + + let base: f64 = if size.ends_with("IB") { + size.truncate(size.len() - 2); + 1024.0 + } else if size.ends_with("B") { + size.truncate(size.len() - 1); + 1000.0 + } else { + return Err(BytesError::ParseFailed { + input: s.to_string(), + reason: "Invalid suffix".to_string(), + }); + }; + + prefixes + .iter() + .enumerate() + .rev() + .find_map(|(i, p)| { + size.strip_suffix(p).and_then(|num| { + num.trim() + .parse::() + .ok() + .map(|n| n * base.powi(i.try_into().unwrap())) + .map(|n| n.round() as u64) + }) + }) + .ok_or_else(|| BytesError::ParseFailed { + input: s.to_string(), + reason: "Unrecognized size format".into(), + }) } #[cfg(test)] @@ -148,49 +115,36 @@ mod tests { fn test_format_bytes_with_precisions() { assert_eq!(format_bytes(1111, 0), "1 KiB"); - let formatter = StandardByteFormatter; - assert_eq!(formatter.format_bytes(0, 0), "0 B"); - assert_eq!(formatter.format_bytes(0, 3), "0.000 B"); - assert_eq!(formatter.format_bytes(1023, 0), "1023 B"); - assert_eq!(formatter.format_bytes(1023, 2), "1023.00 B"); + assert_eq!(format_bytes(0, 0), "0 B"); + assert_eq!(format_bytes(0, 3), "0.000 B"); + assert_eq!(format_bytes(1023, 0), "1023 B"); + assert_eq!(format_bytes(1023, 2), "1023.00 B"); - assert_eq!(formatter.format_bytes(1024, 0), "1 KiB"); - assert_eq!(formatter.format_bytes(1024, 1), "1.0 KiB"); - assert_eq!(formatter.format_bytes(1536, 2), "1.50 KiB"); - assert_eq!(formatter.format_bytes(2047, 3), "1.999 KiB"); - assert_eq!(formatter.format_bytes(2048, 4), "2.0000 KiB"); + assert_eq!(format_bytes(1024, 0), "1 KiB"); + assert_eq!(format_bytes(1024, 1), "1.0 KiB"); + assert_eq!(format_bytes(1536, 2), "1.50 KiB"); + assert_eq!(format_bytes(2047, 3), "1.999 KiB"); + assert_eq!(format_bytes(2048, 4), "2.0000 KiB"); - assert_eq!(formatter.format_bytes(1024_u64.pow(2), 0), "1 MiB"); - assert_eq!( - formatter.format_bytes(3 * 1024_u64.pow(2) / 2, 2), - "1.50 MiB" - ); - assert_eq!( - formatter.format_bytes(2 * 1024_u64.pow(2) - 1, 3), - "2.000 MiB" - ); + assert_eq!(format_bytes(1024_u64.pow(2), 0), "1 MiB"); + assert_eq!(format_bytes(3 * 1024_u64.pow(2) / 2, 2), "1.50 MiB"); + assert_eq!(format_bytes(2 * 1024_u64.pow(2) - 1, 3), "2.000 MiB"); - assert_eq!(formatter.format_bytes(1024_u64.pow(3), 2), "1.00 GiB"); - assert_eq!( - formatter.format_bytes(5 * 1024_u64.pow(3) / 2, 1), - "2.5 GiB" - ); + assert_eq!(format_bytes(1024_u64.pow(3), 2), "1.00 GiB"); + assert_eq!(format_bytes(5 * 1024_u64.pow(3) / 2, 1), "2.5 GiB"); - assert_eq!(formatter.format_bytes(1024_u64.pow(4), 3), "1.000 TiB"); - assert_eq!( - formatter.format_bytes(3 * 1024_u64.pow(4) / 2, 2), - "1.50 TiB" - ); + assert_eq!(format_bytes(1024_u64.pow(4), 3), "1.000 TiB"); + assert_eq!(format_bytes(3 * 1024_u64.pow(4) / 2, 2), "1.50 TiB"); - assert_eq!(formatter.format_bytes(1024_u64.pow(5), 0), "1 PiB"); + assert_eq!(format_bytes(1024_u64.pow(5), 0), "1 PiB"); assert_eq!( - formatter.format_bytes(1024_u64.pow(5) + 512 * 1024_u64.pow(4), 2), + format_bytes(1024_u64.pow(5) + 512 * 1024_u64.pow(4), 2), "1.50 PiB" ); - assert_eq!(formatter.format_bytes(1024_u64.pow(6), 1), "1.0 EiB"); + assert_eq!(format_bytes(1024_u64.pow(6), 1), "1.0 EiB"); assert_eq!( - formatter.format_bytes(1024_u64.pow(6) + 512 * 1024_u64.pow(5), 3), + format_bytes(1024_u64.pow(6) + 512 * 1024_u64.pow(5), 3), "1.500 EiB" ); } @@ -199,84 +153,52 @@ mod tests { fn test_parse_bytes() { assert_eq!(parse_bytes("111").unwrap(), 111); - let formatter = StandardByteFormatter; - assert_eq!(formatter.parse_bytes("42").unwrap(), 42); - assert_eq!(formatter.parse_bytes(" 120 ").unwrap(), 120); - - assert_eq!(formatter.parse_bytes("0B").unwrap(), 0); - assert_eq!(formatter.parse_bytes("1B").unwrap(), 1); - assert_eq!(formatter.parse_bytes("1023B").unwrap(), 1023); - - assert_eq!(formatter.parse_bytes("1KiB").unwrap(), 1024); - assert_eq!(formatter.parse_bytes("1.50KiB").unwrap(), 3 * 1024 / 2); - assert_eq!(formatter.parse_bytes("1KB").unwrap(), 1000); - assert_eq!(formatter.parse_bytes("1.50KB").unwrap(), 3 * 1000 / 2); - - assert_eq!(formatter.parse_bytes("1MiB").unwrap(), 1024_u64.pow(2)); - assert_eq!( - formatter.parse_bytes("1.50MiB").unwrap(), - 3 * 1024_u64.pow(2) / 2 - ); - assert_eq!(formatter.parse_bytes("1MB").unwrap(), 1000_u64.pow(2)); - assert_eq!( - formatter.parse_bytes("1.50MB").unwrap(), - 3 * 1000_u64.pow(2) / 2 - ); - - assert_eq!(formatter.parse_bytes("1GiB").unwrap(), 1024_u64.pow(3)); - assert_eq!( - formatter.parse_bytes("1.50GiB").unwrap(), - 3 * 1024_u64.pow(3) / 2 - ); - assert_eq!(formatter.parse_bytes("1GB").unwrap(), 1000_u64.pow(3)); - assert_eq!( - formatter.parse_bytes("1.50GB").unwrap(), - 3 * 1000_u64.pow(3) / 2 - ); - - assert_eq!(formatter.parse_bytes("1TiB").unwrap(), 1024_u64.pow(4)); - assert_eq!( - formatter.parse_bytes("1.50TiB").unwrap(), - 3 * 1024_u64.pow(4) / 2 - ); - assert_eq!(formatter.parse_bytes("1TB").unwrap(), 1000_u64.pow(4)); - assert_eq!( - formatter.parse_bytes("1.50TB").unwrap(), - 3 * 1000_u64.pow(4) / 2 - ); - - assert_eq!(formatter.parse_bytes("1PiB").unwrap(), 1024_u64.pow(5)); - assert_eq!( - formatter.parse_bytes("1.50PiB").unwrap(), - 3 * 1024_u64.pow(5) / 2 - ); - assert_eq!(formatter.parse_bytes("1PB").unwrap(), 1000_u64.pow(5)); - assert_eq!( - formatter.parse_bytes("1.50PB").unwrap(), - 3 * 1000_u64.pow(5) / 2 - ); - - assert_eq!(formatter.parse_bytes("1EiB").unwrap(), 1024_u64.pow(6)); - assert_eq!( - formatter.parse_bytes("1.50EiB").unwrap(), - 3 * 1024_u64.pow(6) / 2 - ); - assert_eq!(formatter.parse_bytes("1EB").unwrap(), 1000_u64.pow(6)); - assert_eq!( - formatter.parse_bytes("1.50EB").unwrap(), - 3 * 1000_u64.pow(6) / 2 - ); + assert_eq!(parse_bytes("42").unwrap(), 42); + assert_eq!(parse_bytes(" 120 ").unwrap(), 120); + + assert_eq!(parse_bytes("0B").unwrap(), 0); + assert_eq!(parse_bytes("1B").unwrap(), 1); + assert_eq!(parse_bytes("1023B").unwrap(), 1023); + + assert_eq!(parse_bytes("1KiB").unwrap(), 1024); + assert_eq!(parse_bytes("1.50KiB").unwrap(), 3 * 1024 / 2); + assert_eq!(parse_bytes("1KB").unwrap(), 1000); + assert_eq!(parse_bytes("1.50KB").unwrap(), 3 * 1000 / 2); + + assert_eq!(parse_bytes("1MiB").unwrap(), 1024_u64.pow(2)); + assert_eq!(parse_bytes("1.50MiB").unwrap(), 3 * 1024_u64.pow(2) / 2); + assert_eq!(parse_bytes("1MB").unwrap(), 1000_u64.pow(2)); + assert_eq!(parse_bytes("1.50MB").unwrap(), 3 * 1000_u64.pow(2) / 2); + + assert_eq!(parse_bytes("1GiB").unwrap(), 1024_u64.pow(3)); + assert_eq!(parse_bytes("1.50GiB").unwrap(), 3 * 1024_u64.pow(3) / 2); + assert_eq!(parse_bytes("1GB").unwrap(), 1000_u64.pow(3)); + assert_eq!(parse_bytes("1.50GB").unwrap(), 3 * 1000_u64.pow(3) / 2); + + assert_eq!(parse_bytes("1TiB").unwrap(), 1024_u64.pow(4)); + assert_eq!(parse_bytes("1.50TiB").unwrap(), 3 * 1024_u64.pow(4) / 2); + assert_eq!(parse_bytes("1TB").unwrap(), 1000_u64.pow(4)); + assert_eq!(parse_bytes("1.50TB").unwrap(), 3 * 1000_u64.pow(4) / 2); + + assert_eq!(parse_bytes("1PiB").unwrap(), 1024_u64.pow(5)); + assert_eq!(parse_bytes("1.50PiB").unwrap(), 3 * 1024_u64.pow(5) / 2); + assert_eq!(parse_bytes("1PB").unwrap(), 1000_u64.pow(5)); + assert_eq!(parse_bytes("1.50PB").unwrap(), 3 * 1000_u64.pow(5) / 2); + + assert_eq!(parse_bytes("1EiB").unwrap(), 1024_u64.pow(6)); + assert_eq!(parse_bytes("1.50EiB").unwrap(), 3 * 1024_u64.pow(6) / 2); + assert_eq!(parse_bytes("1EB").unwrap(), 1000_u64.pow(6)); + assert_eq!(parse_bytes("1.50EB").unwrap(), 3 * 1000_u64.pow(6) / 2); } #[test] fn test_fail_parse_bytes() { - let formatter = StandardByteFormatter; - assert!(formatter.parse_bytes("1.xE").is_err()); - assert!(formatter.parse_bytes("1.xEB").is_err()); - assert!(formatter.parse_bytes("1.50FB").is_err()); - assert!(formatter.parse_bytes("1LB ").is_err()); - assert!(formatter.parse_bytes(" 1.50Li").is_err()); - assert!(formatter.parse_bytes(" MiB ").is_err()); - assert!(formatter.parse_bytes("MB").is_err()); + assert!(parse_bytes("1.xE").is_err()); + assert!(parse_bytes("1.xEB").is_err()); + assert!(parse_bytes("1.50FB").is_err()); + assert!(parse_bytes("1LB ").is_err()); + assert!(parse_bytes(" 1.50Li").is_err()); + assert!(parse_bytes(" MiB ").is_err()); + assert!(parse_bytes("MB").is_err()); } } diff --git a/crates/soar-utils/src/fs.rs b/crates/soar-utils/src/fs.rs index caac96b20..fcd54a5f4 100644 --- a/crates/soar-utils/src/fs.rs +++ b/crates/soar-utils/src/fs.rs @@ -2,124 +2,90 @@ use std::{fs, path::Path}; use crate::error::{FileSystemError, FileSystemResult}; -pub trait FileSystemProvider { - /// Removes the specified file or directory safely. - /// - /// If the path does not exist, this function returns `Ok(())` without error. If the path - /// points to a directory, it and all of its contents are removed recursively, equivalent to - /// [`std::fs::remove_dir_all`]. If the path points to a file, it is removed with - /// [`std::fs::remove_file`]. - /// - /// # Errors - /// - /// Returns a [`FileSystemError::File`] if the removal fails for any reason other than - /// the path not existing (e.g., permission denied, path is in use, etc.). - /// - /// # Example - /// - /// ```no_run - /// use soar_utils::error::FileSystemResult; - /// use soar_utils::fs::{FileSystemProvider, StandardFileSystemProvider}; - /// - /// fn main() -> FileSystemResult<()> { - /// let fs = StandardFileSystemProvider; - /// // Remove a file or directory, ignoring if it doesn't exist - /// fs.safe_remove("/tmp/some_path")?; - /// Ok(()) - /// } - /// ``` - fn safe_remove>(&self, path: P) -> FileSystemResult<()>; - - /// Creates a directory structure if it doesn't exist. - /// - /// If the directory already exists, this function does nothing. If the directory structure - /// exists but is not a directory, this function returns an error. - /// - /// # Arguments - /// - /// * `path` - The path to create. - /// - /// # Errors - /// - /// * [`FileSystemError::Directory`] if the directory could not be created. - /// * [`FileSystemError::NotADirectory`] if the path exists but is not a directory. - /// - /// # Example - /// - /// ```no_run - /// use soar_utils::error::FileSystemResult; - /// use soar_utils::fs::{FileSystemProvider, StandardFileSystemProvider}; - /// - /// fn main() -> FileSystemResult<()> { - /// let fs = StandardFileSystemProvider; - /// let dir = "/tmp/soar-doc/internal/dir"; - /// fs.ensure_dir_exists(dir)?; - /// Ok(()) - /// } - /// ``` - fn ensure_dir_exists>(&self, path: P) -> FileSystemResult<()>; -} - -#[derive(Default, Clone)] -pub struct StandardFileSystemProvider; - -impl FileSystemProvider for StandardFileSystemProvider { - fn safe_remove>(&self, path: P) -> FileSystemResult<()> { - let path = path.as_ref(); - - if !path.exists() { - return Ok(()); - } - - let result = if path.is_dir() { - fs::remove_dir_all(path) - } else { - fs::remove_file(path) - }; +/// Removes the specified file or directory safely. +/// +/// If the path does not exist, this function returns `Ok(())` without error. If the path +/// points to a directory, it and all of its contents are removed recursively, equivalent to +/// [`std::fs::remove_dir_all`]. If the path points to a file, it is removed with +/// [`std::fs::remove_file`]. +/// +/// # Errors +/// +/// Returns a [`FileSystemError::File`] if the removal fails for any reason other than +/// the path not existing (e.g., permission denied, path is in use, etc.). +/// +/// # Example +/// +/// ```no_run +/// use soar_utils::error::FileSystemResult; +/// use soar_utils::fs::safe_remove; +/// +/// fn main() -> FileSystemResult<()> { +/// safe_remove("/tmp/some_path")?; +/// Ok(()) +/// } +/// ``` +pub fn safe_remove>(path: P) -> FileSystemResult<()> { + let path = path.as_ref(); - result.map_err(|err| FileSystemError::File { - path: path.to_path_buf(), - action: "remove", - source: err, - }) + if !path.exists() { + return Ok(()); } - fn ensure_dir_exists>(&self, path: P) -> FileSystemResult<()> { - let path = path.as_ref(); - if !path.exists() { - std::fs::create_dir_all(path).map_err(|err| FileSystemError::Directory { - path: path.to_path_buf(), - action: "create", - source: err, - })?; - } else if !path.is_dir() { - return Err(FileSystemError::NotADirectory { - path: path.to_path_buf(), - }); - } - - Ok(()) - } + let result = if path.is_dir() { + fs::remove_dir_all(path) + } else { + fs::remove_file(path) + }; + + result.map_err(|err| FileSystemError::File { + path: path.to_path_buf(), + action: "remove", + source: err, + }) } /// Creates a directory structure if it doesn't exist. /// -/// This is a convenience function that creates a [`StandardFileSystemProvider`] and calls -/// [`FileSystemProvider::ensure_dir_exists`] on it. +/// If the directory already exists, this function does nothing. If the directory structure +/// exists but is not a directory, this function returns an error. /// -/// See [`FileSystemProvider::ensure_dir_exists`] for detailed documentation. -pub fn ensure_dir_exists>(path: P) -> FileSystemResult<()> { - StandardFileSystemProvider.ensure_dir_exists(path) -} - -/// Removes the specified file or directory safely. +/// # Arguments /// -/// This is a convenience function that creates a [`StandardFileSystemProvider`] and calls -/// [`FileSystemProvider::safe_remove`] on it. +/// * `path` - The path to create. /// -/// See [`FileSystemProvider::safe_remove`] for detailed documentation. -pub fn safe_remove>(path: P) -> FileSystemResult<()> { - StandardFileSystemProvider.safe_remove(path) +/// # Errors +/// +/// * [`FileSystemError::Directory`] if the directory could not be created. +/// * [`FileSystemError::NotADirectory`] if the path exists but is not a directory. +/// +/// # Example +/// +/// ```no_run +/// use soar_utils::error::FileSystemResult; +/// use soar_utils::fs::ensure_dir_exists; +/// +/// fn main() -> FileSystemResult<()> { +/// let dir = "/tmp/soar-doc/internal/dir"; +/// ensure_dir_exists(dir)?; +/// Ok(()) +/// } +/// ``` +pub fn ensure_dir_exists>(path: P) -> FileSystemResult<()> { + let path = path.as_ref(); + if !path.exists() { + std::fs::create_dir_all(path).map_err(|err| FileSystemError::Directory { + path: path.to_path_buf(), + action: "create", + source: err, + })?; + } else if !path.is_dir() { + return Err(FileSystemError::NotADirectory { + path: path.to_path_buf(), + }); + } + + Ok(()) } #[cfg(test)] diff --git a/crates/soar-utils/src/hash.rs b/crates/soar-utils/src/hash.rs index c1dec1f50..60160f34c 100644 --- a/crates/soar-utils/src/hash.rs +++ b/crates/soar-utils/src/hash.rs @@ -2,107 +2,74 @@ use std::path::Path; use crate::error::{HashError, HashResult}; -pub trait HashProvider { - /// Calculates the checksum of a file. - /// - /// This method reads the contents of a file and computes a checksum, which is returned as a - /// hex-encoded string. The specific hashing algorithm depends on the implementation. The - /// default implementation uses the `blake3` crate. - /// - /// # Arguments - /// - /// * `file_path` - The path to the file to calculate the checksum for. - /// - /// # Errors - /// - /// * [`HashError::ReadFailed`] if the file cannot be read. - /// - /// # Example - /// - /// ```no_run - /// use soar_utils::error::HashResult; - /// use soar_utils::hash::{HashProvider, StandardHashProvider}; - /// - /// fn main() -> HashResult<()> { - /// let hash_provider = StandardHashProvider; - /// let checksum = hash_provider.calculate_checksum("/path/to/file")?; - /// println!("Checksum is {}", checksum); - /// Ok(()) - /// } - /// ``` - fn calculate_checksum>(&self, file_path: P) -> HashResult; - - /// Verifies the checksum of a file against an expected value. - /// - /// This method calculates the checksum of the given file and compares it case-insensitively - /// against the `expected` checksum string. - /// - /// # Arguments - /// - /// * `file_path` - The path to the file to verify the checksum for. - /// * `expected` - The expected checksum. - /// - /// # Errors - /// - /// * [`HashError::ReadFailed`] if the file cannot be read. - /// - /// # Example - /// - /// ```no_run - /// use soar_utils::error::HashResult; - /// use soar_utils::hash::{HashProvider, StandardHashProvider}; - /// - /// fn main() -> HashResult<()> { - /// let hash_provider = StandardHashProvider; - /// let result = hash_provider.verify_checksum("file.dat", "1234567890abcdef")?; - /// println!("Checksum matches: {}", result); - /// Ok(()) - /// } - /// ``` - fn verify_checksum>(&self, file_path: P, expected: &str) -> HashResult; -} - -/// The default [`HashProvider`] implementation using the `blake3` crate. -pub struct StandardHashProvider; - -impl HashProvider for StandardHashProvider { - fn calculate_checksum>(&self, file_path: P) -> HashResult { - let file_path = file_path.as_ref(); - let mut hasher = blake3::Hasher::new(); - hasher - .update_mmap(file_path) - .map_err(|err| HashError::ReadFailed { - path: file_path.to_path_buf(), - source: err, - })?; - Ok(hasher.finalize().to_hex().to_string()) - } - - fn verify_checksum>(&self, file_path: P, expected: &str) -> HashResult { - let file_path = file_path.as_ref(); - let actual = self.calculate_checksum(file_path)?; - Ok(actual.eq_ignore_ascii_case(expected)) - } -} - /// Calculates the checksum of a file. /// -/// This is a convenience function that creates a [`StandardHashProvider`] and calls -/// [`HashProvider::calculate_checksum`] on it. +/// This method reads the contents of a file and computes a checksum, which is returned as a +/// hex-encoded string. The specific hashing algorithm depends on the implementation. The +/// default implementation uses the `blake3` crate. +/// +/// # Arguments +/// +/// * `file_path` - The path to the file to calculate the checksum for. /// -/// See [`HashProvider::calculate_checksum`] for detailed documentation. +/// # Errors +/// +/// * [`HashError::ReadFailed`] if the file cannot be read. +/// +/// # Example +/// +/// ```no_run +/// use soar_utils::error::HashResult; +/// use soar_utils::hash::calculate_checksum; +/// +/// fn main() -> HashResult<()> { +/// let checksum = calculate_checksum("/path/to/file")?; +/// println!("Checksum is {}", checksum); +/// Ok(()) +/// } +/// ``` pub fn calculate_checksum>(file_path: P) -> HashResult { - StandardHashProvider.calculate_checksum(file_path) + let file_path = file_path.as_ref(); + let mut hasher = blake3::Hasher::new(); + hasher + .update_mmap(file_path) + .map_err(|err| HashError::ReadFailed { + path: file_path.to_path_buf(), + source: err, + })?; + Ok(hasher.finalize().to_hex().to_string()) } /// Verifies the checksum of a file against an expected value. /// -/// This is a convenience function that creates a [`StandardHashProvider`] and calls -/// [`HashProvider::verify_checksum`] on it. +/// This method calculates the checksum of the given file and compares it case-insensitively +/// against the `expected` checksum string. +/// +/// # Arguments +/// +/// * `file_path` - The path to the file to verify the checksum for. +/// * `expected` - The expected checksum. +/// +/// # Errors +/// +/// * [`HashError::ReadFailed`] if the file cannot be read. +/// +/// # Example +/// +/// ```no_run +/// use soar_utils::error::HashResult; +/// use soar_utils::hash::verify_checksum; /// -/// See [`HashProvider::verify_checksum`] for detailed documentation. +/// fn main() -> HashResult<()> { +/// let result = verify_checksum("file.dat", "1234567890abcdef")?; +/// println!("Checksum matches: {}", result); +/// Ok(()) +/// } +/// ``` pub fn verify_checksum>(file_path: P, expected: &str) -> HashResult { - StandardHashProvider.verify_checksum(file_path, expected) + let file_path = file_path.as_ref(); + let actual = calculate_checksum(file_path)?; + Ok(actual.eq_ignore_ascii_case(expected)) } #[cfg(test)] diff --git a/crates/soar-utils/src/path.rs b/crates/soar-utils/src/path.rs index ad611e554..dd3678418 100644 --- a/crates/soar-utils/src/path.rs +++ b/crates/soar-utils/src/path.rs @@ -5,289 +5,209 @@ use crate::{ user::get_username, }; -pub trait PathResolver { - /// Resolves a path string that may contain environment variables - /// - /// This method expands environment variables in the format `$VAR` or `${VAR}`, resolves tilde - /// (`~`) to the user's home directory when it appears at the start of the path, and converts - /// relative paths to absolute paths based on the current working directory. - /// - /// # Arguments - /// - /// * `path` - The path string that may contain environment variables and tilde expansion - /// - /// # Returns - /// - /// Returns an absolute [`PathBuf`] with all variables expanded, or a [`PathError`] if the path - /// is invalid or variables cannot be resolved. - /// - /// # Errors - /// - /// * [`PathError::Empty`] if the path is empty - /// * [`PathError::CurrentDir`] if the current directory cannot be determined - /// * [`PathError::MissingEnvVar`] if the environment variables are undefined - /// - /// # Example - /// - /// ``` - /// use soar_utils::error::PathResult; - /// use soar_utils::path::{PathResolver, SystemPathResolver}; - /// - /// fn main() -> PathResult<()> { - /// let resolver = SystemPathResolver; - /// let resolved = resolver.resolve_path("$HOME/path/to/file")?; - /// println!("Resolved path is {:#?}", resolved); - /// Ok(()) - /// } - /// ``` - fn resolve_path(&self, path: &str) -> PathResult; - - /// Returns the user's home directory - /// - /// This method first checks the `HOME` environment variables. If not set, it falls back to - /// constructing the path `/home/{username}` where username is obtained from the system. - /// - /// # Example - /// - /// ``` - /// use soar_utils::path::{PathResolver, SystemPathResolver}; - /// - /// let resolver = SystemPathResolver; - /// let home = resolver.home_dir(); - /// println!("Home dir is {:#?}", home); - /// ``` - fn home_dir(&self) -> PathBuf; - - /// Returns the user's config directory following XDG Base Directory Specification - /// - /// This method checks the `XDG_CONFIG_HOME` environment variable. If not set, it defaults to - /// `$HOME/.config` - /// - /// # Example - /// - /// ``` - /// use soar_utils::path::{PathResolver, SystemPathResolver}; - /// - /// let resolver = SystemPathResolver; - /// let config = resolver.xdg_config_home(); - /// println!("Config dir is {:#?}", config); - /// ``` - fn xdg_config_home(&self) -> PathBuf; - - /// Returns the user's data directory following XDG Base Directory Specification - /// - /// This method checks the `XDG_DATA_HOME` environment variable. If not set, it defaults to - /// `$HOME/.local/share` - /// - /// # Example - /// - /// ``` - /// use soar_utils::path::{PathResolver, SystemPathResolver}; - /// - /// let resolver = SystemPathResolver; - /// let data = resolver.xdg_data_home(); - /// println!("Data dir is {:#?}", data); - /// ``` - fn xdg_data_home(&self) -> PathBuf; - - /// Returns the user's cache directory following XDG Base Directory Specification - /// - /// This method checks the `XDG_CACHE_HOME` environment variable. If not set, it defaults to - /// `$HOME/.cache` - /// - /// # Example - /// - /// ``` - /// use soar_utils::path::{PathResolver, SystemPathResolver}; - /// - /// let resolver = SystemPathResolver; - /// let cache = resolver.xdg_cache_home(); - /// println!("Cache dir is {:#?}", cache); - /// ``` - fn xdg_cache_home(&self) -> PathBuf; -} - -/// The default [`PathResolver`] implementation using environment variables and filesystem calls. -pub struct SystemPathResolver; - -impl PathResolver for SystemPathResolver { - fn resolve_path(&self, path: &str) -> PathResult { - let path = path.trim(); +/// Resolves a path string that may contain environment variables +/// +/// This method expands environment variables in the format `$VAR` or `${VAR}`, resolves tilde +/// (`~`) to the user's home directory when it appears at the start of the path, and converts +/// relative paths to absolute paths based on the current working directory. +/// +/// # Arguments +/// +/// * `path` - The path string that may contain environment variables and tilde expansion +/// +/// # Returns +/// +/// Returns an absolute [`PathBuf`] with all variables expanded, or a [`PathError`] if the path +/// is invalid or variables cannot be resolved. +/// +/// # Errors +/// +/// * [`PathError::Empty`] if the path is empty +/// * [`PathError::CurrentDir`] if the current directory cannot be determined +/// * [`PathError::MissingEnvVar`] if the environment variables are undefined +/// +/// # Example +/// +/// ``` +/// use soar_utils::error::PathResult; +/// use soar_utils::path::resolve_path; +/// +/// fn main() -> PathResult<()> { +/// let resolved = resolve_path("$HOME/path/to/file")?; +/// println!("Resolved path is {:#?}", resolved); +/// Ok(()) +/// } +/// ``` +pub fn resolve_path(path: &str) -> PathResult { + let path = path.trim(); - if path.is_empty() { - return Err(PathError::Empty); - } + if path.is_empty() { + return Err(PathError::Empty); + } - let resolved = self.expand_variables(path)?; - let path_buf = PathBuf::from(resolved); + let resolved = expand_variables(path)?; + let path_buf = PathBuf::from(resolved); - if path_buf.is_absolute() { - Ok(path_buf) - } else { - env::current_dir() - .map(|cwd| cwd.join(path_buf)) - .map_err(|err| PathError::CurrentDir { source: err }) - } + if path_buf.is_absolute() { + Ok(path_buf) + } else { + env::current_dir() + .map(|cwd| cwd.join(path_buf)) + .map_err(|err| PathError::CurrentDir { source: err }) } +} - fn home_dir(&self) -> PathBuf { - env::var("HOME") - .map(PathBuf::from) - .unwrap_or_else(|_| PathBuf::from(format!("/home/{}", get_username()))) - } +/// Returns the user's home directory +/// +/// This method first checks the `HOME` environment variables. If not set, it falls back to +/// constructing the path `/home/{username}` where username is obtained from the system. +/// +/// # Example +/// +/// ``` +/// use soar_utils::path::home_dir; +/// +/// let home = home_dir(); +/// println!("Home dir is {:#?}", home); +/// ``` +pub fn home_dir() -> PathBuf { + env::var("HOME") + .map(PathBuf::from) + .unwrap_or_else(|_| PathBuf::from(format!("/home/{}", get_username()))) +} - fn xdg_config_home(&self) -> PathBuf { - env::var("XDG_CONFIG_HOME") - .map(PathBuf::from) - .unwrap_or_else(|_| self.home_dir().join(".config")) - } +/// Returns the user's config directory following XDG Base Directory Specification +/// +/// This method checks the `XDG_CONFIG_HOME` environment variable. If not set, it defaults to +/// `$HOME/.config` +/// +/// # Example +/// +/// ``` +/// use soar_utils::path::xdg_config_home; +/// +/// let config = xdg_config_home(); +/// println!("Config dir is {:#?}", config); +/// ``` +pub fn xdg_config_home() -> PathBuf { + env::var("XDG_CONFIG_HOME") + .map(PathBuf::from) + .unwrap_or_else(|_| home_dir().join(".config")) +} - fn xdg_data_home(&self) -> PathBuf { - env::var("XDG_DATA_HOME") - .map(PathBuf::from) - .unwrap_or_else(|_| self.home_dir().join(".local/share")) - } +/// Returns the user's data directory following XDG Base Directory Specification +/// +/// This method checks the `XDG_DATA_HOME` environment variable. If not set, it defaults to +/// `$HOME/.local/share` +/// +/// # Example +/// +/// ``` +/// use soar_utils::path::xdg_data_home; +/// +/// let data = xdg_data_home(); +/// println!("Data dir is {:#?}", data); +/// ``` +pub fn xdg_data_home() -> PathBuf { + env::var("XDG_DATA_HOME") + .map(PathBuf::from) + .unwrap_or_else(|_| home_dir().join(".local/share")) +} - fn xdg_cache_home(&self) -> PathBuf { - env::var("XDG_CACHE_HOME") - .map(PathBuf::from) - .unwrap_or_else(|_| self.home_dir().join(".cache")) - } +/// Returns the user's cache directory following XDG Base Directory Specification +/// +/// This method checks the `XDG_CACHE_HOME` environment variable. If not set, it defaults to +/// `$HOME/.cache` +/// +/// # Example +/// +/// ``` +/// use soar_utils::path::xdg_cache_home; +/// +/// let cache = xdg_cache_home(); +/// println!("Cache dir is {:#?}", cache); +/// ``` +pub fn xdg_cache_home() -> PathBuf { + env::var("XDG_CACHE_HOME") + .map(PathBuf::from) + .unwrap_or_else(|_| home_dir().join(".cache")) } -impl SystemPathResolver { - fn expand_variables(&self, path: &str) -> PathResult { - let mut result = String::with_capacity(path.len()); - let mut chars = path.chars().peekable(); - - while let Some(c) = chars.next() { - match c { - '$' => { - if chars.peek() == Some(&'{') { - chars.next(); - let var_name = self.consume_until(&mut chars, '}')?; - self.expand_env_var(&var_name, &mut result, path)?; +fn expand_variables(path: &str) -> PathResult { + let mut result = String::with_capacity(path.len()); + let mut chars = path.chars().peekable(); + + while let Some(c) = chars.next() { + match c { + '$' => { + if chars.peek() == Some(&'{') { + chars.next(); + let var_name = consume_until(&mut chars, '}')?; + expand_env_var(&var_name, &mut result, path)?; + } else { + let var_name = consume_var_name(&mut chars); + if var_name.is_empty() { + result.push('$'); } else { - let var_name = self.consume_var_name(&mut chars); - if var_name.is_empty() { - result.push('$'); - } else { - self.expand_env_var(&var_name, &mut result, path)?; - } + expand_env_var(&var_name, &mut result, path)?; } } - '~' if result.is_empty() => result.push_str(&self.home_dir().to_string_lossy()), - _ => result.push(c), } + '~' if result.is_empty() => result.push_str(&home_dir().to_string_lossy()), + _ => result.push(c), } - - Ok(result) } - fn consume_until( - &self, - chars: &mut std::iter::Peekable, - delimiter: char, - ) -> PathResult { - let mut var_name = String::new(); - - for c in chars.by_ref() { - if c == delimiter { - return Ok(var_name); - } - var_name.push(c); - } - - Err(PathError::UnclosedVariable { - input: format!("${{{var_name}"), - }) - } - - fn consume_var_name(&self, chars: &mut std::iter::Peekable) -> String { - let mut var_name = String::new(); - - while let Some(&c) = chars.peek() { - if c.is_alphanumeric() || c == '_' { - var_name.push(chars.next().unwrap()); - } else { - break; - } - } + Ok(result) +} - var_name - } +fn consume_until( + chars: &mut std::iter::Peekable, + delimiter: char, +) -> PathResult { + let mut var_name = String::new(); - fn expand_env_var( - &self, - var_name: &str, - result: &mut String, - original: &str, - ) -> PathResult<()> { - match var_name { - "HOME" => result.push_str(&self.home_dir().to_string_lossy()), - "XDG_CONFIG_HOME" => result.push_str(&self.xdg_config_home().to_string_lossy()), - "XDG_DATA_HOME" => result.push_str(&self.xdg_data_home().to_string_lossy()), - "XDG_CACHE_HOME" => result.push_str(&self.xdg_cache_home().to_string_lossy()), - _ => { - let value = env::var(var_name).map_err(|_| PathError::MissingEnvVar { - input: original.into(), - var: var_name.into(), - })?; - result.push_str(&value); - } + for c in chars.by_ref() { + if c == delimiter { + return Ok(var_name); } - Ok(()) + var_name.push(c); } -} -/// Resolves a path string using the system path resolver. -/// -/// This is a convenience function that creates a [`SystemPathResolver`] and calls -/// [`PathResolver::resolve_path`] on it. -/// -/// See [`PathResolver::resolve_path`] for detailed documentation. -pub fn resolve_path(path: &str) -> PathResult { - SystemPathResolver.resolve_path(path) + Err(PathError::UnclosedVariable { + input: format!("${{{var_name}"), + }) } -/// Returns the user's home directory using the system path resolver. -/// -/// This is a convenience function that creates a [`SystemPathResolver`] and calls -/// [`PathResolver::home_dir`] on it. -/// -/// See [`PathResolver::home_dir`] for detailed documentation. -pub fn home_dir() -> PathBuf { - SystemPathResolver.home_dir() -} +fn consume_var_name(chars: &mut std::iter::Peekable) -> String { + let mut var_name = String::new(); -/// Returns the user's config directory using the system path resolver. -/// -/// This is a convenience function that creates a [`SystemPathResolver`] and calls -/// [`PathResolver::xdg_config_home`] on it. -/// -/// See [`PathResolver::xdg_config_home`] for detailed documentation. -pub fn xdg_config_home() -> PathBuf { - SystemPathResolver.xdg_config_home() -} + while let Some(&c) = chars.peek() { + if c.is_alphanumeric() || c == '_' { + var_name.push(chars.next().unwrap()); + } else { + break; + } + } -/// Returns the user's data directory using the system path resolver. -/// -/// This is a convenience function that creates a [`SystemPathResolver`] and calls -/// [`PathResolver::xdg_data_home`] on it. -/// -/// See [`PathResolver::xdg_data_home`] for detailed documentation. -pub fn xdg_data_home() -> PathBuf { - SystemPathResolver.xdg_data_home() + var_name } -/// Returns the user's cache directory using the system path resolver. -/// -/// This is a convenience function that creates a [`SystemPathResolver`] and calls -/// [`PathResolver::xdg_cache_home`] on it. -/// -/// See [`PathResolver::xdg_cache_home`] for detailed documentation. -pub fn xdg_cache_home() -> PathBuf { - SystemPathResolver.xdg_cache_home() +fn expand_env_var(var_name: &str, result: &mut String, original: &str) -> PathResult<()> { + match var_name { + "HOME" => result.push_str(&home_dir().to_string_lossy()), + "XDG_CONFIG_HOME" => result.push_str(&xdg_config_home().to_string_lossy()), + "XDG_DATA_HOME" => result.push_str(&xdg_data_home().to_string_lossy()), + "XDG_CACHE_HOME" => result.push_str(&xdg_cache_home().to_string_lossy()), + _ => { + let value = env::var(var_name).map_err(|_| PathError::MissingEnvVar { + input: original.into(), + var: var_name.into(), + })?; + result.push_str(&value); + } + } + Ok(()) } #[cfg(test)] @@ -297,10 +217,9 @@ mod tests { #[test] fn test_expand_variables_simple() { - let resolver = SystemPathResolver; env::set_var("TEST_VAR", "test_value"); - let result = resolver.expand_variables("$TEST_VAR/path").unwrap(); + let result = expand_variables("$TEST_VAR/path").unwrap(); assert_eq!(result, "test_value/path"); env::remove_var("TEST_VAR"); @@ -308,12 +227,9 @@ mod tests { #[test] fn test_expand_variables_braces() { - let resolver = SystemPathResolver; env::set_var("TEST_VAR_BRACES", "test_value"); - let result = resolver - .expand_variables("${TEST_VAR_BRACES}/path") - .unwrap(); + let result = expand_variables("${TEST_VAR_BRACES}/path").unwrap(); assert_eq!(result, "test_value/path"); env::remove_var("TEST_VAR_BRACES"); @@ -321,10 +237,9 @@ mod tests { #[test] fn test_expand_variables_missing_braces() { - let resolver = SystemPathResolver; env::set_var("TEST_VAR_MISSING_BRACES", "test_value"); - let result = resolver.expand_variables("${TEST_VAR_MISSING_BRACES"); + let result = expand_variables("${TEST_VAR_MISSING_BRACES"); assert!(result.is_err()); env::remove_var("TEST_VAR_MISSING_BRACES"); @@ -332,25 +247,22 @@ mod tests { #[test] fn test_expand_variables_missing_var() { - let resolver = SystemPathResolver; - let result = resolver.expand_variables("$THIS_VAR_DOESNT_EXIST"); + let result = expand_variables("$THIS_VAR_DOESNT_EXIST"); assert!(result.is_err()); } #[test] fn test_consume_var_name() { - let resolver = SystemPathResolver; let mut chars = "VAR_NAME_123/extra".chars().peekable(); - let var_name = resolver.consume_var_name(&mut chars); + let var_name = consume_var_name(&mut chars); assert_eq!(var_name, "VAR_NAME_123"); } #[test] fn test_xdg_directories() { - let resolver = SystemPathResolver; // We need to set HOME to have a predictable home directory for the test env::set_var("HOME", "/tmp/home"); - let home = resolver.home_dir(); + let home = home_dir(); assert_eq!(home, PathBuf::from("/tmp/home")); // Test without XDG variables set @@ -358,9 +270,9 @@ mod tests { env::remove_var("XDG_DATA_HOME"); env::remove_var("XDG_CACHE_HOME"); - let config = resolver.xdg_config_home(); - let data = resolver.xdg_data_home(); - let cache = resolver.xdg_cache_home(); + let config = xdg_config_home(); + let data = xdg_data_home(); + let cache = xdg_cache_home(); assert_eq!(config, home.join(".config")); assert_eq!(data, home.join(".local/share")); @@ -374,9 +286,9 @@ mod tests { env::set_var("XDG_DATA_HOME", "/tmp/data"); env::set_var("XDG_CACHE_HOME", "/tmp/cache"); - assert_eq!(resolver.xdg_config_home(), PathBuf::from("/tmp/config")); - assert_eq!(resolver.xdg_data_home(), PathBuf::from("/tmp/data")); - assert_eq!(resolver.xdg_cache_home(), PathBuf::from("/tmp/cache")); + assert_eq!(xdg_config_home(), PathBuf::from("/tmp/config")); + assert_eq!(xdg_data_home(), PathBuf::from("/tmp/data")); + assert_eq!(xdg_cache_home(), PathBuf::from("/tmp/cache")); env::remove_var("XDG_CONFIG_HOME"); env::remove_var("XDG_DATA_HOME"); @@ -386,112 +298,87 @@ mod tests { #[test] fn test_resolve_path() { - let resolver = SystemPathResolver; env::set_var("HOME", "/tmp/home"); - assert!(resolver.resolve_path("").is_err()); + assert!(resolve_path("").is_err()); // Absolute path assert_eq!( - resolver.resolve_path("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/absolute/path").unwrap(), + resolve_path("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/absolute/path").unwrap(), PathBuf::from("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/absolute/path") ); // Relative path let expected_relative = env::current_dir().unwrap().join("relative/path"); - assert_eq!( - resolver.resolve_path("relative/path").unwrap(), - expected_relative - ); + assert_eq!(resolve_path("relative/path").unwrap(), expected_relative); // Tilde path - let home = resolver.home_dir(); - assert_eq!(resolver.resolve_path("~/path").unwrap(), home.join("path")); - assert_eq!(resolver.resolve_path("~").unwrap(), home); + let home = home_dir(); + assert_eq!(resolve_path("~/path").unwrap(), home.join("path")); + assert_eq!(resolve_path("~").unwrap(), home); // Tilde not at start let expected_tilde_middle = env::current_dir().unwrap().join("not/at/~/start"); assert_eq!( - resolver.resolve_path("not/at/~/start").unwrap(), + resolve_path("not/at/~/start").unwrap(), expected_tilde_middle ); env::remove_var("HOME"); // Unclosed variable - let result = resolver.resolve_path("${VAR"); + let result = resolve_path("${VAR"); assert!(result.is_err()); // Missing variable - let result = resolver.resolve_path("${VAR}"); + let result = resolve_path("${VAR}"); assert!(result.is_err()); } #[test] fn test_home_dir() { - let resolver = SystemPathResolver; - // Test with HOME set env::set_var("HOME", "/tmp/home"); - assert_eq!(resolver.home_dir(), PathBuf::from("/tmp/home")); + assert_eq!(home_dir(), PathBuf::from("/tmp/home")); // Test with HOME unset env::remove_var("HOME"); let expected = PathBuf::from(format!("/home/{}", get_username())); - assert_eq!(resolver.home_dir(), expected); + assert_eq!(home_dir(), expected); } #[test] fn test_expand_variables_edge_cases() { - let resolver = SystemPathResolver; env::set_var("HOME", "/tmp/home"); // Dollar at the end - assert_eq!(resolver.expand_variables("path/$").unwrap(), "path/$"); + assert_eq!(expand_variables("path/$").unwrap(), "path/$"); // Dollar with invalid char assert_eq!( - resolver.expand_variables("path/$!invalid").unwrap(), + expand_variables("path/$!invalid").unwrap(), "path/$!invalid" ); // Multiple variables env::set_var("VAR1", "val1"); env::set_var("VAR2", "val2"); - assert_eq!( - resolver.expand_variables("$VAR1/${VAR2}").unwrap(), - "val1/val2" - ); + assert_eq!(expand_variables("$VAR1/${VAR2}").unwrap(), "val1/val2"); env::remove_var("VAR1"); env::remove_var("VAR2"); // Tilde expansion - let home_str = resolver.home_dir().to_string_lossy().to_string(); + let home_str = home_dir().to_string_lossy().to_string(); assert_eq!( - resolver.expand_variables("~/path").unwrap(), + expand_variables("~/path").unwrap(), format!("{}/path", home_str) ); - assert_eq!(resolver.expand_variables("~").unwrap(), home_str); - assert_eq!(resolver.expand_variables("a/~/b").unwrap(), "a/~/b"); - env::remove_var("HOME"); - } - - #[test] - fn test_public_convenience_functions() { - env::set_var("HOME", "/tmp/home"); - env::remove_var("XDG_CONFIG_HOME"); - env::remove_var("XDG_DATA_HOME"); - env::remove_var("XDG_CACHE_HOME"); - assert_eq!(resolve_path("~").unwrap(), PathBuf::from("/tmp/home")); - assert_eq!(home_dir(), PathBuf::from("/tmp/home")); - assert_eq!(xdg_config_home(), PathBuf::from("/tmp/home/.config")); - assert_eq!(xdg_data_home(), PathBuf::from("/tmp/home/.local/share")); - assert_eq!(xdg_cache_home(), PathBuf::from("/tmp/home/.cache")); + assert_eq!(expand_variables("~").unwrap(), home_str); + assert_eq!(expand_variables("a/~/b").unwrap(), "a/~/b"); env::remove_var("HOME"); } #[test] fn test_resolve_path_invalid_cwd() { - let resolver = SystemPathResolver; let temp_dir = tempfile::tempdir().unwrap(); let invalid_path = temp_dir.path().join("invalid"); std::fs::create_dir(&invalid_path).unwrap(); @@ -500,7 +387,7 @@ mod tests { env::set_current_dir(&invalid_path).unwrap(); std::fs::remove_dir(&invalid_path).unwrap(); - let result = resolver.resolve_path("relative/path"); + let result = resolve_path("relative/path"); assert!(result.is_err()); // Restore cwd @@ -509,34 +396,25 @@ mod tests { #[test] fn test_expand_env_var_special_vars() { - let resolver = SystemPathResolver; env::set_var("HOME", "/tmp/home"); env::remove_var("XDG_CONFIG_HOME"); env::remove_var("XDG_DATA_HOME"); env::remove_var("XDG_CACHE_HOME"); let mut result = String::new(); - resolver - .expand_env_var("HOME", &mut result, "$HOME") - .unwrap(); + expand_env_var("HOME", &mut result, "$HOME").unwrap(); assert_eq!(result, "/tmp/home"); result.clear(); - resolver - .expand_env_var("XDG_CONFIG_HOME", &mut result, "$XDG_CONFIG_HOME") - .unwrap(); + expand_env_var("XDG_CONFIG_HOME", &mut result, "$XDG_CONFIG_HOME").unwrap(); assert_eq!(result, "/tmp/home/.config"); result.clear(); - resolver - .expand_env_var("XDG_DATA_HOME", &mut result, "$XDG_DATA_HOME") - .unwrap(); + expand_env_var("XDG_DATA_HOME", &mut result, "$XDG_DATA_HOME").unwrap(); assert_eq!(result, "/tmp/home/.local/share"); result.clear(); - resolver - .expand_env_var("XDG_CACHE_HOME", &mut result, "$XDG_CACHE_HOME") - .unwrap(); + expand_env_var("XDG_CACHE_HOME", &mut result, "$XDG_CACHE_HOME").unwrap(); assert_eq!(result, "/tmp/home/.cache"); env::remove_var("HOME"); From 052b62614bb252537923ea7808548a379c6a64cd Mon Sep 17 00:00:00 2001 From: Rabindra Dhakal Date: Tue, 23 Sep 2025 20:08:17 +0545 Subject: [PATCH 07/12] remove unused deps, fix flaky tests, use soar-utils --- Cargo.lock | 82 ++++++++ Cargo.toml | 1 + crates/soar-utils/Cargo.toml | 1 + crates/soar-utils/src/error.rs | 19 ++ crates/soar-utils/src/fs.rs | 212 +++++++++++++++++++- crates/soar-utils/src/path.rs | 17 ++ soar-cli/Cargo.toml | 1 + soar-cli/src/health.rs | 16 +- soar-cli/src/install.rs | 3 +- soar-cli/src/list.rs | 4 +- soar-cli/src/main.rs | 5 +- soar-cli/src/run.rs | 3 +- soar-core/Cargo.toml | 1 + soar-core/src/config.rs | 42 ++-- soar-core/src/error.rs | 10 + soar-core/src/metadata.rs | 5 +- soar-core/src/package/formats/appimage.rs | 6 +- soar-core/src/package/formats/common.rs | 42 ++-- soar-core/src/package/install.rs | 24 +-- soar-core/src/package/remove.rs | 10 +- soar-core/src/utils.rs | 230 ++-------------------- 21 files changed, 445 insertions(+), 289 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5fc267f15..fdabe3427 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1257,6 +1257,16 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f5e54036fe321fd421e10d732f155734c4e4afd610dd556d9a82833ab3ee0bed" +[[package]] +name = "lock_api" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96936507f153605bddfcda068dd804796c84324ed2510809e5b2a624c81da765" +dependencies = [ + "autocfg", + "scopeguard", +] + [[package]] name = "log" version = "0.4.27" @@ -1418,6 +1428,29 @@ dependencies = [ "syn", ] +[[package]] +name = "parking_lot" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70d58bf43669b5795d1576d0641cfb6fbb2057bf629506267a92807158584a13" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc838d2a56b5b1a6c25f55575dfc605fabb63bb2365f6c2353ef9159aa69e4a5" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-targets 0.52.6", +] + [[package]] name = "pbkdf2" version = "0.12.2" @@ -1949,6 +1982,21 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" +[[package]] +name = "scc" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46e6f046b7fef48e2660c57ed794263155d713de679057f2d0c169bfc6e756cc" +dependencies = [ + "sdd", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + [[package]] name = "scroll" version = "0.12.0" @@ -1969,6 +2017,12 @@ dependencies = [ "syn", ] +[[package]] +name = "sdd" +version = "3.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "490dcfcbfef26be6800d11870ff2df8774fa6e86d047e3e8c8a76b25655e41ca" + [[package]] name = "semver" version = "1.0.27" @@ -2040,6 +2094,31 @@ dependencies = [ "serde", ] +[[package]] +name = "serial_test" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b258109f244e1d6891bf1053a55d63a5cd4f8f4c30cf9a1280989f80e7a1fa9" +dependencies = [ + "futures", + "log", + "once_cell", + "parking_lot", + "scc", + "serial_test_derive", +] + +[[package]] +name = "serial_test_derive" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d69265a08751de7844521fd15003ae0a888e035773ba05695c5c759a6f89eef" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "sha1" version = "0.10.6" @@ -2110,6 +2189,7 @@ dependencies = [ "serde_json", "soar-core", "soar-dl", + "soar-utils", "tokio", "toml", "tracing", @@ -2135,6 +2215,7 @@ dependencies = [ "serde", "serde_json", "soar-dl", + "soar-utils", "squishy", "thiserror 2.0.16", "toml", @@ -2172,6 +2253,7 @@ version = "0.1.0" dependencies = [ "blake3", "nix", + "serial_test", "tempfile", ] diff --git a/Cargo.toml b/Cargo.toml index fe478c00c..43d310045 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,6 +23,7 @@ codegen-units = 1 panic = "abort" [workspace.dependencies] +soar-utils = { path = "crates/soar-utils" } futures = "0.3.31" rayon = "1.11.0" regex = { version = "1.11.2", default-features = false, features = ["unicode-case", "unicode-perl", "std"] } diff --git a/crates/soar-utils/Cargo.toml b/crates/soar-utils/Cargo.toml index 382415929..7f76b71a5 100644 --- a/crates/soar-utils/Cargo.toml +++ b/crates/soar-utils/Cargo.toml @@ -12,6 +12,7 @@ categories.workspace = true [dependencies] blake3 = { version = "1.8.2", features = ["mmap"] } nix = { version = "0.30.1", features = ["ioctl", "term", "user"] } +serial_test = "3.2.0" [dev-dependencies] tempfile = "3.10.1" diff --git a/crates/soar-utils/src/error.rs b/crates/soar-utils/src/error.rs index 98fe42781..9402b1f4a 100644 --- a/crates/soar-utils/src/error.rs +++ b/crates/soar-utils/src/error.rs @@ -97,6 +97,12 @@ pub enum FileSystemError { NotADirectory { path: PathBuf, }, + + Symlink { + from: PathBuf, + target: PathBuf, + source: std::io::Error, + }, } impl fmt::Display for FileSystemError { @@ -123,6 +129,18 @@ impl fmt::Display for FileSystemError { FileSystemError::NotADirectory { path } => { write!(f, "`{}` is not a directory", path.display()) } + FileSystemError::Symlink { + from, + target, + source, + } => { + write!( + f, + "Failed to create symlink from `{}` to `{}`: {source}", + from.display(), + target.display() + ) + } } } } @@ -132,6 +150,7 @@ impl Error for FileSystemError { match self { FileSystemError::File { source, .. } => Some(source), FileSystemError::Directory { source, .. } => Some(source), + FileSystemError::Symlink { source, .. } => Some(source), _ => None, } } diff --git a/crates/soar-utils/src/fs.rs b/crates/soar-utils/src/fs.rs index fcd54a5f4..59dac14ab 100644 --- a/crates/soar-utils/src/fs.rs +++ b/crates/soar-utils/src/fs.rs @@ -1,4 +1,9 @@ -use std::{fs, path::Path}; +use std::{ + fs::{self, File}, + io::{BufReader, Read}, + os, + path::Path, +}; use crate::error::{FileSystemError, FileSystemResult}; @@ -88,6 +93,211 @@ pub fn ensure_dir_exists>(path: P) -> FileSystemResult<()> { Ok(()) } +/// Creates symlink from `source` to `target` +/// If `target` is a symlink, it will be removed before creating the symlink. +/// +/// # Arguments +/// +/// * `source` - The path to the file or directory to symlink +/// * `target` - The path to the symlink +/// +/// # Errors +/// +/// Returns a [`FileSystemError::Symlink`] if the symlink could not be created. +/// Returns a [`FileSystemError::File`] if the symlink could not be removed. +/// +/// # Example +/// +/// ```no_run +/// use soar_utils::error::FileSystemResult; +/// use soar_utils::fs::create_symlink; +/// +/// fn main() -> FileSystemResult<()> { +/// create_symlink("/tmp/source", "/tmp/target")?; +/// Ok(()) +/// } +/// ``` +pub fn create_symlink, Q: AsRef>( + source: P, + target: Q, +) -> FileSystemResult<()> { + let source = source.as_ref(); + let target = target.as_ref(); + + if let Some(parent) = target.parent() { + ensure_dir_exists(parent)?; + } + + if target.is_symlink() { + fs::remove_file(target).map_err(|err| FileSystemError::File { + path: target.to_path_buf(), + action: "remove", + source: err, + })?; + } + + os::unix::fs::symlink(source, target).map_err(|err| FileSystemError::Symlink { + from: source.to_path_buf(), + target: target.to_path_buf(), + source: err, + }) +} + +/// Walks a directory recursively and calls the provided function on each file or directory. +/// +/// # Arguments +/// +/// * `dir` - The directory to walk +/// * `action` - The function to call on each file or directory +/// +/// # Errors +/// +/// Returns a [`FileSystemError::Directory`] if the directory could not be read. +/// Returns a [`FileSystemError::NotADirectory`] if the path is not a directory. +/// +/// # Example +/// +/// ```no_run +/// use std::path::Path; +/// +/// use soar_utils::error::FileSystemResult; +/// use soar_utils::fs::walk_dir; +/// +/// fn main() -> FileSystemResult<()> { +/// let _ = walk_dir("/tmp/dir", &mut |path: &Path| { +/// println!("Found file or directory: {}", path.display()); +/// Ok(()) +/// })?; +/// Ok(()) +/// } +/// ``` +pub fn walk_dir, F>(dir: P, action: &mut F) -> FileSystemResult<()> +where + F: FnMut(&Path) -> FileSystemResult<()>, +{ + let dir = dir.as_ref(); + + if !dir.is_dir() { + return Err(FileSystemError::NotADirectory { + path: dir.to_path_buf(), + }); + } + + for entry in fs::read_dir(dir).map_err(|err| FileSystemError::Directory { + path: dir.to_path_buf(), + action: "read", + source: err, + })? { + let path = entry + .map_err(|err| FileSystemError::Directory { + path: dir.to_path_buf(), + action: "read entry in", + source: err, + })? + .path(); + + if path.is_dir() { + walk_dir(&path, action)?; + continue; + } + + action(&path)?; + } + + Ok(()) +} + +/// Reads the first `bytes` bytes from a file and returns the signature. +/// +/// # Arguments +/// * `path` - The path to the file +/// * `bytes` - The number of bytes to read from the file +/// +/// # Returns +/// Returns a byte array of the first `bytes` bytes from the file. +/// +/// # Errors +/// Returns a [`FileSystemError::File`] if the file could not be opened or read. +/// +/// # Example +/// ```no_run +/// use soar_utils::fs::read_file_signature; +/// use soar_utils::error::FileSystemResult; +/// +/// fn main() -> FileSystemResult<()> { +/// let signature = read_file_signature("/tmp/file", 1024)?; +/// println!("File signature: {:?}", signature); +/// Ok(()) +/// } +pub fn read_file_signature>(path: P, bytes: usize) -> FileSystemResult> { + let path = path.as_ref(); + let file = File::open(path).map_err(|err| FileSystemError::File { + path: path.to_path_buf(), + action: "open", + source: err, + })?; + + let mut reader = BufReader::new(file); + let mut buffer = vec![0u8; bytes]; + reader + .read_exact(&mut buffer) + .map_err(|err| FileSystemError::File { + path: path.to_path_buf(), + action: "read", + source: err, + })?; + Ok(buffer) +} + +/// Returns the total size of a directory and its contents. +/// +/// # Arguments +/// * `path` - The path to the directory +/// +/// # Returns +/// Returns the total size of the directory and its contents. +/// +/// # Errors +/// Returns a [`FileSystemError::Directory`] if the directory could not be read. +/// +/// # Example +/// ```no_run +/// use soar_utils::fs::dir_size; +/// use soar_utils::error::FileSystemResult; +/// +/// fn main() -> FileSystemResult<()> { +/// let size = dir_size("/tmp/dir")?; +/// println!("Directory size: {}", size); +/// Ok(()) +/// } +/// ``` +pub fn dir_size>(path: P) -> FileSystemResult { + let path = path.as_ref(); + let mut total_size = 0; + + for entry in fs::read_dir(path).map_err(|err| FileSystemError::Directory { + path: path.to_path_buf(), + action: "read", + source: err, + })? { + let Ok(entry) = entry else { + continue; + }; + + let Ok(metadata) = entry.metadata() else { + continue; + }; + + if metadata.is_file() { + total_size += metadata.len(); + } else if metadata.is_dir() { + total_size += dir_size(entry.path())?; + } + } + + Ok(total_size) +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/soar-utils/src/path.rs b/crates/soar-utils/src/path.rs index dd3678418..17703a827 100644 --- a/crates/soar-utils/src/path.rs +++ b/crates/soar-utils/src/path.rs @@ -133,6 +133,16 @@ pub fn xdg_cache_home() -> PathBuf { .unwrap_or_else(|_| home_dir().join(".cache")) } +/// Returns the user's desktop directory +pub fn desktop_dir() -> PathBuf { + xdg_data_home().join("applications") +} + +/// Returns the user's icons directory +pub fn icons_dir() -> PathBuf { + xdg_data_home().join("icons/hicolor") +} + fn expand_variables(path: &str) -> PathResult { let mut result = String::with_capacity(path.len()); let mut chars = path.chars().peekable(); @@ -215,6 +225,8 @@ mod tests { use super::*; use std::env; + use serial_test::serial; + #[test] fn test_expand_variables_simple() { env::set_var("TEST_VAR", "test_value"); @@ -259,6 +271,7 @@ mod tests { } #[test] + #[serial] fn test_xdg_directories() { // We need to set HOME to have a predictable home directory for the test env::set_var("HOME", "/tmp/home"); @@ -297,6 +310,7 @@ mod tests { } #[test] + #[serial] fn test_resolve_path() { env::set_var("HOME", "/tmp/home"); @@ -335,6 +349,7 @@ mod tests { } #[test] + #[serial] fn test_home_dir() { // Test with HOME set env::set_var("HOME", "/tmp/home"); @@ -347,6 +362,7 @@ mod tests { } #[test] + #[serial] fn test_expand_variables_edge_cases() { env::set_var("HOME", "/tmp/home"); @@ -395,6 +411,7 @@ mod tests { } #[test] + #[serial] fn test_expand_env_var_special_vars() { env::set_var("HOME", "/tmp/home"); env::remove_var("XDG_CONFIG_HOME"); diff --git a/soar-cli/Cargo.toml b/soar-cli/Cargo.toml index 17c16311a..4433e9ea4 100644 --- a/soar-cli/Cargo.toml +++ b/soar-cli/Cargo.toml @@ -35,6 +35,7 @@ serde = { workspace = true } serde_json = { workspace = true } soar-core = { version = "0.8.1", path = "../soar-core" } soar-dl = { workspace = true } +soar-utils = { workspace = true } tokio = { version = "1.47.1", features = ["macros", "rt-multi-thread"] } toml = "0.9.6" tracing = { workspace = true } diff --git a/soar-cli/src/health.rs b/soar-cli/src/health.rs index d5de5fd75..bdd1a02b3 100644 --- a/soar-cli/src/health.rs +++ b/soar-cli/src/health.rs @@ -5,9 +5,13 @@ use soar_core::{ config::get_config, database::packages::{FilterCondition, PackageQueryBuilder}, package::remove::PackageRemover, - utils::{desktop_dir, icons_dir, process_dir}, SoarResult, }; +use soar_utils::{ + error::FileSystemResult, + fs::walk_dir, + path::{desktop_dir, icons_dir}, +}; use tracing::{info, warn}; use crate::{state::AppState, utils::Colored}; @@ -68,14 +72,14 @@ pub fn list_broken_symlinks() -> SoarResult<()> { let broken_symlinks = Rc::new(RefCell::new(Vec::new())); let broken_symlinks_clone = Rc::clone(&broken_symlinks); - let mut collect_action = |path: &Path| -> SoarResult<()> { + let mut collect_action = |path: &Path| -> FileSystemResult<()> { if !path.exists() { broken_symlinks_clone.borrow_mut().push(path.to_path_buf()); } Ok(()) }; - let mut soar_files_action = |path: &Path| -> SoarResult<()> { + let mut soar_files_action = |path: &Path| -> FileSystemResult<()> { if let Some(filename) = path.file_stem().and_then(|s| s.to_str()) { if filename.ends_with("-soar") && !path.exists() { broken_symlinks_clone.borrow_mut().push(path.to_path_buf()); @@ -84,9 +88,9 @@ pub fn list_broken_symlinks() -> SoarResult<()> { Ok(()) }; - process_dir(&get_config().get_bin_path()?, &mut collect_action)?; - process_dir(desktop_dir(), &mut soar_files_action)?; - process_dir(icons_dir(), &mut soar_files_action)?; + walk_dir(&get_config().get_bin_path()?, &mut collect_action)?; + walk_dir(desktop_dir(), &mut soar_files_action)?; + walk_dir(icons_dir(), &mut soar_files_action)?; let broken_symlinks = Rc::try_unwrap(broken_symlinks) .unwrap_or_else(|rc| rc.borrow().clone().into()) diff --git a/soar-cli/src/install.rs b/soar-cli/src/install.rs index 9d4109d5a..8a44c3267 100644 --- a/soar-cli/src/install.rs +++ b/soar-cli/src/install.rs @@ -26,10 +26,11 @@ use soar_core::{ install::{InstallTarget, PackageInstaller}, query::PackageQuery, }, - utils::{apply_sig_variants, calculate_checksum, default_install_patterns}, + utils::{apply_sig_variants, default_install_patterns}, SoarResult, }; use soar_dl::downloader::DownloadState; +use soar_utils::hash::calculate_checksum; use tokio::sync::Semaphore; use tracing::{error, info, warn}; diff --git a/soar-cli/src/list.rs b/soar-cli/src/list.rs index 3a0c86633..152ebafa7 100644 --- a/soar-cli/src/list.rs +++ b/soar-cli/src/list.rs @@ -13,9 +13,9 @@ use soar_core::{ packages::{FilterCondition, PackageQueryBuilder, PaginatedResponse, SortDirection}, }, package::query::PackageQuery, - utils::calculate_dir_size, SoarResult, }; +use soar_utils::fs::dir_size; use tracing::info; use crate::{ @@ -553,7 +553,7 @@ pub async fn list_installed_packages(repo_name: Option, count: bool) -> |(installed_count, unique_count, broken_count, installed_size, broken_size), package| { let installed_path = PathBuf::from(&package.installed_path); - let size = calculate_dir_size(&installed_path).unwrap_or(0); + let size = dir_size(&installed_path).unwrap_or(0); let is_installed = package.is_installed && installed_path.exists(); info!( pkg_name = package.pkg_name, diff --git a/soar-cli/src/main.rs b/soar-cli/src/main.rs index 481b459a8..7b0b978d6 100644 --- a/soar-cli/src/main.rs +++ b/soar-cli/src/main.rs @@ -16,10 +16,11 @@ use run::run_package; use soar_core::{ config::{self, generate_default_config, get_config, set_current_profile, Config, CONFIG_PATH}, error::{ErrorContext, SoarError}, - utils::{build_path, cleanup_cache, remove_broken_symlinks, setup_required_paths}, + utils::{cleanup_cache, remove_broken_symlinks, setup_required_paths}, SoarResult, }; use soar_dl::http_client::{configure_http_client, create_http_header_map}; +use soar_utils::path::resolve_path; use state::AppState; use tracing::{error, info, warn}; use update::update_packages; @@ -81,7 +82,7 @@ async fn handle_cli() -> SoarResult<()> { if let Some(ref c) = args.config { { let mut config_path = CONFIG_PATH.write().unwrap(); - let path = build_path(c)?; + let path = resolve_path(c)?; let path = if path.is_absolute() { path } else { diff --git a/soar-cli/src/run.rs b/soar-cli/src/run.rs index bc5c6adf7..35f03d31c 100644 --- a/soar-cli/src/run.rs +++ b/soar-cli/src/run.rs @@ -7,13 +7,14 @@ use soar_core::{ }, error::{ErrorContext, SoarError}, package::query::PackageQuery, - utils::{calculate_checksum, get_extract_dir}, + utils::get_extract_dir, SoarResult, }; use soar_dl::{ downloader::{DownloadOptions, Downloader, OciDownloadOptions, OciDownloader}, utils::FileMode, }; +use soar_utils::hash::calculate_checksum; use crate::{ progress::{self, create_progress_bar}, diff --git a/soar-core/Cargo.toml b/soar-core/Cargo.toml index e8704844b..98794069c 100644 --- a/soar-core/Cargo.toml +++ b/soar-core/Cargo.toml @@ -26,6 +26,7 @@ rusqlite = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } soar-dl = { workspace = true } +soar-utils = { workspace = true } squishy = { version = "0.3.2", features = ["appimage"] } thiserror = "2.0.16" toml = "0.9.6" diff --git a/soar-core/src/config.rs b/soar-core/src/config.rs index 97a782cb6..d96df09e9 100644 --- a/soar-core/src/config.rs +++ b/soar-core/src/config.rs @@ -7,6 +7,7 @@ use std::{ use documented::{Documented, DocumentedFields}; use serde::{de::Error, Deserialize, Serialize}; +use soar_utils::path::{home_dir, resolve_path, xdg_data_home}; use toml_edit::{DocumentMut, Item}; use tracing::{info, warn}; @@ -15,10 +16,7 @@ use crate::{ error::{ConfigError, SoarError}, repositories::get_platform_repositories, toml::{annotate_toml_array_of_tables, annotate_toml_table}, - utils::{ - build_path, default_install_patterns, get_platform, home_config_path, home_data_path, - parse_duration, - }, + utils::{default_install_patterns, get_platform, parse_duration}, SoarResult, }; use rusqlite::Connection; @@ -50,7 +48,7 @@ impl Profile { pub fn get_packages_path(&self) -> SoarResult { if let Some(ref packages_path) = self.packages_path { - build_path(packages_path) + Ok(resolve_path(packages_path)?) } else { Ok(self.get_root_path()?.join("packages")) } @@ -70,9 +68,9 @@ impl Profile { pub fn get_root_path(&self) -> SoarResult { if let Ok(env_path) = std::env::var("SOAR_ROOT") { - return build_path(&env_path); + return Ok(resolve_path(&env_path)?); } - build_path(&self.root_path) + Ok(resolve_path(&self.root_path)?) } } @@ -215,9 +213,7 @@ pub static CURRENT_PROFILE: LazyLock>> = LazyLock::new(|| pub static CONFIG_PATH: LazyLock> = LazyLock::new(|| { RwLock::new(match std::env::var("SOAR_CONFIG") { Ok(path_str) => PathBuf::from(path_str), - Err(_) => PathBuf::from(home_config_path()) - .join("soar") - .join("config.toml"), + Err(_) => home_dir().join("soar").join("config.toml"), }) }); @@ -268,8 +264,8 @@ pub fn set_current_profile(name: &str) -> Result<()> { impl Config { pub fn default_config>(external: bool, selected_repos: &[T]) -> Self { - let soar_root = - std::env::var("SOAR_ROOT").unwrap_or_else(|_| format!("{}/soar", home_data_path())); + let soar_root = std::env::var("SOAR_ROOT") + .unwrap_or_else(|_| format!("{}/soar", xdg_data_home().display())); let default_profile = Profile { root_path: soar_root.clone(), @@ -450,27 +446,27 @@ impl Config { pub fn get_bin_path(&self) -> SoarResult { if let Ok(env_path) = std::env::var("SOAR_BIN") { - return build_path(&env_path); + return Ok(resolve_path(&env_path)?); } if let Some(bin_path) = &self.bin_path { - return build_path(bin_path); + return Ok(resolve_path(bin_path)?); } self.default_profile()?.get_bin_path() } pub fn get_db_path(&self) -> SoarResult { if let Ok(env_path) = std::env::var("SOAR_DB") { - return build_path(&env_path); + return Ok(resolve_path(&env_path)?); } if let Some(soar_db) = &self.db_path { - return build_path(soar_db); + return Ok(resolve_path(soar_db)?); } self.default_profile()?.get_db_path() } pub fn get_packages_path(&self, profile_name: Option) -> SoarResult { if let Ok(env_path) = std::env::var("SOAR_PACKAGES") { - return build_path(&env_path); + return Ok(resolve_path(&env_path)?); } let profile_name = profile_name.unwrap_or_else(get_current_profile); self.get_profile(&profile_name)?.get_packages_path() @@ -478,31 +474,31 @@ impl Config { pub fn get_cache_path(&self) -> SoarResult { if let Ok(env_path) = std::env::var("SOAR_CACHE") { - return build_path(&env_path); + return Ok(resolve_path(&env_path)?); } if let Some(soar_cache) = &self.cache_path { - return build_path(soar_cache); + return Ok(resolve_path(soar_cache)?); } self.get_profile(&get_current_profile())?.get_cache_path() } pub fn get_repositories_path(&self) -> SoarResult { if let Ok(env_path) = std::env::var("SOAR_REPOSITORIES") { - return build_path(&env_path); + return Ok(resolve_path(&env_path)?); } if let Some(repositories_path) = &self.repositories_path { - return build_path(repositories_path); + return Ok(resolve_path(repositories_path)?); } self.default_profile()?.get_repositories_path() } pub fn get_portable_dirs(&self) -> SoarResult { if let Ok(env_path) = std::env::var("SOAR_PORTABLE_DIRS") { - return build_path(&env_path); + return Ok(resolve_path(&env_path)?); } if let Some(portable_dirs) = &self.portable_dirs { - return build_path(portable_dirs); + return Ok(resolve_path(portable_dirs)?); } self.default_profile()?.get_portable_dirs() } diff --git a/soar-core/src/error.rs b/soar-core/src/error.rs index 3596e6fae..08acfb434 100644 --- a/soar-core/src/error.rs +++ b/soar-core/src/error.rs @@ -1,3 +1,4 @@ +use soar_utils::error::{FileSystemError, HashError, PathError}; use std::error::Error; use thiserror::Error; @@ -48,6 +49,15 @@ pub enum SoarError { #[error("Environment variable error: {0}")] VarError(#[from] std::env::VarError), + #[error("{0}")] + FileSystemError(#[from] FileSystemError), + + #[error("{0}")] + HashError(#[from] HashError), + + #[error("{0}")] + PathError(#[from] PathError), + #[error("IO error while {action}: {source}")] IoError { action: String, diff --git a/soar-core/src/metadata.rs b/soar-core/src/metadata.rs index b3ad482cb..29ff38cc3 100644 --- a/soar-core/src/metadata.rs +++ b/soar-core/src/metadata.rs @@ -6,6 +6,7 @@ use std::{ use reqwest::header::{self, HeaderMap}; use rusqlite::Connection; +use soar_utils::fs::read_file_signature; use tracing::info; use crate::{ @@ -18,7 +19,7 @@ use crate::{ nests::models::Nest, }, error::{ErrorContext, SoarError}, - utils::{calc_magic_bytes, get_platform}, + utils::get_platform, SoarResult, }; @@ -128,7 +129,7 @@ fn process_metadata_content( io::copy(&mut decoder, &mut tmp_file) .with_context(|| format!("decoding zstd from {tmp_path}"))?; - let magic_bytes = calc_magic_bytes(&tmp_path, 4)?; + let magic_bytes = read_file_signature(&tmp_path, 4)?; if magic_bytes == SQLITE_MAGIC_BYTES { fs::rename(&tmp_path, metadata_db_path).with_context(|| { format!("renaming {} to {}", tmp_path, metadata_db_path.display()) diff --git a/soar-core/src/package/formats/appimage.rs b/soar-core/src/package/formats/appimage.rs index 0919d686b..f875f10c1 100644 --- a/soar-core/src/package/formats/appimage.rs +++ b/soar-core/src/package/formats/appimage.rs @@ -1,10 +1,10 @@ use std::{fs, path::Path}; +use soar_utils::fs::read_file_signature; use squishy::{appimage::AppImage, EntryKind}; use crate::{ - constants::PNG_MAGIC_BYTES, database::models::PackageExt, error::ErrorContext, - utils::calc_magic_bytes, SoarResult, + constants::PNG_MAGIC_BYTES, database::models::PackageExt, error::ErrorContext, SoarResult, }; use super::common::{symlink_desktop, symlink_icon}; @@ -31,7 +31,7 @@ pub async fn integrate_appimage, T: PackageExt>( let dest = format!("{}/{}.DirIcon", install_dir.display(), pkg_name); let _ = squashfs.write_file(basic_file, &dest); - let magic_bytes = calc_magic_bytes(&dest, 8)?; + let magic_bytes = read_file_signature(&dest, 8)?; let ext = if magic_bytes == PNG_MAGIC_BYTES { "png" } else { diff --git a/soar-core/src/package/formats/common.rs b/soar-core/src/package/formats/common.rs index 8802e03ae..f1e98926d 100644 --- a/soar-core/src/package/formats/common.rs +++ b/soar-core/src/package/formats/common.rs @@ -10,13 +10,17 @@ use image::{imageops::FilterType, DynamicImage, GenericImageView}; use regex::Regex; use soar_dl::downloader::{DownloadOptions, Downloader}; use soar_dl::utils::FileMode; +use soar_utils::{ + error::FileSystemResult, + fs::{create_symlink, read_file_signature, walk_dir}, + path::{desktop_dir, icons_dir}, +}; use crate::{ config::get_config, constants::PNG_MAGIC_BYTES, database::models::{Package, PackageExt}, error::{ErrorContext, SoarError}, - utils::{calc_magic_bytes, create_symlink, home_data_path, process_dir}, SoarResult, }; @@ -83,12 +87,14 @@ pub fn symlink_icon>(real_path: P) -> SoarResult { (w, h) }; - let final_path = PathBuf::from(format!( - "{}/icons/hicolor/{w}x{h}/apps/{}-soar.{}", - home_data_path(), - icon_name.to_string_lossy(), - ext.unwrap_or_default().to_string_lossy() - )); + let final_path = icons_dir() + .join(format!("{w}x{h}")) + .join("apps") + .join(format!( + "{}-soar.{}", + icon_name.to_string_lossy(), + ext.unwrap_or_default().to_string_lossy() + )); create_symlink(real_path, &final_path)?; Ok(final_path) @@ -133,11 +139,7 @@ pub fn symlink_desktop, T: PackageExt>( .write_all(final_content.as_bytes()) .with_context(|| format!("writing desktop file to {}", real_path.display()))?; - let final_path = PathBuf::from(format!( - "{}/applications/{}-soar.desktop", - home_data_path(), - file_name.to_string_lossy() - )); + let final_path = desktop_dir().join(format!("{}-soar.desktop", file_name.to_string_lossy())); create_symlink(real_path, &final_path)?; Ok(final_path) @@ -168,7 +170,7 @@ pub async fn integrate_remote>( }; downloader.download(options).await?; - let ext = if calc_magic_bytes(icon_output_path, 8)? == PNG_MAGIC_BYTES { + let ext = if read_file_signature(icon_output_path, 8)? == PNG_MAGIC_BYTES { "png" } else { "svg" @@ -308,25 +310,27 @@ pub async fn integrate_package, T: PackageExt>( let mut has_desktop = false; let mut has_icon = false; - let mut symlink_action = |path: &Path| -> SoarResult<()> { + let mut symlink_action = |path: &Path| -> FileSystemResult<()> { let ext = path.extension(); if ext == Some(OsStr::new("desktop")) { has_desktop = true; - symlink_desktop(path, package)?; + // FIXME: handle error + symlink_desktop(path, package).unwrap(); } Ok(()) }; - process_dir(install_dir, &mut symlink_action)?; + walk_dir(install_dir, &mut symlink_action)?; - let mut symlink_action = |path: &Path| -> SoarResult<()> { + let mut symlink_action = |path: &Path| -> FileSystemResult<()> { let ext = path.extension(); if ext == Some(OsStr::new("png")) || ext == Some(OsStr::new("svg")) { has_icon = true; - symlink_icon(path)?; + // FIXME: handle error + symlink_icon(path).unwrap(); } Ok(()) }; - process_dir(install_dir, &mut symlink_action)?; + walk_dir(install_dir, &mut symlink_action)?; let mut reader = BufReader::new( File::open(&bin_path).with_context(|| format!("opening {}", bin_path.display()))?, diff --git a/soar-core/src/package/install.rs b/soar-core/src/package/install.rs index c110c5d9f..092ef1512 100644 --- a/soar-core/src/package/install.rs +++ b/soar-core/src/package/install.rs @@ -13,6 +13,12 @@ use soar_dl::{ error::DownloadError, utils::FileMode, }; +use soar_utils::{ + error::FileSystemResult, + fs::{safe_remove, walk_dir}, + hash::calculate_checksum, + path::{desktop_dir, icons_dir}, +}; use crate::{ config::get_config, @@ -21,7 +27,7 @@ use crate::{ packages::{FilterCondition, PackageQueryBuilder, ProvideStrategy}, }, error::{ErrorContext, SoarError}, - utils::{calculate_checksum, desktop_dir, get_extract_dir, icons_dir, process_dir}, + utils::get_extract_dir, SoarResult, }; @@ -368,29 +374,25 @@ impl PackageInstaller { for package in alternate_packages { let installed_path = PathBuf::from(&package.installed_path); - let mut remove_action = |path: &Path| -> SoarResult<()> { + let mut remove_action = |path: &Path| -> FileSystemResult<()> { if let Ok(real_path) = fs::read_link(path) { if real_path.parent() == Some(&installed_path) { - fs::remove_file(path).with_context(|| { - format!("removing desktop file {}", path.display()) - })?; + safe_remove(path)?; } } Ok(()) }; - process_dir(desktop_dir(), &mut remove_action)?; + walk_dir(desktop_dir(), &mut remove_action)?; - let mut remove_action = |path: &Path| -> SoarResult<()> { + let mut remove_action = |path: &Path| -> FileSystemResult<()> { if let Ok(real_path) = fs::read_link(path) { if real_path.parent() == Some(&installed_path) { - fs::remove_file(path).with_context(|| { - format!("removing icon file {}", path.display()) - })?; + safe_remove(path)?; } } Ok(()) }; - process_dir(icons_dir(), &mut remove_action)?; + walk_dir(icons_dir(), &mut remove_action)?; if let Some(provides) = package.provides { for provide in provides { diff --git a/soar-core/src/package/remove.rs b/soar-core/src/package/remove.rs index 3ea40080c..7972b51b0 100644 --- a/soar-core/src/package/remove.rs +++ b/soar-core/src/package/remove.rs @@ -6,12 +6,12 @@ use std::{ }; use rusqlite::{params, Connection}; +use soar_utils::{error::FileSystemResult, fs::walk_dir, path::desktop_dir}; use crate::{ config::get_config, database::{models::InstalledPackage, packages::ProvideStrategy}, error::ErrorContext, - utils::{desktop_dir, icons_dir, process_dir}, SoarResult, }; @@ -60,7 +60,7 @@ impl PackageRemover { let installed_path = PathBuf::from(&self.package.installed_path); - let mut remove_action = |path: &Path| -> SoarResult<()> { + let mut remove_action = |path: &Path| -> FileSystemResult<()> { if path.extension() == Some(&OsString::from("desktop")) { if let Ok(real_path) = fs::read_link(path) { if real_path.parent() == Some(&installed_path) { @@ -70,9 +70,9 @@ impl PackageRemover { } Ok(()) }; - process_dir(desktop_dir(), &mut remove_action)?; + walk_dir(desktop_dir(), &mut remove_action)?; - let mut remove_action = |path: &Path| -> SoarResult<()> { + let mut remove_action = |path: &Path| -> FileSystemResult<()> { if let Ok(real_path) = fs::read_link(path) { if real_path.parent() == Some(&installed_path) { let _ = fs::remove_file(path); @@ -80,7 +80,7 @@ impl PackageRemover { } Ok(()) }; - process_dir(icons_dir(), &mut remove_action)?; + walk_dir(desktop_dir(), &mut remove_action)?; } if let Err(err) = fs::remove_dir_all(&self.package.installed_path) { diff --git a/soar-core/src/utils.rs b/soar-core/src/utils.rs index 5a2315b7c..5664cc2d9 100644 --- a/soar-core/src/utils.rs +++ b/soar-core/src/utils.rs @@ -1,136 +1,24 @@ use std::{ - env::{ - self, - consts::{ARCH, OS}, - }, - fs::{self, File}, - io::{self, BufReader, Read, Seek}, - os, + env::consts::{ARCH, OS}, + fs, path::{Path, PathBuf}, }; -use nix::unistd::{geteuid, User}; use regex::Regex; +use soar_utils::{ + error::{FileSystemError, FileSystemResult}, + fs::walk_dir, + path::{desktop_dir, icons_dir}, +}; use tracing::info; use crate::{ config::get_config, error::{ErrorContext, SoarError}, - SoarResult, }; type Result = std::result::Result; -fn get_username() -> Result { - let uid = geteuid(); - User::from_uid(uid)? - .ok_or_else(|| panic!("Failed to get user")) - .map(|user| user.name) -} - -pub fn home_path() -> String { - env::var("HOME").unwrap_or_else(|_| { - let username = env::var("USER") - .or_else(|_| env::var("LOGNAME")) - .or_else(|_| get_username().map_err(|_| ())) - .unwrap_or_else(|_| panic!("Couldn't determine username. Please fix the system.")); - format!("/home/{username}") - }) -} - -pub fn home_config_path() -> String { - env::var("XDG_CONFIG_HOME").unwrap_or(format!("{}/.config", home_path())) -} - -pub fn home_cache_path() -> String { - env::var("XDG_CACHE_HOME").unwrap_or(format!("{}/.cache", home_path())) -} - -pub fn home_data_path() -> String { - env::var("XDG_DATA_HOME").unwrap_or(format!("{}/.local/share", home_path())) -} - -/// Expands the environment variables and user home directory in a given path. -pub fn build_path(path: &str) -> Result { - let mut result = String::new(); - let mut chars = path.chars().peekable(); - - while let Some(c) = chars.next() { - if c == '$' { - let mut var_name = String::new(); - while let Some(&c) = chars.peek() { - if !c.is_alphanumeric() && c != '_' { - break; - } - var_name.push(chars.next().unwrap()); - } - if !var_name.is_empty() { - let expanded = if var_name == "HOME" { - home_path() - } else { - env::var(&var_name)? - }; - result.push_str(&expanded); - } else { - result.push('$'); - } - } else if c == '~' && result.is_empty() { - result.push_str(&home_path()) - } else { - result.push(c); - } - } - - Ok(PathBuf::from(result)) -} - -pub fn format_bytes(bytes: u64) -> String { - let kb = 1024u64; - let mb = kb * 1024; - let gb = mb * 1024; - - match bytes { - b if b >= gb => format!("{:.2} GiB", b as f64 / gb as f64), - b if b >= mb => format!("{:.2} MiB", b as f64 / mb as f64), - b if b >= kb => format!("{:.2} KiB", b as f64 / kb as f64), - _ => format!("{bytes} B"), - } -} - -pub fn parse_size(size_str: &str) -> Option { - let size_str = size_str.trim(); - let units = [ - ("B", 1u64), - ("KB", 1000u64), - ("MB", 1000u64 * 1000), - ("GB", 1000u64 * 1000 * 1000), - ("KiB", 1024u64), - ("MiB", 1024u64 * 1024), - ("GiB", 1024u64 * 1024 * 1024), - ]; - - for (unit, multiplier) in &units { - let size_str = size_str.to_uppercase(); - if size_str.ends_with(unit) { - let number_part = size_str.trim_end_matches(unit).trim(); - if let Ok(num) = number_part.parse::() { - return Some((num * (*multiplier as f64)) as u64); - } - } - } - - None -} - -pub fn calculate_checksum>(file_path: P) -> Result { - let file_path = file_path.as_ref(); - let mut hasher = blake3::Hasher::new(); - hasher - .update_mmap(file_path) - .with_context(|| format!("reading {} using memory mapping", file_path.display()))?; - Ok(hasher.finalize().to_hex().to_string()) -} - pub fn setup_required_paths() -> Result<()> { let config = get_config(); let bin_path = config.get_bin_path()?; @@ -157,34 +45,6 @@ pub fn setup_required_paths() -> Result<()> { Ok(()) } -pub fn calc_magic_bytes>(file_path: P, size: usize) -> Result> { - let file_path = file_path.as_ref(); - let file = File::open(file_path).with_context(|| format!("opening {}", file_path.display()))?; - let mut file = BufReader::new(file); - let mut magic_bytes = vec![0u8; size]; - file.read_exact(&mut magic_bytes) - .with_context(|| format!("reading magic bytes from {}", file_path.display()))?; - file.rewind().unwrap(); - Ok(magic_bytes) -} - -pub fn create_symlink>(from: P, to: P) -> SoarResult<()> { - let from = from.as_ref(); - let to = to.as_ref(); - - if let Some(parent) = to.parent() { - fs::create_dir_all(parent) - .with_context(|| format!("creating parent directory {}", parent.display()))?; - } - - if to.is_symlink() { - fs::remove_file(to).with_context(|| format!("removing symlink {}", to.display()))?; - } - os::unix::fs::symlink(from, to) - .with_context(|| format!("creating symlink {} -> {}", from.display(), to.display()))?; - Ok(()) -} - pub fn cleanup_cache() -> Result<()> { let cache_path = get_config().get_cache_path()?; if cache_path.exists() { @@ -198,44 +58,20 @@ pub fn cleanup_cache() -> Result<()> { Ok(()) } -pub fn process_dir, F>(dir: P, action: &mut F) -> Result<()> -where - F: FnMut(&Path) -> Result<()>, -{ - let dir = dir.as_ref(); - if !dir.is_dir() { - return Ok(()); - } - - for entry in - fs::read_dir(dir).with_context(|| format!("reading directory {}", dir.display()))? - { - let path = entry - .with_context(|| format!("reading entry from directory {}", dir.display()))? - .path(); - - if path.is_dir() { - process_dir(&path, action)?; - continue; - } - - action(&path)?; - } - - Ok(()) -} - -fn remove_action(path: &Path) -> Result<()> { +fn remove_action(path: &Path) -> FileSystemResult<()> { if !path.exists() { - fs::remove_file(path) - .with_context(|| format!("removing broken symlink {}", path.display()))?; + fs::remove_file(path).map_err(|err| FileSystemError::File { + path: path.to_path_buf(), + action: "remove", + source: err, + })?; info!("Removed broken symlink: {}", path.display()); } Ok(()) } pub fn remove_broken_symlinks() -> Result<()> { - let mut soar_files_action = |path: &Path| -> SoarResult<()> { + let mut soar_files_action = |path: &Path| -> FileSystemResult<()> { if let Some(filename) = path.file_stem().and_then(|s| s.to_str()) { if filename.ends_with("-soar") { return remove_action(path); @@ -244,21 +80,13 @@ pub fn remove_broken_symlinks() -> Result<()> { Ok(()) }; - process_dir(&get_config().get_bin_path()?, &mut remove_action)?; - process_dir(desktop_dir(), &mut soar_files_action)?; - process_dir(icons_dir(), &mut soar_files_action)?; + walk_dir(&get_config().get_bin_path()?, &mut remove_action)?; + walk_dir(desktop_dir(), &mut soar_files_action)?; + walk_dir(icons_dir(), &mut soar_files_action)?; Ok(()) } -pub fn desktop_dir() -> String { - format!("{}/applications", home_data_path()) -} - -pub fn icons_dir() -> String { - format!("{}/icons/hicolor", home_data_path()) -} - /// Retrieves the platform string in the format `ARCH-Os`. /// /// This function combines the architecture (e.g., `x86_64`) and the operating @@ -267,30 +95,6 @@ pub fn get_platform() -> String { format!("{}-{}{}", ARCH, &OS[..1].to_uppercase(), &OS[1..]) } -pub fn calculate_dir_size>(path: P) -> io::Result { - let mut total_size = 0; - let path = path.as_ref(); - - if path.is_dir() { - for entry in fs::read_dir(path)? { - let Ok(entry) = entry else { - continue; - }; - let Ok(metadata) = entry.metadata() else { - continue; - }; - - if metadata.is_file() { - total_size += metadata.len(); - } else if metadata.is_dir() { - total_size += calculate_dir_size(entry.path())?; - } - } - } - - Ok(total_size) -} - pub fn parse_duration(input: &str) -> Option { let re = Regex::new(r"(\d+)([smhd])").ok()?; let mut total: u128 = 0; From 8a2053f20c07babb40de3e3144377a3292425c90 Mon Sep 17 00:00:00 2001 From: Rabindra Dhakal Date: Tue, 23 Sep 2025 22:27:57 +0545 Subject: [PATCH 08/12] add tests --- Cargo.lock | 13 -- crates/soar-utils/src/fs.rs | 292 +++++++++++++++++++++++++++++++++- crates/soar-utils/src/path.rs | 16 ++ soar-core/Cargo.toml | 4 - 4 files changed, 306 insertions(+), 19 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index fdabe3427..dfbd3bf50 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -277,15 +277,6 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" -[[package]] -name = "chrono" -version = "0.4.42" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2" -dependencies = [ - "num-traits", -] - [[package]] name = "cipher" version = "0.4.4" @@ -2201,14 +2192,10 @@ name = "soar-core" version = "0.8.1" dependencies = [ "blake3", - "chrono", "documented", - "futures", "image", "include_dir", "nix", - "once_cell", - "rayon", "regex", "reqwest", "rusqlite", diff --git a/crates/soar-utils/src/fs.rs b/crates/soar-utils/src/fs.rs index 59dac14ab..f10ee5b57 100644 --- a/crates/soar-utils/src/fs.rs +++ b/crates/soar-utils/src/fs.rs @@ -94,7 +94,7 @@ pub fn ensure_dir_exists>(path: P) -> FileSystemResult<()> { } /// Creates symlink from `source` to `target` -/// If `target` is a symlink, it will be removed before creating the symlink. +/// If `target` is a file, it will be removed before creating the symlink. /// /// # Arguments /// @@ -128,7 +128,7 @@ pub fn create_symlink, Q: AsRef>( ensure_dir_exists(parent)?; } - if target.is_symlink() { + if target.is_file() { fs::remove_file(target).map_err(|err| FileSystemError::File { path: target.to_path_buf(), action: "remove", @@ -300,6 +300,8 @@ pub fn dir_size>(path: P) -> FileSystemResult { #[cfg(test)] mod tests { + use std::{fs::Permissions, os::unix::fs::PermissionsExt}; + use super::*; use tempfile::tempdir; @@ -415,4 +417,290 @@ mod tests { perms.set_readonly(false); fs::set_permissions(&sub_dir, perms).unwrap(); } + + #[test] + fn test_create_symlink() { + let dir = tempdir().unwrap(); + let source = dir.path().join("source"); + let target = dir.path().join("target"); + fs::write(&source, "content").unwrap(); + create_symlink(&source, &target).unwrap(); + assert!(target.is_symlink()); + assert_eq!(fs::read_link(&target).unwrap(), source); + } + + #[test] + fn test_create_symlink_already_exists() { + let dir = tempdir().unwrap(); + let source = dir.path().join("source"); + let target = dir.path().join("target"); + fs::write(&source, "content").unwrap(); + fs::write(&target, "content").unwrap(); + create_symlink(&source, &target).unwrap(); + assert!(target.is_symlink()); + assert_eq!(fs::read_link(&target).unwrap(), source); + } + + #[test] + fn test_create_symlink_permission_denied() { + let dir = tempdir().unwrap(); + let source = dir.path().join("source"); + let target = dir.path().join("target"); + fs::write(&source, "content").unwrap(); + + // Set read-only permissions on the parent directory. + let mut perms = fs::metadata(dir.path()).unwrap().permissions(); + perms.set_readonly(true); + fs::set_permissions(dir.path(), perms).unwrap(); + + let result = create_symlink(&source, &target); + assert!(result.is_err()); + + // Cleanup: Set back to writable to allow tempdir to be removed. + let mut perms = fs::metadata(dir.path()).unwrap().permissions(); + perms.set_readonly(false); + fs::set_permissions(dir.path(), perms).unwrap(); + } + + #[test] + fn test_walk_dir() { + let tempdir = tempfile::tempdir().unwrap(); + let dir = tempdir.path().join("dir"); + fs::create_dir(&dir).unwrap(); + let file = dir.join("file"); + fs::File::create(&file).unwrap(); + + let mut results = Vec::new(); + walk_dir(&dir, &mut |path| { + results.push(path.to_path_buf()); + Ok(()) + }) + .unwrap(); + + assert_eq!(results, vec![file]); + } + + #[test] + fn test_walk_dir_not_a_dir() { + let tempdir = tempfile::tempdir().unwrap(); + let file = tempdir.path().join("file"); + fs::File::create(&file).unwrap(); + + let result = walk_dir(&file, &mut |_| Ok(())); + assert!(result.is_err()); + } + + #[test] + fn test_walk_recursive_dir() { + let tempdir = tempfile::tempdir().unwrap(); + let dir = tempdir.path().join("dir"); + fs::create_dir(&dir).unwrap(); + let file = dir.join("file"); + File::create(&file).unwrap(); + + let nested_dir = dir.join("nested"); + fs::create_dir(&nested_dir).unwrap(); + let nested_file = nested_dir.join("file"); + File::create(&nested_file).unwrap(); + + let mut results = Vec::new(); + walk_dir(&dir, &mut |path| { + results.push(path.to_path_buf()); + Ok(()) + }) + .unwrap(); + + assert_eq!(results, vec![file, nested_file]); + } + + #[test] + fn test_walk_failing_entry() { + let tempdir = tempfile::tempdir().unwrap(); + let dir = tempdir.path().join("dir"); + fs::create_dir(&dir).unwrap(); + let file = dir.join("file"); + File::create(&file).unwrap(); + + let mut results = Vec::new(); + walk_dir(&dir, &mut |path| { + results.push(path.to_path_buf()); + Err(FileSystemError::File { + path: path.to_path_buf(), + action: "read", + source: std::io::Error::from(std::io::ErrorKind::Other), + }) + }) + .ok(); + + assert_eq!(results, vec![file]); + } + + #[test] + fn test_walk_invalid_dir() { + let result = walk_dir("/this/path/does/not/exist", &mut |_| Ok(())); + assert!(result.is_err()); + } + + #[test] + fn test_walk_dir_permission_denied() { + let tempdir = tempfile::tempdir().unwrap(); + let dir = tempdir.path(); + + fs::set_permissions(dir, Permissions::from_mode(0o000)).unwrap(); + + let result = walk_dir(dir, &mut |_| Ok(())); + + fs::set_permissions(dir, Permissions::from_mode(0o755)).unwrap(); + assert!(result.is_err()); + } + + #[test] + fn test_walk_dir_permission_denied_recursive() { + let tempdir = tempfile::tempdir().unwrap(); + let dir = tempdir.path(); + let nested_dir = dir.join("nested"); + fs::create_dir(&nested_dir).unwrap(); + + fs::set_permissions(&nested_dir, Permissions::from_mode(0o000)).unwrap(); + + let result = walk_dir(dir, &mut |_| Ok(())); + + fs::set_permissions(nested_dir, Permissions::from_mode(0o755)).unwrap(); + assert!(result.is_err()); + } + + #[test] + fn test_read_file_signature() { + let tempdir = tempfile::tempdir().unwrap(); + let file = tempdir.path().join("file"); + File::create(&file).unwrap(); + fs::write(&file, b"sample test content").unwrap(); + + let signature = read_file_signature(&file, 8).unwrap(); + assert_eq!(signature.len(), 8); + assert_eq!(signature, b"sample t"); + } + + #[test] + fn test_read_file_signature_empty() { + let tempdir = tempfile::tempdir().unwrap(); + let file = tempdir.path().join("file"); + File::create(&file).unwrap(); + + let signature = read_file_signature(&file, 0).unwrap(); + assert!(signature.is_empty()); + } + + #[test] + fn test_read_file_signature_invalid() { + let tempdir = tempfile::tempdir().unwrap(); + let file = tempdir.path().join("file"); + File::create(&file).unwrap(); + + let result = read_file_signature(&file, 1024); + assert!(result.is_err()); + } + + #[test] + fn test_read_file_signature_non_existent() { + let result = read_file_signature("/this/path/does/not/exist", 1024); + assert!(result.is_err()); + } + + #[test] + fn test_calculate_directory_size() { + let tempdir = tempfile::tempdir().unwrap(); + let dir = tempdir.path().join("dir"); + fs::create_dir(&dir).unwrap(); + + let file = dir.join("file"); + File::create(&file).unwrap(); + fs::write(&file, b"sample test content").unwrap(); // 19 bytes + + let nested_dir = dir.join("nested"); + fs::create_dir(&nested_dir).unwrap(); + + let nested_file = nested_dir.join("file"); + File::create(&nested_file).unwrap(); + fs::write(&nested_file, b"sample test content").unwrap(); + + let size = dir_size(&dir).unwrap(); + assert_eq!(size, 38); + } + + #[test] + fn test_calculate_directory_size_empty() { + let tempdir = tempfile::tempdir().unwrap(); + let dir = tempdir.path().join("dir"); + fs::create_dir(&dir).unwrap(); + + let size = dir_size(&dir).unwrap(); + assert_eq!(size, 0); + } + + #[test] + fn test_calculate_directory_size_invalid() { + let result = dir_size("/this/path/does/not/exist"); + assert!(result.is_err()); + } + + #[test] + fn test_calculate_directory_size_inner_permission_denied() { + let tempdir = tempfile::tempdir().unwrap(); + let dir = tempdir.path(); + let inner_dir = dir.join("inner"); + ensure_dir_exists(&inner_dir).unwrap(); + + fs::set_permissions(&inner_dir, Permissions::from_mode(0o000)).unwrap(); + + let result = dir_size(dir); + assert!(result.is_err()); + + // Cleanup: Set back to writable to allow tempdir to be removed. + fs::set_permissions(inner_dir, Permissions::from_mode(0o755)).unwrap(); + } + + #[test] + fn test_create_symlink_inner_target() { + let tempdir = tempfile::tempdir().unwrap(); + let source = tempdir.path().join("source"); + let target = tempdir.path().join("inner").join("target"); + + let result = create_symlink(&source, &target); + assert!(result.is_ok()); + } + + #[test] + fn test_create_symlink_target_invalid_parent() { + let tempdir = tempfile::tempdir().unwrap(); + let source = tempdir.path().join("source"); + + let file = tempdir.path().join("file"); + File::create(&file).unwrap(); + let target = tempdir.path().join("file").join("target"); + + let result = create_symlink(&source, &target); + assert!(result.is_err()); + } + + #[test] + fn test_create_symlink_target_no_permissions() { + let tempdir = tempfile::tempdir().unwrap(); + let source = tempdir.path().join("source"); + let target = tempdir.path().join("target"); + File::create(&target).unwrap(); + + // Set read-only permissions on the parent directory. + let mut perms = fs::metadata(tempdir.path()).unwrap().permissions(); + perms.set_readonly(true); + fs::set_permissions(tempdir.path(), perms).unwrap(); + + let result = create_symlink(&source, &target); + assert!(result.is_err()); + + // Cleanup: Set back to writable to allow tempdir to be removed. + let mut perms = fs::metadata(tempdir.path()).unwrap().permissions(); + perms.set_readonly(false); + fs::set_permissions(tempdir.path(), perms).unwrap(); + } } diff --git a/crates/soar-utils/src/path.rs b/crates/soar-utils/src/path.rs index 17703a827..6654c2c41 100644 --- a/crates/soar-utils/src/path.rs +++ b/crates/soar-utils/src/path.rs @@ -436,4 +436,20 @@ mod tests { env::remove_var("HOME"); } + + #[test] + #[serial] + fn test_desktop_dir() { + env::set_var("XDG_DATA_HOME", "/tmp/data"); + let desktop = desktop_dir(); + assert_eq!(desktop, PathBuf::from("/tmp/data/applications")); + } + + #[test] + #[serial] + fn test_icons_dir() { + env::set_var("XDG_DATA_HOME", "/tmp/data"); + let icons = icons_dir(); + assert_eq!(icons, PathBuf::from("/tmp/data/icons/hicolor")); + } } diff --git a/soar-core/Cargo.toml b/soar-core/Cargo.toml index 98794069c..584a31cff 100644 --- a/soar-core/Cargo.toml +++ b/soar-core/Cargo.toml @@ -12,14 +12,10 @@ categories.workspace = true [dependencies] blake3 = { version = "1.8.2", features = ["mmap"] } -chrono = { version = "0.4.42", default-features = false, features = ["now"] } documented = "0.9.2" -futures = { workspace = true } image = { version = "0.25.8", default-features = false, features = ["png"] } include_dir = "0.7.4" nix = { version = "0.30.1", features = ["ioctl", "term", "user"] } -once_cell = "1.21.3" -rayon = { workspace = true } regex = { workspace = true } reqwest = { workspace = true } rusqlite = { workspace = true } From c77235518d98823890c70713e918e2b8081d1e04 Mon Sep 17 00:00:00 2001 From: Rabindra Dhakal Date: Tue, 23 Sep 2025 23:06:16 +0545 Subject: [PATCH 09/12] move more utilities --- Cargo.lock | 1 - crates/soar-utils/src/lib.rs | 3 + crates/soar-utils/src/pattern.rs | 64 +++++++++++++++++ crates/soar-utils/src/system.rs | 28 ++++++++ crates/soar-utils/src/time.rs | 91 +++++++++++++++++++++++++ soar-cli/src/install.rs | 4 +- soar-cli/src/utils.rs | 8 +-- soar-core/Cargo.toml | 1 - soar-core/src/config.rs | 10 ++- soar-core/src/metadata.rs | 7 +- soar-core/src/package/formats/common.rs | 2 +- soar-core/src/utils.rs | 51 -------------- 12 files changed, 202 insertions(+), 68 deletions(-) create mode 100644 crates/soar-utils/src/pattern.rs create mode 100644 crates/soar-utils/src/system.rs create mode 100644 crates/soar-utils/src/time.rs diff --git a/Cargo.lock b/Cargo.lock index dfbd3bf50..50c1c8f8c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2191,7 +2191,6 @@ dependencies = [ name = "soar-core" version = "0.8.1" dependencies = [ - "blake3", "documented", "image", "include_dir", diff --git a/crates/soar-utils/src/lib.rs b/crates/soar-utils/src/lib.rs index 4dec02d3f..237852908 100644 --- a/crates/soar-utils/src/lib.rs +++ b/crates/soar-utils/src/lib.rs @@ -3,4 +3,7 @@ pub mod error; pub mod fs; pub mod hash; pub mod path; +pub mod pattern; +pub mod system; +pub mod time; pub mod user; diff --git a/crates/soar-utils/src/pattern.rs b/crates/soar-utils/src/pattern.rs new file mode 100644 index 000000000..11281f38c --- /dev/null +++ b/crates/soar-utils/src/pattern.rs @@ -0,0 +1,64 @@ +/// Applies the `.sig` variant to a list of patterns. +/// +/// This function takes a list of patterns and appends the `.sig` variant to +/// each pattern. If the pattern starts with `!`, the pattern is negated. +/// +/// # Arguments +/// * `patterns` - A vector of patterns to apply the `.sig` variant to. +/// +/// # Returns +/// A vector of patterns with the `.sig` variant applied. +/// +/// # Examples +/// +/// ``` +/// use soar_utils::pattern::apply_sig_variants; +/// +/// let patterns = vec!["foo", "!bar", "baz"] +/// .into_iter() +/// .map(String::from) +/// .collect(); +/// let sig_variants = apply_sig_variants(patterns); +/// +/// assert_eq!(sig_variants, vec!["{foo,foo.sig}", "!{bar,bar.sig}", "{baz,baz.sig}"]); +/// ``` +pub fn apply_sig_variants(patterns: Vec) -> Vec { + patterns + .into_iter() + .map(|pat| { + let (negate, inner) = if let Some(rest) = pat.strip_prefix('!') { + (true, rest) + } else { + (false, pat.as_str()) + }; + + let sig_variant = format!("{inner}.sig"); + let brace_pattern = format!("{{{inner},{sig_variant}}}"); + + if negate { + format!("!{brace_pattern}") + } else { + brace_pattern + } + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_apply_sig_variants() { + let patterns = vec!["foo", "!bar", "baz"] + .into_iter() + .map(String::from) + .collect(); + let sig_variants = apply_sig_variants(patterns); + + assert_eq!( + sig_variants, + vec!["{foo,foo.sig}", "!{bar,bar.sig}", "{baz,baz.sig}"] + ); + } +} diff --git a/crates/soar-utils/src/system.rs b/crates/soar-utils/src/system.rs new file mode 100644 index 000000000..f0c343222 --- /dev/null +++ b/crates/soar-utils/src/system.rs @@ -0,0 +1,28 @@ +/// Retrieves the platform string in the format `ARCH-Os`. +/// +/// This function combines the architecture (e.g., `x86_64`) and the operating +/// system (e.g., `Linux`) into a single string to identify the platform. +pub fn platform() -> String { + format!( + "{}-{}{}", + std::env::consts::ARCH, + &std::env::consts::OS[..1].to_uppercase(), + &std::env::consts::OS[1..] + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_platform() { + #[cfg(target_arch = "x86_64")] + #[cfg(target_os = "linux")] + assert_eq!(platform(), "x86_64-Linux"); + + #[cfg(target_arch = "aarch64")] + #[cfg(target_os = "linux")] + assert_eq!(platform(), "aarch64-Linux"); + } +} diff --git a/crates/soar-utils/src/time.rs b/crates/soar-utils/src/time.rs new file mode 100644 index 000000000..098746bb7 --- /dev/null +++ b/crates/soar-utils/src/time.rs @@ -0,0 +1,91 @@ +/// Parses a duration string into a number of milliseconds. +/// +/// This function takes a string in the format `1d1h1m1s` and parses it into +/// a number of milliseconds. The string can contain any number of digits, +/// followed by any combination of the letters `s`, `m`, `h`, and `d` to +/// represent seconds, minutes, hours, and days, respectively. +/// +/// # Arguments +/// * `input` - A string in the format `1d1h1m1s1`. +/// +/// # Returns +/// A number of milliseconds, or `None` if the input string is invalid. +/// If the integer overflows, the function returns `None`. +/// +/// # Examples +/// +/// ``` +/// use soar_utils::time::parse_duration; +/// +/// let duration = parse_duration("1d1h1m1s"); +/// println!("Duration: {}", duration.unwrap()); +/// ``` +pub fn parse_duration(input: &str) -> Option { + let mut total: u128 = 0; + let mut chars = input.chars().peekable(); + + while chars.peek().is_some() { + let mut number_str = String::new(); + while let Some(c) = chars.peek() { + if c.is_ascii_digit() { + number_str.push(chars.next()?); + } else { + break; + } + } + + if number_str.is_empty() { + return None; + } + + let number: u128 = number_str.parse().ok()?; + let multiplier = match chars.next()? { + 's' => 1000, + 'm' => 60 * 1000, + 'h' => 60 * 60 * 1000, + 'd' => 24 * 60 * 60 * 1000, + _ => return None, + }; + + total += number * multiplier; + } + + Some(total) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_duration() { + assert_eq!(parse_duration("1s"), Some(1000)); + assert_eq!(parse_duration("1m"), Some(60 * 1000)); + assert_eq!(parse_duration("1h"), Some(60 * 60 * 1000)); + assert_eq!(parse_duration("1d"), Some(24 * 60 * 60 * 1000)); + assert_eq!( + parse_duration("1d1h"), + Some(24 * 60 * 60 * 1000 + 60 * 60 * 1000) + ); + assert_eq!( + parse_duration("1d1h1m"), + Some(24 * 60 * 60 * 1000 + 60 * 60 * 1000 + 60 * 1000) + ); + assert_eq!( + parse_duration("1d1h1m1s"), + Some(24 * 60 * 60 * 1000 + 60 * 60 * 1000 + 60 * 1000 + 1000) + ); + assert_eq!(parse_duration("1d1h1m1s1"), None); + assert_eq!(parse_duration("1d1h1m1s1a"), None); + assert_eq!(parse_duration("fail"), None); + assert_eq!(parse_duration(""), Some(0)); + } + + #[test] + fn test_integer_overflow() { + assert_eq!( + parse_duration("340282366920938463463374607431768211456"), + None + ); + } +} diff --git a/soar-cli/src/install.rs b/soar-cli/src/install.rs index 8a44c3267..31ab3e17e 100644 --- a/soar-cli/src/install.rs +++ b/soar-cli/src/install.rs @@ -26,11 +26,11 @@ use soar_core::{ install::{InstallTarget, PackageInstaller}, query::PackageQuery, }, - utils::{apply_sig_variants, default_install_patterns}, + utils::default_install_patterns, SoarResult, }; use soar_dl::downloader::DownloadState; -use soar_utils::hash::calculate_checksum; +use soar_utils::{hash::calculate_checksum, pattern::apply_sig_variants}; use tokio::sync::Semaphore; use tracing::{error, info, warn}; diff --git a/soar-cli/src/utils.rs b/soar-cli/src/utils.rs index f190ec754..3cb73cb8a 100644 --- a/soar-cli/src/utils.rs +++ b/soar-cli/src/utils.rs @@ -20,10 +20,10 @@ use soar_core::{ error::{ErrorContext, SoarError}, package::install::InstallTarget, repositories::get_platform_repositories, - utils::get_platform, SoarResult, }; use soar_dl::utils::{is_elf, FileMode}; +use soar_utils::system::platform; use tracing::{error, info}; pub static COLOR: LazyLock> = LazyLock::new(|| RwLock::new(true)); @@ -268,11 +268,9 @@ pub async fn mangle_package_symlinks( pub fn parse_default_repos_arg(arg: &str) -> SoarResult { let repo = arg.trim().to_lowercase(); - let platform = get_platform(); - let supported_repos: Vec<&str> = get_platform_repositories() .into_iter() - .filter(|repo| repo.platforms.contains(&platform.as_str())) + .filter(|repo| repo.platforms.contains(&platform().as_str())) .map(|repo| repo.name) .collect(); @@ -282,7 +280,7 @@ pub fn parse_default_repos_arg(arg: &str) -> SoarResult { Err(SoarError::Custom(format!( "Invalid repository '{}'. Valid options for this platform ({}) are: {}", repo, - platform, + platform(), supported_repos.join(", ") ))) } diff --git a/soar-core/Cargo.toml b/soar-core/Cargo.toml index 584a31cff..5a6301e3d 100644 --- a/soar-core/Cargo.toml +++ b/soar-core/Cargo.toml @@ -11,7 +11,6 @@ readme.workspace = true categories.workspace = true [dependencies] -blake3 = { version = "1.8.2", features = ["mmap"] } documented = "0.9.2" image = { version = "0.25.8", default-features = false, features = ["png"] } include_dir = "0.7.4" diff --git a/soar-core/src/config.rs b/soar-core/src/config.rs index d96df09e9..dd148084b 100644 --- a/soar-core/src/config.rs +++ b/soar-core/src/config.rs @@ -7,7 +7,11 @@ use std::{ use documented::{Documented, DocumentedFields}; use serde::{de::Error, Deserialize, Serialize}; -use soar_utils::path::{home_dir, resolve_path, xdg_data_home}; +use soar_utils::{ + path::{home_dir, resolve_path, xdg_data_home}, + system::platform, + time::parse_duration, +}; use toml_edit::{DocumentMut, Item}; use tracing::{info, warn}; @@ -16,7 +20,7 @@ use crate::{ error::{ConfigError, SoarError}, repositories::get_platform_repositories, toml::{annotate_toml_array_of_tables, annotate_toml_table}, - utils::{default_install_patterns, get_platform, parse_duration}, + utils::default_install_patterns, SoarResult, }; use rusqlite::Connection; @@ -273,7 +277,7 @@ impl Config { }; let default_profile_name = "default".to_string(); - let current_platform = get_platform(); + let current_platform = platform(); let mut repositories = Vec::new(); let selected_set: HashSet<&str> = selected_repos.iter().map(|s| s.as_ref()).collect(); diff --git a/soar-core/src/metadata.rs b/soar-core/src/metadata.rs index 29ff38cc3..af3514029 100644 --- a/soar-core/src/metadata.rs +++ b/soar-core/src/metadata.rs @@ -6,7 +6,7 @@ use std::{ use reqwest::header::{self, HeaderMap}; use rusqlite::Connection; -use soar_utils::fs::read_file_signature; +use soar_utils::{fs::read_file_signature, system::platform}; use tracing::info; use crate::{ @@ -19,16 +19,15 @@ use crate::{ nests::models::Nest, }, error::{ErrorContext, SoarError}, - utils::get_platform, SoarResult, }; fn construct_nest_url(url: &str) -> SoarResult { let url = if let Some(repo) = url.strip_prefix("github:") { - let platform = get_platform(); format!( "/{}/releases/download/soar-nest/{}.json", - repo, platform + repo, + platform() ) } else { url.to_string() diff --git a/soar-core/src/package/formats/common.rs b/soar-core/src/package/formats/common.rs index f1e98926d..13da0080c 100644 --- a/soar-core/src/package/formats/common.rs +++ b/soar-core/src/package/formats/common.rs @@ -220,7 +220,7 @@ pub fn create_portable_link>( fs::create_dir_all(&portable_path) .with_context(|| format!("creating directory {}", portable_path.display()))?; - create_symlink(&portable_path, &real_path.as_ref().to_path_buf())?; + create_symlink(&portable_path, real_path)?; Ok(()) } diff --git a/soar-core/src/utils.rs b/soar-core/src/utils.rs index 5664cc2d9..041b860f4 100644 --- a/soar-core/src/utils.rs +++ b/soar-core/src/utils.rs @@ -1,10 +1,8 @@ use std::{ - env::consts::{ARCH, OS}, fs, path::{Path, PathBuf}, }; -use regex::Regex; use soar_utils::{ error::{FileSystemError, FileSystemResult}, fs::walk_dir, @@ -87,33 +85,6 @@ pub fn remove_broken_symlinks() -> Result<()> { Ok(()) } -/// Retrieves the platform string in the format `ARCH-Os`. -/// -/// This function combines the architecture (e.g., `x86_64`) and the operating -/// system (e.g., `Linux`) into a single string to identify the platform. -pub fn get_platform() -> String { - format!("{}-{}{}", ARCH, &OS[..1].to_uppercase(), &OS[1..]) -} - -pub fn parse_duration(input: &str) -> Option { - let re = Regex::new(r"(\d+)([smhd])").ok()?; - let mut total: u128 = 0; - - for cap in re.captures_iter(input) { - let number: u128 = cap[1].parse().ok()?; - let multiplier = match &cap[2] { - "s" => 1000, - "m" => 60 * 1000, - "h" => 60 * 60 * 1000, - "d" => 24 * 60 * 60 * 1000, - _ => return None, - }; - total += number * multiplier; - } - - Some(total) -} - pub fn default_install_patterns() -> Vec { ["!*.log", "!SBUILD", "!*.json", "!*.version"] .into_iter() @@ -125,25 +96,3 @@ pub fn get_extract_dir>(base_dir: P) -> PathBuf { let base_dir = base_dir.as_ref(); base_dir.join("SOAR_AUTOEXTRACT") } - -pub fn apply_sig_variants(patterns: Vec) -> Vec { - patterns - .into_iter() - .map(|pat| { - let (negate, inner) = if let Some(rest) = pat.strip_prefix('!') { - (true, rest) - } else { - (false, pat.as_str()) - }; - - let sig_variant = format!("{inner}.sig"); - let brace_pattern = format!("{{{inner},{sig_variant}}}"); - - if negate { - format!("!{brace_pattern}") - } else { - brace_pattern - } - }) - .collect() -} From 24837cadcc400c74c97fcaa752acb6c316d02f08 Mon Sep 17 00:00:00 2001 From: Rabindra Dhakal Date: Tue, 23 Sep 2025 23:18:53 +0545 Subject: [PATCH 10/12] fix config path --- crates/soar-utils/src/path.rs | 1 + soar-core/src/config.rs | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/crates/soar-utils/src/path.rs b/crates/soar-utils/src/path.rs index 6654c2c41..7aed73c06 100644 --- a/crates/soar-utils/src/path.rs +++ b/crates/soar-utils/src/path.rs @@ -394,6 +394,7 @@ mod tests { } #[test] + #[serial] fn test_resolve_path_invalid_cwd() { let temp_dir = tempfile::tempdir().unwrap(); let invalid_path = temp_dir.path().join("invalid"); diff --git a/soar-core/src/config.rs b/soar-core/src/config.rs index dd148084b..6ae4df12e 100644 --- a/soar-core/src/config.rs +++ b/soar-core/src/config.rs @@ -8,7 +8,7 @@ use std::{ use documented::{Documented, DocumentedFields}; use serde::{de::Error, Deserialize, Serialize}; use soar_utils::{ - path::{home_dir, resolve_path, xdg_data_home}, + path::{resolve_path, xdg_config_home, xdg_data_home}, system::platform, time::parse_duration, }; @@ -217,7 +217,7 @@ pub static CURRENT_PROFILE: LazyLock>> = LazyLock::new(|| pub static CONFIG_PATH: LazyLock> = LazyLock::new(|| { RwLock::new(match std::env::var("SOAR_CONFIG") { Ok(path_str) => PathBuf::from(path_str), - Err(_) => home_dir().join("soar").join("config.toml"), + Err(_) => xdg_config_home().join("soar").join("config.toml"), }) }); From 897cde13b83c4d1ff35f4e6a7e2e39314348051f Mon Sep 17 00:00:00 2001 From: Rabindra Dhakal Date: Thu, 25 Sep 2025 21:23:13 +0545 Subject: [PATCH 11/12] improve errors --- crates/soar-utils/src/error.rs | 272 ++++++++++++++++++++++++++++---- crates/soar-utils/src/fs.rs | 76 +++------ crates/soar-utils/src/lib.rs | 1 - crates/soar-utils/src/path.rs | 4 +- crates/soar-utils/src/system.rs | 73 +++++++++ crates/soar-utils/src/user.rs | 77 --------- soar-core/src/utils.rs | 10 +- 7 files changed, 342 insertions(+), 171 deletions(-) delete mode 100644 crates/soar-utils/src/user.rs diff --git a/crates/soar-utils/src/error.rs b/crates/soar-utils/src/error.rs index 9402b1f4a..5b0b5b32c 100644 --- a/crates/soar-utils/src/error.rs +++ b/crates/soar-utils/src/error.rs @@ -45,7 +45,7 @@ impl Error for BytesError {} #[derive(Debug)] pub enum PathError { - CurrentDir { source: std::io::Error }, + FailedToGetCurrentDir { source: std::io::Error }, Empty, @@ -58,7 +58,7 @@ impl fmt::Display for PathError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { PathError::Empty => write!(f, "Path is empty"), - PathError::CurrentDir { source } => { + PathError::FailedToGetCurrentDir { source } => { write!(f, "Failed to get current directory: {source}") } PathError::UnclosedVariable { input } => { @@ -74,7 +74,7 @@ impl fmt::Display for PathError { impl Error for PathError { fn source(&self) -> Option<&(dyn Error + 'static)> { match self { - PathError::CurrentDir { source } => Some(source), + PathError::FailedToGetCurrentDir { source } => Some(source), _ => None, } } @@ -82,54 +82,107 @@ impl Error for PathError { #[derive(Debug)] pub enum FileSystemError { - File { + // File operations + ReadFile { path: PathBuf, - action: &'static str, source: std::io::Error, }, - Directory { + WriteFile { path: PathBuf, - action: &'static str, source: std::io::Error, }, - NotADirectory { + CreateFile { + path: PathBuf, + source: std::io::Error, + }, + + RemoveFile { + path: PathBuf, + source: std::io::Error, + }, + + // Directory operations + ReadDirectory { path: PathBuf, + source: std::io::Error, + }, + + CreateDirectory { + path: PathBuf, + source: std::io::Error, }, - Symlink { + RemoveDirectory { + path: PathBuf, + source: std::io::Error, + }, + + // Symlink operations + CreateSymlink { from: PathBuf, target: PathBuf, source: std::io::Error, }, + + RemoveSymlink { + path: PathBuf, + source: std::io::Error, + }, + + ReadSymlink { + path: PathBuf, + source: std::io::Error, + }, + + // Path validation + NotFound { + path: PathBuf, + }, + + NotADirectory { + path: PathBuf, + }, + + NotAFile { + path: PathBuf, + }, } impl fmt::Display for FileSystemError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { - FileSystemError::File { - path, - action, - source, - } => { - write!(f, "Failed to {action} file `{}`: {source}", path.display()) + FileSystemError::ReadFile { path, source } => { + write!(f, "Failed to read file `{}`: {source}", path.display()) } - FileSystemError::Directory { - path, - action, - source, - } => { + FileSystemError::WriteFile { path, source } => { + write!(f, "Failed to write file `{}`: {source}", path.display()) + } + FileSystemError::CreateFile { path, source } => { + write!(f, "Failed to create file `{}`: {source}", path.display()) + } + FileSystemError::RemoveFile { path, source } => { + write!(f, "Failed to remove file `{}`: {source}", path.display()) + } + FileSystemError::ReadDirectory { path, source } => { + write!(f, "Failed to read directory `{}`: {source}", path.display()) + } + FileSystemError::CreateDirectory { path, source } => { write!( f, - "Failed to {action} directory `{}`: {source}", + "Failed to create directory `{}`: {source}", path.display() ) } - FileSystemError::NotADirectory { path } => { - write!(f, "`{}` is not a directory", path.display()) + FileSystemError::RemoveDirectory { path, source } => { + write!( + f, + "Failed to remove directory `{}`: {source}", + path.display() + ) } - FileSystemError::Symlink { + FileSystemError::CreateSymlink { from, target, source, @@ -141,6 +194,21 @@ impl fmt::Display for FileSystemError { target.display() ) } + FileSystemError::RemoveSymlink { path, source } => { + write!(f, "Failed to remove symlink `{}`: {source}", path.display()) + } + FileSystemError::ReadSymlink { path, source } => { + write!(f, "Failed to read symlink `{}`: {source}", path.display()) + } + FileSystemError::NotFound { path } => { + write!(f, "Path `{}` not found", path.display()) + } + FileSystemError::NotADirectory { path } => { + write!(f, "`{}` is not a directory", path.display()) + } + FileSystemError::NotAFile { path } => { + write!(f, "`{}` is not a file", path.display()) + } } } } @@ -148,14 +216,156 @@ impl fmt::Display for FileSystemError { impl Error for FileSystemError { fn source(&self) -> Option<&(dyn Error + 'static)> { match self { - FileSystemError::File { source, .. } => Some(source), - FileSystemError::Directory { source, .. } => Some(source), - FileSystemError::Symlink { source, .. } => Some(source), + FileSystemError::ReadFile { source, .. } => Some(source), + FileSystemError::WriteFile { source, .. } => Some(source), + FileSystemError::CreateFile { source, .. } => Some(source), + FileSystemError::RemoveFile { source, .. } => Some(source), + FileSystemError::ReadDirectory { source, .. } => Some(source), + FileSystemError::CreateDirectory { source, .. } => Some(source), + FileSystemError::RemoveDirectory { source, .. } => Some(source), + FileSystemError::CreateSymlink { source, .. } => Some(source), + FileSystemError::RemoveSymlink { source, .. } => Some(source), + FileSystemError::ReadSymlink { source, .. } => Some(source), _ => None, } } } +pub struct IoContext { + path: PathBuf, + operation: IoOperation, +} + +#[derive(Debug, Clone)] +pub enum IoOperation { + ReadFile, + WriteFile, + CreateFile, + RemoveFile, + CreateDirectory, + RemoveDirectory, + ReadDirectory, + CreateSymlink { target: PathBuf }, + RemoveSymlink, + ReadSymlink, +} + +impl IoContext { + pub fn new(path: PathBuf, operation: IoOperation) -> Self { + Self { path, operation } + } + + pub fn read_file>(path: P) -> Self { + Self::new(path.into(), IoOperation::ReadFile) + } + + pub fn write_file>(path: P) -> Self { + Self::new(path.into(), IoOperation::WriteFile) + } + + pub fn create_file>(path: P) -> Self { + Self::new(path.into(), IoOperation::CreateFile) + } + + pub fn remove_file>(path: P) -> Self { + Self::new(path.into(), IoOperation::RemoveFile) + } + + pub fn read_directory>(path: P) -> Self { + Self::new(path.into(), IoOperation::ReadDirectory) + } + + pub fn create_directory>(path: P) -> Self { + Self::new(path.into(), IoOperation::CreateDirectory) + } + + pub fn remove_directory>(path: P) -> Self { + Self::new(path.into(), IoOperation::RemoveDirectory) + } + + pub fn read_symlink>(path: P) -> Self { + Self::new(path.into(), IoOperation::ReadSymlink) + } + + pub fn create_symlink, T: Into>(from: P, target: T) -> Self { + Self::new( + from.into(), + IoOperation::CreateSymlink { + target: target.into(), + }, + ) + } + + pub fn remove_symlink>(path: P) -> Self { + Self::new(path.into(), IoOperation::RemoveSymlink) + } + + pub fn operation(&self) -> &IoOperation { + &self.operation + } +} + +impl From<(IoContext, std::io::Error)> for FileSystemError { + fn from((ctx, source): (IoContext, std::io::Error)) -> Self { + match ctx.operation { + IoOperation::ReadFile => FileSystemError::ReadFile { + path: ctx.path, + source, + }, + IoOperation::WriteFile => FileSystemError::WriteFile { + path: ctx.path, + source, + }, + IoOperation::CreateFile => FileSystemError::CreateFile { + path: ctx.path, + source, + }, + IoOperation::RemoveFile => FileSystemError::RemoveFile { + path: ctx.path, + source, + }, + IoOperation::CreateDirectory => FileSystemError::CreateDirectory { + path: ctx.path, + source, + }, + IoOperation::RemoveDirectory => FileSystemError::RemoveDirectory { + path: ctx.path, + source, + }, + IoOperation::ReadDirectory => FileSystemError::ReadDirectory { + path: ctx.path, + source, + }, + IoOperation::CreateSymlink { target } => FileSystemError::CreateSymlink { + from: ctx.path, + target, + source, + }, + IoOperation::RemoveSymlink => FileSystemError::RemoveSymlink { + path: ctx.path, + source, + }, + IoOperation::ReadSymlink => FileSystemError::ReadSymlink { + path: ctx.path, + source, + }, + } + } +} + +pub trait IoResultExt { + fn with_path>(self, path: P, operation: IoOperation) -> FileSystemResult; +} + +impl IoResultExt for std::io::Result { + fn with_path>(self, path: P, operation: IoOperation) -> FileSystemResult { + self.map_err(|e| { + let ctx = IoContext::new(path.into(), operation); + (ctx, e).into() + }) + } +} + #[derive(Debug)] pub enum UtilsError { Bytes(BytesError), @@ -242,7 +452,7 @@ mod tests { #[test] fn test_path_error_display_and_source() { let io_error = io::Error::other("some error"); - let current_dir_error = PathError::CurrentDir { source: io_error }; + let current_dir_error = PathError::FailedToGetCurrentDir { source: io_error }; assert_eq!( current_dir_error.to_string(), "Failed to get current directory: some error" @@ -276,9 +486,8 @@ mod tests { #[test] fn test_file_system_error_display_and_source() { let io_error = io::Error::new(io::ErrorKind::PermissionDenied, "permission denied"); - let file_error = FileSystemError::File { + let file_error = FileSystemError::ReadFile { path: PathBuf::from("/file"), - action: "read", source: io_error, }; assert_eq!( @@ -288,9 +497,8 @@ mod tests { assert!(file_error.source().is_some()); let io_error2 = io::Error::new(io::ErrorKind::PermissionDenied, "permission denied"); - let dir_error = FileSystemError::Directory { + let dir_error = FileSystemError::CreateDirectory { path: PathBuf::from("/dir"), - action: "create", source: io_error2, }; assert_eq!( diff --git a/crates/soar-utils/src/fs.rs b/crates/soar-utils/src/fs.rs index f10ee5b57..6a3ccb9fc 100644 --- a/crates/soar-utils/src/fs.rs +++ b/crates/soar-utils/src/fs.rs @@ -5,7 +5,7 @@ use std::{ path::Path, }; -use crate::error::{FileSystemError, FileSystemResult}; +use crate::error::{FileSystemError, FileSystemResult, IoOperation, IoResultExt}; /// Removes the specified file or directory safely. /// @@ -43,11 +43,9 @@ pub fn safe_remove>(path: P) -> FileSystemResult<()> { fs::remove_file(path) }; - result.map_err(|err| FileSystemError::File { - path: path.to_path_buf(), - action: "remove", - source: err, - }) + result.with_path(path, IoOperation::RemoveFile)?; + + Ok(()) } /// Creates a directory structure if it doesn't exist. @@ -79,11 +77,7 @@ pub fn safe_remove>(path: P) -> FileSystemResult<()> { pub fn ensure_dir_exists>(path: P) -> FileSystemResult<()> { let path = path.as_ref(); if !path.exists() { - std::fs::create_dir_all(path).map_err(|err| FileSystemError::Directory { - path: path.to_path_buf(), - action: "create", - source: err, - })?; + std::fs::create_dir_all(path).with_path(path, IoOperation::CreateDirectory)?; } else if !path.is_dir() { return Err(FileSystemError::NotADirectory { path: path.to_path_buf(), @@ -129,18 +123,15 @@ pub fn create_symlink, Q: AsRef>( } if target.is_file() { - fs::remove_file(target).map_err(|err| FileSystemError::File { - path: target.to_path_buf(), - action: "remove", - source: err, - })?; - } - - os::unix::fs::symlink(source, target).map_err(|err| FileSystemError::Symlink { - from: source.to_path_buf(), - target: target.to_path_buf(), - source: err, - }) + fs::remove_file(target).with_path(target, IoOperation::RemoveFile)?; + } + + os::unix::fs::symlink(source, target).with_path( + source, + IoOperation::CreateSymlink { + target: target.into(), + }, + ) } /// Walks a directory recursively and calls the provided function on each file or directory. @@ -183,18 +174,12 @@ where }); } - for entry in fs::read_dir(dir).map_err(|err| FileSystemError::Directory { - path: dir.to_path_buf(), - action: "read", - source: err, - })? { - let path = entry - .map_err(|err| FileSystemError::Directory { - path: dir.to_path_buf(), - action: "read entry in", - source: err, - })? - .path(); + for entry in fs::read_dir(dir).with_path(dir, IoOperation::ReadDirectory)? { + let Ok(entry) = entry else { + continue; + }; + + let path = entry.path(); if path.is_dir() { walk_dir(&path, action)?; @@ -231,21 +216,13 @@ where /// } pub fn read_file_signature>(path: P, bytes: usize) -> FileSystemResult> { let path = path.as_ref(); - let file = File::open(path).map_err(|err| FileSystemError::File { - path: path.to_path_buf(), - action: "open", - source: err, - })?; + let file = File::open(path).with_path(path, IoOperation::ReadFile)?; let mut reader = BufReader::new(file); let mut buffer = vec![0u8; bytes]; reader .read_exact(&mut buffer) - .map_err(|err| FileSystemError::File { - path: path.to_path_buf(), - action: "read", - source: err, - })?; + .with_path(path, IoOperation::ReadFile)?; Ok(buffer) } @@ -275,11 +252,7 @@ pub fn dir_size>(path: P) -> FileSystemResult { let path = path.as_ref(); let mut total_size = 0; - for entry in fs::read_dir(path).map_err(|err| FileSystemError::Directory { - path: path.to_path_buf(), - action: "read", - source: err, - })? { + for entry in fs::read_dir(path).with_path(path, IoOperation::ReadDirectory)? { let Ok(entry) = entry else { continue; }; @@ -524,9 +497,8 @@ mod tests { let mut results = Vec::new(); walk_dir(&dir, &mut |path| { results.push(path.to_path_buf()); - Err(FileSystemError::File { + Err(FileSystemError::ReadFile { path: path.to_path_buf(), - action: "read", source: std::io::Error::from(std::io::ErrorKind::Other), }) }) diff --git a/crates/soar-utils/src/lib.rs b/crates/soar-utils/src/lib.rs index 237852908..72d85f9b8 100644 --- a/crates/soar-utils/src/lib.rs +++ b/crates/soar-utils/src/lib.rs @@ -6,4 +6,3 @@ pub mod path; pub mod pattern; pub mod system; pub mod time; -pub mod user; diff --git a/crates/soar-utils/src/path.rs b/crates/soar-utils/src/path.rs index 7aed73c06..1f83bec80 100644 --- a/crates/soar-utils/src/path.rs +++ b/crates/soar-utils/src/path.rs @@ -2,7 +2,7 @@ use std::{env, path::PathBuf}; use crate::{ error::{PathError, PathResult}, - user::get_username, + system::get_username, }; /// Resolves a path string that may contain environment variables @@ -53,7 +53,7 @@ pub fn resolve_path(path: &str) -> PathResult { } else { env::current_dir() .map(|cwd| cwd.join(path_buf)) - .map_err(|err| PathError::CurrentDir { source: err }) + .map_err(|err| PathError::FailedToGetCurrentDir { source: err }) } } diff --git a/crates/soar-utils/src/system.rs b/crates/soar-utils/src/system.rs index f0c343222..26ffae644 100644 --- a/crates/soar-utils/src/system.rs +++ b/crates/soar-utils/src/system.rs @@ -1,3 +1,7 @@ +use std::env; + +use nix::unistd::{geteuid, User}; + /// Retrieves the platform string in the format `ARCH-Os`. /// /// This function combines the architecture (e.g., `x86_64`) and the operating @@ -11,10 +15,58 @@ pub fn platform() -> String { ) } +trait UsernameSource { + fn env_var(&self, key: &str) -> Option; + fn uid_name(&self) -> Option; +} + +struct SystemSource; + +impl UsernameSource for SystemSource { + fn env_var(&self, key: &str) -> Option { + env::var(key).ok() + } + + fn uid_name(&self) -> Option { + User::from_uid(geteuid()) + .ok() + .and_then(|u| u.map(|u| u.name)) + } +} + +fn get_username_with(src: &S) -> String { + src.env_var("USER") + .or_else(|| src.env_var("LOGNAME")) + .or_else(|| src.uid_name()) + .expect("Couldn't determine username.") +} + +/// Returns the username of the current user. +/// +/// This function first checks the `USER` and `LOGNAME` environment variables. If not set, it +/// falls back to fetching the username using the effective user ID. +/// +/// # Panics +/// +/// This function will panic if it cannot determine the username. +pub fn get_username() -> String { + get_username_with(&SystemSource) +} + #[cfg(test)] mod tests { use super::*; + struct AlwaysNone; + impl UsernameSource for AlwaysNone { + fn env_var(&self, _: &str) -> Option { + None + } + fn uid_name(&self) -> Option { + None + } + } + #[test] fn test_platform() { #[cfg(target_arch = "x86_64")] @@ -25,4 +77,25 @@ mod tests { #[cfg(target_os = "linux")] assert_eq!(platform(), "aarch64-Linux"); } + + #[test] + #[should_panic(expected = "Couldn't determine username.")] + fn test_fails_when_all_sources_missing() { + get_username_with(&AlwaysNone); + } + + #[test] + fn test_get_username() { + let username = get_username(); + assert!(!username.is_empty()); + } + + #[test] + fn test_get_username_missing_env_vars() { + env::remove_var("USER"); + env::remove_var("LOGNAME"); + + let username = get_username(); + assert!(!username.is_empty()); + } } diff --git a/crates/soar-utils/src/user.rs b/crates/soar-utils/src/user.rs deleted file mode 100644 index 13447fc79..000000000 --- a/crates/soar-utils/src/user.rs +++ /dev/null @@ -1,77 +0,0 @@ -use std::env; - -use nix::unistd::{geteuid, User}; - -trait UsernameSource { - fn env_var(&self, key: &str) -> Option; - fn uid_name(&self) -> Option; -} - -struct SystemSource; - -impl UsernameSource for SystemSource { - fn env_var(&self, key: &str) -> Option { - env::var(key).ok() - } - - fn uid_name(&self) -> Option { - User::from_uid(geteuid()) - .ok() - .and_then(|u| u.map(|u| u.name)) - } -} - -fn get_username_with(src: &S) -> String { - src.env_var("USER") - .or_else(|| src.env_var("LOGNAME")) - .or_else(|| src.uid_name()) - .expect("Couldn't determine username.") -} - -/// Returns the username of the current user. -/// -/// This function first checks the `USER` and `LOGNAME` environment variables. If not set, it -/// falls back to fetching the username using the effective user ID. -/// -/// # Panics -/// -/// This function will panic if it cannot determine the username. -pub fn get_username() -> String { - get_username_with(&SystemSource) -} - -#[cfg(test)] -mod tests { - use super::*; - - struct AlwaysNone; - impl UsernameSource for AlwaysNone { - fn env_var(&self, _: &str) -> Option { - None - } - fn uid_name(&self) -> Option { - None - } - } - - #[test] - #[should_panic(expected = "Couldn't determine username.")] - fn test_fails_when_all_sources_missing() { - get_username_with(&AlwaysNone); - } - - #[test] - fn test_get_username() { - let username = get_username(); - assert!(!username.is_empty()); - } - - #[test] - fn test_get_username_missing_env_vars() { - env::remove_var("USER"); - env::remove_var("LOGNAME"); - - let username = get_username(); - assert!(!username.is_empty()); - } -} diff --git a/soar-core/src/utils.rs b/soar-core/src/utils.rs index 041b860f4..06974d8eb 100644 --- a/soar-core/src/utils.rs +++ b/soar-core/src/utils.rs @@ -4,8 +4,8 @@ use std::{ }; use soar_utils::{ - error::{FileSystemError, FileSystemResult}, - fs::walk_dir, + error::FileSystemResult, + fs::{safe_remove, walk_dir}, path::{desktop_dir, icons_dir}, }; use tracing::info; @@ -58,11 +58,7 @@ pub fn cleanup_cache() -> Result<()> { fn remove_action(path: &Path) -> FileSystemResult<()> { if !path.exists() { - fs::remove_file(path).map_err(|err| FileSystemError::File { - path: path.to_path_buf(), - action: "remove", - source: err, - })?; + safe_remove(path)?; info!("Removed broken symlink: {}", path.display()); } Ok(()) From a7e1b20f76f3681caf70008e3e198f5f5f2eeff8 Mon Sep 17 00:00:00 2001 From: Rabindra Dhakal Date: Thu, 25 Sep 2025 21:27:31 +0545 Subject: [PATCH 12/12] add description, move crates to workspace --- Cargo.toml | 2 ++ crates/soar-utils/Cargo.toml | 5 +++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 43d310045..e63625de8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,5 +31,7 @@ reqwest = { version = "0.12.23", default-features = false, features = ["rustls-t rusqlite = { version = "0.37.0", features = ["bundled", "rusqlite-macros"] } serde = { version = "1.0.225", features = ["derive"] } serde_json = { version = "1.0.145", features = ["indexmap"] } +serial_test = "3.2.0" soar-dl = { version = "0.6.3" } +tempfile = "3.10.1" tracing = { version = "0.1.41", default-features = false } diff --git a/crates/soar-utils/Cargo.toml b/crates/soar-utils/Cargo.toml index 7f76b71a5..2ab1751e6 100644 --- a/crates/soar-utils/Cargo.toml +++ b/crates/soar-utils/Cargo.toml @@ -1,6 +1,7 @@ [package] name = "soar-utils" version = "0.1.0" +description = "Utilities for soar package manager" authors.workspace = true license.workspace = true edition.workspace = true @@ -12,7 +13,7 @@ categories.workspace = true [dependencies] blake3 = { version = "1.8.2", features = ["mmap"] } nix = { version = "0.30.1", features = ["ioctl", "term", "user"] } -serial_test = "3.2.0" +serial_test = { workspace = true } [dev-dependencies] -tempfile = "3.10.1" +tempfile = { workspace = true }