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");
}
}