use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use crate::address::Address;
use crate::consts_policy::EDIT_WAR_FREEZE;
use crate::error::{AxCode, AxError};
use crate::event::RunId;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DomainPrefixes {
prefixes: Vec<Address>,
}
impl DomainPrefixes {
pub fn new(prefixes: Vec<Address>) -> Result<DomainPrefixes, AxError> {
if let Some(reserved) = prefixes.iter().find(|p| p.is_reserved()) {
return Err(AxError::failure(
AxCode::InvalidArgs,
"construct write domain",
reserved.as_str(),
)
.with_recovery(
"the reserved prefix is outside every write domain (C17); \
drop it from the prefix set",
));
}
Ok(DomainPrefixes { prefixes })
}
pub fn iter(&self) -> impl Iterator<Item = &Address> {
self.prefixes.iter()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum WriteDomain {
Everything(DomainPrefixes),
Documents(DomainPrefixes),
}
impl WriteDomain {
pub fn new(prefixes: Vec<Address>) -> Result<WriteDomain, AxError> {
Ok(WriteDomain::Everything(DomainPrefixes::new(prefixes)?))
}
pub fn documents(prefixes: Vec<Address>) -> Result<WriteDomain, AxError> {
Ok(WriteDomain::Documents(DomainPrefixes::new(prefixes)?))
}
pub fn prefixes(&self) -> impl Iterator<Item = &Address> {
match self {
WriteDomain::Everything(set) | WriteDomain::Documents(set) => set.iter(),
}
}
#[must_use]
pub fn reaches(&self, area: &Address) -> bool {
!area.is_reserved() && self.prefixes().any(|p| area.is_within(p))
}
pub fn admits(&self, target: &Address) -> DomainVerdict {
if !self.reaches(target) {
return DomainVerdict::Outside {
prefixes: self.prefix_strings(),
};
}
match self {
WriteDomain::Everything(_) => DomainVerdict::Within,
WriteDomain::Documents(_) => document_verdict(target),
}
}
fn prefix_strings(&self) -> Vec<String> {
self.prefixes().map(|p| p.as_str().to_owned()).collect()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub enum WriteLimit {
Full,
Create,
}
impl WriteLimit {
pub const ALL: [WriteLimit; 2] = [WriteLimit::Full, WriteLimit::Create];
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
WriteLimit::Full => "full",
WriteLimit::Create => "create",
}
}
}
fn file_name(target: &Address) -> &str {
let raw = target.as_str();
raw.rsplit('/').next().unwrap_or(raw)
}
fn document_verdict(target: &Address) -> DomainVerdict {
let name = file_name(target);
if name == crate::spine::ROADMAP_FILE {
return DomainVerdict::NotWritable {
reason: DocumentReason::ThePlan,
};
}
if name.ends_with(".md") {
DomainVerdict::Within
} else {
DomainVerdict::NotWritable {
reason: DocumentReason::NotMarkdown,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DomainVerdict {
Within,
Outside {
prefixes: Vec<String>,
},
NotWritable {
reason: DocumentReason,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DocumentReason {
NotMarkdown,
ThePlan,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EditSample {
pub addr: Address,
pub run: RunId,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum EditWarVerdict {
Calm,
Freeze { addr: Address },
}
pub fn observe_edit_war(samples: &[EditSample]) -> EditWarVerdict {
let mut per_addr: BTreeMap<&Address, Vec<RunId>> = BTreeMap::new();
for sample in samples {
let runs = per_addr.entry(&sample.addr).or_default();
if runs.last() != Some(&sample.run) {
runs.push(sample.run);
}
}
for (addr, runs) in per_addr {
let mut reclaims: u32 = 0;
for i in 2..runs.len() {
let (Some(now), Some(prev), Some(before)) = (
runs.get(i),
runs.get(i.wrapping_sub(1)),
runs.get(i.wrapping_sub(2)),
) else {
continue;
};
if now == before && now != prev {
reclaims = reclaims.saturating_add(1);
}
}
if reclaims >= EDIT_WAR_FREEZE {
return EditWarVerdict::Freeze { addr: addr.clone() };
}
}
EditWarVerdict::Calm
}
#[cfg(test)]
#[allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
clippy::indexing_slicing,
clippy::wildcard_enum_match_arm,
clippy::let_underscore_must_use,
clippy::let_underscore_untyped,
reason = "test code"
)]
mod tests {
use super::*;
fn addr(raw: &str) -> Address {
Address::parse(raw).unwrap()
}
fn run(n: u8) -> RunId {
RunId::parse(&format!("0198f6a2-7c4a-7bbb-9d1e-0000000000{n:02x}")).unwrap()
}
#[test]
fn a_reserved_member_refuses_the_whole_domain() {
let err = WriteDomain::new(vec![addr("b"), addr(".sprawling/cas")]).unwrap_err();
assert_eq!(err.code(), &AxCode::InvalidArgs);
}
#[test]
fn admits_within_and_refuses_outside_with_nearby() {
let domain = WriteDomain::new(vec![addr("b1/room"), addr("b2")]).unwrap();
assert_eq!(
domain.admits(&addr("b1/room/file.md")),
DomainVerdict::Within
);
assert_eq!(domain.admits(&addr("b2")), DomainVerdict::Within);
match domain.admits(&addr("b3/x")) {
DomainVerdict::Outside { prefixes } => {
assert_eq!(prefixes, ["b1/room", "b2"]);
}
other => panic!("b3/x is outside, not {other:?}"),
}
}
#[test]
fn reserved_target_is_outside_even_for_an_empty_domain() {
let domain = WriteDomain::new(vec![]).unwrap();
assert!(matches!(
domain.admits(&addr(".sprawling/config")),
DomainVerdict::Outside { .. }
));
assert!(matches!(
domain.admits(&addr("anywhere")),
DomainVerdict::Outside { .. }
));
}
#[test]
fn two_full_reclaims_freeze_the_file() {
let file = addr("b/contested.md");
let samples: Vec<EditSample> = [run(1), run(2), run(1), run(2)]
.into_iter()
.map(|r| EditSample {
addr: file.clone(),
run: r,
})
.collect();
assert_eq!(
observe_edit_war(&samples),
EditWarVerdict::Freeze { addr: file }
);
}
#[test]
fn one_reclaim_or_same_run_bursts_stay_calm() {
let file = addr("b/f.md");
let one_reclaim: Vec<EditSample> = [run(1), run(2), run(1)]
.into_iter()
.map(|r| EditSample {
addr: file.clone(),
run: r,
})
.collect();
assert_eq!(observe_edit_war(&one_reclaim), EditWarVerdict::Calm);
let bursts: Vec<EditSample> = [run(1), run(1), run(2), run(2), run(1), run(1)]
.into_iter()
.map(|r| EditSample {
addr: file.clone(),
run: r,
})
.collect();
assert_eq!(observe_edit_war(&bursts), EditWarVerdict::Calm);
}
#[test]
fn a_file_handed_along_without_a_reclaim_stays_calm() {
let file = addr("b/relay.md");
let relay: Vec<EditSample> = (1..=6)
.map(|n| EditSample {
addr: file.clone(),
run: run(n),
})
.collect();
assert_eq!(observe_edit_war(&relay), EditWarVerdict::Calm);
}
#[test]
fn wars_are_per_address_not_global() {
let samples: Vec<EditSample> = [
(addr("b/one.md"), run(1)),
(addr("b/two.md"), run(2)),
(addr("b/one.md"), run(2)),
(addr("b/two.md"), run(1)),
(addr("b/one.md"), run(1)),
(addr("b/two.md"), run(2)),
]
.into_iter()
.map(|(a, r)| EditSample { addr: a, run: r })
.collect();
assert_eq!(observe_edit_war(&samples), EditWarVerdict::Calm);
}
}