use crate::model_pick::grammar::{entry_field, entry_names, remove_entry, set_entry};
use std::path::PathBuf;
use std::time::Duration;
pub const BLOCK: &str = "fleet";
pub const PROJECT: &str = "project";
pub const CAP: &str = "cap";
pub const LEASE_MIN: &str = "lease_min";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Policy {
pub project: PathBuf,
pub cap: usize,
pub lease: Option<Duration>,
}
pub fn armed(text: &str) -> Vec<String> {
entry_names(text, BLOCK)
}
pub fn policy(text: &str, key: &str) -> Option<Policy> {
let field = |name| entry_field(text, BLOCK, key, name).filter(|v| !v.is_empty());
Some(Policy {
project: PathBuf::from(field(PROJECT)?),
cap: field(CAP)?.parse().ok()?,
lease: field(LEASE_MIN)
.and_then(|m| m.parse::<u64>().ok())
.map(|m| Duration::from_secs(m.saturating_mul(60))),
})
}
pub fn arm(text: &str, key: &str, project: &str, cap: usize) -> Option<String> {
set_entry(
text,
BLOCK,
key,
&[(PROJECT, project.to_owned()), (CAP, cap.to_string())],
)
}
pub fn disarm(text: &str, key: &str) -> String {
remove_entry(text, BLOCK, key)
}
#[cfg(test)]
mod tests {
use super::*;
const ARMED: &str = "cadence:\n watcher:\n debounce_ms: 100\nfleet:\n /ws/a:\n project: /dev/yog\n cap: 3\n";
#[test]
fn an_absent_block_arms_nothing() {
assert!(armed("cadence:\n watcher:\n debounce_ms: 100\n").is_empty());
assert_eq!(policy("", "/ws/a"), None);
}
#[test]
fn a_declared_entry_is_the_policy() {
assert_eq!(armed(ARMED), vec!["/ws/a".to_owned()]);
assert_eq!(
policy(ARMED, "/ws/a"),
Some(Policy {
project: PathBuf::from("/dev/yog"),
cap: 3,
lease: None,
})
);
assert_eq!(policy(ARMED, "/ws/b"), None, "a sibling key is not armed");
}
#[test]
fn a_missing_or_unreadable_required_field_is_not_armed() {
assert_eq!(
policy("fleet:\n /ws/a:\n cap: 3\n", "/ws/a"),
None,
"no project, no fleet"
);
assert_eq!(
policy("fleet:\n /ws/a:\n project: /dev/yog\n", "/ws/a"),
None,
"no cap, no fleet"
);
assert_eq!(
policy(
"fleet:\n /ws/a:\n project: /dev/yog\n cap: lots\n",
"/ws/a"
),
None,
"a cap yog cannot read is not a cap it may guess"
);
}
#[test]
fn the_lease_is_optional_and_reads_as_minutes() {
let text = "fleet:\n /ws/a:\n project: /dev/yog\n cap: 1\n lease_min: 30\n";
assert_eq!(
policy(text, "/ws/a").expect("armed").lease,
Some(Duration::from_mins(30))
);
let broken = "fleet:\n /ws/a:\n project: /dev/yog\n cap: 1\n lease_min: soon\n";
assert_eq!(
policy(broken, "/ws/a").expect("armed").lease,
None,
"an unreadable lease reaps nothing rather than reaping on a guess"
);
}
#[test]
fn arming_creates_the_block_then_replaces_the_entry() {
let base = "cadence:\n watcher:\n debounce_ms: 100\n";
let once = arm(base, "/ws/a", "/dev/yog", 2).expect("block absent is creatable");
assert_eq!(policy(&once, "/ws/a").expect("armed").cap, 2);
assert!(
once.contains("debounce_ms: 100"),
"the clock's own entry survives byte-for-byte"
);
let again = arm(&once, "/ws/a", "/dev/yog", 5).expect("armable");
assert_eq!(policy(&again, "/ws/a").expect("armed").cap, 5);
assert_eq!(armed(&again).len(), 1, "re-arming replaces, never appends");
}
#[test]
fn arming_a_second_workspace_leaves_the_first() {
let two = arm(ARMED, "/ws/b", "/dev/litany", 1).expect("armable");
assert_eq!(policy(&two, "/ws/a").expect("armed").cap, 3);
assert_eq!(
policy(&two, "/ws/b").expect("armed").project,
PathBuf::from("/dev/litany")
);
}
#[test]
fn an_inline_block_key_refuses() {
assert_eq!(arm("fleet: {}\n", "/ws/a", "/dev/yog", 1), None);
}
#[test]
fn disarming_removes_the_entry_and_is_idempotent() {
let off = disarm(ARMED, "/ws/a");
assert_eq!(policy(&off, "/ws/a"), None);
assert!(off.contains("debounce_ms: 100"), "the clock is untouched");
assert_eq!(disarm(&off, "/ws/a"), off, "disarming twice is one world");
}
}