use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use kanade_shared::manifest::RepoOrigin;
pub(crate) fn append_origin_yaml(yaml: &mut String, origin: &RepoOrigin) -> Result<()> {
#[derive(serde::Serialize)]
struct Wrap<'a> {
origin: &'a RepoOrigin,
}
let block = serde_yaml::to_string(&Wrap { origin }).context("serialize origin")?;
if !yaml.ends_with('\n') {
yaml.push('\n');
}
yaml.push_str(&block);
Ok(())
}
pub(crate) fn has_top_level_origin(yaml: &str) -> bool {
yaml.lines().any(|l| {
!l.starts_with(char::is_whitespace)
&& l.contains(':')
&& l.split(':').next().map(str::trim) == Some("origin")
})
}
pub(crate) fn detect_repo_origin(yaml: &Path, script_file: Option<&Path>) -> Option<RepoOrigin> {
let dir = yaml
.parent()
.filter(|p| !p.as_os_str().is_empty())
.map(Path::to_path_buf)
.unwrap_or_else(|| PathBuf::from("."));
let (toplevel, git_backed) = match vcs_output(&dir, "git", &["rev-parse", "--show-toplevel"]) {
Some(t) => (t, true),
None => (vcs_output(&dir, "jj", &["root"])?, false),
};
let toplevel = PathBuf::from(toplevel.trim());
let path = repo_relative(&toplevel, yaml)?;
let repo = git_backed
.then(|| vcs_output(&dir, "git", &["remote", "get-url", "origin"]))
.flatten()
.and_then(|s| sanitize_repo_remote(&s));
let script_file = script_file.and_then(|sf| repo_relative(&toplevel, sf));
Some(RepoOrigin {
path,
repo,
script_file,
})
}
fn sanitize_repo_remote(raw: &str) -> Option<String> {
let trimmed = raw.trim();
if trimmed.is_empty() {
return None;
}
if let Ok(mut url) = reqwest::Url::parse(trimmed) {
let _ = url.set_username("");
let _ = url.set_password(None);
return Some(url.to_string());
}
Some(trimmed.to_string())
}
fn vcs_output(dir: &Path, prog: &str, args: &[&str]) -> Option<String> {
let out = std::process::Command::new(prog)
.current_dir(dir)
.args(args)
.output()
.ok()?;
if !out.status.success() {
return None;
}
Some(String::from_utf8_lossy(&out.stdout).into_owned())
}
fn repo_relative(toplevel: &Path, file: &Path) -> Option<String> {
let top = toplevel.canonicalize().ok()?;
let abs = file.canonicalize().ok()?;
let rel = abs.strip_prefix(&top).ok()?;
Some(rel.to_string_lossy().replace('\\', "/"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn detects_top_level_origin_key() {
assert!(has_top_level_origin("id: j\norigin:\n path: x\n"));
assert!(has_top_level_origin("origin: {}\n"));
assert!(!has_top_level_origin("execute:\n origin: nope\n"));
assert!(!has_top_level_origin("id: j\nversion: 1.0.0\n"));
assert!(!has_top_level_origin("origin\n"));
assert!(!has_top_level_origin("origin_path: x\n"));
}
#[test]
fn appends_parseable_origin_block() {
let mut yaml = String::from("id: j\nversion: 1.0.0\n");
append_origin_yaml(
&mut yaml,
&RepoOrigin {
path: "configs/jobs/j.yaml".into(),
repo: Some("https://github.com/o/r".into()),
script_file: None,
},
)
.expect("append");
assert!(has_top_level_origin(&yaml));
#[derive(serde::Deserialize)]
struct Probe {
origin: RepoOrigin,
}
let p: Probe = serde_yaml::from_str(&yaml).expect("parse appended");
assert_eq!(p.origin.path, "configs/jobs/j.yaml");
}
#[test]
fn sanitize_repo_remote_strips_credentials() {
assert_eq!(
sanitize_repo_remote("https://ghp_secret@github.com/o/r.git").as_deref(),
Some("https://github.com/o/r.git"),
);
assert_eq!(
sanitize_repo_remote("https://user:pass@example.com/o/r").as_deref(),
Some("https://example.com/o/r"),
);
assert_eq!(
sanitize_repo_remote("git@github.com:o/r.git").as_deref(),
Some("git@github.com:o/r.git"),
);
assert_eq!(
sanitize_repo_remote("https://github.com/o/r").as_deref(),
Some("https://github.com/o/r"),
);
assert_eq!(sanitize_repo_remote(" ").as_deref(), None);
}
#[test]
fn detect_repo_origin_resolves_in_repo_checkout() {
let here = Path::new(env!("CARGO_MANIFEST_DIR")).join("Cargo.toml");
if let Some(origin) = detect_repo_origin(&here, None) {
assert!(
origin.path.ends_with("crates/kanade/Cargo.toml"),
"unexpected repo-relative path: {}",
origin.path,
);
}
}
}