use std::path::{Path, PathBuf};
use crate::error::{Error, Result};
use crate::registry::Registry;
use crate::rules::{
Approvals, BaseMerge, Checks, DeclaredVisibility, Drafts, MergePolicy, Policy, RuleMatch,
RulesFile, WaitSeconds,
};
use crate::store::{Normalized, Resolution};
use crate::{home, ids, yaml};
pub const VERSION: u32 = 4;
pub const OLDEST_VERSION: u32 = 1;
const TRAILER_PREFIX_VERSION: u32 = 2;
const GATE_REMOVED_VERSION: u32 = 3;
const DRAFTS_VERSION: u32 = 3;
const VISIBILITY_VERSION: u32 = 4;
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",
}
}
pub fn spell_base_merge(base_merge: BaseMerge) -> &'static str {
match base_merge {
BaseMerge::Always => "always",
BaseMerge::OnConflict => "on-conflict",
}
}
#[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,
pub visibility: Option<DeclaredVisibility>,
pub base_merge: Decided<BaseMerge>,
pub checks_wait: Decided<WaitSeconds>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct Decided<T> {
pub value: T,
pub from: String,
}
impl Decided<WaitSeconds> {
pub fn effective(&self) -> Result<Decided<WaitSeconds>> {
match crate::gh::checks_timeout_override()? {
Some(seconds) => Ok(Decided {
value: seconds,
from: crate::gh::CHECKS_TIMEOUT_ENV.to_owned(),
}),
None => Ok(self.clone()),
}
}
}
pub(crate) fn derived_base_merge(publication: MergePolicy) -> BaseMerge {
match publication {
MergePolicy::LocalDirect => BaseMerge::Always,
MergePolicy::ChangeOpen | MergePolicy::ChangeAuto | MergePolicy::ChangeDirect => {
BaseMerge::OnConflict
}
}
}
fn resolve_base_merge(
rule: Option<(&str, Option<BaseMerge>)>,
default: Option<BaseMerge>,
publication: MergePolicy,
) -> Decided<BaseMerge> {
if let Some((named, Some(value))) = rule {
return Decided {
value,
from: named.to_owned(),
};
}
if let Some(value) = default {
return Decided {
value,
from: "the default".to_owned(),
};
}
Decided {
value: derived_base_merge(publication),
from: format!("publication {}", spell(publication)),
}
}
fn resolve_checks_wait(
rule: Option<(&str, &Checks)>,
default: Option<&Checks>,
) -> Decided<WaitSeconds> {
if let Some((named, Some(wait))) = rule.map(|(named, checks)| (named, checks.wait_seconds)) {
return Decided {
value: wait,
from: named.to_owned(),
};
}
if let Some(wait) = default.and_then(|checks| checks.wait_seconds) {
return Decided {
value: wait,
from: "the default".to_owned(),
};
}
Decided {
value: WaitSeconds::SHIPPED,
from: "the shipped default".to_owned(),
}
}
#[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,
base_merge: None,
checks: 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 file = parse(&path, &raw)?;
Ok((file, RulesSource::File(path)))
}
pub fn parse(path: &Path, raw: &str) -> Result<RulesFile> {
let file = read(path, raw)?;
validate(path, &file)?;
Ok(file)
}
pub(crate) fn read(path: &Path, raw: &str) -> Result<RulesFile> {
let path = path.to_path_buf();
let malformed = |e: serde_saphyr::Error| Error::Invalid {
reason: format!("the rules file at {} is malformed: {e}", path.display()),
};
let document: serde_json::Value = yaml::from_str(raw).map_err(malformed)?;
if let Some(declared) = yaml::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, spent_gates(&document));
} else {
refuse_removed_gate(&path, &document)?;
}
}
refuse_malformed_drafts(&path, &document)?;
refuse_malformed_base_merge(&path, &document)?;
refuse_malformed_checks(&path, &document)?;
yaml::from_str(raw).map_err(malformed)
}
fn spent_gates(document: &serde_json::Value) -> usize {
let carries = |policy: Option<&serde_json::Value>| {
usize::from(policy.and_then(|policy| policy.get("gate")).is_some())
};
let rules = match document.get("rules") {
Some(serde_json::Value::Array(rules)) => rules.iter().map(|rule| carries(Some(rule))).sum(),
_ => 0,
};
carries(document.get("default")) + rules
}
fn refuse_removed_gate(path: &Path, document: &serde_json::Value) -> Result<()> {
let named = |value: Option<&serde_json::Value>, where_: String| match value {
Some(serde_json::Value::Object(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_json::Value::Array(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_json::Value) -> Result<()> {
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 policy_keys(document, "drafts") {
let serde_json::Value::Object(fields) = drafts else {
return Err(refused(&where_, "is not a mapping".to_owned()));
};
for (key, value) in fields {
if let Err(said) = alone::<Drafts>(key, &serde_json::Value::Bool(true)) {
return Err(refused(
&where_,
format!("names {key:?}, which it does not have: {said}"),
));
}
if yaml::convert::<Drafts>(&single(key, value)).is_none() {
return Err(refused(
&where_,
format!(
"{key} is {}, which is not a boolean; write true or false",
spelled(value)
),
));
}
}
}
Ok(())
}
fn policy_keys<'d>(
document: &'d serde_json::Value,
key: &str,
) -> Vec<(String, &'d serde_json::Value)> {
let set = |policy: &'d serde_json::Value| policy.get(key).filter(|value| !value.is_null());
let mut found = Vec::new();
if let Some(value) = document.get("default").and_then(set) {
found.push(("default".to_owned(), value));
}
if let Some(serde_json::Value::Array(rules)) = document.get("rules") {
for (index, rule) in rules.iter().enumerate() {
if let Some(value) = set(rule) {
found.push((format!("rule {}", index + 1), value));
}
}
}
found
}
fn spelled(value: &serde_json::Value) -> String {
yaml::to_string(value)
.map(|spelled| spelled.trim().to_owned())
.unwrap_or_default()
}
fn single(key: &str, value: &serde_json::Value) -> serde_json::Value {
serde_json::Value::Object(serde_json::Map::from_iter([(
key.to_owned(),
value.clone(),
)]))
}
fn alone<T: serde::de::DeserializeOwned>(
key: &str,
value: &serde_json::Value,
) -> serde_json::Result<T> {
serde_json::from_value(single(key, value))
}
fn refuse_malformed_base_merge(path: &Path, document: &serde_json::Value) -> Result<()> {
for (where_, value) in policy_keys(document, "base_merge") {
if yaml::convert::<BaseMerge>(value).is_none() {
return Err(Error::Invalid {
reason: format!(
"the rules file at {} is malformed: {where_} base_merge: {} is not a base \
merge; write always or on-conflict",
path.display(),
spelled(value)
),
});
}
}
Ok(())
}
fn refuse_malformed_checks(path: &Path, document: &serde_json::Value) -> Result<()> {
let refused = |where_: &str, what: String| Error::Invalid {
reason: format!(
"the rules file at {} is malformed: {where_} checks: {what}",
path.display()
),
};
for (where_, checks) in policy_keys(document, "checks") {
let serde_json::Value::Object(fields) = checks else {
return Err(refused(&where_, "is not a mapping".to_owned()));
};
for (key, value) in fields {
if let Err(said) = alone::<Checks>(key, &serde_json::Value::from(60)) {
return Err(refused(
&where_,
format!("names {key:?}, which it does not have: {said}"),
));
}
if yaml::convert::<Checks>(&single(key, value)).is_none() {
return Err(refused(
&where_,
format!(
"{key} is {}, which is not a wait; write {}",
spelled(value),
WaitSeconds::RANGE
),
));
}
}
}
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()
);
}
pub(crate) 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
),
});
}
}
if file.version < VISIBILITY_VERSION {
if let Some(index) = file.rules.iter().position(|rule| rule.visibility.is_some()) {
return Err(Error::Invalid {
reason: format!(
"the rules file at {} declares version {} and rule {} names visibility, \
which version {VISIBILITY_VERSION} admits; declare version \
{VISIBILITY_VERSION} to configure it",
path.display(),
file.version,
index + 1
),
});
}
}
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);
let publication = rule.publication.unwrap_or(file.default.publication);
return Resolved {
policy: Policy {
publication,
approvals: rule.approvals.unwrap_or(file.default.approvals),
drafts: rule.drafts.or(file.default.drafts),
base_merge: rule.base_merge.or(file.default.base_merge),
checks: rule.checks.or(file.default.checks),
},
base_merge: resolve_base_merge(
Some((named.as_str(), rule.base_merge)),
file.default.base_merge,
publication,
),
checks_wait: resolve_checks_wait(
rule.checks.as_ref().map(|checks| (named.as_str(), checks)),
file.default.checks.as_ref(),
),
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()),
visibility: rule.visibility,
};
}
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()),
visibility: None,
base_merge: resolve_base_merge(None, file.default.base_merge, file.default.publication),
checks_wait: resolve_checks_wait(None, file.default.checks.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)
}
}