majordomus_cli/policy.rs
1//! The canonical policy, typed: `.ai/repo/policy.yaml` and the profiles beside it. The
2//! policy is the one canonical input of the provider projections; the shell tool reads the
3//! same file with the same subset reader, so the two agree byte for byte on what a
4//! projection is and which hash it carries.
5
6use std::path::{Path, PathBuf};
7
8use schemars::JsonSchema;
9use serde::{Deserialize, Serialize};
10use sha2::{Digest, Sha256};
11
12use crate::error::{Error, Result};
13use crate::metadata::yaml;
14use crate::repository::Repository;
15
16/// The manifest section that holds the policy file.
17pub const POLICY_SECTION: &str = "policy";
18/// The manifest section that holds the profiles directory.
19pub const PROFILES_SECTION: &str = "profiles";
20
21/// How a projection owns its target.
22#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Default)]
23#[serde(rename_all = "snake_case")]
24pub enum ProjectionMode {
25 /// The whole file is generated; its first line is the stamp.
26 #[default]
27 File,
28 /// Only the text between `majordomus:begin` and `majordomus:end` markers is generated.
29 Region,
30}
31
32/// One declared provider projection: `projections[]` in the policy.
33#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
34#[serde(deny_unknown_fields)]
35pub struct Projection {
36 /// The provider whose template renders the target: `agents`, `claude-code`, ...
37 pub provider: String,
38 /// Repository-relative target path, e.g. `AGENTS.md`.
39 pub target: String,
40 /// Whether every worker loads this target without asking; bounded by the budget.
41 #[serde(default)]
42 pub always_loaded: bool,
43 /// Whole file or a marked region.
44 #[serde(default)]
45 pub mode: ProjectionMode,
46}
47
48/// `context:` of the policy, the part the projections need.
49#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Default)]
50pub struct ContextPolicy {
51 /// Hard cap, in lines, on an `always_loaded` projection.
52 #[serde(default)]
53 pub always_loaded_budget_lines: Option<u64>,
54}
55
56/// `profiles:` of the policy, the part the projections need.
57#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Default)]
58pub struct ProfilesPolicy {
59 /// The profile a worker starts with.
60 #[serde(default)]
61 pub default: Option<String>,
62 /// The checkpoint interval the default profile sets.
63 #[serde(default)]
64 pub checkpoint_interval_default: Option<String>,
65}
66
67/// `session.freshness:` of the policy: how old a continuation record may be before it stops
68/// being current.
69///
70/// These are the only numbers that decide it, and they are declared once. Divergence
71/// (`exact`/`advanced`/`diverged`/`different_context`) answers a different question — where
72/// the record's commit sits relative to HEAD — and answers it identically on the day a
73/// record is written and a month later. Keeping a default here would be a second source of
74/// truth for the same thresholds, so absence is carried as absence and reported as
75/// `unknown`, naming the missing key.
76///
77/// Both keys are independently optional, and the pair is what
78/// [`Thresholds::judge`](crate::capability::builtin::continuity::Thresholds::judge) needs:
79/// with one of them missing it has no band to place a record in and answers `unknown`
80/// rather than inventing the other half.
81///
82/// ```
83/// use majordomus_cli::policy::FreshnessPolicy;
84/// use serde_json::json;
85///
86/// // the declared form: the two numbers that draw fresh | aging | stale
87/// let declared: FreshnessPolicy =
88/// serde_json::from_value(json!({"fresh_minutes": 30, "stale_minutes": 240})).unwrap();
89/// assert_eq!(declared.fresh_minutes, Some(30));
90/// assert_eq!(declared.stale_minutes, Some(240));
91///
92/// // a policy that predates the key: absent, not defaulted. Nothing in this file
93/// // supplies a number the repository never declared.
94/// let silent: FreshnessPolicy = serde_json::from_value(json!({})).unwrap();
95/// assert_eq!(silent, FreshnessPolicy::default());
96/// assert!(silent.fresh_minutes.is_none() && silent.stale_minutes.is_none());
97/// ```
98#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Default)]
99pub struct FreshnessPolicy {
100 /// Below this age a record is `fresh`.
101 #[serde(default)]
102 pub fresh_minutes: Option<i64>,
103 /// At or beyond this age a record is `stale`, and is never presented as current.
104 #[serde(default)]
105 pub stale_minutes: Option<i64>,
106}
107
108/// `knowledge:` of the policy: the bounds on the review queue of derived knowledge records.
109///
110/// A candidate is a record the deriver wrote at an episode boundary and nobody has judged
111/// (ADR 0091). The queue is measured, never emptied by a machine, and both numbers are
112/// declared once in the policy and read from it by the shell validator and by
113/// `knowledge_base.candidates` alike. Absence is carried as absence: a repository whose
114/// policy predates the keys is reported as declaring no cap, never judged against one
115/// nobody wrote down.
116///
117/// ```
118/// use majordomus_cli::policy::KnowledgePolicy;
119/// let declared: KnowledgePolicy =
120/// serde_json::from_str(r#"{"candidates_max_files": 40}"#).expect("a knowledge block");
121/// assert_eq!(declared.candidates_max_files, Some(40));
122/// assert_eq!(declared.candidate_max_age_minutes, None, "absent is absent, not a default");
123/// assert_eq!(KnowledgePolicy::default().candidates_max_files, None);
124/// ```
125#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Default)]
126pub struct KnowledgePolicy {
127 /// More records with status `candidate` than this awaiting review is a finding.
128 #[serde(default)]
129 pub candidates_max_files: Option<usize>,
130 /// A candidate waiting longer than this since it entered the queue is a finding.
131 #[serde(default)]
132 pub candidate_max_age_minutes: Option<i64>,
133}
134
135/// The policy, typed to what the projections consume. Every other key is carried through
136/// unread: the policy schema under `share/schemas/majordomus/policy/policy.v1.schema.json` owns the full shape.
137#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Default)]
138pub struct Policy {
139 /// `version:`.
140 #[serde(default)]
141 pub version: Option<u64>,
142 /// `context:`.
143 #[serde(default)]
144 pub context: ContextPolicy,
145 /// `profiles:`.
146 #[serde(default)]
147 pub profiles: ProfilesPolicy,
148 /// `session:`.
149 #[serde(default)]
150 pub session: SessionPolicy,
151 /// `knowledge:`.
152 #[serde(default)]
153 pub knowledge: KnowledgePolicy,
154 /// `projections:`.
155 #[serde(default)]
156 pub projections: Vec<Projection>,
157 /// `server:` — the timings a lease contest between two processes is judged by. Absent
158 /// keeps the constants `lease.rs` declares, so a policy that predates the block, or one
159 /// that could not be read, does not silently change how a server is taken over.
160 #[serde(default)]
161 pub server: ServerPolicy,
162 /// `commit:` — what this repository holds a commit message to. Absent is the default,
163 /// which is what this repository's own history already satisfies.
164 #[serde(default)]
165 pub commit: crate::commit::CommitPolicy,
166 /// `intent:` — whether work must be bound to what it serves before it starts, and the
167 /// exemption classes a worker may give instead. Absent is `off`: a repository whose
168 /// policy predates the block starts work exactly as it did (ADR 0111).
169 #[serde(default)]
170 pub intent: IntentPolicy,
171 /// `release:` — who raises the version and by at least how much (ADR 0134). Absent is
172 /// `allocation: off` with no floor: a repository whose policy predates the block raises
173 /// its version exactly as it did.
174 #[serde(default)]
175 pub release: crate::release::landing::ReleasePolicy,
176}
177
178/// How strictly a start is held to its binding.
179///
180/// ```
181/// use majordomus_cli::policy::BindingMode;
182/// // a policy that says nothing asks for nothing
183/// assert_eq!(BindingMode::default(), BindingMode::Off);
184/// let m: BindingMode = serde_json::from_str("\"required\"").unwrap();
185/// assert_eq!(m, BindingMode::Required);
186/// assert_eq!(m.as_str(), "required");
187/// ```
188#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, Serialize, JsonSchema)]
189#[serde(rename_all = "snake_case")]
190pub enum BindingMode {
191 /// Nothing is asked unless the worker names an issue, an intent or an exemption.
192 #[default]
193 Off,
194 /// Every start asks; a refused binding is reported and the work starts.
195 Advisory,
196 /// Every start asks; a refused or unreadable binding does not start.
197 Required,
198}
199
200impl BindingMode {
201 /// The word the policy file and every surface use.
202 ///
203 /// ```
204 /// use majordomus_cli::policy::BindingMode;
205 /// assert_eq!(BindingMode::Advisory.as_str(), "advisory");
206 /// ```
207 pub fn as_str(self) -> &'static str {
208 match self {
209 BindingMode::Off => "off",
210 BindingMode::Advisory => "advisory",
211 BindingMode::Required => "required",
212 }
213 }
214}
215
216/// One exemption class: a named reason work may proceed under no intent.
217///
218/// ```
219/// use majordomus_cli::policy::ExemptionClass;
220/// let c: ExemptionClass = serde_json::from_str(
221/// r#"{"id": "emergency", "description": "Restoring a broken trunk"}"#,
222/// )
223/// .unwrap();
224/// assert_eq!(c.id, "emergency");
225/// ```
226#[derive(Debug, Clone, PartialEq, Eq, Default, Deserialize, Serialize, JsonSchema)]
227pub struct ExemptionClass {
228 /// The word a worker gives: `--exempt <id>`.
229 pub id: String,
230 /// What the class is for, in one line.
231 #[serde(default)]
232 pub description: String,
233}
234
235/// `intent:` — the binding a start is held to (ADR 0111).
236///
237/// ```
238/// use majordomus_cli::policy::{BindingMode, IntentPolicy};
239/// // absent: off, and no exemption class exists to give
240/// let absent = IntentPolicy::default();
241/// assert_eq!(absent.binding, BindingMode::Off);
242/// assert!(absent.exemption("maintenance").is_none());
243/// let p: IntentPolicy = serde_json::from_str(
244/// r#"{"binding": "required", "exemptions": [{"id": "maintenance"}]}"#,
245/// )
246/// .unwrap();
247/// assert!(p.exemption("maintenance").is_some());
248/// assert!(p.exemption("because-i-say-so").is_none());
249/// ```
250#[derive(Debug, Clone, PartialEq, Eq, Default, Deserialize, Serialize, JsonSchema)]
251pub struct IntentPolicy {
252 /// `intent.binding:` — `off`, `advisory` or `required`.
253 #[serde(default)]
254 pub binding: BindingMode,
255 /// `intent.exemptions:` — the classes a worker may give instead of naming work.
256 #[serde(default)]
257 pub exemptions: Vec<ExemptionClass>,
258}
259
260impl IntentPolicy {
261 /// The declared class with this id, when the policy declares one.
262 ///
263 /// ```
264 /// use majordomus_cli::policy::IntentPolicy;
265 /// assert!(IntentPolicy::default().exemption("x").is_none());
266 /// ```
267 pub fn exemption(&self, id: &str) -> Option<&ExemptionClass> {
268 self.exemptions.iter().find(|c| c.id == id)
269 }
270}
271
272/// `session:` — what the episode boundary does beyond drawing itself.
273///
274/// Only the half two entry paths both read is typed here. The rest of the block is carried
275/// through unread, as every other key is: the policy schema owns the full shape.
276///
277/// ```
278/// use majordomus_cli::policy::SessionPolicy;
279/// // A policy that says nothing about it still converges. The default is what this
280/// // repository's policy and the skeleton a new one is written from both declare, and a
281/// // policy that could not be read must not silently change behaviour.
282/// assert!(SessionPolicy::default().ensure_server_on_start);
283/// assert!(SessionPolicy::default().knowledge_on_end);
284/// assert!(SessionPolicy::default().knowledge_on_compact);
285/// // and a repository that has turned one off is read as having turned it off
286/// let off: SessionPolicy =
287/// serde_json::from_str(r#"{"ensure_server_on_start": false, "knowledge_on_end": false}"#)
288/// .expect("a session block");
289/// assert!(!off.ensure_server_on_start);
290/// assert!(!off.knowledge_on_end);
291/// assert!(off.knowledge_on_compact, "the two switches are independent");
292/// ```
293#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
294pub struct SessionPolicy {
295 /// `session.freshness:` — how old a continuation record may be before it stops being
296 /// current. A separate question from divergence, which is about git topology and says
297 /// nothing about age.
298 #[serde(default)]
299 pub freshness: FreshnessPolicy,
300 /// Whether entering the repository converges on a ready shared server.
301 ///
302 /// One switch, both entry paths: the provider's start event (ADR 0035) and the file a
303 /// shell evaluates on entry (ADR 0043). It defaults to `true` because that is what
304 /// this repository's policy and the skeleton a new one is written from both declare,
305 /// and because a policy that could not be read must not silently change behaviour —
306 /// an unreadable policy is `doctor`'s finding, not a reason to stop converging.
307 #[serde(default = "yes")]
308 pub ensure_server_on_start: bool,
309 /// Whether closing an episode derives the knowledge it produced into candidate records
310 /// under the tracked knowledge section (ADR 0091). Read here so that
311 /// `knowledge_base.status` judges a silent writer only where the writer is switched on,
312 /// and defaults to `true` for the reason `ensure_server_on_start` does: the policy and
313 /// the skeleton both declare it, and an unreadable policy must not change behaviour.
314 #[serde(default = "yes")]
315 pub knowledge_on_end: bool,
316 /// Whether a provider's compaction event derives the same records. Independent of
317 /// `checkpoint_on_compact`: the two record different things.
318 #[serde(default = "yes")]
319 pub knowledge_on_compact: bool,
320 /// `session.brief_interval_minutes:` — how often a session reports on itself without
321 /// being asked (ADR 0136). Absent is the default `brief.session` carries; the provider
322 /// mod reads the interval from that answer rather than from this file.
323 #[serde(default)]
324 pub brief_interval_minutes: Option<u64>,
325 /// `session.edits_require_claim:` — whether a provider's tool may write a file of the
326 /// checkout only while a claim covers it: the active task's scope, or a live mesh claim
327 /// of the writing session (I2735, [`crate::guard`]). Off unless a repository turns it
328 /// on, because it refuses edits, and a repository that never asked for that must not
329 /// find its workers stopped by an upgrade.
330 #[serde(default)]
331 pub edits_require_claim: bool,
332 /// `session.stranded_after:` — how long an open episode may be silent before its client
333 /// is taken to be gone (`90s`, `30m`, `12h`). `majordomus recover` reads it to judge an
334 /// episode stranded, and `worktree.uncommitted` reads the same value twice over: an
335 /// episode silent this long holds no work, and uncommitted work untouched this long
336 /// that nothing holds is abandoned. Absent is absent: no reader carries a default of
337 /// its own, because a second copy of the number is the drift the policy exists to stop.
338 ///
339 /// ```
340 /// use majordomus_cli::policy::SessionPolicy;
341 /// let declared: SessionPolicy =
342 /// serde_json::from_str(r#"{"stranded_after": "12h"}"#).expect("a session block");
343 /// assert_eq!(declared.stranded_after.as_deref(), Some("12h"));
344 /// assert_eq!(SessionPolicy::default().stranded_after, None, "absent is not a default");
345 /// // a bare number is seconds, as the shell reader takes it, and never a parse failure
346 /// // that would take the rest of the policy down with it
347 /// let bare: SessionPolicy =
348 /// serde_json::from_str(r#"{"stranded_after": 3600}"#).expect("a session block");
349 /// assert_eq!(bare.stranded_after.as_deref(), Some("3600"));
350 /// ```
351 #[serde(default, deserialize_with = "scalar_text")]
352 pub stranded_after: Option<String>,
353}
354
355/// A scalar read as the text it was written as: the subset reader hands `12h` over as a
356/// string and `3600` as a number, and both are the same kind of value to a duration.
357fn scalar_text<'de, D: serde::Deserializer<'de>>(
358 d: D,
359) -> std::result::Result<Option<String>, D::Error> {
360 Ok(match serde_json::Value::deserialize(d)? {
361 serde_json::Value::String(s) => Some(s),
362 serde_json::Value::Number(n) => Some(n.to_string()),
363 _ => None,
364 })
365}
366
367impl Default for SessionPolicy {
368 fn default() -> Self {
369 SessionPolicy {
370 freshness: FreshnessPolicy::default(),
371 ensure_server_on_start: true,
372 knowledge_on_end: true,
373 knowledge_on_compact: true,
374 brief_interval_minutes: None,
375 edits_require_claim: false,
376 stranded_after: None,
377 }
378 }
379}
380
381fn yes() -> bool {
382 true
383}
384
385/// The policy as loaded: the typed value, where it came from, and the hash the stamps
386/// carry.
387#[derive(Debug, Clone)]
388pub struct LoadedPolicy {
389 /// The typed policy.
390 pub policy: Policy,
391 /// Repository-relative path of the policy file.
392 pub path: String,
393 /// SHA-256 (hex) of the [`normal_form`] of the policy file followed by that of every
394 /// profile file, in name order, concatenated: exactly what the shell tool's
395 /// `mj_policy_cat` hashes. A comment or a blank line does not move it (I1991).
396 pub sha256: String,
397}
398
399impl LoadedPolicy {
400 /// Read the policy and profiles of a repository.
401 pub fn load(repository: &Repository) -> Result<Self> {
402 let rel =
403 repository
404 .section_path(POLICY_SECTION)
405 .ok_or_else(|| Error::InvalidManifest {
406 path: repository.root().join(crate::repository::MANIFEST),
407 reason: format!("the manifest names no `{POLICY_SECTION}` section"),
408 })?;
409 let path = repository.root().join(&rel);
410 let text = std::fs::read_to_string(&path).map_err(|e| Error::io(&path, e))?;
411 let policy: Policy = yaml::parse_into(&text).map_err(|reason| Error::InvalidPolicy {
412 path: path.clone(),
413 reason,
414 })?;
415 // The one point a repository's policy is read, and therefore the one place the lease
416 // timings can be declared for this process. `declare_timings` is a OnceLock set, so
417 // reading the policy twice does not move a judgement already in flight.
418 crate::lease::declare_timings(&policy.server);
419 let mut hasher = Sha256::new();
420 hasher.update(normal_form(&text).as_bytes());
421 for profile in profile_files(repository)? {
422 let bytes = std::fs::read(&profile).map_err(|e| Error::io(&profile, e))?;
423 hasher.update(normal_form(&String::from_utf8_lossy(&bytes)).as_bytes());
424 }
425 Ok(LoadedPolicy {
426 policy,
427 path: rel,
428 sha256: format!("{:x}", hasher.finalize()),
429 })
430 }
431}
432
433/// The normal form of a file in the layer's YAML subset, which the policy hash covers in
434/// place of its bytes: trailing spaces, tabs and carriage returns dropped; blank lines and
435/// whole-line comments dropped; a trailing comment (a `#` after a space or tab) dropped
436/// from a line that holds no quote, since inside a quoted scalar ` #` may be text.
437/// Indentation, order and every value are kept, and every line ends in a newline.
438///
439/// It is the shell tool's `mj_yaml_normal` (lib/common.sh), line for line: the two must
440/// agree, or each renderer would call the other's bootstraps stale. The subset refuses
441/// block scalars, so no `#` line is ever content.
442///
443/// ```
444/// use majordomus_cli::policy::normal_form;
445///
446/// let edited = "# a new comment\nversion: 1 # the schema\n\ncontext:\n name: \"a #b\"\n";
447/// assert_eq!(normal_form(edited), "version: 1\ncontext:\n name: \"a #b\"\n");
448/// assert_eq!(normal_form(edited), normal_form("version: 1\ncontext:\n name: \"a #b\"\n"));
449/// assert_ne!(normal_form("a: 1\n"), normal_form("a: 2\n"));
450/// ```
451pub fn normal_form(text: &str) -> String {
452 let mut out = String::with_capacity(text.len());
453 for raw in text.split('\n') {
454 let mut line = raw.trim_end_matches([' ', '\t', '\r']);
455 let body = line.trim_start_matches([' ', '\t']);
456 if body.is_empty() || body.starts_with('#') {
457 continue;
458 }
459 if !line.contains(['\'', '"']) {
460 let b = line.as_bytes();
461 if let Some(i) =
462 (1..b.len()).find(|&i| b[i] == b'#' && (b[i - 1] == b' ' || b[i - 1] == b'\t'))
463 {
464 line = line[..i].trim_end_matches([' ', '\t']);
465 }
466 }
467 out.push_str(line);
468 out.push('\n');
469 }
470 out
471}
472
473/// The profile files, `<profiles>/*.yaml`, sorted by name. An absent directory is no
474/// profile, not an error.
475pub fn profile_files(repository: &Repository) -> Result<Vec<PathBuf>> {
476 let Some(rel) = repository.section_path(PROFILES_SECTION) else {
477 return Ok(Vec::new());
478 };
479 let dir = repository.root().join(rel);
480 let entries = match std::fs::read_dir(&dir) {
481 Ok(e) => e,
482 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
483 Err(e) => return Err(Error::io(&dir, e)),
484 };
485 let mut files: Vec<PathBuf> = entries
486 .filter_map(|e| e.ok().map(|e| e.path()))
487 .filter(|p| p.extension().is_some_and(|x| x == "yaml") && p.is_file())
488 .collect();
489 files.sort();
490 Ok(files)
491}
492
493/// The repository-relative directory where a repository may override a provider template.
494pub fn repository_providers_dir(repository: &Repository) -> PathBuf {
495 repository
496 .ai_dir()
497 .join(&repository.manifest().repo.path)
498 .join("providers")
499}
500
501/// Hex SHA-256 of a text.
502pub fn sha256_hex(text: &str) -> String {
503 let mut h = Sha256::new();
504 h.update(text.as_bytes());
505 format!("{:x}", h.finalize())
506}
507
508/// Hex SHA-256 of arbitrary bytes: the same digest [`sha256_hex`] takes, for content that
509/// is not necessarily text. A file the repository tracks may hold anything, and decoding it
510/// to hash it would be a decision about its encoding that nothing here is entitled to make.
511///
512/// ```
513/// use majordomus_cli::policy::{sha256_bytes_hex, sha256_hex};
514/// assert_eq!(sha256_bytes_hex(b"majordomus"), sha256_hex("majordomus"));
515/// assert_ne!(sha256_bytes_hex(&[0xff, 0xfe]), sha256_bytes_hex(&[0xfe, 0xff]));
516/// ```
517pub fn sha256_bytes_hex(bytes: &[u8]) -> String {
518 let mut h = Sha256::new();
519 h.update(bytes);
520 format!("{:x}", h.finalize())
521}
522
523/// A path is inside `root` after lexical normalisation: no absolute path, no `..`
524/// component, nothing empty.
525pub fn is_safe_relative(path: &str) -> bool {
526 let p = Path::new(path);
527 !path.is_empty()
528 && !p.is_absolute()
529 && p.components().all(|c| {
530 matches!(
531 c,
532 std::path::Component::Normal(_) | std::path::Component::CurDir
533 )
534 })
535}
536
537#[cfg(test)]
538mod tests {
539 use super::*;
540
541 /// `session.stranded_after` is read as the text it was written as: a duration as a
542 /// string, a bare number as its digits, anything else as absent, and a value that is
543 /// not even well-formed as the parse failure it is.
544 #[test]
545 fn a_stranded_after_is_text_a_number_or_absent() {
546 let read = |json: &str| serde_json::from_str::<SessionPolicy>(json);
547 let text = read(r#"{"stranded_after": "12h"}"#).unwrap();
548 assert_eq!(text.stranded_after.as_deref(), Some("12h"));
549 let number = read(r#"{"stranded_after": 3600}"#).unwrap();
550 assert_eq!(number.stranded_after.as_deref(), Some("3600"));
551 let other = read(r#"{"stranded_after": true}"#).unwrap();
552 assert_eq!(other.stranded_after, None);
553 assert!(read(r#"{"stranded_after": tru}"#).is_err());
554 }
555
556 #[test]
557 fn a_comment_or_a_blank_line_is_not_part_of_the_normal_form() {
558 let plain = "version: 1\ncontext:\n budget: 150\n list: [a, b]\n";
559 let commented = "# head\nversion: 1 # v\r\n\ncontext: \n # inside\n budget: 150\t# cap\n list: [a, b] #l\n";
560 assert_eq!(normal_form(commented), plain);
561 // a quoted line keeps what follows its quote, and a `#` with no space before it is text
562 assert_eq!(normal_form("a: 'x' # c\n"), "a: 'x' # c\n");
563 assert_eq!(normal_form("a: b#c\n"), "a: b#c\n");
564 // the last line needs no newline, and indentation is significant
565 assert_eq!(normal_form("a:\n b: 1"), "a:\n b: 1\n");
566 assert_ne!(normal_form("a:\n b: 1\n"), normal_form("a:\nb: 1\n"));
567 }
568
569 #[test]
570 fn the_intent_block_is_read_from_the_policy_file_and_absent_means_off() {
571 let absent: Policy = yaml::parse_into("version: 1\n").expect("a policy");
572 assert_eq!(absent.intent.binding, BindingMode::Off);
573 assert!(absent.intent.exemptions.is_empty());
574 let p: Policy = yaml::parse_into(
575 "version: 1\nintent:\n binding: required\n exemptions:\n - id: emergency\n description: Restoring a broken trunk\n - id: maintenance\n",
576 )
577 .expect("a policy with an intent block");
578 assert_eq!(p.intent.binding, BindingMode::Required);
579 let class = p.intent.exemption("emergency").expect("the declared class");
580 assert_eq!(class.description, "Restoring a broken trunk");
581 assert_eq!(p.intent.exemption("maintenance").unwrap().description, "");
582 assert!(p.intent.exemption("whim").is_none());
583 // each mode is the word the file spells it with
584 for (mode, word) in [
585 (BindingMode::Off, "off"),
586 (BindingMode::Advisory, "advisory"),
587 (BindingMode::Required, "required"),
588 ] {
589 assert_eq!(mode.as_str(), word);
590 let read: BindingMode = serde_json::from_value(serde_json::json!(word)).unwrap();
591 assert_eq!(read, mode);
592 }
593 }
594
595 #[test]
596 fn projection_defaults_are_file_mode_and_not_always_loaded() {
597 let p: Policy = yaml::parse_into(
598 "version: 1\nprojections:\n - provider: agents\n target: AGENTS.md\n",
599 )
600 .unwrap();
601 assert_eq!(p.projections.len(), 1);
602 assert_eq!(p.projections[0].mode, ProjectionMode::File);
603 assert!(!p.projections[0].always_loaded);
604 assert_eq!(p.profiles.default, None);
605 }
606
607 #[test]
608 fn unknown_projection_keys_are_refused() {
609 let r: std::result::Result<Policy, String> = yaml::parse_into(
610 "projections:\n - provider: agents\n target: AGENTS.md\n colour: red\n",
611 );
612 assert!(r.unwrap_err().contains("colour"));
613 }
614
615 #[test]
616 fn safe_relative_paths() {
617 assert!(is_safe_relative("AGENTS.md"));
618 assert!(is_safe_relative("docs/x/y.md"));
619 assert!(is_safe_relative("./AGENTS.md"));
620 assert!(!is_safe_relative(""));
621 assert!(!is_safe_relative("/etc/passwd"));
622 assert!(!is_safe_relative("../x"));
623 assert!(!is_safe_relative("a/../../x"));
624 }
625}
626
627/// `server:` — what a lease contest is judged by.
628///
629/// Every field is optional and absent means "keep the compiled default", which is why there
630/// is no reader-side default *value* here: the defaults live in one place, `lease::Timings`,
631/// and this block only says which of them this repository overrides.
632///
633/// ```
634/// use majordomus_cli::policy::ServerPolicy;
635/// // a policy that predates the block: absent, not defaulted to zero
636/// assert_eq!(ServerPolicy::default().probe_timeout_seconds, None);
637/// let p: ServerPolicy =
638/// serde_json::from_str(r#"{"probe_timeout_seconds": 5}"#).expect("a server block");
639/// assert_eq!(p.probe_timeout_seconds, Some(5));
640/// ```
641#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Default)]
642pub struct ServerPolicy {
643 /// `server.probe_timeout_seconds:` — how long a probe waits for the current owner before
644 /// that silence counts as evidence that it is gone.
645 #[serde(default)]
646 pub probe_timeout_seconds: Option<u64>,
647 /// `server.bind_grace_seconds:` — how long a lease naming no URL yet is left alone,
648 /// because a server writes its lease before it can serve.
649 #[serde(default)]
650 pub bind_grace_seconds: Option<u64>,
651 /// `server.join_timeout_seconds:` — how long a process waits to join or create the lease
652 /// file before refusing rather than waiting forever.
653 #[serde(default)]
654 pub join_timeout_seconds: Option<u64>,
655 /// `server.busy_grace_seconds:` — how long a live owner that does not answer is waited
656 /// on, and asked again, before its lease is taken over.
657 #[serde(default)]
658 pub busy_grace_seconds: Option<u64>,
659 /// `server.read_deadline_seconds:` — how long the shared server lets a connection go
660 /// without a request read in full on it before it closes the connection.
661 #[serde(default)]
662 pub read_deadline_seconds: Option<u64>,
663 /// `server.idle_seconds:` — how long a server started for a checkout no client owns
664 /// (`serve ensure`, a shell entering the checkout) outlives its last peer before it stops
665 /// by itself. `--idle` on the command line overrides it.
666 #[serde(default)]
667 pub idle_seconds: Option<u64>,
668}