cargo-worktree 1.0.0

Deterministic build namespace layer for Cargo
use crate::core::workspace;
use anyhow::{Context, Result, bail};
use serde::Serialize;
use std::path::Path;
use std::process::Command;

#[derive(Debug, Serialize)]
pub struct NamespaceComponents {
    pub workspace_root: String,
    pub source: NamespaceSource,
}

#[derive(Debug, Serialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum NamespaceSource {
    Branch {
        branch: String,
        escaped_branch: String,
    },
    Commit {
        rev: String,
    },
    DefaultTarget,
}

pub fn collect_components() -> Result<NamespaceComponents> {
    let workspace_root_path = workspace::workspace_root()?.canonicalize()?;
    let workspace_root = workspace_root_path.to_string_lossy().to_string();

    let source = resolve_git_source(&workspace_root_path)?;

    Ok(NamespaceComponents {
        workspace_root,
        source,
    })
}

impl NamespaceComponents {
    pub fn namespace_key(&self) -> Option<String> {
        match &self.source {
            NamespaceSource::Branch { escaped_branch, .. } => {
                Some(format!("branch/{escaped_branch}",))
            }
            NamespaceSource::Commit { rev } => Some(format!("rev/{rev}",)),
            NamespaceSource::DefaultTarget => None,
        }
    }
}

fn resolve_git_source(workspace_root: &Path) -> Result<NamespaceSource> {
    if !is_inside_git_worktree(workspace_root)? {
        return Ok(NamespaceSource::DefaultTarget);
    }

    if let Some(branch) = current_branch(workspace_root)? {
        let escaped_branch = escape_path_component(&branch);
        return Ok(NamespaceSource::Branch {
            branch,
            escaped_branch,
        });
    }

    let rev = current_commit_rev(workspace_root)?;
    Ok(NamespaceSource::Commit { rev })
}

fn is_inside_git_worktree(workspace_root: &Path) -> Result<bool> {
    let output = Command::new("git")
        .args(["-C"])
        .arg(workspace_root)
        .args(["rev-parse", "--is-inside-work-tree"])
        .output();

    let Ok(output) = output else {
        return Ok(false);
    };

    if !output.status.success() {
        return Ok(false);
    }

    let stdout = String::from_utf8_lossy(&output.stdout);
    Ok(stdout.trim() == "true")
}

fn current_branch(workspace_root: &Path) -> Result<Option<String>> {
    let output = Command::new("git")
        .args(["-C"])
        .arg(workspace_root)
        .args(["symbolic-ref", "--quiet", "--short", "HEAD"])
        .output()
        .context("failed to run git symbolic-ref")?;

    if !output.status.success() {
        return Ok(None);
    }

    let branch = String::from_utf8_lossy(&output.stdout).trim().to_string();
    if branch.is_empty() {
        return Ok(None);
    }

    Ok(Some(branch))
}

fn current_commit_rev(workspace_root: &Path) -> Result<String> {
    let output = Command::new("git")
        .args(["-C"])
        .arg(workspace_root)
        .args(["rev-parse", "HEAD"])
        .output()
        .context("failed to run git rev-parse HEAD")?;

    if !output.status.success() {
        bail!("failed to resolve current commit rev");
    }

    let rev = String::from_utf8_lossy(&output.stdout).trim().to_string();
    if rev.is_empty() {
        bail!("git returned an empty commit rev");
    }

    Ok(rev)
}

fn escape_path_component(input: &str) -> String {
    let mut out = String::with_capacity(input.len());

    for b in input.bytes() {
        match b {
            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'.' | b'_' | b'-' => {
                out.push(b as char);
            }
            _ => {
                out.push('%');
                out.push_str(&format!("{b:02X}"));
            }
        }
    }

    out
}

#[cfg(test)]
mod tests {
    use super::escape_path_component;

    #[test]
    fn escapes_path_separators() {
        assert_eq!(escape_path_component("feature/foo"), "feature%2Ffoo");
    }

    #[test]
    fn preserves_safe_ascii() {
        assert_eq!(escape_path_component("release-1.2.3"), "release-1.2.3");
    }
}