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};
use crate::remainder::Remainder;
use crate::rules::MergePolicy;
use crate::{git, home, lock};
pub const VERSION: u32 = 7;
pub const SHAPE_SINCE: u32 = 6;
pub const OLDEST_VERSION: u32 = 2;
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 read = migrate(&path, value)?;
if read.migrated {
let _guard = lock::exclusive(®istry_identity())?;
home::atomic_write(&path, &serialize(&read.registry, &read.carried)?)?;
}
Ok(read.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 read = match std::fs::read_to_string(&path) {
Ok(raw) => migrate(&path, serde_json::from_str(&raw).map_err(not_json(&path))?)?,
Err(_) => Read {
registry: empty(),
migrated: false,
carried: Remainder::default(),
},
};
let outcome = change(&mut read.registry)?;
read.registry.version = read.registry.version.max(VERSION);
home::atomic_write(&path, &serialize(&read.registry, &read.carried)?)?;
Ok(outcome)
}
fn empty() -> Registry {
Registry {
version: VERSION,
identities: BTreeMap::new(),
checkouts: BTreeMap::new(),
rules: None,
}
}
fn serialize(registry: &Registry, carried: &Remainder) -> Result<String> {
let named = PathBuf::from("the registry");
let mut document = serde_json::to_value(registry).map_err(error::at("serialize", &named))?;
carried.restore(&mut document);
let mut json =
serde_json::to_string_pretty(&document).map_err(error::at("serialize", &named))?;
json.push('\n');
Ok(json)
}
struct Read {
registry: Registry,
migrated: bool,
carried: Remainder,
}
fn migrate(path: &Path, value: Value) -> Result<Read> {
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; version {OLDEST_VERSION} and \
newer are readable",
path.display()
),
})?;
match version {
current if current >= u64::from(SHAPE_SINCE) => {
let registry: Registry = serde_json::from_value(value.clone())
.map_err(error::at("read the registry at", path))?;
coherent(path, ®istry)?;
let carried = Remainder::between(
&value,
&serde_json::to_value(®istry)
.map_err(error::at("read the registry at", path))?,
);
Ok(Read {
registry,
migrated: false,
carried,
})
}
2..=5 => {
let mut registry = if version == 5 {
serde_json::from_value(value.clone())
.map_err(error::at("read the registry at", path))?
} else {
legacy(path, object, version as u32)?
};
registry.version = VERSION;
coherent(path, ®istry)?;
let carried = Remainder::between(
&without_inference(&value),
&serde_json::to_value(®istry)
.map_err(error::at("read the registry at", path))?,
);
Ok(Read {
registry,
migrated: true,
carried,
})
}
other => Err(Error::Invalid {
reason: format!(
"the registry at {} declares version {other}; this build reads version \
{OLDEST_VERSION} and newer",
path.display()
),
}),
}
}
const INFERRED_KEYS: [&str; 2] = ["workflow", "repo_type"];
fn without_inference(value: &Value) -> Value {
let mut stripped = value.clone();
if let Some(identities) = stripped
.get_mut("identities")
.and_then(Value::as_object_mut)
{
for identity in identities.values_mut() {
if let Some(fields) = identity.as_object_mut() {
for key in INFERRED_KEYS {
fields.remove(key);
}
}
}
}
stripped
}
fn coherent(path: &Path, registry: &Registry) -> Result<()> {
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 gate = if version >= 4 {
field(path, value, "gate")?
} else {
NOOP_GATE.to_owned()
};
identities.insert(key.clone(), Identity::new(origin, 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_here(registry: &Registry) -> Result<Resolution> {
let here = std::env::current_dir()
.map_err(|e| error::invalid(format!("cannot read the current directory: {e}")))?;
let canonical = std::fs::canonicalize(&here).unwrap_or(here);
let mut candidate: Option<&Path> = Some(canonical.as_path());
while let Some(path) = candidate {
for (alias, checkout) in ®istry.checkouts {
if checkout.path == path {
return resolve(registry, alias);
}
}
candidate = path.parent();
}
Err(Error::Invalid {
reason: format!(
"{} is not inside a registered checkout; register it with `onevcs register PATH`",
canonical.display()
),
})
}
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(", ")
}
),
})
}
pub fn resolve_path(registry: &Registry, repo: &Path) -> Result<Resolution> {
let named = repo.to_str().ok_or_else(|| Error::Invalid {
reason: format!(
"the repository path {} is not valid UTF-8, so it can name no registered checkout; \
`onevcs repos` lists them as they are recorded",
repo.display()
),
})?;
resolve(registry, named)
}
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::new(normalized.key.clone(), 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,
publication: MergePolicy,
) -> Coverage {
if let Some(hook) = pre_push_hook(checkout) {
return Coverage::PrePushHook(hook);
}
let through_a_change_request = publication != MergePolicy::LocalDirect;
let hosted = normalize(&resolution.identity.origin).hosted.is_some();
if through_a_change_request && hosted {
Coverage::RequiredChecks
} else {
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()
}