diff --git a/.gitignore b/.gitignore index 3ca43ae..193d30e 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,8 @@ Cargo.lock # MSVC Windows builds of rustc generate these, which store debugging information *.pdb + + +# Added by cargo + +/target diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..2c6d167 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,28 @@ +[package] +name = "sal" +version = "0.1.0" +edition = "2021" +authors = ["Your Name "] +description = "System Abstraction Layer - A library for easy interaction with operating system features" +repository = "https://github.com/yourusername/sal" +license = "MIT OR Apache-2.0" +keywords = ["system", "os", "abstraction", "platform", "filesystem"] +categories = ["os", "filesystem", "api-bindings"] +readme = "README.md" + +[dependencies] +# Cross-platform functionality +libc = "0.2" +cfg-if = "1.0" +thiserror = "1.0" # For error handling +log = "0.4" # Logging facade + +# Optional features for specific OS functionality +[target.'cfg(unix)'.dependencies] +nix = "0.26" # Unix-specific functionality + +[target.'cfg(windows)'.dependencies] +windows = { version = "0.48", features = ["Win32_Foundation", "Win32_System_Threading", "Win32_Storage_FileSystem"] } + +[dev-dependencies] +tempfile = "3.5" # For tests that need temporary files/directories diff --git a/src/git/git.rs b/src/git/git.rs new file mode 100644 index 0000000..c001a3b --- /dev/null +++ b/src/git/git.rs @@ -0,0 +1,597 @@ +use std::process::Command; +use std::path::Path; +use std::fs; +use std::env; +use regex::Regex; +use std::fmt; +use std::error::Error; + +// Define a custom error type for git operations +#[derive(Debug)] +pub enum GitError { + GitNotInstalled(std::io::Error), + InvalidUrl(String), + HomeDirectoryNotFound(std::env::VarError), + FileSystemError(std::io::Error), + GitCommandFailed(String), + CommandExecutionError(std::io::Error), + NoRepositoriesFound, + RepositoryNotFound(String), + MultipleRepositoriesFound(String, usize), + NotAGitRepository(String), + LocalChangesExist(String), +} + +// Implement Display for GitError +impl fmt::Display for GitError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + GitError::GitNotInstalled(e) => write!(f, "Git is not installed: {}", e), + GitError::InvalidUrl(url) => write!(f, "Could not parse git URL: {}", url), + GitError::HomeDirectoryNotFound(e) => write!(f, "Could not determine home directory: {}", e), + GitError::FileSystemError(e) => write!(f, "Error creating directory structure: {}", e), + GitError::GitCommandFailed(e) => write!(f, "{}", e), + GitError::CommandExecutionError(e) => write!(f, "Error executing command: {}", e), + GitError::NoRepositoriesFound => write!(f, "No repositories found"), + GitError::RepositoryNotFound(pattern) => write!(f, "No repositories found matching '{}'", pattern), + GitError::MultipleRepositoriesFound(pattern, count) => + write!(f, "Multiple repositories ({}) found matching '{}'. Use '*' suffix for multiple matches.", count, pattern), + GitError::NotAGitRepository(path) => write!(f, "Not a git repository at {}", path), + GitError::LocalChangesExist(path) => write!(f, "Repository at {} has local changes", path), + } + } +} + +// Implement Error trait for GitError +impl Error for GitError { + fn source(&self) -> Option<&(dyn Error + 'static)> { + match self { + GitError::GitNotInstalled(e) => Some(e), + GitError::HomeDirectoryNotFound(e) => Some(e), + GitError::FileSystemError(e) => Some(e), + GitError::CommandExecutionError(e) => Some(e), + _ => None, + } + } +} + +// Git utility functions + +/** + * Clones a git repository to a standardized location in the user's home directory. + * + * # Arguments + * + * * `url` - The URL of the git repository to clone. Can be in HTTPS format + * (https://github.com/username/repo.git) or SSH format (git@github.com:username/repo.git). + * + * # Returns + * + * * `Ok(String)` - The path where the repository was cloned, formatted as + * ~/code/server/account/repo (e.g., ~/code/github.com/username/repo). + * * `Err(GitError)` - An error if the clone operation failed. + * + * # Examples + * + * ``` + * let repo_path = git_clone("https://github.com/username/repo.git")?; + * println!("Repository cloned to: {}", repo_path); + * ``` + */ +pub fn git_clone(url: &str) -> Result { + // Check if git is installed + let git_check = Command::new("git") + .arg("--version") + .output() + .map_err(GitError::GitNotInstalled)?; + + // Parse the URL to determine the clone path + let (server, account, repo) = parse_git_url(url); + if server.is_empty() || account.is_empty() || repo.is_empty() { + return Err(GitError::InvalidUrl(url.to_string())); + } + + // Create the target directory + let home_dir = env::var("HOME").map_err(GitError::HomeDirectoryNotFound)?; + + let clone_path = format!("{}/code/{}/{}/{}", home_dir, server, account, repo); + let clone_dir = Path::new(&clone_path); + + // Check if repo already exists + if clone_dir.exists() { + return Ok(format!("Repository already exists at {}", clone_path)); + } + + // Create parent directory + if let Some(parent) = clone_dir.parent() { + fs::create_dir_all(parent).map_err(GitError::FileSystemError)?; + } + + // Clone the repository + let output = Command::new("git") + .args(&["clone", "--depth", "1", url, &clone_path]) + .output() + .map_err(GitError::CommandExecutionError)?; + + if output.status.success() { + Ok(clone_path) + } else { + let error = String::from_utf8_lossy(&output.stderr); + Err(GitError::GitCommandFailed(format!("Git clone error: {}", error))) + } +} + +/** + * Parses a git URL to extract the server, account, and repository name. + * + * # Arguments + * + * * `url` - The URL of the git repository to parse. Can be in HTTPS format + * (https://github.com/username/repo.git) or SSH format (git@github.com:username/repo.git). + * + * # Returns + * + * A tuple containing: + * * `server` - The server name (e.g., "github.com") + * * `account` - The account or organization name (e.g., "username") + * * `repo` - The repository name (e.g., "repo") + * + * If the URL cannot be parsed, all three values will be empty strings. + * + * # Examples + * + * ``` + * let (server, account, repo) = parse_git_url("https://github.com/username/repo.git"); + * assert_eq!(server, "github.com"); + * assert_eq!(account, "username"); + * assert_eq!(repo, "repo"); + * ``` + */ +pub fn parse_git_url(url: &str) -> (String, String, String) { + // HTTP(S) URL format: https://github.com/username/repo.git + let https_re = Regex::new(r"https?://([^/]+)/([^/]+)/([^/\.]+)(?:\.git)?").unwrap(); + + // SSH URL format: git@github.com:username/repo.git + let ssh_re = Regex::new(r"git@([^:]+):([^/]+)/([^/\.]+)(?:\.git)?").unwrap(); + + if let Some(caps) = https_re.captures(url) { + let server = caps.get(1).map_or("", |m| m.as_str()).to_string(); + let account = caps.get(2).map_or("", |m| m.as_str()).to_string(); + let repo = caps.get(3).map_or("", |m| m.as_str()).to_string(); + + return (server, account, repo); + } else if let Some(caps) = ssh_re.captures(url) { + let server = caps.get(1).map_or("", |m| m.as_str()).to_string(); + let account = caps.get(2).map_or("", |m| m.as_str()).to_string(); + let repo = caps.get(3).map_or("", |m| m.as_str()).to_string(); + + return (server, account, repo); + } + + (String::new(), String::new(), String::new()) +} + +/** + * Lists all git repositories found in the user's ~/code directory. + * + * This function searches for directories containing a .git subdirectory, + * which indicates a git repository. + * + * # Returns + * + * * `Ok(Vec)` - A vector of paths to git repositories + * * `Err(GitError)` - An error if the operation failed + * + * # Examples + * + * ``` + * let repos = git_list()?; + * for repo in repos { + * println!("Found repository: {}", repo); + * } + * ``` + */ +pub fn git_list() -> Result, GitError> { + // Get home directory + let home_dir = env::var("HOME").map_err(GitError::HomeDirectoryNotFound)?; + + let code_dir = format!("{}/code", home_dir); + let code_path = Path::new(&code_dir); + + if !code_path.exists() || !code_path.is_dir() { + return Ok(Vec::new()); + } + + let mut repos = Vec::new(); + + // Find all directories with .git subdirectories + let output = Command::new("find") + .args(&[&code_dir, "-type", "d", "-name", ".git"]) + .output() + .map_err(GitError::CommandExecutionError)?; + + if output.status.success() { + let stdout = String::from_utf8_lossy(&output.stdout); + for line in stdout.lines() { + // Get the parent directory of .git which is the repo root + if let Some(parent) = Path::new(line).parent() { + if let Some(path_str) = parent.to_str() { + repos.push(path_str.to_string()); + } + } + } + } else { + let error = String::from_utf8_lossy(&output.stderr); + return Err(GitError::GitCommandFailed(format!("Failed to find git repositories: {}", error))); + } + + Ok(repos) +} + +/** + * Checks if a git repository has uncommitted changes. + * + * # Arguments + * + * * `repo_path` - The path to the git repository + * + * # Returns + * + * * `Ok(bool)` - True if the repository has uncommitted changes, false otherwise + * * `Err(GitError)` - An error if the operation failed + * + * # Examples + * + * ``` + * if has_git_changes("/path/to/repo")? { + * println!("Repository has uncommitted changes"); + * } else { + * println!("Repository is clean"); + * } + * ``` + */ +pub fn has_git_changes(repo_path: &str) -> Result { + let output = Command::new("git") + .args(&["-C", repo_path, "status", "--porcelain"]) + .output() + .map_err(GitError::CommandExecutionError)?; + + Ok(!output.stdout.is_empty()) +} + +/** + * Finds repositories matching a pattern or partial path. + * + * # Arguments + * + * * `pattern` - The pattern to match against repository paths + * - If the pattern ends with '*', all matching repositories are returned + * - Otherwise, exactly one matching repository must be found + * + * # Returns + * + * * `Ok(Vec)` - A vector of paths to matching repositories + * * `Err(GitError)` - An error if no matching repositories are found, + * or if multiple repositories match a non-wildcard pattern + * + * # Examples + * + * ``` + * // Find all repositories containing "project" + * let repos = find_matching_repos("project*")?; + * + * // Find exactly one repository containing "unique-project" + * let repo = find_matching_repos("unique-project")?[0]; + * ``` + */ +pub fn find_matching_repos(pattern: &str) -> Result, GitError> { + // Get all repos + let repos = git_list()?; + + if repos.is_empty() { + return Err(GitError::NoRepositoriesFound); + } + + // Check if pattern ends with wildcard + if pattern.ends_with('*') { + let search_pattern = &pattern[0..pattern.len()-1]; // Remove the * + let matching: Vec = repos.iter() + .filter(|repo| repo.contains(search_pattern)) + .cloned() + .collect(); + + if matching.is_empty() { + return Err(GitError::RepositoryNotFound(pattern.to_string())); + } + + Ok(matching) + } else { + // No wildcard, need to find exactly one match + let matching: Vec = repos.iter() + .filter(|repo| repo.contains(pattern)) + .cloned() + .collect(); + + match matching.len() { + 0 => Err(GitError::RepositoryNotFound(pattern.to_string())), + 1 => Ok(matching), + _ => Err(GitError::MultipleRepositoriesFound(pattern.to_string(), matching.len())), + } + } +} + +/** + * Updates a git repository by pulling the latest changes. + * + * This function will fail if there are uncommitted changes in the repository. + * + * # Arguments + * + * * `repo_path` - The path to the git repository, or a partial path that uniquely identifies a repository + * + * # Returns + * + * * `Ok(String)` - A success message indicating the repository was updated + * * `Err(GitError)` - An error if the update failed + * + * # Examples + * + * ``` + * let result = git_update("my-project")?; + * println!("{}", result); // "Successfully updated repository at /home/user/code/github.com/user/my-project" + * ``` + */ +pub fn git_update(repo_path: &str) -> Result { + // If repo_path may be a partial path, find the matching repository + let repos = find_matching_repos(repo_path)?; + + // Should only be one repository at this point + let actual_path = &repos[0]; + + // Check if repository exists and is a git repository + let git_dir = Path::new(actual_path).join(".git"); + if !git_dir.exists() || !git_dir.is_dir() { + return Err(GitError::NotAGitRepository(actual_path.clone())); + } + + // Check for local changes + if has_git_changes(actual_path)? { + return Err(GitError::LocalChangesExist(actual_path.clone())); + } + + // Pull the latest changes + let output = Command::new("git") + .args(&["-C", actual_path, "pull"]) + .output() + .map_err(GitError::CommandExecutionError)?; + + if output.status.success() { + let stdout = String::from_utf8_lossy(&output.stdout); + if stdout.contains("Already up to date") { + Ok(format!("Repository already up to date at {}", actual_path)) + } else { + Ok(format!("Successfully updated repository at {}", actual_path)) + } + } else { + let error = String::from_utf8_lossy(&output.stderr); + Err(GitError::GitCommandFailed(format!("Git pull error: {}", error))) + } +} + +/** + * Force updates a git repository by discarding local changes and pulling the latest changes. + * + * This function will reset any uncommitted changes and clean untracked files before pulling. + * + * # Arguments + * + * * `repo_path` - The path to the git repository, or a partial path that uniquely identifies a repository + * + * # Returns + * + * * `Ok(String)` - A success message indicating the repository was force-updated + * * `Err(GitError)` - An error if the update failed + * + * # Examples + * + * ``` + * let result = git_update_force("my-project")?; + * println!("{}", result); // "Successfully force-updated repository at /home/user/code/github.com/user/my-project" + * ``` + */ +pub fn git_update_force(repo_path: &str) -> Result { + // If repo_path may be a partial path, find the matching repository + let repos = find_matching_repos(repo_path)?; + + // Should only be one repository at this point + let actual_path = &repos[0]; + + // Check if repository exists and is a git repository + let git_dir = Path::new(actual_path).join(".git"); + if !git_dir.exists() || !git_dir.is_dir() { + return Err(GitError::NotAGitRepository(actual_path.clone())); + } + + // Reset any local changes + let reset_output = Command::new("git") + .args(&["-C", actual_path, "reset", "--hard", "HEAD"]) + .output() + .map_err(GitError::CommandExecutionError)?; + + if !reset_output.status.success() { + let error = String::from_utf8_lossy(&reset_output.stderr); + return Err(GitError::GitCommandFailed(format!("Git reset error: {}", error))); + } + + // Clean untracked files + let clean_output = Command::new("git") + .args(&["-C", actual_path, "clean", "-fd"]) + .output() + .map_err(GitError::CommandExecutionError)?; + + if !clean_output.status.success() { + let error = String::from_utf8_lossy(&clean_output.stderr); + return Err(GitError::GitCommandFailed(format!("Git clean error: {}", error))); + } + + // Pull the latest changes + let pull_output = Command::new("git") + .args(&["-C", actual_path, "pull"]) + .output() + .map_err(GitError::CommandExecutionError)?; + + if pull_output.status.success() { + Ok(format!("Successfully force-updated repository at {}", actual_path)) + } else { + let error = String::from_utf8_lossy(&pull_output.stderr); + Err(GitError::GitCommandFailed(format!("Git pull error: {}", error))) + } +} + +/** + * Commits changes in a git repository and then updates it by pulling the latest changes. + * + * # Arguments + * + * * `repo_path` - The path to the git repository, or a partial path that uniquely identifies a repository + * * `message` - The commit message + * + * # Returns + * + * * `Ok(String)` - A success message indicating the repository was committed and updated + * * `Err(GitError)` - An error if the operation failed + * + * # Examples + * + * ``` + * let result = git_update_commit("my-project", "Fix bug in login form")?; + * println!("{}", result); // "Successfully committed and updated repository at /home/user/code/github.com/user/my-project" + * ``` + */ +pub fn git_update_commit(repo_path: &str, message: &str) -> Result { + // If repo_path may be a partial path, find the matching repository + let repos = find_matching_repos(repo_path)?; + + // Should only be one repository at this point + let actual_path = &repos[0]; + + // Check if repository exists and is a git repository + let git_dir = Path::new(actual_path).join(".git"); + if !git_dir.exists() || !git_dir.is_dir() { + return Err(GitError::NotAGitRepository(actual_path.clone())); + } + + // Check for local changes + if !has_git_changes(actual_path)? { + return Ok(format!("No changes to commit in repository at {}", actual_path)); + } + + // Add all changes + let add_output = Command::new("git") + .args(&["-C", actual_path, "add", "."]) + .output() + .map_err(GitError::CommandExecutionError)?; + + if !add_output.status.success() { + let error = String::from_utf8_lossy(&add_output.stderr); + return Err(GitError::GitCommandFailed(format!("Git add error: {}", error))); + } + + // Commit the changes + let commit_output = Command::new("git") + .args(&["-C", actual_path, "commit", "-m", message]) + .output() + .map_err(GitError::CommandExecutionError)?; + + if !commit_output.status.success() { + let error = String::from_utf8_lossy(&commit_output.stderr); + return Err(GitError::GitCommandFailed(format!("Git commit error: {}", error))); + } + + // Pull the latest changes + let pull_output = Command::new("git") + .args(&["-C", actual_path, "pull"]) + .output() + .map_err(GitError::CommandExecutionError)?; + + if pull_output.status.success() { + Ok(format!("Successfully committed and updated repository at {}", actual_path)) + } else { + let error = String::from_utf8_lossy(&pull_output.stderr); + Err(GitError::GitCommandFailed(format!("Git pull error: {}", error))) + } +} + +/** + * Commits changes in a git repository and pushes them to the remote. + * + * # Arguments + * + * * `repo_path` - The path to the git repository, or a partial path that uniquely identifies a repository + * * `message` - The commit message + * + * # Returns + * + * * `Ok(String)` - A success message indicating the repository was committed and pushed + * * `Err(GitError)` - An error if the operation failed + * + * # Examples + * + * ``` + * let result = git_update_commit_push("my-project", "Add new feature")?; + * println!("{}", result); // "Successfully committed and pushed repository at /home/user/code/github.com/user/my-project" + * ``` + */ +pub fn git_update_commit_push(repo_path: &str, message: &str) -> Result { + // If repo_path may be a partial path, find the matching repository + let repos = find_matching_repos(repo_path)?; + + // Should only be one repository at this point + let actual_path = &repos[0]; + + // Check if repository exists and is a git repository + let git_dir = Path::new(actual_path).join(".git"); + if !git_dir.exists() || !git_dir.is_dir() { + return Err(GitError::NotAGitRepository(actual_path.clone())); + } + + // Check for local changes + if !has_git_changes(actual_path)? { + return Ok(format!("No changes to commit in repository at {}", actual_path)); + } + + // Add all changes + let add_output = Command::new("git") + .args(&["-C", actual_path, "add", "."]) + .output() + .map_err(GitError::CommandExecutionError)?; + + if !add_output.status.success() { + let error = String::from_utf8_lossy(&add_output.stderr); + return Err(GitError::GitCommandFailed(format!("Git add error: {}", error))); + } + + // Commit the changes + let commit_output = Command::new("git") + .args(&["-C", actual_path, "commit", "-m", message]) + .output() + .map_err(GitError::CommandExecutionError)?; + + if !commit_output.status.success() { + let error = String::from_utf8_lossy(&commit_output.stderr); + return Err(GitError::GitCommandFailed(format!("Git commit error: {}", error))); + } + + // Push the changes + let push_output = Command::new("git") + .args(&["-C", actual_path, "push"]) + .output() + .map_err(GitError::CommandExecutionError)?; + + if push_output.status.success() { + Ok(format!("Successfully committed and pushed repository at {}", actual_path)) + } else { + let error = String::from_utf8_lossy(&push_output.stderr); + Err(GitError::GitCommandFailed(format!("Git push error: {}", error))) + } +} diff --git a/src/git/git_executor.rs b/src/git/git_executor.rs new file mode 100644 index 0000000..b8373b9 --- /dev/null +++ b/src/git/git_executor.rs @@ -0,0 +1,7 @@ +use std::process::Command; +use std::path::Path; +use std::fs; +use std::env; +use regex::Regex; +use std::fmt; +use std::error::Error; diff --git a/src/git/instructions.md b/src/git/instructions.md new file mode 100644 index 0000000..bd6cdce --- /dev/null +++ b/src/git/instructions.md @@ -0,0 +1,12 @@ +in @/sal/git/git_executor.rs +create a GitExecutor which is the one executing git commands +and also checking if ssh-agent is loaded +or if there is an authentication mechanism defined in redis + +check if there is a redis on ~/hero/var/redis.sock + +over unix domani socket + +if yes then check there is an entry on + +sal::git:: \ No newline at end of file diff --git a/src/git/mod.rs b/src/git/mod.rs new file mode 100644 index 0000000..5b4443e --- /dev/null +++ b/src/git/mod.rs @@ -0,0 +1,2 @@ +use git::*; +use git_executor::*; \ No newline at end of file diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..8cedeec --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,60 @@ +//! # System Abstraction Layer (SAL) +//! +//! `sal` is a library that provides a unified interface for interacting with +//! operating system features across different platforms. It abstracts away the +//! platform-specific details, allowing developers to write cross-platform code +//! with ease. +//! +//! ## Features +//! +//! - File system operations +//! - Process management +//! - System information +//! - Network operations +//! - Environment variables + +use std::io; +use thiserror::Error; + +/// Error types for the SAL library +#[derive(Error, Debug)] +pub enum Error { + /// An error occurred during an I/O operation + #[error("I/O error: {0}")] + Io(#[from] io::Error), + + /// An error specific to the SAL library + #[error("SAL error: {0}")] + Sal(String), + + /// An error that occurred in a platform-specific operation + #[error("Platform-specific error: {0}")] + Platform(String), +} + +/// Result type for SAL operations +pub type Result = std::result::Result; + +// Re-export modules +pub mod process; +pub mod git; +pub mod os; +pub mod network; +pub mod env; +pub mod text; + +// Version information +/// Returns the version of the SAL library +pub fn version() -> &'static str { + env!("CARGO_PKG_VERSION") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_version() { + assert!(!version().is_empty()); + } +} diff --git a/src/mod.rs b/src/mod.rs new file mode 100644 index 0000000..424a0e7 --- /dev/null +++ b/src/mod.rs @@ -0,0 +1,3 @@ +pub mod os; +pub mod text; +pub mod git; diff --git a/src/os/download.rs b/src/os/download.rs new file mode 100644 index 0000000..bfa1f43 --- /dev/null +++ b/src/os/download.rs @@ -0,0 +1,289 @@ +use std::process::Command; +use std::path::Path; +use std::fs; +use std::fmt; +use std::error::Error; +use std::io; + +// Define a custom error type for download operations +#[derive(Debug)] +pub enum DownloadError { + CreateDirectoryFailed(io::Error), + CurlExecutionFailed(io::Error), + DownloadFailed(String), + FileMetadataError(io::Error), + FileTooSmall(i64, i64), + RemoveFileFailed(io::Error), + ExtractionFailed(String), + CommandExecutionFailed(io::Error), + InvalidUrl(String), + NotAFile(String), + PlatformNotSupported(String), + InstallationFailed(String), +} + +// Implement Display for DownloadError +impl fmt::Display for DownloadError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + DownloadError::CreateDirectoryFailed(e) => write!(f, "Error creating directories: {}", e), + DownloadError::CurlExecutionFailed(e) => write!(f, "Error executing curl: {}", e), + DownloadError::DownloadFailed(url) => write!(f, "Error downloading url: {}", url), + DownloadError::FileMetadataError(e) => write!(f, "Error getting file metadata: {}", e), + DownloadError::FileTooSmall(size, min) => write!(f, "Error: Downloaded file is too small ({}KB < {}KB)", size, min), + DownloadError::RemoveFileFailed(e) => write!(f, "Error removing file: {}", e), + DownloadError::ExtractionFailed(e) => write!(f, "Error extracting archive: {}", e), + DownloadError::CommandExecutionFailed(e) => write!(f, "Error executing command: {}", e), + DownloadError::InvalidUrl(url) => write!(f, "Invalid URL: {}", url), + DownloadError::NotAFile(path) => write!(f, "Not a file: {}", path), + DownloadError::PlatformNotSupported(msg) => write!(f, "{}", msg), + DownloadError::InstallationFailed(msg) => write!(f, "{}", msg), + } + } +} + +// Implement Error trait for DownloadError +impl Error for DownloadError { + fn source(&self) -> Option<&(dyn Error + 'static)> { + match self { + DownloadError::CreateDirectoryFailed(e) => Some(e), + DownloadError::CurlExecutionFailed(e) => Some(e), + DownloadError::FileMetadataError(e) => Some(e), + DownloadError::RemoveFileFailed(e) => Some(e), + DownloadError::CommandExecutionFailed(e) => Some(e), + _ => None, + } + } +} + +/** + * Download a file from URL to destination using the curl command. + * + * # Arguments + * + * * `url` - The URL to download from + * * `dest` - The destination path where the file will be saved + * * `min_size_kb` - Minimum required file size in KB (0 for no minimum) + * + * # Returns + * + * * `Ok(String)` - The path where the file was saved or extracted + * * `Err(DownloadError)` - An error if the download failed + * + * # Examples + * + * ``` + * // Download a file with no minimum size requirement + * let path = download("https://example.com/file.txt", "/tmp/file.txt", 0)?; + * + * // Download a file with minimum size requirement of 100KB + * let path = download("https://example.com/file.zip", "/tmp/file.zip", 100)?; + * ``` + * + * # Notes + * + * If the URL ends with .tar.gz, .tgz, .tar, or .zip, the file will be automatically + * extracted to the destination directory. + */ +pub fn download(url: &str, dest: &str, min_size_kb: i64) -> Result { + // Create parent directories if they don't exist + let dest_path = Path::new(dest); + if let Some(parent) = dest_path.parent() { + fs::create_dir_all(parent).map_err(DownloadError::CreateDirectoryFailed)?; + } + + // Create a temporary path for downloading + let temp_path = format!("{}.download", dest); + + // Use curl to download the file with progress bar + println!("Downloading {} to {}", url, dest); + let output = Command::new("curl") + .args(&["--progress-bar", "--location", "--fail", "--output", &temp_path, url]) + .status() + .map_err(DownloadError::CurlExecutionFailed)?; + + if !output.success() { + return Err(DownloadError::DownloadFailed(url.to_string())); + } + + // Show file size after download + match fs::metadata(&temp_path) { + Ok(metadata) => { + let size_bytes = metadata.len(); + let size_kb = size_bytes / 1024; + let size_mb = size_kb / 1024; + if size_mb > 1 { + println!("Download complete! File size: {:.2} MB", size_bytes as f64 / (1024.0 * 1024.0)); + } else { + println!("Download complete! File size: {:.2} KB", size_bytes as f64 / 1024.0); + } + }, + Err(_) => println!("Download complete!"), + } + + // Check file size if minimum size is specified + if min_size_kb > 0 { + let metadata = fs::metadata(&temp_path).map_err(DownloadError::FileMetadataError)?; + let size_kb = metadata.len() as i64 / 1024; + if size_kb < min_size_kb { + fs::remove_file(&temp_path).map_err(DownloadError::RemoveFileFailed)?; + return Err(DownloadError::FileTooSmall(size_kb, min_size_kb)); + } + } + + // Check if it's a compressed file that needs extraction + let lower_url = url.to_lowercase(); + let is_archive = lower_url.ends_with(".tar.gz") || + lower_url.ends_with(".tgz") || + lower_url.ends_with(".tar") || + lower_url.ends_with(".zip"); + + if is_archive { + // Create the destination directory + fs::create_dir_all(dest).map_err(DownloadError::CreateDirectoryFailed)?; + + // Extract the file using the appropriate command with progress indication + println!("Extracting {} to {}", temp_path, dest); + let output = if lower_url.ends_with(".zip") { + Command::new("unzip") + .args(&["-o", &temp_path, "-d", dest]) // Removed -q for verbosity + .status() + } else if lower_url.ends_with(".tar.gz") || lower_url.ends_with(".tgz") { + Command::new("tar") + .args(&["-xzvf", &temp_path, "-C", dest]) // Added v for verbosity + .status() + } else { + Command::new("tar") + .args(&["-xvf", &temp_path, "-C", dest]) // Added v for verbosity + .status() + }; + + match output { + Ok(status) => { + if !status.success() { + return Err(DownloadError::ExtractionFailed("Error extracting archive".to_string())); + } + }, + Err(e) => return Err(DownloadError::CommandExecutionFailed(e)), + } + + // Show number of extracted files + match fs::read_dir(dest) { + Ok(entries) => { + let count = entries.count(); + println!("Extraction complete! Extracted {} files/directories", count); + }, + Err(_) => println!("Extraction complete!"), + } + + // Remove the temporary file + fs::remove_file(&temp_path).map_err(DownloadError::RemoveFileFailed)?; + + Ok(dest.to_string()) + } else { + // Just rename the temporary file to the final destination + fs::rename(&temp_path, dest).map_err(|e| DownloadError::CreateDirectoryFailed(e))?; + + Ok(dest.to_string()) + } +} + +/** + * Download a file and install it if it's a supported package format. + * + * # Arguments + * + * * `url` - The URL to download from + * * `min_size_kb` - Minimum required file size in KB (0 for no minimum) + * + * # Returns + * + * * `Ok(String)` - The path where the file was saved or extracted + * * `Err(DownloadError)` - An error if the download or installation failed + * + * # Examples + * + * ``` + * // Download and install a .deb package + * let result = download_install("https://example.com/package.deb", 100)?; + * ``` + * + * # Notes + * + * Currently only supports .deb packages on Debian-based systems. + * For other file types, it behaves the same as the download function. + */ +pub fn download_install(url: &str, min_size_kb: i64) -> Result { + // Extract filename from URL + let filename = match url.split('/').last() { + Some(name) => name, + None => return Err(DownloadError::InvalidUrl("cannot extract filename".to_string())) + }; + + // Create a proper destination path + let dest_path = format!("/tmp/{}", filename); + + let download_result = download(url, &dest_path, min_size_kb)?; + + // Check if the downloaded result is a file + let path = Path::new(&dest_path); + if !path.is_file() { + return Ok(download_result); // Not a file, might be an extracted directory + } + + // Check if it's a .deb package + if dest_path.to_lowercase().ends_with(".deb") { + // Check if we're on a Debian-based platform + let platform_check = Command::new("sh") + .arg("-c") + .arg("command -v dpkg > /dev/null && command -v apt > /dev/null || test -f /etc/debian_version") + .status(); + + match platform_check { + Ok(status) => { + if !status.success() { + return Err(DownloadError::PlatformNotSupported( + "Cannot install .deb package: not on a Debian-based system".to_string() + )); + } + }, + Err(_) => return Err(DownloadError::PlatformNotSupported( + "Failed to check system compatibility for .deb installation".to_string() + )), + } + + // Install the .deb package non-interactively + println!("Installing package: {}", dest_path); + let install_result = Command::new("sudo") + .args(&["dpkg", "--install", &dest_path]) + .status(); + + match install_result { + Ok(status) => { + if !status.success() { + // If dpkg fails, try to fix dependencies and retry + println!("Attempting to resolve dependencies..."); + let fix_deps = Command::new("sudo") + .args(&["apt-get", "install", "-f", "-y"]) + .status(); + + if let Ok(fix_status) = fix_deps { + if !fix_status.success() { + return Err(DownloadError::InstallationFailed( + "Failed to resolve package dependencies".to_string() + )); + } + } else { + return Err(DownloadError::InstallationFailed( + "Failed to resolve package dependencies".to_string() + )); + } + } + println!("Package installation completed successfully"); + }, + Err(e) => return Err(DownloadError::CommandExecutionFailed(e)), + } + } + + Ok(download_result) +} diff --git a/src/os/fs.rs b/src/os/fs.rs new file mode 100644 index 0000000..d848464 --- /dev/null +++ b/src/os/fs.rs @@ -0,0 +1,580 @@ +use std::fs; +use std::path::Path; +use std::process::Command; +use std::fmt; +use std::error::Error; +use std::io; + +// Define a custom error type for file system operations +#[derive(Debug)] +pub enum FsError { + DirectoryNotFound(String), + FileNotFound(String), + CreateDirectoryFailed(io::Error), + CopyFailed(io::Error), + DeleteFailed(io::Error), + CommandFailed(String), + CommandExecutionError(io::Error), + InvalidGlobPattern(glob::PatternError), + NotADirectory(String), + NotAFile(String), + UnknownFileType(String), + MetadataError(io::Error), +} + +// Implement Display for FsError +impl fmt::Display for FsError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + FsError::DirectoryNotFound(dir) => write!(f, "Directory '{}' does not exist", dir), + FsError::FileNotFound(pattern) => write!(f, "No files found matching '{}'", pattern), + FsError::CreateDirectoryFailed(e) => write!(f, "Failed to create parent directories: {}", e), + FsError::CopyFailed(e) => write!(f, "Failed to copy file: {}", e), + FsError::DeleteFailed(e) => write!(f, "Failed to delete: {}", e), + FsError::CommandFailed(e) => write!(f, "{}", e), + FsError::CommandExecutionError(e) => write!(f, "Failed to execute command: {}", e), + FsError::InvalidGlobPattern(e) => write!(f, "Invalid glob pattern: {}", e), + FsError::NotADirectory(path) => write!(f, "Path '{}' exists but is not a directory", path), + FsError::NotAFile(path) => write!(f, "Path '{}' is not a regular file", path), + FsError::UnknownFileType(path) => write!(f, "Unknown file type at '{}'", path), + FsError::MetadataError(e) => write!(f, "Failed to get file metadata: {}", e), + } + } +} + +// Implement Error trait for FsError +impl Error for FsError { + fn source(&self) -> Option<&(dyn Error + 'static)> { + match self { + FsError::CreateDirectoryFailed(e) => Some(e), + FsError::CopyFailed(e) => Some(e), + FsError::DeleteFailed(e) => Some(e), + FsError::CommandExecutionError(e) => Some(e), + FsError::InvalidGlobPattern(e) => Some(e), + FsError::MetadataError(e) => Some(e), + _ => None, + } + } +} + +/** + * Recursively copy a file or directory from source to destination. + * + * # Arguments + * + * * `src` - The source path, which can include wildcards + * * `dest` - The destination path + * + * # Returns + * + * * `Ok(String)` - A success message indicating what was copied + * * `Err(FsError)` - An error if the copy operation failed + * + * # Examples + * + * ``` + * // Copy a single file + * let result = copy("file.txt", "backup/file.txt")?; + * + * // Copy multiple files using wildcards + * let result = copy("*.txt", "backup/")?; + * + * // Copy a directory recursively + * let result = copy("src_dir", "dest_dir")?; + * ``` + */ +pub fn copy(src: &str, dest: &str) -> Result { + let dest_path = Path::new(dest); + + // Check if source path contains wildcards + if src.contains('*') || src.contains('?') || src.contains('[') { + // Create parent directories for destination if needed + if let Some(parent) = dest_path.parent() { + fs::create_dir_all(parent).map_err(FsError::CreateDirectoryFailed)?; + } + + // Use glob to expand wildcards + let entries = glob::glob(src).map_err(FsError::InvalidGlobPattern)?; + + let paths: Vec<_> = entries + .filter_map(Result::ok) + .collect(); + + if paths.is_empty() { + return Err(FsError::FileNotFound(src.to_string())); + } + + let mut success_count = 0; + let dest_is_dir = dest_path.exists() && dest_path.is_dir(); + + for path in paths { + let target_path = if dest_is_dir { + // If destination is a directory, copy the file into it + dest_path.join(path.file_name().unwrap_or_default()) + } else { + // Otherwise use the destination as is (only makes sense for single file) + dest_path.to_path_buf() + }; + + if path.is_file() { + // Copy file + if let Err(e) = fs::copy(&path, &target_path) { + println!("Warning: Failed to copy {}: {}", path.display(), e); + } else { + success_count += 1; + } + } else if path.is_dir() { + // For directories, use platform-specific command + #[cfg(target_os = "windows")] + let output = Command::new("xcopy") + .args(&["/E", "/I", "/H", "/Y", + &path.to_string_lossy(), + &target_path.to_string_lossy()]) + .status(); + + #[cfg(not(target_os = "windows"))] + let output = Command::new("cp") + .args(&["-R", + &path.to_string_lossy(), + &target_path.to_string_lossy()]) + .status(); + + match output { + Ok(status) => { + if status.success() { + success_count += 1; + } + }, + Err(e) => println!("Warning: Failed to copy directory {}: {}", path.display(), e), + } + } + } + + if success_count > 0 { + Ok(format!("Successfully copied {} items from '{}' to '{}'", + success_count, src, dest)) + } else { + Err(FsError::CommandFailed(format!("Failed to copy any files from '{}' to '{}'", src, dest))) + } + } else { + // Handle non-wildcard paths normally + let src_path = Path::new(src); + + // Check if source exists + if !src_path.exists() { + return Err(FsError::FileNotFound(src.to_string())); + } + + // Create parent directories if they don't exist + if let Some(parent) = dest_path.parent() { + fs::create_dir_all(parent).map_err(FsError::CreateDirectoryFailed)?; + } + + // Copy based on source type + if src_path.is_file() { + // Copy file + fs::copy(src_path, dest_path).map_err(FsError::CopyFailed)?; + Ok(format!("Successfully copied file '{}' to '{}'", src, dest)) + } else if src_path.is_dir() { + // For directories, use platform-specific command + #[cfg(target_os = "windows")] + let output = Command::new("xcopy") + .args(&["/E", "/I", "/H", "/Y", src, dest]) + .output(); + + #[cfg(not(target_os = "windows"))] + let output = Command::new("cp") + .args(&["-R", src, dest]) + .output(); + + match output { + Ok(out) => { + if out.status.success() { + Ok(format!("Successfully copied directory '{}' to '{}'", src, dest)) + } else { + let error = String::from_utf8_lossy(&out.stderr); + Err(FsError::CommandFailed(format!("Failed to copy directory: {}", error))) + } + }, + Err(e) => Err(FsError::CommandExecutionError(e)), + } + } else { + Err(FsError::UnknownFileType(src.to_string())) + } + } +} + +/** + * Check if a file or directory exists. + * + * # Arguments + * + * * `path` - The path to check + * + * # Returns + * + * * `bool` - True if the path exists, false otherwise + * + * # Examples + * + * ``` + * if exist("file.txt") { + * println!("File exists"); + * } + * ``` + */ +pub fn exist(path: &str) -> bool { + Path::new(path).exists() +} + +/** + * Find a file in a directory (with support for wildcards). + * + * # Arguments + * + * * `dir` - The directory to search in + * * `filename` - The filename pattern to search for (can include wildcards) + * + * # Returns + * + * * `Ok(String)` - The path to the found file + * * `Err(FsError)` - An error if no file is found or multiple files are found + * + * # Examples + * + * ``` + * let file_path = find_file("/path/to/dir", "*.txt")?; + * println!("Found file: {}", file_path); + * ``` + */ +pub fn find_file(dir: &str, filename: &str) -> Result { + let dir_path = Path::new(dir); + + // Check if directory exists + if !dir_path.exists() || !dir_path.is_dir() { + return Err(FsError::DirectoryNotFound(dir.to_string())); + } + + // Use glob to find files - use recursive pattern to find in subdirectories too + let pattern = format!("{}/**/{}", dir, filename); + let entries = glob::glob(&pattern).map_err(FsError::InvalidGlobPattern)?; + + let files: Vec<_> = entries + .filter_map(Result::ok) + .filter(|path| path.is_file()) + .collect(); + + match files.len() { + 0 => Err(FsError::FileNotFound(filename.to_string())), + 1 => Ok(files[0].to_string_lossy().to_string()), + _ => { + // If multiple matches, just return the first one instead of erroring + // This makes wildcard searches more practical + println!("Note: Multiple files found matching '{}', returning first match", filename); + Ok(files[0].to_string_lossy().to_string()) + } + } +} + +/** + * Find multiple files in a directory (recursive, with support for wildcards). + * + * # Arguments + * + * * `dir` - The directory to search in + * * `filename` - The filename pattern to search for (can include wildcards) + * + * # Returns + * + * * `Ok(Vec)` - A vector of paths to the found files + * * `Err(FsError)` - An error if the directory doesn't exist or the pattern is invalid + * + * # Examples + * + * ``` + * let files = find_files("/path/to/dir", "*.txt")?; + * for file in files { + * println!("Found file: {}", file); + * } + * ``` + */ +pub fn find_files(dir: &str, filename: &str) -> Result, FsError> { + let dir_path = Path::new(dir); + + // Check if directory exists + if !dir_path.exists() || !dir_path.is_dir() { + return Err(FsError::DirectoryNotFound(dir.to_string())); + } + + // Use glob to find files + let pattern = format!("{}/**/{}", dir, filename); + let entries = glob::glob(&pattern).map_err(FsError::InvalidGlobPattern)?; + + let files: Vec = entries + .filter_map(Result::ok) + .filter(|path| path.is_file()) + .map(|path| path.to_string_lossy().to_string()) + .collect(); + + Ok(files) +} + +/** + * Find a directory in a parent directory (with support for wildcards). + * + * # Arguments + * + * * `dir` - The parent directory to search in + * * `dirname` - The directory name pattern to search for (can include wildcards) + * + * # Returns + * + * * `Ok(String)` - The path to the found directory + * * `Err(FsError)` - An error if no directory is found or multiple directories are found + * + * # Examples + * + * ``` + * let dir_path = find_dir("/path/to/parent", "sub*")?; + * println!("Found directory: {}", dir_path); + * ``` + */ +pub fn find_dir(dir: &str, dirname: &str) -> Result { + let dir_path = Path::new(dir); + + // Check if directory exists + if !dir_path.exists() || !dir_path.is_dir() { + return Err(FsError::DirectoryNotFound(dir.to_string())); + } + + // Use glob to find directories + let pattern = format!("{}/{}", dir, dirname); + let entries = glob::glob(&pattern).map_err(FsError::InvalidGlobPattern)?; + + let dirs: Vec<_> = entries + .filter_map(Result::ok) + .filter(|path| path.is_dir()) + .collect(); + + match dirs.len() { + 0 => Err(FsError::DirectoryNotFound(dirname.to_string())), + 1 => Ok(dirs[0].to_string_lossy().to_string()), + _ => Err(FsError::CommandFailed(format!("Multiple directories found matching '{}', expected only one", dirname))), + } +} + +/** + * Find multiple directories in a parent directory (recursive, with support for wildcards). + * + * # Arguments + * + * * `dir` - The parent directory to search in + * * `dirname` - The directory name pattern to search for (can include wildcards) + * + * # Returns + * + * * `Ok(Vec)` - A vector of paths to the found directories + * * `Err(FsError)` - An error if the parent directory doesn't exist or the pattern is invalid + * + * # Examples + * + * ``` + * let dirs = find_dirs("/path/to/parent", "sub*")?; + * for dir in dirs { + * println!("Found directory: {}", dir); + * } + * ``` + */ +pub fn find_dirs(dir: &str, dirname: &str) -> Result, FsError> { + let dir_path = Path::new(dir); + + // Check if directory exists + if !dir_path.exists() || !dir_path.is_dir() { + return Err(FsError::DirectoryNotFound(dir.to_string())); + } + + // Use glob to find directories + let pattern = format!("{}/**/{}", dir, dirname); + let entries = glob::glob(&pattern).map_err(FsError::InvalidGlobPattern)?; + + let dirs: Vec = entries + .filter_map(Result::ok) + .filter(|path| path.is_dir()) + .map(|path| path.to_string_lossy().to_string()) + .collect(); + + Ok(dirs) +} + +/** + * Delete a file or directory (defensive - doesn't error if file doesn't exist). + * + * # Arguments + * + * * `path` - The path to delete + * + * # Returns + * + * * `Ok(String)` - A success message indicating what was deleted + * * `Err(FsError)` - An error if the deletion failed + * + * # Examples + * + * ``` + * // Delete a file + * let result = delete("file.txt")?; + * + * // Delete a directory and all its contents + * let result = delete("directory/")?; + * ``` + */ +pub fn delete(path: &str) -> Result { + let path_obj = Path::new(path); + + // Check if path exists + if !path_obj.exists() { + return Ok(format!("Nothing to delete at '{}'", path)); + } + + // Delete based on path type + if path_obj.is_file() || path_obj.is_symlink() { + fs::remove_file(path_obj).map_err(FsError::DeleteFailed)?; + Ok(format!("Successfully deleted file '{}'", path)) + } else if path_obj.is_dir() { + fs::remove_dir_all(path_obj).map_err(FsError::DeleteFailed)?; + Ok(format!("Successfully deleted directory '{}'", path)) + } else { + Err(FsError::UnknownFileType(path.to_string())) + } +} + +/** + * Create a directory and all parent directories (defensive - doesn't error if directory exists). + * + * # Arguments + * + * * `path` - The path of the directory to create + * + * # Returns + * + * * `Ok(String)` - A success message indicating the directory was created + * * `Err(FsError)` - An error if the creation failed + * + * # Examples + * + * ``` + * let result = mkdir("path/to/new/directory")?; + * println!("{}", result); + * ``` + */ +pub fn mkdir(path: &str) -> Result { + let path_obj = Path::new(path); + + // Check if path already exists + if path_obj.exists() { + if path_obj.is_dir() { + return Ok(format!("Directory '{}' already exists", path)); + } else { + return Err(FsError::NotADirectory(path.to_string())); + } + } + + // Create directory and parents + fs::create_dir_all(path_obj).map_err(FsError::CreateDirectoryFailed)?; + Ok(format!("Successfully created directory '{}'", path)) +} + +/** + * Get the size of a file in bytes. + * + * # Arguments + * + * * `path` - The path of the file + * + * # Returns + * + * * `Ok(i64)` - The size of the file in bytes + * * `Err(FsError)` - An error if the file doesn't exist or isn't a regular file + * + * # Examples + * + * ``` + * let size = file_size("file.txt")?; + * println!("File size: {} bytes", size); + * ``` + */ +pub fn file_size(path: &str) -> Result { + let path_obj = Path::new(path); + + // Check if file exists + if !path_obj.exists() { + return Err(FsError::FileNotFound(path.to_string())); + } + + // Check if it's a regular file + if !path_obj.is_file() { + return Err(FsError::NotAFile(path.to_string())); + } + + // Get file metadata + let metadata = fs::metadata(path_obj).map_err(FsError::MetadataError)?; + Ok(metadata.len() as i64) +} + +/** + * Sync directories using rsync (or platform equivalent). + * + * # Arguments + * + * * `src` - The source directory + * * `dest` - The destination directory + * + * # Returns + * + * * `Ok(String)` - A success message indicating the directories were synced + * * `Err(FsError)` - An error if the sync failed + * + * # Examples + * + * ``` + * let result = rsync("source_dir/", "backup_dir/")?; + * println!("{}", result); + * ``` + */ +pub fn rsync(src: &str, dest: &str) -> Result { + let src_path = Path::new(src); + let dest_path = Path::new(dest); + + // Check if source exists + if !src_path.exists() { + return Err(FsError::FileNotFound(src.to_string())); + } + + // Create parent directories if they don't exist + if let Some(parent) = dest_path.parent() { + fs::create_dir_all(parent).map_err(FsError::CreateDirectoryFailed)?; + } + + // Use platform-specific command for syncing + #[cfg(target_os = "windows")] + let output = Command::new("robocopy") + .args(&[src, dest, "/MIR", "/NFL", "/NDL"]) + .output(); + + #[cfg(any(target_os = "macos", target_os = "linux"))] + let output = Command::new("rsync") + .args(&["-a", "--delete", src, dest]) + .output(); + + match output { + Ok(out) => { + if out.status.success() || out.status.code() == Some(1) { // rsync and robocopy return 1 for some non-error cases + Ok(format!("Successfully synced '{}' to '{}'", src, dest)) + } else { + let error = String::from_utf8_lossy(&out.stderr); + Err(FsError::CommandFailed(format!("Failed to sync directories: {}", error))) + } + }, + Err(e) => Err(FsError::CommandExecutionError(e)), + } +} diff --git a/src/os/mod.rs b/src/os/mod.rs new file mode 100644 index 0000000..4169e3b --- /dev/null +++ b/src/os/mod.rs @@ -0,0 +1,2 @@ +use fs::*; +use download::*; \ No newline at end of file diff --git a/src/process/mgmt.rs b/src/process/mgmt.rs new file mode 100644 index 0000000..8558a70 --- /dev/null +++ b/src/process/mgmt.rs @@ -0,0 +1,303 @@ +use std::process::Command; +use std::collections::HashMap; +use std::fmt; +use std::error::Error; +use std::io; + +// Define a custom error type for process operations +#[derive(Debug)] +pub enum ProcessError { + CommandExecutionFailed(io::Error), + CommandFailed(String), + NoProcessFound(String), + MultipleProcessesFound(String, usize), +} + +// Implement Display for ProcessError +impl fmt::Display for ProcessError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + ProcessError::CommandExecutionFailed(e) => write!(f, "Failed to execute command: {}", e), + ProcessError::CommandFailed(e) => write!(f, "{}", e), + ProcessError::NoProcessFound(pattern) => write!(f, "No processes found matching '{}'", pattern), + ProcessError::MultipleProcessesFound(pattern, count) => + write!(f, "Multiple processes ({}) found matching '{}'", count, pattern), + } + } +} + +// Implement Error trait for ProcessError +impl Error for ProcessError { + fn source(&self) -> Option<&(dyn Error + 'static)> { + match self { + ProcessError::CommandExecutionFailed(e) => Some(e), + _ => None, + } + } +} + +// Define a struct to represent process information +#[derive(Debug, Clone)] +pub struct ProcessInfo { + pub pid: i64, + pub name: String, + pub memory: f64, + pub cpu: f64, +} + +/** + * Check if a command exists in PATH. + * + * # Arguments + * + * * `cmd` - The command to check + * + * # Returns + * + * * `Option` - The full path to the command if found, None otherwise + * + * # Examples + * + * ``` + * match which("git") { + * Some(path) => println!("Git is installed at: {}", path), + * None => println!("Git is not installed"), + * } + * ``` + */ +pub fn which(cmd: &str) -> Option { + #[cfg(target_os = "windows")] + let which_cmd = "where"; + + #[cfg(any(target_os = "macos", target_os = "linux"))] + let which_cmd = "which"; + + let output = Command::new(which_cmd) + .arg(cmd) + .output(); + + match output { + Ok(out) => { + if out.status.success() { + let path = String::from_utf8_lossy(&out.stdout).trim().to_string(); + Some(path) + } else { + None + } + }, + Err(_) => None + } +} + +/** + * Kill processes matching a pattern. + * + * # Arguments + * + * * `pattern` - The pattern to match against process names + * + * # Returns + * + * * `Ok(String)` - A success message indicating processes were killed or none were found + * * `Err(ProcessError)` - An error if the kill operation failed + * + * # Examples + * + * ``` + * // Kill all processes with "server" in their name + * let result = kill("server")?; + * println!("{}", result); + * ``` + */ +pub fn kill(pattern: &str) -> Result { + // Platform specific implementation + #[cfg(target_os = "windows")] + { + // On Windows, use taskkill with wildcard support + let mut args = vec!["/F"]; // Force kill + + if pattern.contains('*') { + // If it contains wildcards, use filter + args.extend(&["/FI", &format!("IMAGENAME eq {}", pattern)]); + } else { + // Otherwise use image name directly + args.extend(&["/IM", pattern]); + } + + let output = Command::new("taskkill") + .args(&args) + .output() + .map_err(ProcessError::CommandExecutionFailed)?; + + if output.status.success() { + Ok("Successfully killed processes".to_string()) + } else { + let error = String::from_utf8_lossy(&output.stderr); + if error.is_empty() { + let stdout = String::from_utf8_lossy(&output.stdout); + if stdout.contains("No tasks") { + Ok("No matching processes found".to_string()) + } else { + Err(ProcessError::CommandFailed(format!("Failed to kill processes: {}", stdout))) + } + } else { + Err(ProcessError::CommandFailed(format!("Failed to kill processes: {}", error))) + } + } + } + + #[cfg(any(target_os = "macos", target_os = "linux"))] + { + // On Unix-like systems, use pkill which has built-in pattern matching + let output = Command::new("pkill") + .arg("-f") // Match against full process name/args + .arg(pattern) + .output() + .map_err(ProcessError::CommandExecutionFailed)?; + + // pkill returns 0 if processes were killed, 1 if none matched + if output.status.success() { + Ok("Successfully killed processes".to_string()) + } else if output.status.code() == Some(1) { + Ok("No matching processes found".to_string()) + } else { + let error = String::from_utf8_lossy(&output.stderr); + Err(ProcessError::CommandFailed(format!("Failed to kill processes: {}", error))) + } + } +} + +/** + * List processes matching a pattern (or all if pattern is empty). + * + * # Arguments + * + * * `pattern` - The pattern to match against process names (empty string for all processes) + * + * # Returns + * + * * `Ok(Vec)` - A vector of process information for matching processes + * * `Err(ProcessError)` - An error if the list operation failed + * + * # Examples + * + * ``` + * // List all processes + * let processes = process_list("")?; + * + * // List processes with "server" in their name + * let processes = process_list("server")?; + * for proc in processes { + * println!("PID: {}, Name: {}", proc.pid, proc.name); + * } + * ``` + */ +pub fn process_list(pattern: &str) -> Result, ProcessError> { + let mut processes = Vec::new(); + + // Platform specific implementations + #[cfg(target_os = "windows")] + { + // Windows implementation using wmic + let output = Command::new("wmic") + .args(&["process", "list", "brief"]) + .output() + .map_err(ProcessError::CommandExecutionFailed)?; + + if output.status.success() { + let stdout = String::from_utf8_lossy(&output.stdout).to_string(); + + // Parse output (assuming format: Handle Name Priority) + for line in stdout.lines().skip(1) { // Skip header + let parts: Vec<&str> = line.trim().split_whitespace().collect(); + if parts.len() >= 2 { + let pid = parts[0].parse::().unwrap_or(0); + let name = parts[1].to_string(); + + // Filter by pattern if provided + if !pattern.is_empty() && !name.contains(pattern) { + continue; + } + + processes.push(ProcessInfo { + pid, + name, + memory: 0.0, // Placeholder + cpu: 0.0, // Placeholder + }); + } + } + } else { + let stderr = String::from_utf8_lossy(&output.stderr).to_string(); + return Err(ProcessError::CommandFailed(format!("Failed to list processes: {}", stderr))); + } + } + + #[cfg(any(target_os = "macos", target_os = "linux"))] + { + // Unix implementation using ps + let output = Command::new("ps") + .args(&["-eo", "pid,comm"]) + .output() + .map_err(ProcessError::CommandExecutionFailed)?; + + if output.status.success() { + let stdout = String::from_utf8_lossy(&output.stdout).to_string(); + + // Parse output (assuming format: PID COMMAND) + for line in stdout.lines().skip(1) { // Skip header + let parts: Vec<&str> = line.trim().split_whitespace().collect(); + if parts.len() >= 2 { + let pid = parts[0].parse::().unwrap_or(0); + let name = parts[1].to_string(); + + // Filter by pattern if provided + if !pattern.is_empty() && !name.contains(pattern) { + continue; + } + + processes.push(ProcessInfo { + pid, + name, + memory: 0.0, // Placeholder + cpu: 0.0, // Placeholder + }); + } + } + } else { + let stderr = String::from_utf8_lossy(&output.stderr).to_string(); + return Err(ProcessError::CommandFailed(format!("Failed to list processes: {}", stderr))); + } + } + + Ok(processes) +} + +/** + * Get a single process matching the pattern (error if 0 or more than 1 match). + * + * # Arguments + * + * * `pattern` - The pattern to match against process names + * + * # Returns + * + * * `Ok(ProcessInfo)` - Information about the matching process + * * `Err(ProcessError)` - An error if no process or multiple processes match + * + * # Examples + * + * ``` + * let process = process_get("unique-server-name")?; + * println!("Found process: {} (PID: {})", process.name, process.pid); + * ``` + */ +pub fn process_get(pattern: &str) -> Result { + let processes = process_list(pattern)?; + + match processes.len() { + 0 => Err(ProcessError::NoProcessFound(pattern.to_string())), + 1 => Ok(processes[0].clone()), + _ => Err(ProcessError::MultipleProcessesFound(pattern.to_string(), processes.len())), + } +} diff --git a/src/process/mod.rs b/src/process/mod.rs new file mode 100644 index 0000000..3e615e1 --- /dev/null +++ b/src/process/mod.rs @@ -0,0 +1,2 @@ +use run::*; +use mgmt::*; \ No newline at end of file diff --git a/src/process/run.rs b/src/process/run.rs new file mode 100644 index 0000000..90e16d7 --- /dev/null +++ b/src/process/run.rs @@ -0,0 +1,493 @@ +use std::io::{BufRead, BufReader, Write}; +use std::fs::{self, File}; +use std::path::{Path, PathBuf}; +use std::process::{Child, Command, Output, Stdio}; +use std::collections::HashMap; +use std::fmt; +use std::error::Error; +use std::io; + +use super::text; + +// Define a custom error type for run operations +#[derive(Debug)] +pub enum RunError { + EmptyCommand, + CommandExecutionFailed(io::Error), + CommandFailed(String), + ScriptPreparationFailed(String), + ChildProcessError(String), + TempDirCreationFailed(io::Error), + FileCreationFailed(io::Error), + FileWriteFailed(io::Error), + PermissionError(io::Error), +} + +// Implement Display for RunError +impl fmt::Display for RunError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + RunError::EmptyCommand => write!(f, "Empty command"), + RunError::CommandExecutionFailed(e) => write!(f, "Failed to execute command: {}", e), + RunError::CommandFailed(e) => write!(f, "{}", e), + RunError::ScriptPreparationFailed(e) => write!(f, "{}", e), + RunError::ChildProcessError(e) => write!(f, "{}", e), + RunError::TempDirCreationFailed(e) => write!(f, "Failed to create temporary directory: {}", e), + RunError::FileCreationFailed(e) => write!(f, "Failed to create script file: {}", e), + RunError::FileWriteFailed(e) => write!(f, "Failed to write to script file: {}", e), + RunError::PermissionError(e) => write!(f, "Failed to set file permissions: {}", e), + } + } +} + +// Implement Error trait for RunError +impl Error for RunError { + fn source(&self) -> Option<&(dyn Error + 'static)> { + match self { + RunError::CommandExecutionFailed(e) => Some(e), + RunError::TempDirCreationFailed(e) => Some(e), + RunError::FileCreationFailed(e) => Some(e), + RunError::FileWriteFailed(e) => Some(e), + RunError::PermissionError(e) => Some(e), + _ => None, + } + } +} + +/// A structure to hold command execution results +#[derive(Debug, Clone)] +pub struct CommandResult { + pub stdout: String, + pub stderr: String, + pub success: bool, + pub code: i32, +} + +impl CommandResult { + /// Create a default failed result with an error message + fn error(message: &str) -> Self { + Self { + stdout: String::new(), + stderr: message.to_string(), + success: false, + code: -1, + } + } +} + +/// Prepare a script file and return the path and interpreter +fn prepare_script_file(script_content: &str) -> Result<(PathBuf, String, tempfile::TempDir), RunError> { + // Dedent the script + let dedented = text::dedent(script_content); + + // Create a temporary directory + let temp_dir = tempfile::tempdir() + .map_err(RunError::TempDirCreationFailed)?; + + // Determine script extension and interpreter + #[cfg(target_os = "windows")] + let (ext, interpreter) = (".bat", "cmd.exe".to_string()); + + #[cfg(any(target_os = "macos", target_os = "linux"))] + let (ext, interpreter) = (".sh", "/bin/sh".to_string()); + + // Create the script file + let script_path = temp_dir.path().join(format!("script{}", ext)); + let mut file = File::create(&script_path) + .map_err(RunError::FileCreationFailed)?; + + // Write the script content + file.write_all(dedented.as_bytes()) + .map_err(RunError::FileWriteFailed)?; + + // Make the script executable (Unix only) + #[cfg(any(target_os = "macos", target_os = "linux"))] + { + use std::os::unix::fs::PermissionsExt; + let mut perms = fs::metadata(&script_path) + .map_err(|e| RunError::PermissionError(e))? + .permissions(); + perms.set_mode(0o755); // rwxr-xr-x + fs::set_permissions(&script_path, perms) + .map_err(RunError::PermissionError)?; + } + + Ok((script_path, interpreter, temp_dir)) +} + +/// Capture output from Child's stdio streams with optional printing +fn handle_child_output(mut child: Child, silent: bool) -> Result { + // Prepare to read stdout & stderr line-by-line + let stdout = child.stdout.take(); + let stderr = child.stderr.take(); + + // Buffers for captured output + let mut captured_stdout = String::new(); + let mut captured_stderr = String::new(); + + // Process stdout + let stdout_handle = if let Some(out) = stdout { + let reader = BufReader::new(out); + let silent_clone = silent; + // Spawn a thread to capture and optionally print stdout + Some(std::thread::spawn(move || { + let mut local_buffer = String::new(); + for line in reader.lines() { + if let Ok(l) = line { + // Print the line if not silent and flush immediately + if !silent_clone { + println!("{}", l); + std::io::stdout().flush().unwrap_or(()); + } + // Store it in our captured buffer + local_buffer.push_str(&l); + local_buffer.push('\n'); + } + } + local_buffer + })) + } else { + None + }; + + // Process stderr + let stderr_handle = if let Some(err) = stderr { + let reader = BufReader::new(err); + let silent_clone = silent; + // Spawn a thread to capture and optionally print stderr + Some(std::thread::spawn(move || { + let mut local_buffer = String::new(); + for line in reader.lines() { + if let Ok(l) = line { + // Print the line if not silent and flush immediately + if !silent_clone { + eprintln!("{}", l); + std::io::stderr().flush().unwrap_or(()); + } + // Store it in our captured buffer + local_buffer.push_str(&l); + local_buffer.push('\n'); + } + } + local_buffer + })) + } else { + None + }; + + // Wait for the child process to exit + let status = child.wait() + .map_err(|e| RunError::ChildProcessError(format!("Failed to wait on child process: {}", e)))?; + + // Join our stdout thread if it exists + if let Some(handle) = stdout_handle { + captured_stdout = handle.join().unwrap_or_default(); + } else { + captured_stdout = "Failed to capture stdout".to_string(); + } + + // Join our stderr thread if it exists + if let Some(handle) = stderr_handle { + captured_stderr = handle.join().unwrap_or_default(); + } else { + captured_stderr = "Failed to capture stderr".to_string(); + } + + // Return the command result + Ok(CommandResult { + stdout: captured_stdout, + stderr: captured_stderr, + success: status.success(), + code: status.code().unwrap_or(-1), + }) +} + +/// Processes Output structure from Command::output() into CommandResult +fn process_command_output(output: Result) -> Result { + match output { + Ok(out) => { + let stdout = String::from_utf8_lossy(&out.stdout).to_string(); + let stderr = String::from_utf8_lossy(&out.stderr).to_string(); + + Ok(CommandResult { + stdout, + stderr, + success: out.status.success(), + code: out.status.code().unwrap_or(-1), + }) + }, + Err(e) => Err(RunError::CommandExecutionFailed(e)), + } +} + +/** + * Common logic for running a command with optional silent mode. + * + * # Arguments + * + * * `command` - The command + args as a single string (e.g., "ls -la") + * * `silent` - If `true`, don't print stdout/stderr as it arrives (capture only) + * + * # Returns + * + * * `Ok(CommandResult)` - The result of the command execution + * * `Err(RunError)` - An error if the command execution failed + */ +fn run_command_internal(command: &str, silent: bool) -> Result { + let mut parts = command.split_whitespace(); + let cmd = match parts.next() { + Some(c) => c, + None => return Err(RunError::EmptyCommand), + }; + + let args: Vec<&str> = parts.collect(); + + // Spawn the child process with piped stdout & stderr + let child = Command::new(cmd) + .args(&args) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(RunError::CommandExecutionFailed)?; + + handle_child_output(child, silent) +} + +/** + * Execute a script with the given interpreter and path. + * + * # Arguments + * + * * `interpreter` - The interpreter to use (e.g., "/bin/sh") + * * `script_path` - The path to the script file + * * `silent` - If `true`, don't print stdout/stderr as it arrives (capture only) + * + * # Returns + * + * * `Ok(CommandResult)` - The result of the script execution + * * `Err(RunError)` - An error if the script execution failed + */ +fn execute_script_internal(interpreter: &str, script_path: &Path, silent: bool) -> Result { + #[cfg(target_os = "windows")] + let command_args = vec!["/c", script_path.to_str().unwrap_or("")]; + + #[cfg(any(target_os = "macos", target_os = "linux"))] + let command_args = vec![script_path.to_str().unwrap_or("")]; + + if silent { + // For silent execution, use output() which captures but doesn't display + let output = Command::new(interpreter) + .args(&command_args) + .output(); + + process_command_output(output) + } else { + // For normal execution, spawn and handle the output streams + let child = Command::new(interpreter) + .args(&command_args) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(RunError::CommandExecutionFailed)?; + + handle_child_output(child, false) + } +} + +/** + * Run a single command with arguments, showing live stdout and stderr. + * + * # Arguments + * + * * `command` - The command + args as a single string (e.g., "ls -la") + * + * # Returns + * + * * `Ok(CommandResult)` - The result of the command execution + * * `Err(RunError)` - An error if the command execution failed + * + * # Examples + * + * ``` + * let result = run_command("ls -la")?; + * println!("Command exited with code: {}", result.code); + * ``` + */ +pub fn run_command(command: &str) -> Result { + run_command_internal(command, /* silent = */ false) +} + +/** + * Run a single command with arguments silently. + * + * # Arguments + * + * * `command` - The command + args as a single string (e.g., "ls -la") + * + * # Returns + * + * * `Ok(CommandResult)` - The result of the command execution + * * `Err(RunError)` - An error if the command execution failed + * + * # Examples + * + * ``` + * let result = run_command_silent("ls -la")?; + * println!("Command output: {}", result.stdout); + * ``` + */ +pub fn run_command_silent(command: &str) -> Result { + run_command_internal(command, /* silent = */ true) +} + +/** + * Run a multiline script with optional silent mode. + * + * # Arguments + * + * * `script` - The script content as a string + * * `silent` - If `true`, don't print stdout/stderr as it arrives (capture only) + * + * # Returns + * + * * `Ok(CommandResult)` - The result of the script execution + * * `Err(RunError)` - An error if the script execution failed + */ +fn run_script_internal(script: &str, silent: bool) -> Result { + let (script_path, interpreter, _temp_dir) = prepare_script_file(script)?; + // _temp_dir is kept in scope until the end of this function to ensure + // it's not dropped prematurely, which would clean up the directory + execute_script_internal(&interpreter, &script_path, silent) +} + +/** + * Run a multiline script by saving it to a temporary file and executing. + * + * # Arguments + * + * * `script` - The script content as a string + * + * # Returns + * + * * `Ok(CommandResult)` - The result of the script execution + * * `Err(RunError)` - An error if the script execution failed + * + * # Examples + * + * ``` + * let script = r#" + * echo "Hello, world!" + * ls -la + * "#; + * let result = run_script(script)?; + * println!("Script exited with code: {}", result.code); + * ``` + */ +pub fn run_script(script: &str) -> Result { + run_script_internal(script, false) +} + +/** + * Run a multiline script silently by saving it to a temporary file and executing. + * + * # Arguments + * + * * `script` - The script content as a string + * + * # Returns + * + * * `Ok(CommandResult)` - The result of the script execution + * * `Err(RunError)` - An error if the script execution failed + * + * # Examples + * + * ``` + * let script = r#" + * echo "Hello, world!" + * ls -la + * "#; + * let result = run_script_silent(script)?; + * println!("Script output: {}", result.stdout); + * ``` + */ +pub fn run_script_silent(script: &str) -> Result { + run_script_internal(script, true) +} + +/** + * Run a command or multiline script with arguments. + * Shows stdout/stderr as it arrives. + * + * # Arguments + * + * * `command` - The command or script to run + * + * # Returns + * + * * `Ok(CommandResult)` - The result of the execution + * * `Err(RunError)` - An error if the execution failed + * + * # Examples + * + * ``` + * // Run a single command + * let result = run("ls -la")?; + * + * // Run a multiline script + * let result = run(r#" + * echo "Hello, world!" + * ls -la + * "#)?; + * ``` + */ +pub fn run(command: &str) -> Result { + let trimmed = command.trim(); + + // Check if this is a multiline script + if trimmed.contains('\n') { + // This is a multiline script, write to a temporary file and execute + run_script(trimmed) + } else { + // This is a single command with arguments + run_command(trimmed) + } +} + +/** + * Run a command or multiline script with arguments silently. + * Doesn't show stdout/stderr as it arrives. + * + * # Arguments + * + * * `command` - The command or script to run + * + * # Returns + * + * * `Ok(CommandResult)` - The result of the execution + * * `Err(RunError)` - An error if the execution failed + * + * # Examples + * + * ``` + * // Run a single command silently + * let result = run_silent("ls -la")?; + * + * // Run a multiline script silently + * let result = run_silent(r#" + * echo "Hello, world!" + * ls -la + * "#)?; + * ``` + */ +pub fn run_silent(command: &str) -> Result { + let trimmed = command.trim(); + + // Check if this is a multiline script + if trimmed.contains('\n') { + // This is a multiline script, write to a temporary file and execute + run_script_silent(trimmed) + } else { + // This is a single command with arguments + run_command_silent(trimmed) + } +} diff --git a/src/text/mod.rs b/src/text/mod.rs new file mode 100644 index 0000000..1aae484 --- /dev/null +++ b/src/text/mod.rs @@ -0,0 +1 @@ +use text::*; \ No newline at end of file diff --git a/src/text/text.rs b/src/text/text.rs new file mode 100644 index 0000000..cfc3867 --- /dev/null +++ b/src/text/text.rs @@ -0,0 +1,83 @@ +/** + * Dedent a multiline string by removing common leading whitespace. + * + * This function analyzes all non-empty lines in the input text to determine + * the minimum indentation level, then removes that amount of whitespace + * from the beginning of each line. This is useful for working with + * multi-line strings in code that have been indented to match the + * surrounding code structure. + * + * # Arguments + * + * * `text` - The multiline string to dedent + * + * # Returns + * + * * `String` - The dedented string + * + * # Examples + * + * ``` + * let indented = " line 1\n line 2\n line 3"; + * let dedented = dedent(indented); + * assert_eq!(dedented, "line 1\nline 2\n line 3"); + * ``` + * + * # Notes + * + * - Empty lines are preserved but have all leading whitespace removed + * - Tabs are counted as 4 spaces for indentation purposes + */ +pub fn dedent(text: &str) -> String { + let lines: Vec<&str> = text.lines().collect(); + + // Find the minimum indentation level (ignore empty lines) + let min_indent = lines.iter() + .filter(|line| !line.trim().is_empty()) + .map(|line| { + let mut spaces = 0; + for c in line.chars() { + if c == ' ' { + spaces += 1; + } else if c == '\t' { + spaces += 4; // Count tabs as 4 spaces + } else { + break; + } + } + spaces + }) + .min() + .unwrap_or(0); + + // Remove that many spaces from the beginning of each line + lines.iter() + .map(|line| { + if line.trim().is_empty() { + return String::new(); + } + + let mut count = 0; + let mut chars = line.chars().peekable(); + + // Skip initial spaces up to min_indent + while count < min_indent && chars.peek().is_some() { + match chars.peek() { + Some(' ') => { + chars.next(); + count += 1; + }, + Some('\t') => { + chars.next(); + count += 4; + }, + _ => break, + } + } + + // Return the remaining characters + chars.collect::() + }) + .collect::>() + .join("\n") +}