use anyhow::{Context, Result};
use std::path::Path;
use std::process::Command;
#[derive(Debug, Clone)]
pub struct GitContext {
pub project: String,
pub branch: String,
}
impl GitContext {
pub fn sandbox_name(&self) -> String {
format!("{}-{}", self.project, self.branch)
}
}
pub fn detect() -> Result<GitContext> {
detect_in(".")
}
pub fn detect_in(dir: &str) -> Result<GitContext> {
let project = git_project_name(dir)?;
let branch = git_branch_name(dir)?;
Ok(GitContext { project, branch })
}
fn git_project_name(dir: &str) -> Result<String> {
let output = Command::new("git")
.args(["rev-parse", "--show-toplevel"])
.current_dir(dir)
.output()
.context("Failed to run git rev-parse")?;
if !output.status.success() {
anyhow::bail!("Not a git repository");
}
let toplevel = String::from_utf8_lossy(&output.stdout).trim().to_string();
let name = Path::new(&toplevel)
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| "unknown".to_string());
Ok(sanitize_name(&name))
}
fn git_branch_name(dir: &str) -> Result<String> {
let output = Command::new("git")
.args(["rev-parse", "--abbrev-ref", "HEAD"])
.current_dir(dir)
.output()
.context("Failed to run git rev-parse")?;
if !output.status.success() {
anyhow::bail!("Failed to get current branch");
}
let branch = String::from_utf8_lossy(&output.stdout).trim().to_string();
if branch == "HEAD" {
let sha_output = Command::new("git")
.args(["rev-parse", "--short", "HEAD"])
.current_dir(dir)
.output()
.context("Failed to get HEAD SHA")?;
let sha = String::from_utf8_lossy(&sha_output.stdout)
.trim()
.to_string();
return Ok(sanitize_name(&sha));
}
Ok(sanitize_name(&branch))
}
fn sanitize_name(name: &str) -> String {
name.to_lowercase()
.replace(['/', '\\', ' ', '_', '.'], "-")
.trim_matches('-')
.to_string()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_sanitize_name() {
assert_eq!(sanitize_name("feature/auth"), "feature-auth");
assert_eq!(sanitize_name("my_project"), "my-project");
assert_eq!(sanitize_name("UPPER.Case"), "upper-case");
assert_eq!(sanitize_name("--trimmed--"), "trimmed");
assert_eq!(sanitize_name("fix/bug 42"), "fix-bug-42");
}
#[test]
fn test_detect_in_current_repo() {
if let Ok(ctx) = detect() {
assert!(!ctx.project.is_empty());
assert!(!ctx.branch.is_empty());
let name = ctx.sandbox_name();
assert!(name.contains('-') || !ctx.branch.is_empty());
}
}
#[test]
fn test_sandbox_name_format() {
let ctx = GitContext {
project: "myproject".to_string(),
branch: "feature-auth".to_string(),
};
assert_eq!(ctx.sandbox_name(), "myproject-feature-auth");
}
#[test]
fn test_detect_in_non_git_dir() {
let result = detect_in("/tmp");
assert!(result.is_err());
}
}