use std::path::{Path, PathBuf};
use crate::error::{Error, Result};
use crate::registry::Registry;
use crate::rules::{Approvals, Drafts, MergePolicy, Policy, RuleMatch, RulesFile};
use crate::store::{Normalized, Resolution};
use crate::{home, ids};
pub const VERSION: u32 = 3;
pub const OLDEST_VERSION: u32 = 1;
const TRAILER_PREFIX_VERSION: u32 = 2;
const GATE_REMOVED_VERSION: u32 = 3;
const DRAFTS_VERSION: u32 = 3;
pub const DRAFTS_DISABLED_DEFAULT: bool = false;
pub const WARN_ON_EARLY_LIFT_DEFAULT: bool = true;
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",
}
}
#[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 drafts: DraftLifecycle,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DraftLifecycle {
pub disabled: bool,
pub disabled_from: String,
pub warn_on_early_lift: bool,
pub warn_on_early_lift_from: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum GreenDraft {
Lift,
KeepForReview,
NotApplicable,
}
impl GreenDraft {
pub(crate) fn of(publication: MergePolicy, approvals: Approvals) -> Self {
match (publication, approvals) {
(MergePolicy::LocalDirect, _) => GreenDraft::NotApplicable,
(MergePolicy::ChangeOpen, Approvals::Required) => GreenDraft::KeepForReview,
_ => GreenDraft::Lift,
}
}
pub(crate) fn spell(self) -> &'static str {
match self {
GreenDraft::Lift => "lift",
GreenDraft::KeepForReview => "keep for review",
GreenDraft::NotApplicable => "not applicable",
}
}
}
fn resolve_drafts(rule: Option<(&str, &Drafts)>, default: Option<&Drafts>) -> DraftLifecycle {
let pick = |key: fn(&Drafts) -> Option<bool>, shipped: bool| -> (bool, String) {
if let Some((named, drafts)) = rule {
if let Some(value) = key(drafts) {
return (value, named.to_owned());
}
}
if let Some(value) = default.and_then(key) {
return (value, "the default".to_owned());
}
(shipped, "the shipped default".to_owned())
};
let (disabled, disabled_from) = pick(|d| d.disabled, DRAFTS_DISABLED_DEFAULT);
let (warn_on_early_lift, warn_on_early_lift_from) =
pick(|d| d.warn_on_early_lift, WARN_ON_EARLY_LIFT_DEFAULT);
DraftLifecycle {
disabled,
disabled_from,
warn_on_early_lift,
warn_on_early_lift_from,
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RulesSource {
File(PathBuf),
BuiltIn,
}
impl RulesSource {
pub fn file(&self) -> Result<PathBuf> {
match self {
RulesSource::File(path) => Ok(path.clone()),
RulesSource::BuiltIn => default_path(),
}
}
}
impl std::fmt::Display for RulesSource {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
RulesSource::File(path) => write!(f, "{}", path.display()),
RulesSource::BuiltIn => f.write_str("the built-in default policy"),
}
}
}
pub fn built_in_default() -> Policy {
Policy {
publication: MergePolicy::ChangeOpen,
approvals: Approvals::Required,
drafts: None,
}
}
pub fn load(registry: &Registry) -> Result<(RulesFile, RulesSource)> {
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(),
},
RulesSource::BuiltIn,
));
}
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 malformed = |e: serde_yaml_ng::Error| Error::Invalid {
reason: format!("the rules file at {} is malformed: {e}", path.display()),
};
let mut document: serde_yaml_ng::Value = serde_yaml_ng::from_str(&raw).map_err(malformed)?;
if let Some(declared) = declared_version(&document) {
if declared < u64::from(OLDEST_VERSION) {
return Err(Error::Invalid {
reason: format!(
"the rules file at {} declares version {declared}; this build reads version \
{OLDEST_VERSION} and newer",
path.display(),
),
});
}
if declared < u64::from(GATE_REMOVED_VERSION) {
report_spent_gate(&path, drop_gate(&mut document));
} else {
refuse_removed_gate(&path, &document)?;
}
}
refuse_malformed_drafts(&path, &document)?;
let file: RulesFile = serde_yaml_ng::from_value(document).map_err(malformed)?;
validate(&path, &file)?;
Ok((file, RulesSource::File(path)))
}
fn declared_version(document: &serde_yaml_ng::Value) -> Option<u64> {
document.get("version")?.as_u64()
}
fn drop_gate(document: &mut serde_yaml_ng::Value) -> usize {
let mut dropped = 0;
let mut take = |from: Option<&mut serde_yaml_ng::Value>| {
if let Some(serde_yaml_ng::Value::Mapping(fields)) = from {
dropped += usize::from(fields.remove("gate").is_some());
}
};
take(document.get_mut("default"));
if let Some(serde_yaml_ng::Value::Sequence(rules)) = document.get_mut("rules") {
for rule in rules {
take(Some(rule));
}
}
dropped
}
fn refuse_removed_gate(path: &Path, document: &serde_yaml_ng::Value) -> Result<()> {
let named = |value: Option<&serde_yaml_ng::Value>, where_: String| match value {
Some(serde_yaml_ng::Value::Mapping(fields)) if fields.contains_key("gate") => Some(where_),
_ => None,
};
let found = named(document.get("default"), "default".to_owned()).or_else(|| {
match document.get("rules") {
Some(serde_yaml_ng::Value::Sequence(rules)) => rules
.iter()
.enumerate()
.find_map(|(index, rule)| named(Some(rule), format!("rule {}", index + 1))),
_ => None,
}
});
match found {
None => Ok(()),
Some(where_) => Err(Error::Invalid {
reason: format!(
"the rules file at {} is malformed: unknown field `gate` in {where_}, which \
version {GATE_REMOVED_VERSION} removed. What verifies a change is the \
repository's own merge path — the host's required checks, or its pre-push \
hook — and `onevcs repos --audit-gates` reports which of those each identity \
has. Delete the gate keys",
path.display()
),
}),
}
}
fn refuse_malformed_drafts(path: &Path, document: &serde_yaml_ng::Value) -> Result<()> {
let mut found: Vec<(String, &serde_yaml_ng::Value)> = Vec::new();
if let Some(drafts) = document
.get("default")
.and_then(|policy| policy.get("drafts"))
{
found.push(("default".to_owned(), drafts));
}
if let Some(serde_yaml_ng::Value::Sequence(rules)) = document.get("rules") {
for (index, rule) in rules.iter().enumerate() {
if let Some(drafts) = rule.get("drafts") {
found.push((format!("rule {}", index + 1), drafts));
}
}
}
let refused = |where_: &str, what: String| Error::Invalid {
reason: format!(
"the rules file at {} is malformed: {where_} drafts: {what}",
path.display()
),
};
for (where_, drafts) in found {
let serde_yaml_ng::Value::Mapping(fields) = drafts else {
return Err(refused(&where_, "is not a mapping".to_owned()));
};
for (key, value) in fields {
let mut alone = serde_yaml_ng::Mapping::new();
alone.insert(key.clone(), value.clone());
let Err(said) =
serde_yaml_ng::from_value::<Drafts>(serde_yaml_ng::Value::Mapping(alone))
else {
continue;
};
let key = serde_yaml_ng::to_string(key)
.map(|spelled| spelled.trim().to_owned())
.unwrap_or_default();
return Err(refused(
&where_,
if value.is_bool() {
format!("names {key:?}, which it does not have: {said}")
} else {
format!(
"{key} is {}, which is not a boolean; write true or false",
serde_yaml_ng::to_string(value)
.map(|spelled| spelled.trim().to_owned())
.unwrap_or_default()
)
},
));
}
}
Ok(())
}
fn report_spent_gate(path: &Path, dropped: usize) {
if dropped == 0 {
return;
}
eprintln!(
"onevcs: warning: the rules file at {} names a gate, which version \
{GATE_REMOVED_VERSION} removed; it is ignored. What verifies a change is the \
repository's own merge path — the host's required checks, or its pre-push hook — \
and `onevcs repos --audit-gates` reports which of those each identity has. Delete \
the gate keys and declare version {GATE_REMOVED_VERSION}",
path.display()
);
}
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
),
});
}
if file.version < DRAFTS_VERSION {
let named = file
.default
.drafts
.map(|_| "default".to_owned())
.or_else(|| {
file.rules
.iter()
.position(|rule| rule.drafts.is_some())
.map(|index| format!("rule {}", index + 1))
});
if let Some(where_) = named {
return Err(Error::Invalid {
reason: format!(
"the rules file at {} declares version {} and {where_} names drafts, which \
version {DRAFTS_VERSION} admits; declare version {DRAFTS_VERSION} to \
configure the draft lifecycle",
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: &RulesSource,
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),
drafts: rule.drafts.or(file.default.drafts),
},
drafts: resolve_drafts(
rule.drafts.as_ref().map(|drafts| (named.as_str(), drafts)),
file.default.drafts.as_ref(),
),
source: source.to_string(),
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()),
};
}
Resolved {
policy: file.default.clone(),
source: source.to_string(),
matched: None,
publication_from: "the default".to_owned(),
approvals_from: "the default".to_owned(),
drafts: resolve_drafts(None, file.default.drafts.as_ref()),
}
}
pub fn resolve_for(file: &RulesFile, source: &RulesSource, resolution: &Resolution) -> Resolved {
let normalized = crate::store::normalize(&resolution.identity.origin);
resolve(file, source, &normalized, &resolution.publication)
}
fn field_source(named: &str, from_rule: bool) -> String {
if from_rule {
named.to_owned()
} else {
"the default".to_owned()
}
}
pub(crate) 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
}
pub(crate) 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> {
narrow_publication(resolved.publication, requested)
}
pub fn narrow_publication(resolved: MergePolicy, requested: MergePolicy) -> Result<MergePolicy> {
if review_rank(requested) < review_rank(resolved) {
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)
),
});
}
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)
}
}