use std::path::{Path, PathBuf};
use crate::error::{Error, Result};
use crate::registry::Registry;
use crate::rules::{Approvals, Gate, GateKind, MergePolicy, Policy, RuleMatch, RulesFile};
use crate::store::Normalized;
use crate::{home, ids};
pub const VERSION: u32 = 2;
pub const OLDEST_VERSION: u32 = 1;
const TRAILER_PREFIX_VERSION: u32 = 2;
fn review_rank(policy: MergePolicy) -> u8 {
match policy {
MergePolicy::LocalDirect => 0,
MergePolicy::ChangeDirect => 1,
MergePolicy::ChangeAuto => 2,
MergePolicy::ChangeOpen => 3,
}
}
pub fn spell(policy: MergePolicy) -> &'static str {
match policy {
MergePolicy::LocalDirect => "local-direct",
MergePolicy::ChangeOpen => "change-open",
MergePolicy::ChangeAuto => "change-auto",
MergePolicy::ChangeDirect => "change-direct",
}
}
pub fn spell_gate(gate: &Gate) -> String {
match gate {
Gate::Kind {
kind: GateKind::Checks,
} => "checks".to_owned(),
Gate::Kind {
kind: GateKind::PrePush,
} => "pre-push".to_owned(),
Gate::Command { command } => format!("command: {}", command.join(" ")),
}
}
#[derive(Debug, Clone)]
pub struct Matched {
pub index: usize,
pub criteria: RuleMatch,
}
#[derive(Debug, Clone)]
pub struct Resolved {
pub policy: Policy,
pub source: String,
pub matched: Option<Matched>,
pub publication_from: String,
pub approvals_from: String,
pub gate_from: String,
}
pub fn built_in_default() -> Policy {
Policy {
publication: MergePolicy::ChangeOpen,
approvals: Approvals::Required,
gate: Gate::Kind {
kind: GateKind::Checks,
},
}
}
pub fn load(registry: &Registry) -> Result<(RulesFile, String)> {
let path = match registry.rules.as_ref() {
Some(reference) => home::expand_tilde(&reference.to_string_lossy()),
None => default_path()?,
};
if registry.rules.is_none() && !path.is_file() {
return Ok((
RulesFile {
version: VERSION,
trailer_prefix: None,
rules: Vec::new(),
default: built_in_default(),
},
"the built-in default policy".to_owned(),
));
}
let raw = std::fs::read_to_string(&path).map_err(|e| Error::Invalid {
reason: format!("cannot read the rules file at {}: {e}", path.display()),
})?;
let file: RulesFile = serde_yaml_ng::from_str(&raw).map_err(|e| Error::Invalid {
reason: format!("the rules file at {} is malformed: {e}", path.display()),
})?;
if !(OLDEST_VERSION..=VERSION).contains(&file.version) {
return Err(Error::Invalid {
reason: format!(
"the rules file at {} declares version {}; this build reads versions \
{OLDEST_VERSION} to {VERSION}",
path.display(),
file.version
),
});
}
validate(&path, &file)?;
Ok((file, path.display().to_string()))
}
fn validate(path: &Path, file: &RulesFile) -> Result<()> {
if file.version < TRAILER_PREFIX_VERSION && file.trailer_prefix.is_some() {
return Err(Error::Invalid {
reason: format!(
"the rules file at {} declares version {} and names a trailer_prefix, which \
version {TRAILER_PREFIX_VERSION} added; declare version \
{TRAILER_PREFIX_VERSION} to configure one. A file whose key is not in the \
version it declares reads one way here and another wherever that version is \
trusted, and for this key those two readings are provenance written under one \
prefix and searched for under another",
path.display(),
file.version
),
});
}
let mut checked: Vec<(String, MergePolicy, Approvals)> = vec![(
"default".to_owned(),
file.default.publication,
file.default.approvals,
)];
for (index, rule) in file.rules.iter().enumerate() {
checked.push((
format!("rule {}", index + 1),
rule.publication.unwrap_or(file.default.publication),
rule.approvals.unwrap_or(file.default.approvals),
));
}
for (where_, publication, approvals) in checked {
if approvals == Approvals::Required
&& review_rank(publication) < review_rank(MergePolicy::ChangeAuto)
{
return Err(Error::Invalid {
reason: format!(
"the rules file at {} has {where_} combining publication: {} with \
approvals: required, which merges without the host ever evaluating an \
approval",
path.display(),
spell(publication)
),
});
}
}
Ok(())
}
pub fn resolve(file: &RulesFile, source: &str, identity: &Normalized, checkout: &Path) -> Resolved {
for (index, rule) in file.rules.iter().enumerate() {
if !matches(&rule.r#match, identity, checkout) {
continue;
}
let named = format!("rule {}", index + 1);
return Resolved {
policy: Policy {
publication: rule.publication.unwrap_or(file.default.publication),
approvals: rule.approvals.unwrap_or(file.default.approvals),
gate: rule
.gate
.clone()
.unwrap_or_else(|| file.default.gate.clone()),
},
source: source.to_owned(),
matched: Some(Matched {
index: index + 1,
criteria: rule.r#match.clone(),
}),
publication_from: field_source(&named, rule.publication.is_some()),
approvals_from: field_source(&named, rule.approvals.is_some()),
gate_from: field_source(&named, rule.gate.is_some()),
};
}
Resolved {
policy: file.default.clone(),
source: source.to_owned(),
matched: None,
publication_from: "the default".to_owned(),
approvals_from: "the default".to_owned(),
gate_from: "the default".to_owned(),
}
}
fn field_source(named: &str, from_rule: bool) -> String {
if from_rule {
named.to_owned()
} else {
"the default".to_owned()
}
}
fn matches(criteria: &RuleMatch, identity: &Normalized, checkout: &Path) -> bool {
let hosted = |part: fn(&crate::store::Hosted) -> &str, want: &String| {
identity
.hosted
.as_ref()
.is_some_and(|hosted| glob(want, part(hosted)))
};
let host = criteria
.host
.as_ref()
.is_none_or(|want| hosted(|h| &h.host, want));
let owner = criteria
.owner
.as_ref()
.is_none_or(|want| hosted(|h| &h.owner, want));
let name = criteria
.name
.as_ref()
.is_none_or(|want| hosted(|h| &h.name, want));
let path = criteria.path.as_ref().is_none_or(|want| {
let expanded = home::expand_tilde(want);
glob(&expanded.to_string_lossy(), &checkout.to_string_lossy())
});
host && owner && name && path
}
fn glob(pattern: &str, value: &str) -> bool {
let mut segments = pattern.split('*');
let first = segments.next().unwrap_or(pattern);
let Some(mut rest) = value.strip_prefix(first) else {
return false;
};
let parts: Vec<&str> = segments.collect();
let Some((last, middle)) = parts.split_last() else {
return rest.is_empty();
};
for part in middle {
match rest.find(part) {
Some(at) => rest = &rest[at + part.len()..],
None => return false,
}
}
rest.len() >= last.len() && rest.ends_with(last)
}
pub fn narrow(resolved: &Policy, requested: MergePolicy) -> Result<MergePolicy> {
if review_rank(requested) < review_rank(resolved.publication) {
return Err(Error::Invalid {
reason: format!(
"--policy {} would widen the policy this repository resolves to ({}); a per-run \
policy may narrow but never widen",
spell(requested),
spell(resolved.publication)
),
});
}
Ok(requested)
}
pub fn default_path() -> Result<PathBuf> {
Ok(home::root()?.join("rules.yml"))
}
pub fn branch_slug(branch: &str) -> String {
let flattened: String = branch
.chars()
.map(|c| if c == '/' { '-' } else { c })
.collect();
if flattened
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '.' || c == '_')
{
flattened
} else {
ids::short_digest(branch)
}
}