use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use serde_json::{Map, Value};
use crate::error::{self, Error, Result};
use crate::registry::{Checkout, Identity, Registry, RepoType, Workflow};
use crate::{git, home, lock};
pub const VERSION: u32 = 5;
pub const NOOP_GATE: &str = "<no-op>";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Hosted {
pub host: String,
pub owner: String,
pub name: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Normalized {
pub key: String,
pub hosted: Option<Hosted>,
}
pub fn normalize(origin: &str) -> Normalized {
let trimmed = origin.trim();
if let Some(parts) = hosted(trimmed) {
return parts;
}
let path = trimmed.strip_prefix("file://").unwrap_or(trimmed);
let path = PathBuf::from(path);
let key = std::fs::canonicalize(&path).unwrap_or(path);
Normalized {
key: key.to_string_lossy().trim_end_matches(".git").to_owned(),
hosted: None,
}
}
fn hosted(origin: &str) -> Option<Normalized> {
if !origin.contains("://") && !origin.contains(':') && !origin.starts_with('/') {
let segments: Vec<&str> = origin.split('/').collect();
if let [host, owner, name] = segments[..] {
if host.contains('.') && !owner.is_empty() && !name.is_empty() {
return Some(Normalized {
key: origin.to_owned(),
hosted: Some(Hosted {
host: host.to_owned(),
owner: owner.to_owned(),
name: name.to_owned(),
}),
});
}
}
return None;
}
let rest = if let Some(rest) = origin.strip_prefix("https://") {
rest.to_owned()
} else if let Some(rest) = origin.strip_prefix("http://") {
rest.to_owned()
} else if let Some(rest) = origin.strip_prefix("ssh://") {
rest.to_owned()
} else if let Some((before, after)) = origin.split_once(':') {
if before.contains('/') || after.starts_with("//") || after.starts_with('/') {
return None;
}
format!("{before}/{after}")
} else {
return None;
};
let rest = rest
.split_once('@')
.map_or(rest.as_str(), |(_, after)| after);
let mut segments = rest.trim_end_matches('/').split('/');
let host = segments.next()?.split(':').next()?.to_owned();
let owner = segments.next()?.to_owned();
let name = segments.next()?.trim_end_matches(".git").to_owned();
if host.is_empty() || owner.is_empty() || name.is_empty() || segments.next().is_some() {
return None;
}
Some(Normalized {
key: format!("{host}/{owner}/{name}"),
hosted: Some(Hosted { host, owner, name }),
})
}
fn not_json(path: &Path) -> impl FnOnce(serde_json::Error) -> Error + '_ {
move |error| {
error::invalid(format!(
"the registry at {} is not JSON: {error}",
path.display()
))
}
}
fn registry_identity() -> String {
"registry".to_owned()
}
pub fn load() -> Result<Registry> {
let path = home::registry_path()?;
let Ok(raw) = std::fs::read_to_string(&path) else {
return Ok(empty());
};
let value: Value = serde_json::from_str(&raw).map_err(not_json(&path))?;
let (registry, migrated) = migrate(&path, value)?;
if migrated {
let _guard = lock::exclusive(®istry_identity())?;
home::atomic_write(&path, &serialize(®istry)?)?;
}
Ok(registry)
}
pub fn update<T>(change: impl FnOnce(&mut Registry) -> Result<T>) -> Result<T> {
let _guard = lock::exclusive(®istry_identity())?;
let path = home::registry_path()?;
let mut registry = match std::fs::read_to_string(&path) {
Ok(raw) => migrate(&path, serde_json::from_str(&raw).map_err(not_json(&path))?)?.0,
Err(_) => empty(),
};
let outcome = change(&mut registry)?;
home::atomic_write(&path, &serialize(®istry)?)?;
Ok(outcome)
}
fn empty() -> Registry {
Registry {
version: VERSION,
identities: BTreeMap::new(),
checkouts: BTreeMap::new(),
rules: None,
}
}
fn serialize(registry: &Registry) -> Result<String> {
let mut json = serde_json::to_string_pretty(registry)
.map_err(error::at("serialize", &PathBuf::from("the registry")))?;
json.push('\n');
Ok(json)
}
fn migrate(path: &Path, value: Value) -> Result<(Registry, bool)> {
let object = value.as_object().ok_or_else(|| Error::Invalid {
reason: format!("the registry at {} must be a JSON object", path.display()),
})?;
let version = object
.get("version")
.and_then(Value::as_u64)
.ok_or_else(|| Error::Invalid {
reason: format!(
"the registry at {} declares no version; versions 2 to {VERSION} are readable",
path.display()
),
})?;
match version {
VERSION_5 => {
let registry: Registry = serde_json::from_value(value.clone())
.map_err(error::at("read the registry at", path))?;
coherent(path, ®istry)?;
Ok((registry, false))
}
2..=4 => {
let migrated = legacy(path, object, version as u32)?;
coherent(path, &migrated)?;
Ok((migrated, true))
}
other => Err(Error::Invalid {
reason: format!(
"the registry at {} declares version {other}; this build reads 2 to {VERSION}",
path.display()
),
}),
}
}
const VERSION_5: u64 = VERSION as u64;
fn coherent(path: &Path, registry: &Registry) -> Result<()> {
for (key, identity) in ®istry.identities {
if identity.repo_type == RepoType::Team && identity.workflow == Workflow::Local {
return Err(error::invalid(format!(
"the registry at {} has identity {key:?} combining repo_type=team with \
workflow=local, which no publication policy can honour",
path.display()
)));
}
}
for (alias, checkout) in ®istry.checkouts {
if !registry.identities.contains_key(&checkout.identity) {
return Err(error::invalid(format!(
"the registry at {} has checkout {alias:?} referencing unknown identity {:?}",
path.display(),
checkout.identity
)));
}
if !checkout.path.is_absolute() {
return Err(error::invalid(format!(
"the registry at {} has checkout {alias:?} at {}, which is not an absolute path",
path.display(),
checkout.path.display()
)));
}
}
Ok(())
}
fn legacy(path: &Path, object: &Map<String, Value>, version: u32) -> Result<Registry> {
let mut identities = BTreeMap::new();
for (key, value) in object
.get("identities")
.and_then(Value::as_object)
.ok_or_else(|| Error::Invalid {
reason: format!(
"the version {version} registry at {} must contain identities",
path.display()
),
})?
{
let origin = field(path, value, "origin")?;
let workflow = match field(path, value, "workflow")?.as_str() {
"local" => Workflow::Local,
"remote" => Workflow::Remote,
other => {
return Err(Error::Invalid {
reason: format!(
"registry identity {key:?} has workflow {other:?}, which is not \
'local' or 'remote'"
),
})
}
};
let repo_type = match (version, workflow) {
(2, Workflow::Local) => RepoType::SingleOwner,
(2, Workflow::Remote) => RepoType::Team,
_ => match field(path, value, "repo_type")?.as_str() {
"single-owner" => RepoType::SingleOwner,
"team" => RepoType::Team,
other => {
return Err(Error::Invalid {
reason: format!(
"registry identity {key:?} has repo_type {other:?}, which is not \
'single-owner' or 'team'"
),
})
}
},
};
if repo_type == RepoType::Team && workflow == Workflow::Local {
return Err(Error::Invalid {
reason: format!(
"registry identity {key:?} combines repo_type=team with workflow=local, \
which no publication policy can honour"
),
});
}
let gate = if version >= 4 {
field(path, value, "gate")?
} else {
NOOP_GATE.to_owned()
};
identities.insert(
key.clone(),
Identity {
origin,
workflow,
repo_type,
gate,
},
);
}
let mut checkouts = BTreeMap::new();
for (alias, value) in object
.get("checkouts")
.and_then(Value::as_object)
.ok_or_else(|| Error::Invalid {
reason: format!(
"the version {version} registry at {} must contain checkouts",
path.display()
),
})?
{
let identity = field(path, value, "identity")?;
if !identities.contains_key(&identity) {
return Err(Error::Invalid {
reason: format!(
"registry checkout {alias:?} references unknown identity {identity:?}"
),
});
}
checkouts.insert(
alias.clone(),
Checkout {
path: PathBuf::from(field(path, value, "path")?),
identity,
},
);
}
Ok(Registry {
version: VERSION,
identities,
checkouts,
rules: None,
})
}
fn field(path: &Path, value: &Value, name: &str) -> Result<String> {
value
.get(name)
.and_then(Value::as_str)
.map(str::to_owned)
.ok_or_else(|| Error::Invalid {
reason: format!(
"the registry at {} has a record missing its {name}",
path.display()
),
})
}
#[derive(Debug, Clone)]
pub struct Resolution {
pub key: String,
pub identity: Identity,
pub alias: String,
pub publication: PathBuf,
}
pub fn resolve(registry: &Registry, repo: &str) -> Result<Resolution> {
if let Some(checkout) = registry.checkouts.get(repo) {
return build(registry, repo, checkout);
}
if let Ok(canonical) = std::fs::canonicalize(repo) {
if let Some((alias, checkout)) = registry
.checkouts
.iter()
.find(|(_, checkout)| checkout.path == canonical)
{
return build(registry, alias, checkout);
}
}
let key = if registry.identities.contains_key(repo) {
repo.to_owned()
} else {
normalize(repo).key
};
if let Some((alias, checkout)) = registry
.checkouts
.iter()
.find(|(_, checkout)| checkout.identity == key)
{
return build(registry, alias, checkout);
}
if registry.identities.contains_key(&key) {
return Err(Error::Invalid {
reason: format!("identity {key:?} has no registered checkout"),
});
}
let known: Vec<&str> = registry.checkouts.keys().map(String::as_str).collect();
Err(Error::Invalid {
reason: format!(
"{repo:?} is not a registered repository; register it with `onevcs register PATH`. \
Known checkouts: {}",
if known.is_empty() {
"none".to_owned()
} else {
known.join(", ")
}
),
})
}
fn build(registry: &Registry, alias: &str, checkout: &Checkout) -> Result<Resolution> {
let identity = registry
.identities
.get(&checkout.identity)
.ok_or_else(|| Error::Invalid {
reason: format!(
"registered checkout {alias:?} names identity {:?}, which the registry does \
not hold",
checkout.identity
),
})?;
Ok(Resolution {
key: checkout.identity.clone(),
identity: identity.clone(),
alias: alias.to_owned(),
publication: checkout.path.clone(),
})
}
pub fn register(path: &Path, origin_override: Option<&str>) -> Result<Resolution> {
let checkout = std::fs::canonicalize(path).map_err(error::at("register", path))?;
if !git::is_repo(&checkout) {
return Err(Error::Invalid {
reason: format!("{} is not a git checkout", checkout.display()),
});
}
let origin = match origin_override {
Some(value) => value.to_owned(),
None => git::remote_url(&checkout, "origin")?,
};
let normalized = normalize(&origin);
let gate = detect_gate(&checkout);
let alias = alias_for(&checkout);
update(|registry| {
registry
.identities
.entry(normalized.key.clone())
.or_insert_with(|| Identity {
origin: normalized.key.clone(),
workflow: if normalized.hosted.is_some() {
Workflow::Remote
} else {
Workflow::Local
},
repo_type: if normalized.hosted.is_some() {
RepoType::Team
} else {
RepoType::SingleOwner
},
gate: gate.clone(),
});
if let Some(identity) = registry.identities.get_mut(&normalized.key) {
if identity.gate == NOOP_GATE && gate != NOOP_GATE {
identity.gate = gate.clone();
}
}
registry.checkouts.insert(
alias.clone(),
Checkout {
path: checkout.clone(),
identity: normalized.key.clone(),
},
);
Ok(())
})?;
let registry = load()?;
resolve(®istry, &alias)
}
fn alias_for(checkout: &Path) -> String {
checkout
.file_name()
.map(|name| name.to_string_lossy().into_owned())
.unwrap_or_else(|| checkout.to_string_lossy().into_owned())
}
fn detect_gate(checkout: &Path) -> String {
for (marker, command) in [
("justfile", "just gate"),
("Justfile", "just gate"),
("Makefile", "make check"),
("package.json", "npm test"),
("Cargo.toml", "cargo test"),
("pyproject.toml", "pytest"),
] {
if checkout.join(marker).is_file() {
return command.to_owned();
}
}
NOOP_GATE.to_owned()
}
pub fn merge_path_coverage(resolution: &Resolution, checkout: &Path) -> Coverage {
let hook = pre_push_hook(checkout);
match (hook.is_some(), resolution.identity.workflow) {
(true, _) => Coverage::PrePushHook(hook.expect("checked above")),
(false, Workflow::Remote) => Coverage::RequiredChecks,
(false, Workflow::Local) => Coverage::None,
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Coverage {
PrePushHook(PathBuf),
RequiredChecks,
None,
}
impl Coverage {
pub fn describe(&self) -> String {
match self {
Coverage::PrePushHook(path) => format!("pre-push hook at {}", path.display()),
Coverage::RequiredChecks => "the host's required checks".to_owned(),
Coverage::None => "nothing".to_owned(),
}
}
}
pub fn pre_push_hook(checkout: &Path) -> Option<PathBuf> {
let hooks = git::hooks_dir(checkout).ok()?;
let hook = hooks.join("pre-push");
is_executable(&hook).then_some(hook)
}
#[cfg(unix)]
fn is_executable(path: &Path) -> bool {
use std::os::unix::fs::PermissionsExt;
std::fs::metadata(path)
.map(|meta| meta.is_file() && meta.permissions().mode() & 0o111 != 0)
.unwrap_or(false)
}
#[cfg(not(unix))]
fn is_executable(path: &Path) -> bool {
path.is_file()
}