use std::path::PathBuf;
use std::process::{Command, Stdio};
use std::sync::mpsc;
use std::thread;
use std::time::Duration;
use super::*;
use tempfile::TempDir;
fn detect_project_at_test(root: &Path, explicit: Option<&str>) -> String {
detect_project_at_internal(root, explicit, Some(String::new()))
}
fn create_git_repo() -> TempDir {
let dir = TempDir::new().expect("create temp dir for git repo");
init_git_repo(dir.path());
dir
}
fn init_git_repo(path: &Path) {
Command::new("git")
.args(["-C", path.to_str().unwrap(), "init", "-b", "main"])
.env("GIT_TERMINAL_PROMPT", "0")
.output()
.expect("git init failed");
Command::new("git")
.args([
"-C",
path.to_str().unwrap(),
"config",
"user.name",
"Test User",
])
.output()
.expect("git config user.name failed");
Command::new("git")
.args([
"-C",
path.to_str().unwrap(),
"config",
"user.email",
"test@example.com",
])
.output()
.expect("git config user.email failed");
}
fn add_remote(path: &Path, name: &str, url: &str) {
Command::new("git")
.args(["-C", path.to_str().unwrap(), "remote", "add", name, url])
.output()
.expect("git remote add failed");
}
fn create_subdirectory(repo_path: &Path) -> PathBuf {
let sub = repo_path.join("src").join("deep");
std::fs::create_dir_all(&sub).expect("create subdirectory");
sub
}
#[test]
fn test_parse_ssh_remote() {
assert_eq!(
parse_git_remote("git@github.com:owner/repo.git"),
"owner/repo"
);
assert_eq!(parse_git_remote("git@github.com:owner/repo"), "owner/repo");
}
#[test]
fn test_parse_https_remote() {
assert_eq!(
parse_git_remote("https://github.com/owner/repo.git"),
"owner/repo"
);
assert_eq!(
parse_git_remote("https://github.com/owner/repo"),
"owner/repo"
);
}
#[test]
fn test_parse_ssh_url_with_protocol() {
assert_eq!(
parse_git_remote("ssh://git@github.com/owner/repo.git"),
"owner/repo"
);
}
#[test]
fn test_git_suffix_stripping() {
assert_eq!(parse_git_remote("owner/repo.git"), "owner/repo");
}
#[test]
fn test_fallback_when_no_domain() {
assert_eq!(parse_git_remote("just-name"), "just-name");
}
#[test]
fn test_parse_mixed_case_host_and_owner() {
assert_eq!(
parse_git_remote("git@GitHub.com:Owner/Repo.git"),
"Owner/Repo"
);
assert_eq!(
parse_git_remote("https://GitHub.com/Owner/Repo.git"),
"Owner/Repo"
);
}
#[test]
fn test_explicit_override() {
let dir = TempDir::new().expect("temp dir");
assert_eq!(
detect_project_at(dir.path(), Some("my-project")),
"my-project"
);
}
#[test]
fn test_explicit_override_empty() {
let dir = create_git_repo();
let result = detect_project_at_test(dir.path(), Some(""));
assert_eq!(result, dir.path().file_name().unwrap().to_str().unwrap());
}
#[test]
fn test_explicit_override_whitespace() {
let dir = create_git_repo();
let result = detect_project_at_test(dir.path(), Some(" \t "));
assert_eq!(result, dir.path().file_name().unwrap().to_str().unwrap());
}
#[test]
fn test_env_var_whitespace() {
let dir = create_git_repo();
let result = detect_project_at_internal(dir.path(), None, Some(" ".to_string()));
assert_eq!(result, dir.path().file_name().unwrap().to_str().unwrap());
}
#[test]
fn test_env_var_override_with_no_git() {
let dir = TempDir::new().expect("temp dir");
let result = detect_project_at_internal(dir.path(), None, Some("env-project".to_string()));
assert_eq!(result, "env-project");
}
#[test]
fn test_env_var_trimmed() {
let dir = TempDir::new().expect("temp dir");
let result = detect_project_at_internal(dir.path(), None, Some(" trimmed ".to_string()));
assert_eq!(result, "trimmed");
}
#[test]
fn test_detect_https_remote() {
let dir = create_git_repo();
add_remote(
dir.path(),
"origin",
"https://github.com/randomm/vipune.git",
);
assert_eq!(detect_project_at_test(dir.path(), None), "randomm/vipune");
}
#[test]
fn test_detect_ssh_remote() {
let dir = create_git_repo();
add_remote(dir.path(), "origin", "git@github.com:randomm/vipune.git");
assert_eq!(detect_project_at_test(dir.path(), None), "randomm/vipune");
}
#[test]
fn test_detect_ssh_url_with_protocol() {
let dir = create_git_repo();
add_remote(
dir.path(),
"origin",
"ssh://git@github.com/randomm/vipune.git",
);
assert_eq!(detect_project_at_test(dir.path(), None), "randomm/vipune");
}
#[test]
fn test_detect_remote_without_git_suffix() {
let dir = create_git_repo();
add_remote(dir.path(), "origin", "https://github.com/randomm/vipune");
assert_eq!(detect_project_at_test(dir.path(), None), "randomm/vipune");
}
#[test]
fn test_detect_only_upstream_remote_uses_dir_name() {
let dir = create_git_repo();
add_remote(
dir.path(),
"upstream",
"https://github.com/canonical/project.git",
);
let result = detect_project_at_test(dir.path(), None);
assert_eq!(result, dir.path().file_name().unwrap().to_str().unwrap());
assert_ne!(result, "canonical/project");
}
#[test]
fn test_detect_no_git_repo() {
let dir = TempDir::new().expect("temp dir");
let result = detect_project_at_test(dir.path(), None);
assert!(!result.is_empty());
}
#[test]
fn test_detect_same_id_from_root_and_subdirectory() {
let dir = create_git_repo();
add_remote(dir.path(), "origin", "https://github.com/owner/repo.git");
let sub = create_subdirectory(dir.path());
let from_root = detect_project_at_test(dir.path(), None);
let from_sub = detect_project_at_test(&sub, None);
assert_eq!(from_root, "owner/repo");
assert_eq!(from_sub, "owner/repo");
assert_eq!(from_root, from_sub);
}
#[test]
fn test_detect_fallback_same_from_root_and_subdirectory() {
let dir = create_git_repo();
let sub = create_subdirectory(dir.path());
let from_root = detect_project_at_test(dir.path(), None);
let from_sub = detect_project_at_test(&sub, None);
let expected = dir.path().file_name().unwrap().to_str().unwrap();
assert_eq!(from_root, expected);
assert_eq!(from_sub, expected);
}
#[test]
fn test_fallback_no_remotes_yields_dir_name() {
let dir = create_git_repo();
let result = detect_project_at_test(dir.path(), None);
assert_eq!(result, dir.path().file_name().unwrap().to_str().unwrap());
}
#[test]
fn test_fallback_warning_message_includes_project_id() {
let dir = create_git_repo();
let msg = build_fallback_warning_message(dir.path().file_name().unwrap().to_str().unwrap());
assert!(msg.contains("using directory name as project_id"));
assert!(msg.contains(dir.path().file_name().unwrap().to_str().unwrap()));
}
#[test]
fn test_fallback_warning_message_no_other_remotes() {
let dir = create_git_repo();
let msg = build_fallback_warning_message(dir.path().file_name().unwrap().to_str().unwrap());
assert!(!msg.contains("other remotes"));
assert!(msg.contains("This project_id may differ"));
}
#[test]
fn test_remote_derived_ids_unchanged() {
let cases = [
("https://github.com/randomm/vipune.git", "randomm/vipune"),
("https://github.com/randomm/vipune", "randomm/vipune"),
("git@github.com:randomm/vipune.git", "randomm/vipune"),
("git@github.com:randomm/vipune", "randomm/vipune"),
("ssh://git@github.com/randomm/vipune.git", "randomm/vipune"),
(
"https://gitlab.example.com/group/subgroup/project.git",
"subgroup/project",
),
(
"git@gitlab.example.com:group/subgroup/project.git",
"subgroup/project",
),
];
for (remote_url, expected_id) in cases {
let dir = create_git_repo();
add_remote(dir.path(), "origin", remote_url);
let result = detect_project_at_test(dir.path(), None);
assert_eq!(
result, expected_id,
"remote '{}' should produce project_id '{}'",
remote_url, expected_id
);
}
}
#[test]
fn test_detect_project_delegates_to_current_dir() {
let project = detect_project(None);
assert!(!project.is_empty());
}
#[test]
fn test_detect_project_explicit_override() {
assert_eq!(detect_project(Some("custom-id")), "custom-id");
}
fn make_git_sleep_stub(dir: &Path) {
let stub = dir.join("git");
std::fs::write(&stub, "#!/bin/sh\n/bin/sleep 5\n").expect("write sleep stub");
use std::os::unix::fs::PermissionsExt;
let mut perms = std::fs::metadata(&stub).expect("stat stub").permissions();
perms.set_mode(0o755);
std::fs::set_permissions(&stub, perms).expect("chmod stub");
}
fn run_git_in_env(
root: &Path,
args: &[&str],
timeout: Duration,
path_dir: Option<&Path>,
) -> Result<String, GitError> {
let root_str = root
.to_str()
.map(|s| s.to_string())
.ok_or_else(|| GitError::Spawn(format!("non-UTF-8 path: {:?}", root)))?;
let mut cmd = Command::new("git");
cmd.arg("-C").arg(&root_str);
cmd.args(args);
cmd.env("GIT_TERMINAL_PROMPT", "0");
cmd.stdout(Stdio::piped());
cmd.stderr(Stdio::piped());
if let Some(dir) = path_dir {
cmd.env("PATH", dir);
}
let mut child = cmd.spawn().map_err(|e| GitError::Spawn(e.to_string()))?;
let (tx, rx) = mpsc::channel();
{
let stdout = child.stdout.take().expect("stdout piped");
let stderr = child.stderr.take().expect("stderr piped");
thread::spawn(move || {
let stdout_buf = read_pipe(stdout);
let stderr_buf = read_pipe(stderr);
let _ = tx.send((stdout_buf, stderr_buf));
});
}
match rx.recv_timeout(timeout) {
Ok((stdout_buf, stderr_buf)) => match child.wait() {
Ok(status) => {
if status.success() {
Ok(String::from_utf8_lossy(&stdout_buf).trim().to_string())
} else {
Err(GitError::NonZeroExit {
code: status.code(),
stderr: String::from_utf8_lossy(&stderr_buf).to_string(),
})
}
}
Err(e) => Err(GitError::Spawn(format!("wait failure: {e}"))),
},
Err(_) => {
let _ = child.kill();
let _ = child.wait();
Err(GitError::Timeout(timeout))
}
}
}
#[test]
fn test_run_git_timeout_kills_stub() {
let stub_dir = TempDir::new().expect("stub bin dir");
make_git_sleep_stub(stub_dir.path());
let result = run_git_in_env(
Path::new("/"),
&["--version"],
Duration::from_millis(100),
Some(stub_dir.path()),
);
match result {
Err(GitError::Timeout(d)) => {
assert!(d.as_millis() <= 100);
}
other => panic!("expected Err(GitError::Timeout), got {:?}", other),
}
}
#[test]
fn test_run_git_captures_stdout_on_success() {
let dir = create_git_repo();
let out = run_git(dir.path(), &["rev-parse", "--show-toplevel"], GIT_TIMEOUT);
let root = out.expect("expected Ok");
let canonical = std::fs::canonicalize(dir.path()).expect("canonicalize temp dir");
let expected = canonical.to_str().expect("path is valid UTF-8");
assert_eq!(root, expected);
}
#[test]
fn test_run_git_nonzero_exit_is_typed() {
let dir = create_git_repo();
let result = run_git(dir.path(), &["remote", "get-url", "origin"], GIT_TIMEOUT);
match result {
Err(GitError::NonZeroExit { code, stderr }) => {
assert!(
code == Some(1) || code == Some(2),
"expected non-zero exit, got {code:?}"
);
assert!(
!stderr.is_empty(),
"stderr should be captured for diagnostics"
);
}
other => panic!("expected Err(GitError::NonZeroExit), got {:?}", other),
}
}
#[test]
fn test_run_git_spawn_failure_is_typed() {
let empty_bin = TempDir::new().expect("empty bin dir");
let result = run_git_in_env(
Path::new("/"),
&["--version"],
GIT_TIMEOUT,
Some(empty_bin.path()),
);
match result {
Err(GitError::Spawn(msg)) => {
assert!(!msg.is_empty(), "spawn error should carry a reason");
}
other => panic!("expected Err(GitError::Spawn), got {:?}", other),
}
}