use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::error::{Error, Result};
use crate::rules::{Approvals, Drafts, MergePolicy, Rule, RulesFile, TrailerPrefix};
use crate::{home, policy, store, yaml};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RulesApplyRequest {
pub base: PathBuf,
#[serde(default)]
pub overlays: Vec<PathBuf>,
#[serde(default)]
pub dry_run: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RulesApplied {
pub path: PathBuf,
pub installed: bool,
pub rules: RulesFile,
pub document: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct Overlay {
version: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
trailer_prefix: Option<TrailerPrefix>,
#[serde(default)]
rules: Vec<Rule>,
#[serde(default, skip_serializing_if = "Option::is_none")]
default: Option<DefaultPatch>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct DefaultPatch {
#[serde(default, skip_serializing_if = "Option::is_none")]
publication: Option<MergePolicy>,
#[serde(default, skip_serializing_if = "Option::is_none")]
approvals: Option<Approvals>,
#[serde(default, skip_serializing_if = "Option::is_none")]
drafts: Option<Drafts>,
}
const HEADER: &str = "# Installed by `onevcs rules apply`. Edit the base and overlays it was \
composed from, and apply them again.\n";
pub fn rules_apply(request: &RulesApplyRequest) -> Result<RulesApplied> {
let raw = read(&request.base)?;
strictly::<RulesFile>(&request.base, &raw)?;
let mut composed = policy::parse(&request.base, &raw)?;
for overlay in &request.overlays {
let raw = read(overlay)?;
let laid: Overlay = strictly(overlay, &raw)?;
lay(&mut composed, laid, overlay)?;
}
let destination = destination()?;
policy::validate(&destination, &composed)?;
let document = format!(
"{HEADER}{}",
yaml::to_string(&composed).map_err(|error| crate::error::invalid(error.to_string()))?
);
policy::parse(&destination, &document)?;
if !request.dry_run {
install(&destination, &document)?;
}
Ok(RulesApplied {
path: destination,
installed: !request.dry_run,
rules: composed,
document,
})
}
fn read(path: &Path) -> Result<String> {
std::fs::read_to_string(path).map_err(|error| Error::Invalid {
reason: format!("cannot read the rules file at {}: {error}", path.display()),
})
}
fn lay(composed: &mut RulesFile, overlay: Overlay, path: &Path) -> Result<()> {
if overlay.version < policy::OLDEST_VERSION {
return Err(Error::Invalid {
reason: format!(
"the overlay at {} declares version {}; this build reads version {} and newer",
path.display(),
overlay.version,
policy::OLDEST_VERSION
),
});
}
let alone = RulesFile {
version: overlay.version,
trailer_prefix: overlay.trailer_prefix.clone(),
rules: overlay.rules.clone(),
default: composed.default.clone(),
};
policy::validate(path, &alone)?;
composed.version = composed.version.max(overlay.version);
if overlay.trailer_prefix.is_some() {
composed.trailer_prefix = overlay.trailer_prefix;
}
if let Some(patch) = overlay.default {
if let Some(publication) = patch.publication {
composed.default.publication = publication;
}
if let Some(approvals) = patch.approvals {
composed.default.approvals = approvals;
}
if let Some(drafts) = patch.drafts {
let mut merged = composed.default.drafts.unwrap_or_default();
merged.disabled = drafts.disabled.or(merged.disabled);
merged.warn_on_early_lift = drafts.warn_on_early_lift.or(merged.warn_on_early_lift);
composed.default.drafts = Some(merged);
}
}
let mut ahead = Vec::new();
for rule in overlay.rules {
match composed
.rules
.iter_mut()
.find(|earlier| earlier.r#match == rule.r#match)
{
Some(earlier) => {
earlier.publication = rule.publication.or(earlier.publication);
earlier.approvals = rule.approvals.or(earlier.approvals);
earlier.visibility = rule.visibility.or(earlier.visibility);
earlier.drafts = match (rule.drafts, earlier.drafts) {
(Some(later), Some(mut merged)) => {
merged.disabled = later.disabled.or(merged.disabled);
merged.warn_on_early_lift =
later.warn_on_early_lift.or(merged.warn_on_early_lift);
Some(merged)
}
(later, earlier) => later.or(earlier),
};
}
None => ahead.push(rule),
}
}
ahead.append(&mut composed.rules);
composed.rules = ahead;
Ok(())
}
fn strictly<T: Serialize + for<'de> Deserialize<'de>>(path: &Path, raw: &str) -> Result<T> {
let malformed = |error: serde_saphyr::Error| Error::Invalid {
reason: format!("the rules file at {} is malformed: {error}", path.display()),
};
let document: serde_json::Value = yaml::from_str(raw).map_err(malformed)?;
let typed: T = yaml::from_str(raw).map_err(malformed)?;
let understood = serde_json::to_value(&typed).map_err(|error| Error::Invalid {
reason: format!(
"the rules file at {} reads as rules that cannot be written back: {error}",
path.display()
),
})?;
if let Some(key) = unknown_key(&document, &understood, "") {
return Err(Error::Invalid {
reason: format!(
"the rules file at {} names `{key}`, which this build does not have; nothing \
was installed",
path.display()
),
});
}
Ok(typed)
}
fn unknown_key(
given: &serde_json::Value,
understood: &serde_json::Value,
at: &str,
) -> Option<String> {
use serde_json::Value;
match (given, understood) {
(Value::Object(given), Value::Object(understood)) => {
for (key, value) in given {
let path = if at.is_empty() {
key.clone()
} else {
format!("{at}.{key}")
};
match understood.get(key) {
None if !value.is_null() => return Some(path),
None => {}
Some(seen) => {
if let Some(found) = unknown_key(value, seen, &path) {
return Some(found);
}
}
}
}
None
}
(Value::Array(given), Value::Array(understood)) if given.len() == understood.len() => given
.iter()
.zip(understood)
.enumerate()
.find_map(|(index, (value, seen))| {
unknown_key(value, seen, &format!("{at}[{}]", index + 1))
}),
_ => None,
}
}
fn destination() -> Result<PathBuf> {
match store::load()?.rules {
Some(reference) => Ok(home::expand_tilde(&reference.to_string_lossy())),
None => policy::default_path(),
}
}
fn install(destination: &Path, document: &str) -> Result<()> {
home::atomic_write(destination, document)?;
store::update(|registry| {
if registry.rules.is_none() {
registry.rules = Some(destination.to_path_buf());
}
Ok(())
})
}