use crate::error::{Error, Result};
use std::path::{Path, PathBuf};
use std::process::Command;
pub fn find_repository_root() -> Result<PathBuf> {
let cwd = std::env::current_dir().map_err(|e| Error::GitCommandFailed {
command: "git rev-parse --show-toplevel".to_string(),
stderr: "Could not determine current working directory".to_string(),
source: e,
suggestion: "Ensure the current directory exists and is accessible".to_string(),
})?;
find_repository_root_from(&cwd)
}
pub fn find_repository_root_from(start_dir: &Path) -> Result<PathBuf> {
let output = Command::new("git")
.args(["rev-parse", "--show-toplevel"])
.current_dir(start_dir)
.output()
.map_err(|e| map_spawn_error(e, start_dir))?;
parse_git_output(output.status, &output.stdout, &output.stderr, start_dir)
}
fn map_spawn_error(e: std::io::Error, start_dir: &Path) -> Error {
if e.kind() == std::io::ErrorKind::NotFound {
Error::GitCommandFailed {
command: "git".to_string(),
stderr: "Git is not installed or not in PATH".to_string(),
source: e,
suggestion: "Install git and ensure it's available in PATH".to_string(),
}
} else {
Error::GitCommandFailed {
command: format!("git rev-parse (in {})", start_dir.display()),
stderr: String::new(),
source: e,
suggestion: "Check that git is installed and accessible".to_string(),
}
}
}
fn parse_git_output(
status: std::process::ExitStatus,
stdout: &[u8],
stderr: &[u8],
start_dir: &Path,
) -> Result<PathBuf> {
if !status.success() {
let stderr_str = String::from_utf8_lossy(stderr);
return Err(Error::NotInGitRepository {
message: format!("in directory {}", start_dir.display()),
stderr: stderr_str.trim().to_string(),
suggestion: "Run from a git repository or omit --git flag".to_string(),
});
}
let path_str = String::from_utf8(stdout.to_vec()).map_err(|e| Error::GitInvalidUtf8 {
source: e,
suggestion: "Git output contains invalid UTF-8. Ensure your repository \
paths are valid UTF-8."
.to_string(),
})?;
let root = PathBuf::from(path_str.trim());
if !root.is_absolute() {
return Err(Error::GitCommandFailed {
command: format!("git rev-parse --show-toplevel (in {})", start_dir.display()),
stderr: format!("Expected absolute path from git, got: {}", root.display()),
source: std::io::Error::new(
std::io::ErrorKind::InvalidData,
"git returned a relative path",
),
suggestion: "This is unexpected. Check your git installation and repository state."
.to_string(),
});
}
Ok(root)
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(unix)]
mod parse {
use super::*;
use std::os::unix::process::ExitStatusExt;
use std::process::ExitStatus;
fn success_status() -> ExitStatus {
ExitStatus::from_raw(0)
}
fn failure_status() -> ExitStatus {
ExitStatus::from_raw(1 << 8)
}
#[test]
fn valid_absolute_path() {
let stdout = b"/home/user/project\n";
let result = parse_git_output(
success_status(),
stdout,
b"",
Path::new("/home/user/project"),
);
let root = result.expect("should parse valid absolute path");
assert_eq!(root, PathBuf::from("/home/user/project"));
}
#[test]
fn trims_whitespace() {
let stdout = b" /home/user/project \n";
let result = parse_git_output(
success_status(),
stdout,
b"",
Path::new("/home/user/project"),
);
let root = result.expect("should trim whitespace");
assert_eq!(root, PathBuf::from("/home/user/project"));
}
#[test]
fn rejects_relative_path() {
let stdout = b"relative/path\n";
let result = parse_git_output(success_status(), stdout, b"", Path::new("/somewhere"));
let err = result.expect_err("should reject relative path");
assert!(
matches!(err, Error::GitCommandFailed { .. }),
"expected GitCommandFailed, got: {err}"
);
}
#[test]
fn rejects_invalid_utf8() {
let stdout: &[u8] = &[0xFF, 0xFE, 0x2F, 0x0A]; let result = parse_git_output(success_status(), stdout, b"", Path::new("/somewhere"));
let err = result.expect_err("should reject invalid UTF-8");
assert!(
matches!(err, Error::GitInvalidUtf8 { .. }),
"expected GitInvalidUtf8, got: {err}"
);
}
#[test]
fn failed_status_returns_not_in_repo() {
let stderr = b"fatal: not a git repository";
let result = parse_git_output(failure_status(), b"", stderr, Path::new("/tmp"));
let err = result.expect_err("should return error on failed status");
assert!(
matches!(err, Error::NotInGitRepository { .. }),
"expected NotInGitRepository, got: {err}"
);
}
#[test]
fn failed_status_includes_stderr() {
let stderr = b"fatal: not a git repository (or any parent)";
let result = parse_git_output(failure_status(), b"", stderr, Path::new("/tmp"));
match result.expect_err("should fail") {
Error::NotInGitRepository {
stderr: captured, ..
} => {
assert!(captured.contains("fatal: not a git repository"));
}
other => panic!("expected NotInGitRepository, got: {other}"),
}
}
}
#[test]
fn test_find_repo_root() {
if let Ok(root) = find_repository_root() {
assert!(root.exists());
assert!(root.is_dir());
assert!(root.is_absolute());
assert!(root.join(".git").exists());
}
}
#[test]
fn test_find_repo_root_from_subdirectory() {
if let Ok(root) = find_repository_root() {
let src_dir = root.join("src");
if src_dir.is_dir() {
let root_from_src =
find_repository_root_from(&src_dir).expect("should find root from src/");
assert_eq!(root, root_from_src);
}
}
}
#[test]
fn test_not_a_repo() {
let result = find_repository_root_from(Path::new("/"));
if let Err(e) = result {
assert!(
matches!(e, Error::NotInGitRepository { .. }),
"expected NotInGitRepository, got: {e}"
);
}
}
#[test]
fn test_error_message_format() {
let err = Error::NotInGitRepository {
message: "test message".to_string(),
stderr: "git error output".to_string(),
suggestion: "run git init".to_string(),
};
assert!(err.to_string().contains("Not in a git repository"));
assert!(err.to_string().contains("test message"));
}
#[test]
fn test_structured_git_errors() {
let err = Error::GitCommandFailed {
command: "git rev-parse".to_string(),
stderr: "fatal: not a git repository".to_string(),
source: std::io::Error::other("test"),
suggestion: "check your git setup".to_string(),
};
assert!(err.to_string().contains("Git command failed"));
assert!(err.to_string().contains("git rev-parse"));
}
}