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"
+ );
+}