use tokio::process::Command;
#[derive(Debug, Clone)]
pub struct TurnContext {
pub repo_root: Option<String>,
pub repo_name: Option<String>,
pub branch: Option<String>,
pub cwd: String,
}
pub async fn detect_context(cwd: &str) -> TurnContext {
let effective_cwd = tokio::fs::canonicalize(cwd)
.await
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_else(|_| cwd.to_string());
let repo_root = git_toplevel(&effective_cwd).await;
if repo_root.is_none() {
return TurnContext {
repo_root: None,
repo_name: None,
branch: None,
cwd: effective_cwd,
};
}
let root = repo_root.clone().unwrap();
let branch = git_branch(&effective_cwd).await;
let repo_name = git_repo_name(&effective_cwd, &root).await;
TurnContext {
repo_root,
repo_name,
branch,
cwd: effective_cwd,
}
}
async fn git_toplevel(cwd: &str) -> Option<String> {
let output = Command::new("git")
.args(["rev-parse", "--show-toplevel"])
.current_dir(cwd)
.output()
.await
.ok()?;
if !output.status.success() {
return None;
}
let s = String::from_utf8(output.stdout).ok()?;
let trimmed = s.trim().to_string();
if trimmed.is_empty() { None } else { Some(trimmed) }
}
async fn git_branch(cwd: &str) -> Option<String> {
let show_current = Command::new("git")
.args(["branch", "--show-current"])
.current_dir(cwd)
.output()
.await
.ok();
if let Some(out) = show_current {
if out.status.success()
&& let Ok(s) = String::from_utf8(out.stdout)
{
let trimmed = s.trim().to_string();
if !trimmed.is_empty() {
return Some(trimmed);
}
}
}
let output = Command::new("git")
.args(["rev-parse", "--abbrev-ref", "HEAD"])
.current_dir(cwd)
.output()
.await
.ok()?;
if !output.status.success() {
return None;
}
let s = String::from_utf8(output.stdout).ok()?;
let trimmed = s.trim().to_string();
if trimmed.is_empty() { None } else { Some(trimmed) }
}
async fn git_repo_name(cwd: &str, repo_root: &str) -> Option<String> {
if let Some(name) = git_name_from_remote(cwd).await {
return Some(name);
}
std::path::Path::new(repo_root)
.file_name()
.map(|n| n.to_string_lossy().to_string())
}
async fn git_name_from_remote(cwd: &str) -> Option<String> {
let output = Command::new("git")
.args(["remote", "get-url", "origin"])
.current_dir(cwd)
.output()
.await
.ok()?;
if !output.status.success() {
return None;
}
let url = String::from_utf8(output.stdout).ok()?;
repo_name_from_url(url.trim())
}
fn repo_name_from_url(url: &str) -> Option<String> {
let url = url.trim_end_matches('/');
let last = url.split('/').next_back()?;
let name = last.strip_suffix(".git").unwrap_or(last);
if name.is_empty() {
None
} else {
Some(name.to_string())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn repo_name_from_https_url_with_git_suffix() {
assert_eq!(
repo_name_from_url("https://github.com/user/my-repo.git"),
Some("my-repo".to_string())
);
}
#[test]
fn repo_name_from_ssh_url() {
assert_eq!(
repo_name_from_url("git@github.com:user/my-repo.git"),
Some("my-repo".to_string())
);
}
#[test]
fn repo_name_from_url_without_git_suffix() {
assert_eq!(
repo_name_from_url("https://github.com/user/my-repo"),
Some("my-repo".to_string())
);
}
#[test]
fn repo_name_from_url_with_trailing_slash() {
assert_eq!(
repo_name_from_url("https://github.com/user/my-repo/"),
Some("my-repo".to_string())
);
}
#[tokio::test]
async fn detect_context_non_git_dir() {
let dir = tempfile::tempdir().unwrap();
let ctx = detect_context(dir.path().to_str().unwrap()).await;
assert!(ctx.repo_root.is_none(), "tmp dir should not be a git repo");
assert!(ctx.repo_name.is_none());
assert!(ctx.branch.is_none());
assert!(!ctx.cwd.is_empty());
}
#[tokio::test]
async fn detect_context_repo_root_null_outside_git() {
let dir = tempfile::tempdir().unwrap();
let ctx = detect_context(dir.path().to_str().unwrap()).await;
assert!(ctx.repo_root.is_none());
}
#[tokio::test]
async fn detect_context_in_git_repo() {
let manifest_dir = env!("CARGO_MANIFEST_DIR");
let ctx = detect_context(manifest_dir).await;
assert!(
ctx.repo_root.is_some(),
"crate manifest dir should be detected as a git repo"
);
}
#[tokio::test]
async fn detect_context_repo_root_not_null_in_git_repo() {
let manifest_dir = env!("CARGO_MANIFEST_DIR");
let ctx = detect_context(manifest_dir).await;
assert!(
ctx.repo_root.is_some(),
"repo_root must be Some inside a git repo"
);
}
#[tokio::test]
async fn detect_context_cwd_always_set() {
let dir = tempfile::tempdir().unwrap();
let ctx = detect_context(dir.path().to_str().unwrap()).await;
assert!(!ctx.cwd.is_empty());
}
#[tokio::test]
async fn detect_context_branch_some_in_git_repo() {
let manifest_dir = env!("CARGO_MANIFEST_DIR");
let ctx = detect_context(manifest_dir).await;
assert!(
ctx.branch.is_some(),
"branch should be detected in a git repo"
);
}
#[tokio::test]
async fn detect_context_repo_name_some_in_git_repo() {
let manifest_dir = env!("CARGO_MANIFEST_DIR");
let ctx = detect_context(manifest_dir).await;
assert!(
ctx.repo_name.is_some(),
"repo_name should be Some in a git repo"
);
}
}