luff 0.2.1

Print files with formatting
Documentation
//! Git repository utilities

use crate::error::{Error, Result};
use std::path::{Path, PathBuf};
use std::process::Command;

/// Find the root directory of the current git repository.
///
/// Convenience wrapper around [`find_repository_root_from`] that uses
/// the process's current working directory as the starting point.
///
/// # Errors
///
/// Returns an error if:
/// - The current working directory cannot be determined
/// - Git is not installed or not in PATH
/// - Current directory is not within a git repository
/// - Git command execution fails
/// - Git output is not valid UTF-8
///
/// # Examples
///
/// ```no_run
/// use luff::git::find_repository_root;
///
/// let root = find_repository_root()?;
/// println!("Repository root: {}", root.display());
/// # Ok::<(), luff::Error>(())
/// ```
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)
}

/// Find the root directory of the git repository containing `start_dir`.
///
/// Executes `git rev-parse --show-toplevel` with `start_dir` as the
/// working directory. This is more reliable than walking the directory
/// tree looking for `.git` as it respects git's internal logic for
/// repository boundaries (worktrees, submodules, etc.).
///
/// # Errors
///
/// Returns an error if:
/// - Git is not installed or not in PATH
/// - `start_dir` is not within a git repository
/// - Git command execution fails
/// - Git output is not valid UTF-8
/// - Git returns a non-absolute path (should never happen)
///
/// # Examples
///
/// ```no_run
/// use luff::git::find_repository_root_from;
/// use std::path::Path;
///
/// let root = find_repository_root_from(Path::new("/some/nested/dir"))?;
/// println!("Repository root: {}", root.display());
/// # Ok::<(), luff::Error>(())
/// ```
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)
}

/// Map a subprocess spawn error into our domain error.
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(),
        }
    }
}

/// Parse the raw output of `git rev-parse --show-toplevel` into a repository root path.
///
/// Separated from subprocess execution to enable thorough unit testing of
/// all error branches without requiring a real git binary or filesystem state.
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::*;

    // ── Unit tests for parse_git_output (deterministic, no I/O) ──

    // ExitStatusExt::from_raw is Unix-only; these unit tests construct
    // synthetic ExitStatus values and are therefore platform-gated.
    #[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 {
            // Raw value 256 == exit code 1 on Unix (status is encoded as code << 8)
            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]; // invalid UTF-8 with / and \n
            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() {
        // This test only works if run within a git repository
        if let Ok(root) = find_repository_root() {
            assert!(root.exists());
            assert!(root.is_dir());
            assert!(root.is_absolute());
            // Verify .git exists in the root
            assert!(root.join(".git").exists());
        }
    }

    #[test]
    fn test_find_repo_root_from_subdirectory() {
        // Verify that calling from a subdirectory still finds the same root
        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() {
        // A directory that is almost certainly not inside a git repo
        let result = find_repository_root_from(Path::new("/"));
        // On most systems, `/` is not a git repo. If it somehow is, skip.
        if let Err(e) = result {
            assert!(
                matches!(e, Error::NotInGitRepository { .. }),
                "expected NotInGitRepository, got: {e}"
            );
        }
    }

    #[test]
    fn test_error_message_format() {
        // Verify error messages are properly formatted
        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"));
    }
}