diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..bf7b89f --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,20 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +## 1.7.0 - 2026-02-09 + +Added +- `--repo` to process a remote git repository in a temporary directory. +- `--repo-branch` to checkout a branch or tag when using `--repo`. +- `--repo-commit` to checkout a specific commit when using `--repo`. +- Repository integration tests covering clone, cleanup, and commit checkout. + +Changed +- Error handling now uses typed `AppError` variants (via `thiserror`), removing string-based checks for clipboard and config errors. +- When `--repo-commit` is used, cloning no longer uses `--depth 1` to ensure the commit is available. +- Documentation updated with a remote-repo usage example. +- Applied a formatting pass to keep code style consistent. + +Fixed +- CLI now prevents using `--repo` and `--dir` together. diff --git a/Cargo.lock b/Cargo.lock index f3d0f24..0786342 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -223,6 +223,7 @@ dependencies = [ "serde", "serde_yaml", "tempfile", + "thiserror 1.0.69", "walkdir", ] @@ -428,7 +429,7 @@ checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" dependencies = [ "getrandom 0.2.16", "libredox", - "thiserror", + "thiserror 2.0.17", ] [[package]] @@ -549,13 +550,33 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + [[package]] name = "thiserror" version = "2.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" dependencies = [ - "thiserror-impl", + "thiserror-impl 2.0.17", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index deec0e1..bfde2e2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,6 +22,7 @@ ignore = "0.4.23" serde = { version = "1.0.228", features = ["derive"] } serde_yaml = "0.9.34" walkdir = "2.5.0" +tempfile = "3.19.1" +thiserror = "1.0" [dev-dependencies] -tempfile = "3.19.1" diff --git a/README.md b/README.md index 9405e17..63d5c87 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,7 @@ A command-line tool to combine files from a directory into a single file for AI ## Features - Combines multiple text files into one output file +- Can process a remote git repository in a temporary directory - Supports configuration via CLI options **and config files** (YAML) - Filters files by: - Size @@ -80,21 +81,25 @@ fyai ``` USAGE: - fyai [OPTIONS] + fyai [OPTIONS] [SUBCOMMAND] OPTIONS: -d, --dir Sets the input directory [default: .] -o, --output Sets the output file [default: fyai.txt] + --repo Clone a git repository (e.g., GitHub/GitLab) into a temporary directory before processing + --repo-branch Branch or tag to checkout when using --repo + --repo-commit Commit SHA to checkout when using --repo --include-dirs Comma-separated list of directories to include (e.g., src,docs) -x, --exclude-dirs Comma-separated list of directories to exclude (e.g., node_modules,dist) --include-files Comma-separated list of files to include (e.g., README.md,main.rs) --exclude-files Comma-separated list of files to exclude (e.g., LICENSE,config.json) --include-ext Comma-separated list of file extensions to include (e.g., txt,md) -e, --exclude-ext Comma-separated list of file extensions to exclude (e.g., log,tmp) - -n, --min-size Exclude files smaller than this size in bytes (default: 51200) + -n, --min-size Exclude files smaller than this size in bytes -m, --max-size Exclude files larger than this size in bytes --respect-gitignore Whether to respect .gitignore rules (true/false) [default: true] --tree-only Only output the project directory tree, no file contents + -t, --test Run in test mode -h, --help Print help information -V, --version Print version information @@ -104,6 +109,9 @@ CONFIG FILE SUPPORT: Global config: ~/.config/fyai.yaml (used if no local config found) CLI options override config file values. See README for details and examples. + +SUBCOMMANDS: + init Generate a template fyai.yaml config file ``` ### Examples @@ -155,6 +163,24 @@ CONFIG FILE SUPPORT: fyai --respect-gitignore false ``` +- Run against a remote GitHub/GitLab repository without leaving the clone on disk: + + ```bash + fyai --repo https://github.com/owner/repo.git --repo-branch main + ``` + +- Run against a specific commit: + + ```bash + fyai --repo https://github.com/owner/repo.git --repo-commit 1234abcd + ``` + +- Generate a config template: + + ```bash + fyai init + ``` + ## Output Format The combined file includes headers for each source file: @@ -179,6 +205,10 @@ Contributions are welcome! Please: This project is licensed under the MIT License. +## Changelog + +See `CHANGELOG.md` for release notes. + ## Acknowledgments - Built with [Rust](https://www.rust-lang.org/) diff --git a/src/cli.rs b/src/cli.rs index cb6ea58..2838972 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -20,6 +20,26 @@ pub fn create_commands() -> Command { .help("Sets the output file") .default_value("fyai.txt"), ) + .arg( + Arg::new("repo") + .long("repo") + .value_name("URL") + .help("Clone a git repository (GitHub/GitLab) into a temporary directory before processing").conflicts_with("directory"), + ) + .arg( + Arg::new("repo_branch") + .long("repo-branch") + .value_name("BRANCH") + .help("Branch or tag to checkout when using --repo") + .requires("repo"), + ) + .arg( + Arg::new("repo_commit") + .long("repo-commit") + .value_name("COMMIT") + .help("Commit SHA to checkout when using --repo") + .requires("repo"), + ) .arg( Arg::new("include_dirs") .long("include-dirs") diff --git a/src/clipboard.rs b/src/clipboard.rs index c43fbcd..cf6f7cf 100644 --- a/src/clipboard.rs +++ b/src/clipboard.rs @@ -1,19 +1,21 @@ use clipboard::{ClipboardContext, ClipboardProvider}; use std::fs::File; -use std::io::{self, Error, Read}; +use std::io::Read; use std::path::Path; +use crate::error::{AppError, AppResult}; + /// Copies the contents of the specified file to the system clipboard. -pub fn copy_to_clipboard(output_path: &Path) -> io::Result<()> { +pub fn copy_to_clipboard(output_path: &Path) -> AppResult<()> { let mut output_contents = String::new(); File::open(output_path)?.read_to_string(&mut output_contents)?; let mut clipboard: ClipboardContext = - ClipboardProvider::new().map_err(|e| Error::other(format!("Clipboard error: {}", e)))?; + ClipboardProvider::new().map_err(|e| AppError::Clipboard(e.to_string()))?; clipboard .set_contents(output_contents) - .map_err(|e| Error::other(format!("Clipboard error: {}", e)))?; + .map_err(|e| AppError::Clipboard(e.to_string()))?; Ok(()) } diff --git a/src/config.rs b/src/config.rs index 66017b4..270572a 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,8 +1,9 @@ use serde::{Deserialize, Serialize}; use std::fs; -use std::io; use std::path::{Path, PathBuf}; +use crate::error::{AppError, AppResult}; + /// Main config struct used throughout the app. #[derive(Debug, PartialEq, Clone)] pub struct Config { @@ -39,14 +40,13 @@ pub struct FileConfig { impl FileConfig { /// Load config from a YAML file path. - pub fn from_path>(path: P) -> io::Result { - let content = fs::read_to_string(path)?; - let config: FileConfig = serde_yaml::from_str(&content).map_err(|e| { - io::Error::new( - io::ErrorKind::InvalidData, - format!("YAML parse error: {}", e), - ) - })?; + pub fn from_path>(path: P) -> AppResult { + let content = fs::read_to_string(path.as_ref())?; + let config: FileConfig = + serde_yaml::from_str(&content).map_err(|e| AppError::YamlParse { + path: path.as_ref().to_path_buf(), + source: e, + })?; Ok(config) } } @@ -127,7 +127,7 @@ pub fn merge_config(file: FileConfig, cli: Config, explicit: ExplicitFlags) -> C /// Returns both the built `Config` and an `ExplicitFlags` struct that indicates /// which CLI values were actually provided on the command line (as opposed to /// being left as clap defaults). -pub fn config_from_matches(matches: clap::ArgMatches) -> std::io::Result<(Config, ExplicitFlags)> { +pub fn config_from_matches(matches: clap::ArgMatches) -> AppResult<(Config, ExplicitFlags)> { let directory_set = match matches.try_get_one::("directory") { Ok(Some(_)) => true, Ok(None) => false, @@ -151,24 +151,14 @@ pub fn config_from_matches(matches: clap::ArgMatches) -> std::io::Result<(Config let directory = matches .try_get_one::("directory") - .map_err(|e| { - std::io::Error::new( - std::io::ErrorKind::InvalidInput, - format!("Missing directory: {}", e), - ) - })? - .ok_or_else(|| std::io::Error::new(std::io::ErrorKind::InvalidInput, "Missing directory"))? + .map_err(|_| AppError::MissingDirectory)? + .ok_or(AppError::MissingDirectory)? .into(); let output = matches .try_get_one::("output") - .map_err(|e| { - std::io::Error::new( - io::ErrorKind::InvalidInput, - format!("Missing output: {}", e), - ) - })? - .ok_or_else(|| std::io::Error::new(std::io::ErrorKind::InvalidInput, "Missing output"))? + .map_err(|_| AppError::MissingOutput)? + .ok_or(AppError::MissingOutput)? .into(); let include_dirs = match matches.try_get_one::("include_dirs") { @@ -234,17 +224,13 @@ pub fn config_from_matches(matches: clap::ArgMatches) -> std::io::Result<(Config }; let min_size = match matches.try_get_one::("min_size") { - Ok(Some(s)) => Some(s.parse::().map_err(|_| { - std::io::Error::new(std::io::ErrorKind::InvalidInput, "Invalid min-size") - })?), + Ok(Some(s)) => Some(s.parse::().map_err(|_| AppError::InvalidMinSize)?), Ok(None) => None, Err(_) => None, }; let max_size = match matches.try_get_one::("max_size") { - Ok(Some(s)) => Some(s.parse::().map_err(|_| { - std::io::Error::new(std::io::ErrorKind::InvalidInput, "Invalid max-size") - })?), + Ok(Some(s)) => Some(s.parse::().map_err(|_| AppError::InvalidMaxSize)?), Ok(None) => None, Err(_) => None, }; diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..9281873 --- /dev/null +++ b/src/error.rs @@ -0,0 +1,49 @@ +use std::io; +use std::path::PathBuf; + +use thiserror::Error; + +pub type AppResult = Result; + +#[derive(Debug, Error)] +pub enum AppError { + #[error(transparent)] + Io(#[from] io::Error), + + #[error("Clipboard error: {0}")] + Clipboard(String), + + #[error("YAML parse error in {path}: {source}")] + YamlParse { + path: PathBuf, + #[source] + source: serde_yaml::Error, + }, + + #[error("Missing directory")] + MissingDirectory, + + #[error("Missing output")] + MissingOutput, + + #[error("Invalid min-size")] + InvalidMinSize, + + #[error("Invalid max-size")] + InvalidMaxSize, + + #[error("Config file already exists at {path}. Use --force to overwrite.")] + ConfigAlreadyExists { path: String }, + + #[error("Failed to run git clone: {0}")] + GitCloneExec(#[source] io::Error), + + #[error("git clone failed: {0}")] + GitCloneFailed(String), + + #[error("Failed to run git checkout: {0}")] + GitCheckoutExec(#[source] io::Error), + + #[error("git checkout failed: {0}")] + GitCheckoutFailed(String), +} diff --git a/src/main.rs b/src/main.rs index 6df30ed..afc2d24 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,7 +1,6 @@ -use std::io; - use crate::clipboard::copy_to_clipboard; use crate::data::{IGNORED_DIRS, IGNORED_FILES}; +use crate::error::{AppError, AppResult}; use crate::file_processing::{get_directory_structure, process_files}; use crate::gitignore::build_gitignore; @@ -12,14 +11,16 @@ mod cli; mod clipboard; mod config; mod data; +mod error; mod file_processing; mod gitignore; +mod repository; /// Run the core application logic using a fully-resolved `Config`. /// /// This function is extracted from `main` and made public so tests can call it /// directly with a controlled `Config`. -pub fn run_with_config(config: crate::config::Config) -> io::Result<()> { +pub fn run_with_config(config: crate::config::Config) -> AppResult<()> { let gitignore = build_gitignore(&config.directory, IGNORED_FILES, IGNORED_DIRS, &config)?; let dir_structure = @@ -30,17 +31,40 @@ pub fn run_with_config(config: crate::config::Config) -> io::Result<()> { println!("Project tree written to {}", config.output.display()); } else { process_files(&config, &gitignore, &dir_structure, IGNORED_DIRS)?; - copy_to_clipboard(&config.output)?; + let mut copied = true; + if let Err(err) = copy_to_clipboard(&config.output) { + if matches!(err, AppError::Clipboard(_)) && should_ignore_clipboard_error() { + copied = false; + eprintln!("Warning: clipboard unavailable; skipping copy. {}", err); + } else { + return Err(err); + } + } println!( "Files combined successfully into {}", config.output.display() ); - println!("Output copied to clipboard successfully!"); + if copied { + println!("Output copied to clipboard successfully!"); + } } Ok(()) } -pub fn handle_init_subcommand(matches: &clap::ArgMatches) -> io::Result { +fn should_ignore_clipboard_error() -> bool { + if std::env::var_os("CI").is_some() { + return true; + } + if cfg!(target_os = "linux") { + let has_display = std::env::var_os("DISPLAY").is_some() + || std::env::var_os("WAYLAND_DISPLAY").is_some() + || std::env::var_os("SWAYSOCK").is_some(); + return !has_display; + } + false +} + +pub fn handle_init_subcommand(matches: &clap::ArgMatches) -> AppResult { if let Some(sub_m) = matches.subcommand_matches("init") { let global = sub_m.get_flag("global"); let force = sub_m.get_flag("force"); @@ -59,13 +83,7 @@ pub fn handle_init_subcommand(matches: &clap::ArgMatches) -> io::Result { }; if path.exists() && !force { - return Err(io::Error::new( - io::ErrorKind::AlreadyExists, - format!( - "Config file already exists at {}. Use --force to overwrite.", - display_path - ), - )); + return Err(AppError::ConfigAlreadyExists { path: display_path }); } let template = r#"# fyai.yaml - Configuration file for fyai @@ -105,7 +123,7 @@ tree_only: false # Only output directory tree, no file contents Ok(false) } -fn main() -> io::Result<()> { +fn main() -> AppResult<()> { let matches = crate::cli::create_commands().get_matches(); // Handle init subcommand via helper so tests can call it directly. @@ -113,6 +131,10 @@ fn main() -> io::Result<()> { return Ok(()); } + let repo_url = matches.get_one::("repo").cloned(); + let repo_branch = matches.get_one::("repo_branch").cloned(); + let repo_commit = matches.get_one::("repo_commit").cloned(); + // Normal flow: parse CLI args and config file // `config_from_matches_with_explicit` returns both the parsed CLI `Config` and an // `ExplicitFlags` struct indicating which CLI options were explicitly set. @@ -140,6 +162,15 @@ fn main() -> io::Result<()> { // Merge configs (CLI takes precedence, but allow file to provide values when CLI didn't explicitly set them) let config = crate::config::merge_config(file_config, cli_config, explicit); + if let Some(repo_url) = repo_url { + return crate::repository::run_on_repository( + &repo_url, + repo_branch.as_deref(), + repo_commit.as_deref(), + config, + ); + } + // Delegate to the extracted function so it can be tested in isolation. run_with_config(config) } diff --git a/src/repository.rs b/src/repository.rs new file mode 100644 index 0000000..238a533 --- /dev/null +++ b/src/repository.rs @@ -0,0 +1,77 @@ +use std::{path::PathBuf, process::Command}; + +use tempfile::TempDir; + +use crate::config::Config; +use crate::error::{AppError, AppResult}; + +fn command_error_details(output: &std::process::Output) -> String { + let stderr = String::from_utf8_lossy(&output.stderr); + let stdout = String::from_utf8_lossy(&output.stdout); + let details = if !stderr.trim().is_empty() { + stderr.trim() + } else { + stdout.trim() + }; + if details.is_empty() { + "unknown error".to_string() + } else { + details.to_string() + } +} + +pub(crate) fn clone_repository( + repo_url: &str, + branch: Option<&str>, + commit: Option<&str>, +) -> AppResult<(TempDir, PathBuf)> { + let temp_dir = tempfile::tempdir()?; + let clone_path = temp_dir.path().join("repo"); + + let mut cmd = Command::new("git"); + cmd.arg("clone"); + if commit.is_none() { + cmd.arg("--depth").arg("1"); + } + if let Some(branch) = branch { + cmd.args(["--branch", branch]); + } + cmd.arg(repo_url).arg(&clone_path); + + let output = cmd.output().map_err(AppError::GitCloneExec)?; + if !output.status.success() { + return Err(AppError::GitCloneFailed(command_error_details(&output))); + } + + if let Some(commit) = commit { + let output = Command::new("git") + .arg("-C") + .arg(&clone_path) + .args(["checkout", commit]) + .output() + .map_err(AppError::GitCheckoutExec)?; + if !output.status.success() { + return Err(AppError::GitCheckoutFailed(command_error_details(&output))); + } + } + + Ok((temp_dir, clone_path)) +} + +pub fn run_on_repository( + repo_url: &str, + branch: Option<&str>, + commit: Option<&str>, + config: Config, +) -> AppResult<()> { + let (temp_dir, clone_path) = clone_repository(repo_url, branch, commit)?; + + let mut config = config; + config.directory = clone_path; + + let result = crate::run_with_config(config); + + drop(temp_dir); + + result +} diff --git a/src/tests/cli_tests.rs b/src/tests/cli_tests.rs index f0bb7a2..43c6c2f 100644 --- a/src/tests/cli_tests.rs +++ b/src/tests/cli_tests.rs @@ -2,6 +2,7 @@ mod tests { use std::path::PathBuf; + use crate::error::AppError; use crate::{cli::create_commands, config::config_from_matches}; #[test] @@ -100,7 +101,7 @@ mod tests { let result = config_from_matches(args); assert!(result.is_err()); - assert_eq!(result.unwrap_err().to_string(), "Invalid min-size"); + assert!(matches!(result.unwrap_err(), AppError::InvalidMinSize)); } #[test] @@ -109,7 +110,7 @@ mod tests { let result = config_from_matches(args); assert!(result.is_err()); - assert_eq!(result.unwrap_err().to_string(), "Invalid max-size"); + assert!(matches!(result.unwrap_err(), AppError::InvalidMaxSize)); } #[test] diff --git a/src/tests/clipboard_tests.rs b/src/tests/clipboard_tests.rs index 927d662..6895040 100644 --- a/src/tests/clipboard_tests.rs +++ b/src/tests/clipboard_tests.rs @@ -1,11 +1,12 @@ #[cfg(test)] mod tests { use crate::clipboard::copy_to_clipboard; + use crate::error::{AppError, AppResult}; use crate::tests::common::{create_file, setup_temp_dir}; use std::io; #[test] - fn test_copy_to_clipboard_valid_file() -> io::Result<()> { + fn test_copy_to_clipboard_valid_file() -> AppResult<()> { let temp_dir = setup_temp_dir(); let file_path = temp_dir.path().join("test.txt"); create_file(&file_path, "Hello, clipboard!")?; @@ -25,23 +26,26 @@ mod tests { || result .as_ref() .err() - .is_some_and(|e| e.kind() == io::ErrorKind::Other) + .is_some_and(|e| matches!(e, AppError::Clipboard(_))) ); Ok(()) } #[test] - fn test_copy_to_clipboard_nonexistent_file() -> io::Result<()> { + fn test_copy_to_clipboard_nonexistent_file() -> AppResult<()> { let temp_dir = setup_temp_dir(); let file_path = temp_dir.path().join("nonexistent.txt"); let result = copy_to_clipboard(&file_path); assert!(result.is_err()); - assert_eq!(result.unwrap_err().kind(), io::ErrorKind::NotFound); + match result.unwrap_err() { + AppError::Io(err) => assert_eq!(err.kind(), io::ErrorKind::NotFound), + err => panic!("Expected io::Error NotFound, got {err:?}"), + } Ok(()) } #[test] - fn test_copy_to_clipboard_empty_file() -> io::Result<()> { + fn test_copy_to_clipboard_empty_file() -> AppResult<()> { let temp_dir = setup_temp_dir(); let file_path = temp_dir.path().join("empty.txt"); create_file(&file_path, "")?; @@ -61,7 +65,7 @@ mod tests { || result .as_ref() .err() - .is_some_and(|e| e.kind() == io::ErrorKind::Other) + .is_some_and(|e| matches!(e, AppError::Clipboard(_))) ); Ok(()) } diff --git a/src/tests/config_tests.rs b/src/tests/config_tests.rs index c2ff5a0..b6679f4 100644 --- a/src/tests/config_tests.rs +++ b/src/tests/config_tests.rs @@ -1,11 +1,11 @@ use std::fs; -use std::io; use std::path::PathBuf; use std::sync::{Mutex, OnceLock}; use clap::{Arg, ArgAction, Command}; use crate::config::{Config, FileConfig, config_from_matches, discover_config_file, merge_config}; +use crate::error::AppError; static TEST_LOCK: OnceLock> = OnceLock::new(); @@ -46,7 +46,7 @@ respect_gitignore: false #[test] fn test_fileconfig_from_path_invalid_yaml() { - // invalid YAML should produce an io::Error with InvalidData + // invalid YAML should surface a structured YAML parse error let path = "./bad_fyai_config.yaml"; fs::write(path, "not: [valid").expect("write bad yaml"); @@ -57,7 +57,7 @@ fn test_fileconfig_from_path_invalid_yaml() { assert!(res.is_err()); let err = res.err().unwrap(); - assert_eq!(err.kind(), io::ErrorKind::InvalidData); + assert!(matches!(err, AppError::YamlParse { .. })); } #[test] @@ -235,6 +235,8 @@ fn test_config_from_matches_invalid_min_size() { let res = config_from_matches(matches); assert!(res.is_err()); + let err = res.unwrap_err(); + assert!(matches!(err, AppError::InvalidMinSize)); } // ---- New tests covering additional branches and error cases ---- @@ -357,8 +359,7 @@ fn test_missing_directory_error_message() { let res = config_from_matches(matches); assert!(res.is_err()); let err = res.unwrap_err(); - // The error message was constructed with "Missing directory" - assert!(err.to_string().to_lowercase().contains("missing directory")); + assert!(matches!(err, AppError::MissingDirectory)); } #[test] @@ -371,7 +372,7 @@ fn test_missing_output_error_message() { let res = config_from_matches(matches); assert!(res.is_err()); let err = res.unwrap_err(); - assert!(err.to_string().to_lowercase().contains("missing output")); + assert!(matches!(err, AppError::MissingOutput)); } /// Additional tests to cover branches where args are registered-but-not-provided diff --git a/src/tests/main_tests.rs b/src/tests/main_tests.rs index 6e138ec..76f87cb 100644 --- a/src/tests/main_tests.rs +++ b/src/tests/main_tests.rs @@ -1,11 +1,12 @@ use std::{ - env, fs, io, + env, fs, path::PathBuf, sync::{Mutex, OnceLock}, }; use tempfile::TempDir; use crate::cli::create_commands; +use crate::error::AppError; static SERIALIZE_TESTS: OnceLock> = OnceLock::new(); @@ -80,7 +81,7 @@ fn test_init_already_exists_without_force_errors() { "Expected error when config exists and --force not provided" ); let err = res.unwrap_err(); - assert_eq!(err.kind(), io::ErrorKind::AlreadyExists); + assert!(matches!(err, AppError::ConfigAlreadyExists { .. })); } #[test] diff --git a/src/tests/mod.rs b/src/tests/mod.rs index 80b6b65..879d5b8 100644 --- a/src/tests/mod.rs +++ b/src/tests/mod.rs @@ -13,3 +13,5 @@ mod file_processing_tests; mod gitignore_tests; #[cfg(test)] mod main_tests; +#[cfg(test)] +mod repository_tests; diff --git a/src/tests/repository_tests.rs b/src/tests/repository_tests.rs new file mode 100644 index 0000000..3babdaa --- /dev/null +++ b/src/tests/repository_tests.rs @@ -0,0 +1,135 @@ +use std::{fs, path::PathBuf, process::Command}; + +use tempfile::TempDir; + +use crate::{ + repository::{clone_repository, run_on_repository}, + tests::common::create_test_config, +}; + +fn init_sample_repo() -> TempDir { + let repo_dir = TempDir::new().expect("create temp repo"); + + let status = Command::new("git") + .args(["init", "."]) + .current_dir(repo_dir.path()) + .status() + .expect("init git repo"); + assert!(status.success(), "git init failed"); + + for (key, value) in [ + ("user.email", "test@example.com"), + ("user.name", "Test User"), + ] { + let status = Command::new("git") + .args(["config", key, value]) + .current_dir(repo_dir.path()) + .status() + .expect("configure git"); + assert!(status.success(), "git config {key} failed", key = key); + } + + fs::write(repo_dir.path().join("README.md"), "hello remote repo").expect("write README.md"); + + let status = Command::new("git") + .args(["add", "."]) + .current_dir(repo_dir.path()) + .status() + .expect("git add"); + assert!(status.success(), "git add failed"); + + let status = Command::new("git") + .args(["commit", "-m", "init"]) + .current_dir(repo_dir.path()) + .status() + .expect("git commit"); + assert!(status.success(), "git commit failed"); + + repo_dir +} + +fn get_head_commit(repo_dir: &TempDir) -> String { + let output = Command::new("git") + .args(["rev-parse", "HEAD"]) + .current_dir(repo_dir.path()) + .output() + .expect("git rev-parse"); + assert!(output.status.success(), "git rev-parse failed"); + String::from_utf8_lossy(&output.stdout).trim().to_string() +} + +#[test] +fn run_on_repository_processes_remote_repo() { + let remote_repo = init_sample_repo(); + let output_dir = TempDir::new().expect("create output temp dir"); + let output_path = output_dir.path().join("combined.txt"); + + let config = create_test_config(PathBuf::from("."), output_path.clone(), |_| {}); + + run_on_repository( + remote_repo + .path() + .to_str() + .expect("repo path should be valid UTF-8"), + None, + None, + config, + ) + .expect("run_on_repository should succeed"); + + let contents = fs::read_to_string(&output_path).expect("read output file"); + assert!( + contents.contains("README.md") && contents.contains("hello remote repo"), + "output should include cloned file contents" + ); +} + +#[test] +fn run_on_repository_supports_commit() { + let remote_repo = init_sample_repo(); + let commit = get_head_commit(&remote_repo); + let output_dir = TempDir::new().expect("create output temp dir"); + let output_path = output_dir.path().join("combined.txt"); + + let config = create_test_config(PathBuf::from("."), output_path.clone(), |_| {}); + + run_on_repository( + remote_repo + .path() + .to_str() + .expect("repo path should be valid UTF-8"), + None, + Some(&commit), + config, + ) + .expect("run_on_repository should succeed with commit"); + + let contents = fs::read_to_string(&output_path).expect("read output file"); + assert!( + contents.contains("README.md") && contents.contains("hello remote repo"), + "output should include cloned file contents" + ); +} + +#[test] +fn clone_repository_cleans_up_temp_dir() { + let remote_repo = init_sample_repo(); + let clone_path = { + let (_temp_dir, path) = clone_repository( + remote_repo + .path() + .to_str() + .expect("repo path should be valid UTF-8"), + None, + None, + ) + .expect("clone_repository should succeed"); + assert!(path.join(".git").exists(), "expected cloned repo"); + path + }; + + assert!( + !clone_path.exists(), + "temp clone directory should be cleaned up after drop" + ); +}