use std::path::PathBuf;
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub enum RegistryConfig {
#[default]
None,
FileUrl(PathBuf),
Git { url: String, rev: Option<String> },
}
pub fn parse_registry_config(
raw: &str,
registry_rev: Option<&str>,
) -> Result<RegistryConfig, crate::error::ConfigError> {
if raw == "none" {
return Ok(RegistryConfig::None);
}
if let Some(rest) = raw.strip_prefix("file://") {
if !rest.starts_with('/') {
return Err(crate::error::ConfigError::RegistryPathNotAbsolute {
path: rest.to_string(),
});
}
if rest.ends_with(".git") {
return Ok(RegistryConfig::Git {
url: raw.to_string(),
rev: registry_rev.map(|s| s.to_string()),
});
}
return Ok(RegistryConfig::FileUrl(PathBuf::from(rest)));
}
if let Some(rest) = raw.strip_prefix("github.com/") {
let parts: Vec<&str> = rest.split('/').collect();
if parts.len() == 2 && !parts[0].is_empty() && !parts[1].is_empty() {
return Ok(RegistryConfig::Git {
url: raw.to_string(),
rev: registry_rev.map(|s| s.to_string()),
});
}
}
Err(crate::error::ConfigError::BadRegistry(raw.to_string()))
}
#[derive(Debug, Clone)]
pub struct FetchResult {
pub sha: String,
pub working_tree: std::path::PathBuf,
}
pub fn fetch_into_cache(
url: &str,
rev: Option<&str>,
offline: bool,
) -> Result<FetchResult, crate::fixup::FixupError> {
use crate::fixup::FixupError;
if let Some(rev_str) = rev
&& is_full_hex_sha(rev_str)
{
let candidate =
crate::cache::fixup_cache_path_for_sha(rev_str).map_err(|e| FixupError::Io {
path: std::path::PathBuf::from("<cache-root>"),
source: std::io::Error::other(e.to_string()),
})?;
if candidate.is_dir() {
let packages_dir = candidate.join("packages");
if !packages_dir.is_dir() {
return Err(FixupError::CacheCorrupt {
path: candidate,
reason: "missing packages/ subdir".into(),
});
}
return Ok(FetchResult {
sha: rev_str.to_string(),
working_tree: candidate,
});
}
}
if offline {
let pin = rev
.map(|s| s.to_string())
.unwrap_or_else(|| "(default branch)".into());
return Err(FixupError::Offline { pin });
}
fetch_and_stage(url, rev)
}
fn is_full_hex_sha(s: &str) -> bool {
s.len() == 40 && s.chars().all(|c| c.is_ascii_hexdigit())
}
struct StagingGuard {
path: Option<std::path::PathBuf>,
}
impl StagingGuard {
fn new(path: std::path::PathBuf) -> Self {
Self { path: Some(path) }
}
fn disarm(mut self) {
self.path = None;
}
}
impl Drop for StagingGuard {
fn drop(&mut self) {
if let Some(p) = self.path.take() {
let _ = std::fs::remove_dir_all(&p);
}
}
}
fn fetch_and_stage(url: &str, rev: Option<&str>) -> Result<FetchResult, crate::fixup::FixupError> {
use crate::fixup::FixupError;
let cache_root = crate::cache::cache_root().map_err(|e| FixupError::Io {
path: std::path::PathBuf::from("<cache-root>"),
source: std::io::Error::other(e.to_string()),
})?;
let staging_root = cache_root.join("fixups").join(".staging");
std::fs::create_dir_all(&staging_root).map_err(|e| FixupError::Io {
path: staging_root.clone(),
source: e,
})?;
let staging = staging_root.join(format!(
"stage-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0)
));
let guard = StagingGuard::new(staging.clone());
let prepare = gix::prepare_clone(url, &staging).map_err(|e| FixupError::GitFetch {
url: url.to_string(),
rev: rev.map(|s| s.to_string()),
source: Box::new(e),
})?;
let prepare = match rev {
Some(rev_str) if !is_full_hex_sha(rev_str) => prepare
.with_ref_name(Some(rev_str))
.map_err(|e| FixupError::GitFetch {
url: url.to_string(),
rev: rev.map(|s| s.to_string()),
source: Box::new(e),
})?,
_ => prepare,
};
let mut prepare = prepare;
let (mut checkout, _outcome) = prepare
.fetch_then_checkout(gix::progress::Discard, &gix::interrupt::IS_INTERRUPTED)
.map_err(|e| FixupError::GitFetch {
url: url.to_string(),
rev: rev.map(|s| s.to_string()),
source: Box::new(e),
})?;
let (repo, _checkout_outcome) = checkout
.main_worktree(gix::progress::Discard, &gix::interrupt::IS_INTERRUPTED)
.map_err(|e| FixupError::GitFetch {
url: url.to_string(),
rev: rev.map(|s| s.to_string()),
source: Box::new(e),
})?;
let head_id = repo.head_id().map_err(|e| FixupError::GitFetch {
url: url.to_string(),
rev: rev.map(|s| s.to_string()),
source: Box::new(e),
})?;
let resolved_sha = head_id.to_string();
let final_dest = cache_root.join("fixups").join(&resolved_sha);
if final_dest.is_dir() {
return Ok(FetchResult {
sha: resolved_sha,
working_tree: final_dest,
});
}
drop(repo);
std::fs::rename(&staging, &final_dest).map_err(|e| FixupError::Io {
path: final_dest.clone(),
source: e,
})?;
guard.disarm();
Ok(FetchResult {
sha: resolved_sha,
working_tree: final_dest,
})
}
#[cfg(test)]
mod tests {
use super::*;
static ENV_GUARD: std::sync::Mutex<()> = std::sync::Mutex::new(());
#[test]
fn parses_none() {
assert_eq!(
parse_registry_config("none", None).unwrap(),
RegistryConfig::None
);
}
#[test]
fn parses_file_url_abs() {
let got = parse_registry_config("file:///abs/path/to/registry", None).unwrap();
assert_eq!(
got,
RegistryConfig::FileUrl(PathBuf::from("/abs/path/to/registry"))
);
}
#[test]
fn rejects_file_url_relative() {
let err = parse_registry_config("file://relative", None).unwrap_err();
assert!(matches!(
err,
crate::error::ConfigError::RegistryPathNotAbsolute { .. }
));
}
#[test]
fn rejects_file_url_dot_slash() {
let err = parse_registry_config("file://./registry", None).unwrap_err();
assert!(matches!(
err,
crate::error::ConfigError::RegistryPathNotAbsolute { .. }
));
}
#[test]
fn parses_github_url_with_rev() {
let got = parse_registry_config("github.com/rsJames-ttrpg/muntjac-fixups", Some("abc123"))
.unwrap();
assert_eq!(
got,
RegistryConfig::Git {
url: "github.com/rsJames-ttrpg/muntjac-fixups".into(),
rev: Some("abc123".into()),
}
);
}
#[test]
fn parses_github_url_no_rev() {
let got = parse_registry_config("github.com/o/r", None).unwrap();
match got {
RegistryConfig::Git { url, rev } => {
assert_eq!(url, "github.com/o/r");
assert_eq!(rev, None);
}
other => panic!("expected Git, got {:?}", other),
}
}
#[test]
fn rejects_malformed_github_url() {
let err = parse_registry_config("github.com/onlyname", None).unwrap_err();
assert!(matches!(err, crate::error::ConfigError::BadRegistry(_)));
}
#[test]
fn rejects_bare_string() {
let err = parse_registry_config("not-a-url", None).unwrap_err();
assert!(matches!(err, crate::error::ConfigError::BadRegistry(_)));
}
#[test]
fn parses_file_url_with_dot_git_as_git_form() {
let got = parse_registry_config("file:///abs/path/to/bare.git", Some("abc123")).unwrap();
assert_eq!(
got,
RegistryConfig::Git {
url: "file:///abs/path/to/bare.git".into(),
rev: Some("abc123".into()),
}
);
}
#[test]
fn parses_file_url_without_dot_git_stays_file_url() {
let got = parse_registry_config("file:///abs/path/to/checkout", None).unwrap();
assert_eq!(
got,
RegistryConfig::FileUrl(std::path::PathBuf::from("/abs/path/to/checkout"))
);
}
#[test]
fn fetch_cache_hit_returns_without_network() {
let _g = ENV_GUARD.lock().unwrap();
use tempfile::TempDir;
let tmp = TempDir::new().unwrap();
unsafe {
std::env::set_var("MUNTJAC_CACHE_HOME", tmp.path());
}
let sha = "a".repeat(40);
let cache_dir = tmp.path().join("fixups").join(&sha);
std::fs::create_dir_all(cache_dir.join("packages")).unwrap();
let result = super::fetch_into_cache("bogus://not-a-url", Some(&sha), false).unwrap();
assert_eq!(result.sha, sha);
assert_eq!(result.working_tree, cache_dir);
unsafe {
std::env::remove_var("MUNTJAC_CACHE_HOME");
}
}
#[test]
fn fetch_cache_hit_but_corrupt_errors() {
let _g = ENV_GUARD.lock().unwrap();
use tempfile::TempDir;
let tmp = TempDir::new().unwrap();
unsafe {
std::env::set_var("MUNTJAC_CACHE_HOME", tmp.path());
}
let sha = "b".repeat(40);
let cache_dir = tmp.path().join("fixups").join(&sha);
std::fs::create_dir_all(&cache_dir).unwrap();
let err = super::fetch_into_cache("bogus://", Some(&sha), false).unwrap_err();
match err {
crate::fixup::FixupError::CacheCorrupt { reason, .. } => {
assert!(reason.contains("packages"), "got: {}", reason);
}
other => panic!("expected CacheCorrupt, got {:?}", other),
}
unsafe {
std::env::remove_var("MUNTJAC_CACHE_HOME");
}
}
#[test]
fn fetch_offline_with_cache_miss_errors() {
let _g = ENV_GUARD.lock().unwrap();
use tempfile::TempDir;
let tmp = TempDir::new().unwrap();
unsafe {
std::env::set_var("MUNTJAC_CACHE_HOME", tmp.path());
}
let err = super::fetch_into_cache(
"bogus://",
Some(&"c".repeat(40)),
true, )
.unwrap_err();
match err {
crate::fixup::FixupError::Offline { pin } => {
assert_eq!(pin, "c".repeat(40));
}
other => panic!("expected Offline, got {:?}", other),
}
unsafe {
std::env::remove_var("MUNTJAC_CACHE_HOME");
}
}
#[test]
fn fetch_offline_without_rev_uses_default_branch_in_pin_message() {
let _g = ENV_GUARD.lock().unwrap();
use tempfile::TempDir;
let tmp = TempDir::new().unwrap();
unsafe {
std::env::set_var("MUNTJAC_CACHE_HOME", tmp.path());
}
let err = super::fetch_into_cache("bogus://", None, true).unwrap_err();
match err {
crate::fixup::FixupError::Offline { pin } => {
assert_eq!(pin, "(default branch)");
}
other => panic!("expected Offline, got {:?}", other),
}
unsafe {
std::env::remove_var("MUNTJAC_CACHE_HOME");
}
}
}