use std::env;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::sync::mpsc;
use std::thread;
use std::time::Duration;
const GIT_TIMEOUT: Duration = Duration::from_secs(5);
pub fn detect_project(explicit: Option<&str>) -> String {
let cwd = env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
detect_project_at(&cwd, explicit)
}
pub fn detect_project_at(root: &Path, explicit: Option<&str>) -> String {
detect_project_at_internal(root, explicit, None)
}
pub(crate) fn detect_project_at_internal(
root: &Path,
explicit: Option<&str>,
env_project: Option<String>,
) -> String {
if let Some(project) = explicit {
if !project.trim().is_empty() {
return project.trim().to_string();
}
}
if let Some(project) = env_project {
let trimmed = project.trim();
if !trimmed.is_empty() {
return trimmed.to_string();
}
} else if let Ok(project) = env::var("VIPUNE_PROJECT") {
let trimmed = project.trim();
if !trimmed.is_empty() {
return trimmed.to_string();
}
}
match get_git_remote_url_at(root) {
Ok(remote) => {
let project = parse_git_remote(&remote);
if !project.is_empty() {
return project;
}
}
Err(e) => debug_log_git_failure("remote get-url origin", &e),
}
match find_git_root_at(root) {
Ok(git_root) => {
if let Some(name) = git_root.file_name() {
if let Some(s) = name.to_str() {
emit_fallback_warning(s);
return s.to_string();
}
}
}
Err(e) => {
debug_log_git_failure("rev-parse --show-toplevel", &e);
return root
.file_name()
.and_then(|n| n.to_str())
.map(|s| s.to_string())
.unwrap_or_else(|| "unknown".to_string());
}
}
root.file_name()
.and_then(|n| n.to_str())
.map(|s| s.to_string())
.unwrap_or_else(|| "unknown".to_string())
}
#[derive(Debug)]
enum GitError {
Spawn(String),
NonZeroExit { code: Option<i32>, stderr: String },
Timeout(Duration),
}
impl std::fmt::Display for GitError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
GitError::Spawn(e) => write!(f, "spawn failure: {e}"),
GitError::NonZeroExit { code, stderr } => {
let code = code
.map(|c| c.to_string())
.unwrap_or_else(|| "?".to_string());
write!(f, "non-zero exit ({code}): {}", stderr.trim())
}
GitError::Timeout(d) => write!(f, "timed out after {d:?}"),
}
}
}
fn run_git(root: &Path, args: &[&str], timeout: Duration) -> 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 child = Command::new("git")
.args(["-C", root_str.as_str()])
.args(args)
.env("GIT_TERMINAL_PROMPT", "0")
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.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))
}
}
}
fn read_pipe<R: std::io::Read + Send>(mut pipe: R) -> Vec<u8> {
let mut buf = Vec::new();
let _ = std::io::Read::read_to_end(&mut pipe, &mut buf);
buf
}
fn get_git_remote_url_at(root: &Path) -> Result<String, GitError> {
let out = run_git(root, &["remote", "get-url", "origin"], GIT_TIMEOUT)?;
if out.is_empty() {
Ok(String::new())
} else {
Ok(out)
}
}
fn find_git_root_at(root: &Path) -> Result<PathBuf, GitError> {
let out = run_git(root, &["rev-parse", "--show-toplevel"], GIT_TIMEOUT)?;
if out.is_empty() {
return Err(GitError::NonZeroExit {
code: None,
stderr: "rev-parse --show-toplevel produced no output".to_string(),
});
}
Ok(PathBuf::from(out))
}
fn debug_log_git_failure(step: &str, err: &GitError) {
if debug_enabled() {
eprintln!("debug: git step '{step}' failed: {err}");
}
}
fn debug_enabled() -> bool {
match env::var("VIPUNE_DEBUG") {
Ok(v) => !v.is_empty() && v != "0" && v != "false",
Err(_) => false,
}
}
pub(crate) fn build_fallback_warning_message(project_id: &str) -> String {
format!(
"Warning: no git remote 'origin' found, using directory name as project_id: '{}'. This project_id may differ from the remote-derived one.",
project_id
)
}
fn emit_fallback_warning(project_id: &str) {
eprintln!("{}", build_fallback_warning_message(project_id));
}
fn parse_git_remote(url: &str) -> String {
let url = url.trim().trim_end_matches(".git");
if let Some(rest) = url.strip_prefix("git@") {
if let Some(colon_pos) = rest.find(':') {
let path = &rest[colon_pos + 1..];
let segments: Vec<&str> = path.split('/').collect();
if segments.len() >= 2 {
return format!(
"{}/{}",
segments[segments.len() - 2],
segments[segments.len() - 1]
);
}
return path.to_string();
}
}
if let Some(rest) = url.split("://").nth(1) {
let parts: Vec<&str> = rest.split('/').collect();
if parts.len() >= 3 {
return format!("{}/{}", parts[parts.len() - 2], parts[parts.len() - 1]);
}
}
url.to_string()
}
#[cfg(test)]
#[path = "project_tests.rs"]
mod project_tests;