use std::path::Path;
use serde::{Deserialize, Serialize};
use crate::descriptor::Descriptor;
use crate::library::Library;
pub const POLICY_VERSION: u32 = 2;
pub const LIBRARY_FILE: &str = "library.ron";
pub const POLICY_FILE: &str = "project.ron";
static LAYERED: std::sync::Mutex<Vec<std::path::PathBuf>> = std::sync::Mutex::new(Vec::new());
pub fn layered_for_test() -> Vec<std::path::PathBuf> {
LAYERED
.lock()
.unwrap_or_else(|e| e.into_inner())
.clone()
}
pub fn forget_layered_for_test() {
LAYERED.lock().unwrap_or_else(|e| e.into_inner()).clear();
}
pub fn layered_library(dir: &Path) -> Result<Layered, String> {
LAYERED
.lock()
.unwrap_or_else(|e| e.into_inner())
.push(dir.to_path_buf());
let read = |name: &str| -> Result<String, String> {
let path = dir.join(name);
std::fs::read_to_string(&path).map_err(|e| format!("{}: {e}", path.display()))
};
let measured = Library::parse(&read(LIBRARY_FILE)?)
.map_err(|e| format!("{}: {e}", dir.join(LIBRARY_FILE).display()))?;
let policy = Policy::parse(&read(POLICY_FILE)?)
.map_err(|e| format!("{}: {e}", dir.join(POLICY_FILE).display()))?;
let library = policy
.apply(&measured)
.map_err(|e| format!("{}: {e}", dir.join(POLICY_FILE).display()))?;
Ok(Layered {
measured,
library,
policy,
})
}
pub struct Layered {
pub measured: Library,
pub library: Library,
pub policy: Policy,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub enum Match {
Id(String),
Kind(String),
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Patch {
#[serde(rename = "match")]
pub matches: Match,
pub because: String,
pub patch: Descriptor,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Policy {
pub version: u32,
#[serde(default)]
pub note: Option<String>,
#[serde(default)]
pub exclude: Vec<String>,
#[serde(default)]
pub patches: Vec<Patch>,
}
impl Policy {
pub fn excludes(&self, mesh: &str) -> bool {
self.exclude.iter().any(|prefix| {
let prefix = prefix.trim_end_matches('/');
mesh == prefix
|| mesh
.strip_prefix(prefix)
.is_some_and(|rest| rest.starts_with('/'))
})
}
}
impl Default for Policy {
fn default() -> Self {
Policy {
version: POLICY_VERSION,
note: None,
exclude: Vec::new(),
patches: Vec::new(),
}
}
}
impl Policy {
pub fn parse(text: &str) -> Result<Policy, String> {
let p: Policy = ron::from_str(text).map_err(|e| format!("policy: {e}"))?;
p.validate()?;
Ok(p)
}
pub fn to_ron(&self) -> Result<String, String> {
ron::ser::to_string_pretty(self, ron::ser::PrettyConfig::default())
.map_err(|e| format!("policy: {e}"))
}
fn validate(&self) -> Result<(), String> {
if self.version != POLICY_VERSION {
return Err(format!(
"policy: version {} but this build reads {POLICY_VERSION} — refusing to load rather \
than guess at a migration",
self.version
));
}
for p in &self.patches {
if p.because.trim().is_empty() {
return Err(format!(
"policy: the patch for {:?} says nothing about why. A policy layer is where an \
unjustifiable number hides longest, because the file it changes is not the file \
it lives in.",
p.matches
));
}
if !p.patch.id.is_empty() {
return Err(format!(
"policy: the patch for {:?} sets `id`. A policy layers fields onto a descriptor; \
renaming it would break every placement that names it.",
p.matches
));
}
}
Ok(())
}
pub fn apply(&self, library: &Library) -> Result<Library, String> {
let mut out = library.clone();
for rule in &self.patches {
let mut hits = 0usize;
for d in &mut out.descriptors {
let applies = match &rule.matches {
Match::Id(id) => &d.id == id,
Match::Kind(token) => d.kind.iter().any(|k| k == token),
};
if applies {
*d = d.patched_with(&rule.patch);
hits += 1;
}
}
if hits == 0 {
return Err(match &rule.matches {
Match::Id(id) => format!(
"policy: the patch for `{id}` matches no descriptor in this library. Either \
the id is misspelled or the piece is gone; a rule that silently applies to \
nothing is how a policy rots."
),
Match::Kind(token) => format!(
"policy: the patch for kind `{token}` matches no descriptor in this library. \
Either the token is misspelled or nothing carries it yet."
),
});
}
}
Ok(out)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::descriptor::{Align, Extent};
use crate::library::LIBRARY_VERSION;
fn descriptor(id: &str, kind: &[&str], height: f32) -> Descriptor {
Descriptor {
id: id.to_owned(),
mesh: Some(format!("{id}.glb")),
extent: Extent {
footprint: Some((1.0, 1.0)),
height: Some(height),
},
kind: kind.iter().map(|k| (*k).to_owned()).collect(),
..Descriptor::default()
}
}
fn library() -> Library {
Library {
version: LIBRARY_VERSION,
note: None,
descriptors: vec![
descriptor("door_single", &["door"], 2.0),
descriptor("door_double", &["door"], 2.0),
descriptor("crate", &["container"], 0.6),
],
}
}
fn stretch(by: f32) -> Descriptor {
Descriptor {
align: Align {
stretch_y: Some(by),
..Align::default()
},
..Descriptor::default()
}
}
fn rule(matches: Match, patch: Descriptor) -> Patch {
Patch {
matches,
because: "this game's doorways are 2.4 m".into(),
patch,
}
}
#[test]
fn a_rule_about_a_kind_reaches_every_piece_that_carries_it() {
let policy = Policy {
patches: vec![rule(Match::Kind("door".into()), stretch(1.2))],
..Policy::default()
};
let out = policy.apply(&library()).unwrap_or_else(|e| panic!("{e}"));
assert_eq!(out.descriptors[0].align.stretch_y, Some(1.2));
assert_eq!(out.descriptors[1].align.stretch_y, Some(1.2));
assert_eq!(out.descriptors[2].align.stretch_y, None);
}
#[test]
fn the_measurements_underneath_are_left_alone() {
let base = library();
let policy = Policy {
patches: vec![rule(Match::Kind("door".into()), stretch(1.2))],
..Policy::default()
};
let _ = policy.apply(&base).unwrap_or_else(|e| panic!("{e}"));
assert_eq!(base.descriptors[0].align.stretch_y, None);
let tall = Policy {
patches: vec![rule(Match::Kind("door".into()), stretch(1.5))],
..Policy::default()
};
let a = policy.apply(&base).unwrap_or_else(|e| panic!("{e}"));
let b = tall.apply(&base).unwrap_or_else(|e| panic!("{e}"));
assert_eq!(a.descriptors[0].align.stretch_y, Some(1.2));
assert_eq!(b.descriptors[0].align.stretch_y, Some(1.5));
}
#[test]
fn a_later_rule_wins_over_an_earlier_one() {
let policy = Policy {
patches: vec![
rule(Match::Kind("door".into()), stretch(1.2)),
rule(Match::Id("door_double".into()), stretch(1.4)),
],
..Policy::default()
};
let out = policy.apply(&library()).unwrap_or_else(|e| panic!("{e}"));
assert_eq!(out.descriptors[0].align.stretch_y, Some(1.2));
assert_eq!(out.descriptors[1].align.stretch_y, Some(1.4));
}
#[test]
fn a_rule_that_matches_nothing_is_refused() {
let policy = Policy {
patches: vec![rule(Match::Id("dooor_single".into()), stretch(1.2))],
..Policy::default()
};
let err = policy.apply(&library()).err().unwrap_or_default();
assert!(err.contains("misspelled"), "{err}");
let policy = Policy {
patches: vec![rule(Match::Kind("hatch".into()), stretch(1.2))],
..Policy::default()
};
let err = policy.apply(&library()).err().unwrap_or_default();
assert!(err.contains("hatch"), "{err}");
}
#[test]
fn a_patch_has_to_say_why() {
let text = Policy {
patches: vec![Patch {
matches: Match::Kind("door".into()),
because: " ".into(),
patch: stretch(1.2),
}],
..Policy::default()
}
.to_ron()
.unwrap_or_else(|e| panic!("{e}"));
let err = Policy::parse(&text).err().unwrap_or_default();
assert!(err.contains("says nothing about why"), "{err}");
}
#[test]
fn a_patch_may_not_rename_its_target() {
let text = Policy {
patches: vec![Patch {
matches: Match::Kind("door".into()),
because: "because".into(),
patch: Descriptor {
id: "something_else".into(),
..Descriptor::default()
},
}],
..Policy::default()
}
.to_ron()
.unwrap_or_else(|e| panic!("{e}"));
let err = Policy::parse(&text).err().unwrap_or_default();
assert!(err.contains("renaming it"), "{err}");
}
#[test]
fn a_policy_round_trips() {
let policy = Policy {
version: POLICY_VERSION,
note: Some("this facility has 2.4 m ceilings".into()),
exclude: vec!["characters".into()],
patches: vec![rule(Match::Kind("door".into()), stretch(1.2))],
};
let text = policy.to_ron().unwrap_or_else(|e| panic!("{e}"));
assert_eq!(Policy::parse(&text).unwrap_or_else(|e| panic!("{e}")), policy);
}
#[test]
fn a_policy_from_another_schema_is_refused() {
let text = Policy {
version: POLICY_VERSION + 1,
..Policy::default()
}
.to_ron()
.unwrap_or_else(|e| panic!("{e}"));
let err = Policy::parse(&text).err().unwrap_or_default();
assert!(err.contains("refusing to load"), "{err}");
}
}
#[cfg(test)]
mod exclude_tests {
use super::*;
#[test]
fn an_excluded_prefix_stops_at_a_path_boundary() {
let policy = Policy {
exclude: vec!["characters".into(), "kit/sky.glb".into()],
..Policy::default()
};
assert!(policy.excludes("characters/cipher_field.glb"), "the folder");
assert!(policy.excludes("characters"), "and the folder itself");
assert!(policy.excludes("kit/sky.glb"), "a single file by its whole path");
assert!(
!policy.excludes("characters_of_note/rig.glb"),
"a longer folder that merely starts the same way is a DIFFERENT folder"
);
assert!(!policy.excludes("kit/sky.glb.bak"), "and a longer file name is a different file");
assert!(!policy.excludes("ozea_kit/crate.glb"), "everything else is untouched");
}
#[test]
fn a_kit_with_no_exclusions_excludes_nothing() {
let policy = Policy::default();
assert!(policy.exclude.is_empty());
for mesh in ["characters/rig.glb", "ozea_kit/crate.glb", "anything"] {
assert!(!policy.excludes(mesh));
}
}
#[test]
fn a_trailing_slash_is_the_same_prefix() {
let policy = Policy {
exclude: vec!["characters/".into()],
..Policy::default()
};
assert!(policy.excludes("characters/rig.glb"));
assert!(!policy.excludes("characters_of_note/rig.glb"));
}
}
pub fn rewrite_exclude(text: &str, exclude: &[String]) -> Result<String, String> {
let rendered = if exclude.is_empty() {
" exclude: [],".to_owned()
} else {
let items: Vec<String> = exclude.iter().map(|e| format!(" \"{e}\",")).collect();
format!(" exclude: [\n{}\n ],", items.join("\n"))
};
let out = match find_exclude(text) {
Some(span) => format!("{}{rendered}{}", &text[..span.start], &text[span.end..]),
None => {
let at = line_span(text, "version:")
.ok_or_else(|| "policy: no `version:` line to write `exclude` after".to_owned())?;
format!("{}\n{rendered}{}", &text[..at.end], &text[at.end..])
}
};
Policy::parse(&out).map_err(|e| format!("policy: rewriting `exclude` broke the file: {e}"))?;
Ok(out)
}
pub fn remove_patch(path: &Path, ordinal: usize) -> Result<(), String> {
let text = std::fs::read_to_string(path)
.map_err(|e| format!("{}: {e}", path.display()))?;
let policy = Policy::parse(&text)
.map_err(|e| format!("{}: {e}", path.display()))?;
if ordinal >= policy.patches.len() {
return Err(format!(
"policy: no patch at index {ordinal} — {} holds {}.",
path.display(),
policy.patches.len()
));
}
let mut doc = crate::ron_surgery::LineDoc::parse(&text, &["patches"])
.map_err(|e| format!("{}: {e}", path.display()))?;
doc.remove("patches", ordinal)
.map_err(|e| format!("{}: {e}", path.display()))?;
let out = doc.render();
Policy::parse(&out).map_err(|e| format!("policy: removing the patch broke the file: {e}"))?;
crate::ron_surgery::save_atomic(path, &out)
}
fn find_exclude(text: &str) -> Option<std::ops::Range<usize>> {
let mut at = 0usize;
for line in text.split_inclusive('\n') {
let start = at;
at += line.len();
let trimmed = line.trim_start();
if trimmed.starts_with("//") || !trimmed.starts_with("exclude:") {
continue;
}
let field_start = start + (line.len() - trimmed.len());
let rest = &text[field_start..];
let mut depth = 0i32;
let mut in_string = false;
for (i, c) in rest.char_indices() {
match c {
'"' => in_string = !in_string,
'[' if !in_string => depth += 1,
']' if !in_string => {
depth -= 1;
if depth == 0 {
let end = rest[i + 1..]
.find(',')
.filter(|c| rest[i + 1..i + 1 + c].trim().is_empty())
.map_or(field_start + i + 1, |c| field_start + i + 2 + c);
return Some(field_start..end);
}
}
_ => {}
}
}
return None;
}
None
}
fn line_span(text: &str, prefix: &str) -> Option<std::ops::Range<usize>> {
let mut at = 0usize;
for line in text.split_inclusive('\n') {
let start = at;
at += line.len();
let trimmed = line.trim_start();
if !trimmed.starts_with("//") && trimmed.starts_with(prefix) {
return Some(start..at - usize::from(line.ends_with('\n')));
}
}
None
}
#[cfg(test)]
mod exclude_write_tests {
use super::*;
const PROSE: &str = r#"(
version: 2,
// A comment that must survive, because this file is read by people.
note: Some("SCP-9191 containment site."),
)
"#;
#[test]
fn writing_an_exclusion_keeps_every_comment() {
let out = rewrite_exclude(PROSE, &["characters".into()]).unwrap_or_else(|e| panic!("{e}"));
assert!(
out.contains("// A comment that must survive"),
"a serialize-and-write would have eaten this:\n{out}"
);
assert!(out.contains("SCP-9191 containment site."), "and the note:\n{out}");
let policy = Policy::parse(&out).unwrap_or_else(|e| panic!("{e}"));
assert_eq!(policy.exclude, vec!["characters".to_owned()]);
assert!(
policy.note.is_some(),
"and the rest of the policy is untouched"
);
}
#[test]
fn rewriting_replaces_the_list_it_finds() {
let once = rewrite_exclude(PROSE, &["characters".into()]).unwrap_or_else(|e| panic!("{e}"));
let twice = rewrite_exclude(&once, &["characters".into(), "scp610".into()])
.unwrap_or_else(|e| panic!("{e}"));
assert_eq!(twice.matches("exclude:").count(), 1, "one field, not two:\n{twice}");
let policy = Policy::parse(&twice).unwrap_or_else(|e| panic!("{e}"));
assert_eq!(policy.exclude, vec!["characters".to_owned(), "scp610".to_owned()]);
let none = rewrite_exclude(&twice, &[]).unwrap_or_else(|e| panic!("{e}"));
assert!(Policy::parse(&none).unwrap_or_else(|e| panic!("{e}")).exclude.is_empty());
assert!(none.contains("// A comment that must survive"), "still:\n{none}");
}
#[test]
fn a_comment_naming_the_field_is_not_the_field() {
let text = r#"(
version: 2,
// exclude: this line is prose about excluding, not an exclusion.
note: Some("a policy with nothing but prose in it"),
)
"#;
let out = rewrite_exclude(text, &["characters".into()]).unwrap_or_else(|e| panic!("{e}"));
assert!(out.contains("// exclude: this line is prose"), "the comment stands:\n{out}");
assert_eq!(
Policy::parse(&out).unwrap_or_else(|e| panic!("{e}")).exclude,
vec!["characters".to_owned()]
);
}
}
#[cfg(test)]
fn temp_policy(name: &str, text: &str) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!("grimoire-policy-{}-{name}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap_or_else(|e| panic!("cannot make {dir:?}: {e}"));
let path = dir.join("project.ron");
std::fs::write(&path, text).unwrap_or_else(|e| panic!("cannot write {path:?}: {e}"));
path
}
#[test]
fn removing_a_patch_takes_the_ordinal_and_keeps_everything_else() {
let text = r#"(
version: 2,
// A comment that must survive, because this file is read by people.
note: Some("SCP-9191 containment site."),
exclude: ["characters"],
patches: [
( match: Id("crate_b"), because: "the first reason", patch: ( align: ( stretch_y: Some(1.2) ) ) ),
( match: Id("crate_b"), because: "the second reason", patch: ( align: ( stretch_y: Some(1.4) ) ) ),
],
)
"#;
let path = temp_policy("shared-key", text);
remove_patch(&path, 0).unwrap_or_else(|e| panic!("{e}"));
let out = std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("{e}"));
assert!(out.contains("// A comment that must survive"), "the comment stands:\n{out}");
let policy = Policy::parse(&out).unwrap_or_else(|e| panic!("{e}"));
assert_eq!(policy.exclude, vec!["characters".to_owned()], "the exclusion is untouched");
assert_eq!(policy.patches.len(), 1, "exactly one patch left:\n{out}");
assert_eq!(
policy.patches[0].because, "the second reason",
"the SECOND patch survives — removal is by ordinal, not by key"
);
}
#[test]
fn removing_a_patch_past_the_end_refuses_without_writing() {
let text = r#"(
version: 2,
note: Some("a policy with one patch"),
patches: [
( match: Id("crate_a"), because: "the only reason", patch: ( align: ( stretch_y: Some(1.2) ) ) ),
],
)
"#;
let path = temp_policy("past-end", text);
let before = std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("{e}"));
let err = remove_patch(&path, 7)
.err()
.unwrap_or_else(|| panic!("an ordinal past the end must be refused"));
assert!(err.contains("7"), "names the ordinal: {err}");
let after = std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("{e}"));
assert_eq!(before, after, "nothing was written: {err}");
}