Skip to main content

spec_driven_docs/domain/
debt.rs

1//! Budget debt: the inherited violations a project carries, and only downward.
2//!
3//! A budget gate measures one document on one or more dimensions and fails
4//! the measurement that exceeds its budget. A project adopting the convention
5//! with a corpus written before it would fail its first commit on files
6//! nobody touched, so it records each inherited violation here, per gate,
7//! per path, per dimension. A numeric dimension carries a ceiling the gate
8//! judges against instead of the budget. A boolean dimension is an exception
9//! that clears once the condition is corrected.
10//!
11//! The file can only shrink. Nothing delivers it, nothing raises a ceiling,
12//! and a recorded dimension that stops matching what the gate measures is a
13//! failure naming the command that lowers it. The tightening is pure: it
14//! takes the parsed debt and a set of measurements and returns the new debt,
15//! so the gates and the command share one implementation and a test drives
16//! it with no filesystem. What a gate measures is each gate's business.
17//!
18//! SATISFIES budget-debt:a-recorded-dimension-only-shrinks
19
20use std::collections::BTreeMap;
21use std::fmt::Write as _;
22
23use camino::Utf8Path;
24
25use crate::domain::gate_id::GateId;
26
27pub use crate::domain::paths::{DEBT_PATH, LEGACY_DEBT_PATH};
28/// The debt file schema this binary reads and writes.
29pub const SCHEMA_VERSION: u64 = 1;
30
31/// The gates that measure a budget, in id order.
32pub const BUDGET_GATES: &[GateId] = &[
33    GateId::AdrWordCap,
34    GateId::AgentsDigestSize,
35    GateId::ChapterSizeCap,
36    GateId::SpecSizeCap,
37];
38
39/// What a dimension measures.
40#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41pub enum Kind {
42    /// A count judged against a budget, and against a ceiling once recorded.
43    Count,
44    /// A condition that either holds or does not.
45    Flag,
46}
47
48/// One dimension a budget gate measures: its key in the file, its kind, and
49/// the noun a finding prints after the number.
50#[derive(Debug, Clone, Copy, PartialEq, Eq)]
51pub struct DimensionSpec {
52    /// The key under a path entry.
53    pub name: &'static str,
54    /// Count or flag.
55    pub kind: Kind,
56    /// The noun a finding prints after a count.
57    pub label: &'static str,
58}
59
60const fn dim(name: &'static str, kind: Kind, label: &'static str) -> DimensionSpec {
61    DimensionSpec { name, kind, label }
62}
63
64const WORDS: &[DimensionSpec] = &[dim("words", Kind::Count, "words")];
65const LINES: &[DimensionSpec] = &[dim("lines", Kind::Count, "lines")];
66const SPEC: &[DimensionSpec] = &[
67    dim("authored_lines", Kind::Count, "authored lines"),
68    dim("missing_toc", Kind::Flag, "missing table of contents"),
69];
70
71/// The dimensions one budget gate measures, or none for a gate that measures
72/// no budget.
73#[must_use]
74pub const fn dimensions(gate: GateId) -> &'static [DimensionSpec] {
75    match gate {
76        GateId::AdrWordCap => WORDS,
77        GateId::AgentsDigestSize | GateId::ChapterSizeCap => LINES,
78        GateId::SpecSizeCap => SPEC,
79        _ => &[],
80    }
81}
82
83fn dimension_spec(gate: GateId, name: &str) -> Option<&'static DimensionSpec> {
84    dimensions(gate).iter().find(|spec| spec.name == name)
85}
86
87/// One recorded dimension.
88#[derive(Debug, Clone, Copy, PartialEq, Eq)]
89pub enum Recorded {
90    /// The gate fails above this, and at or below the budget the entry is
91    /// stale.
92    Ceiling(usize),
93    /// The condition is accepted until it is corrected.
94    Exception,
95}
96
97/// What a gate measured on one dimension of one path.
98#[derive(Debug, Clone, Copy, PartialEq, Eq)]
99pub enum Measured {
100    /// A count, with the budget the gate would judge it against.
101    Count {
102        /// What the gate counted.
103        value: usize,
104        /// The budget the specification states.
105        budget: usize,
106    },
107    /// Whether the condition holds.
108    Flag(bool),
109}
110
111/// One measurement: a gate, a path, a dimension, and what was found.
112#[derive(Debug, Clone, PartialEq, Eq)]
113pub struct Measurement {
114    /// The gate that measured.
115    pub gate: GateId,
116    /// The path, in the form the gate reports it.
117    pub path: String,
118    /// The dimension's key.
119    pub dimension: &'static str,
120    /// What was found.
121    pub value: Measured,
122}
123
124impl Measurement {
125    /// A count measurement.
126    #[must_use]
127    pub fn count(
128        gate: GateId,
129        path: impl Into<String>,
130        dimension: &'static str,
131        value: usize,
132        budget: usize,
133    ) -> Self {
134        Self {
135            gate,
136            path: path.into(),
137            dimension,
138            value: Measured::Count { value, budget },
139        }
140    }
141
142    /// A flag measurement.
143    #[must_use]
144    pub fn flag(
145        gate: GateId,
146        path: impl Into<String>,
147        dimension: &'static str,
148        holds: bool,
149    ) -> Self {
150        Self {
151            gate,
152            path: path.into(),
153            dimension,
154            value: Measured::Flag(holds),
155        }
156    }
157
158    /// Whether the measurement violates the budget on its own.
159    #[must_use]
160    pub const fn violates(&self) -> bool {
161        match self.value {
162            Measured::Count { value, budget } => value > budget,
163            Measured::Flag(holds) => holds,
164        }
165    }
166}
167
168/// The entries of the legacy chapter list, normalized, in file order.
169///
170/// One parser for the gate and the migration, so the migration can never
171/// convert a line the gate did not honour. The gate never trimmed a line,
172/// so a line with surrounding whitespace named no file and exempted
173/// nothing; it is kept as it was written and matches nothing here either.
174#[must_use]
175pub fn legacy_list(text: &str) -> Vec<String> {
176    text.lines()
177        .filter(|entry| !entry.is_empty() && !entry.starts_with('#'))
178        .map(normalize)
179        .collect()
180}
181
182/// The form a path takes in the file: repository-relative, no `./`.
183#[must_use]
184pub fn normalize(path: &str) -> String {
185    path.trim_start_matches("./").to_string()
186}
187
188/// A debt file that cannot be trusted, or a verb the state refuses.
189#[derive(Debug, thiserror::Error, PartialEq, Eq)]
190pub enum DebtError {
191    /// The file is not YAML of this shape.
192    #[error("{DEBT_PATH} does not parse: {0}")]
193    Shape(String),
194    /// One entry is not of this shape; the gate and path locate it.
195    #[error("{DEBT_PATH}: {gate}: {path}: {detail}")]
196    Malformed {
197        /// The gate key the entry sits under.
198        gate: String,
199        /// The path key the entry sits under, or `-` for the gate itself.
200        path: String,
201        /// What is wrong.
202        detail: String,
203    },
204    /// Both the legacy list and the dimensional file are present.
205    #[error(
206        "both {LEGACY_DEBT_PATH} and {DEBT_PATH} are present; run 'sdd debt migrate --apply' to finish the migration"
207    )]
208    TwoFormats,
209    /// A baseline was requested over an existing debt file.
210    #[error(
211        "{DEBT_PATH} already exists and a baseline never widens it; fix the violation, or run 'sdd debt tighten --apply' where a recorded ceiling has slack"
212    )]
213    AlreadyBaselined,
214    /// A baseline was requested while the legacy list is still in place.
215    #[error("{LEGACY_DEBT_PATH} is present; run 'sdd debt migrate --apply' before a baseline")]
216    LegacyBlocksBaseline,
217    /// A migration was requested with no legacy list to migrate.
218    #[error("{LEGACY_DEBT_PATH} is absent; there is no legacy list to migrate")]
219    NothingToMigrate,
220}
221
222/// Which files are on disk.
223#[derive(Debug, Clone, Copy, PartialEq, Eq)]
224pub struct Presence {
225    /// `.spec-driven-docs/debt.yaml` exists.
226    pub dimensional: bool,
227    /// `.spec-driven-docs/chapter-size-debt.txt` exists.
228    pub legacy: bool,
229}
230
231impl Presence {
232    /// What an instance root carries.
233    #[must_use]
234    pub fn at(root: &Utf8Path) -> Self {
235        Self {
236            dimensional: root.join(DEBT_PATH).is_file(),
237            legacy: root.join(LEGACY_DEBT_PATH).is_file(),
238        }
239    }
240}
241
242type Entries = BTreeMap<GateId, BTreeMap<String, BTreeMap<&'static str, Recorded>>>;
243
244/// The recorded debt.
245#[derive(Debug, Clone, Default, PartialEq, Eq)]
246pub struct Debt {
247    entries: Entries,
248}
249
250/// One change a tightening makes, or declines to make.
251#[derive(Debug, Clone, PartialEq, Eq)]
252pub enum Change {
253    /// A ceiling came down to the measurement.
254    Lowered {
255        /// The gate.
256        gate: GateId,
257        /// The path.
258        path: String,
259        /// The dimension.
260        dimension: &'static str,
261        /// The recorded ceiling.
262        from: usize,
263        /// The measurement it lowers to.
264        to: usize,
265    },
266    /// A dimension left the file: within budget, corrected, or unmeasured.
267    Removed {
268        /// The gate.
269        gate: GateId,
270        /// The path.
271        path: String,
272        /// The dimension.
273        dimension: &'static str,
274        /// Why it left.
275        reason: String,
276    },
277    /// A measurement rose above its ceiling; the entry stands and the gate
278    /// fails on it.
279    Grew {
280        /// The gate.
281        gate: GateId,
282        /// The path.
283        path: String,
284        /// The dimension.
285        dimension: &'static str,
286        /// The recorded ceiling.
287        ceiling: usize,
288        /// The measurement above it.
289        measured: usize,
290    },
291}
292
293/// What a tightening produced.
294#[derive(Debug, Clone, PartialEq, Eq)]
295pub struct Tightened {
296    /// The debt after tightening.
297    pub debt: Debt,
298    /// Every change, in gate, path, dimension order.
299    pub changes: Vec<Change>,
300}
301
302impl Debt {
303    /// Whether nothing is recorded.
304    #[must_use]
305    pub fn is_empty(&self) -> bool {
306        self.entries.values().all(BTreeMap::is_empty)
307    }
308
309    /// Record one dimension.
310    pub fn record(&mut self, gate: GateId, path: &str, dimension: &'static str, value: Recorded) {
311        self.entries
312            .entry(gate)
313            .or_default()
314            .entry(normalize(path))
315            .or_default()
316            .insert(dimension, value);
317    }
318
319    /// What is recorded for one dimension of one path.
320    #[must_use]
321    pub fn recorded(&self, gate: GateId, path: &str, dimension: &str) -> Option<Recorded> {
322        self.entries
323            .get(&gate)?
324            .get(&normalize(path))?
325            .get(dimension)
326            .copied()
327    }
328
329    /// Every recorded dimension of one gate, as `(path, dimension, value)`.
330    #[must_use]
331    pub fn recorded_for(&self, gate: GateId) -> Vec<(String, &'static str, Recorded)> {
332        self.entries
333            .get(&gate)
334            .into_iter()
335            .flat_map(|paths| {
336                paths.iter().flat_map(|(path, dims)| {
337                    dims.iter()
338                        .map(|(dimension, value)| (path.clone(), *dimension, *value))
339                })
340            })
341            .collect()
342    }
343
344    /// The debt a set of measurements becomes: every violating dimension,
345    /// at the measurement.
346    #[must_use]
347    pub fn baseline(measurements: &[Measurement]) -> Self {
348        let mut debt = Self::default();
349        for measurement in measurements {
350            match measurement.value {
351                Measured::Count { value, budget } if value > budget => {
352                    debt.record(
353                        measurement.gate,
354                        &measurement.path,
355                        measurement.dimension,
356                        Recorded::Ceiling(value),
357                    );
358                }
359                Measured::Flag(true) => {
360                    debt.record(
361                        measurement.gate,
362                        &measurement.path,
363                        measurement.dimension,
364                        Recorded::Exception,
365                    );
366                }
367                Measured::Count { .. } | Measured::Flag(false) => {}
368            }
369        }
370        debt
371    }
372
373    /// The debt after every ceiling comes down to its measurement.
374    ///
375    /// A ceiling never rises and a cleared exception is never reinstated.
376    /// A dimension within its budget, a corrected condition, and a path the
377    /// gate no longer measures all leave the file. A measurement above its
378    /// ceiling is reported and left alone, because the gate already fails on
379    /// it and lowering is not the fix.
380    #[must_use]
381    pub fn tighten(&self, measurements: &[Measurement]) -> Tightened {
382        let found: BTreeMap<(GateId, String, &str), Measured> = measurements
383            .iter()
384            .map(|m| ((m.gate, normalize(&m.path), m.dimension), m.value))
385            .collect();
386        let mut debt = Self::default();
387        let mut changes = Vec::new();
388        for (gate, paths) in &self.entries {
389            for (path, dims) in paths {
390                for (dimension, recorded) in dims {
391                    let key = (*gate, path.clone(), *dimension);
392                    let removed = |reason: &str| Change::Removed {
393                        gate: *gate,
394                        path: path.clone(),
395                        dimension,
396                        reason: reason.to_string(),
397                    };
398                    match (recorded, found.get(&key)) {
399                        (_, None) => {
400                            changes.push(removed("the gate measures no such path"));
401                        }
402                        (Recorded::Ceiling(_), Some(Measured::Count { value, budget }))
403                            if value <= budget =>
404                        {
405                            changes.push(removed("within the budget"));
406                        }
407                        (Recorded::Ceiling(ceiling), Some(Measured::Count { value, .. })) => {
408                            if value < ceiling {
409                                changes.push(Change::Lowered {
410                                    gate: *gate,
411                                    path: path.clone(),
412                                    dimension,
413                                    from: *ceiling,
414                                    to: *value,
415                                });
416                                debt.record(*gate, path, dimension, Recorded::Ceiling(*value));
417                            } else {
418                                if value > ceiling {
419                                    changes.push(Change::Grew {
420                                        gate: *gate,
421                                        path: path.clone(),
422                                        dimension,
423                                        ceiling: *ceiling,
424                                        measured: *value,
425                                    });
426                                }
427                                debt.record(*gate, path, dimension, *recorded);
428                            }
429                        }
430                        (Recorded::Exception, Some(Measured::Flag(false))) => {
431                            changes.push(removed("corrected"));
432                        }
433                        (Recorded::Exception, Some(Measured::Flag(true))) => {
434                            debt.record(*gate, path, dimension, *recorded);
435                        }
436                        (Recorded::Ceiling(_), Some(Measured::Flag(_)))
437                        | (Recorded::Exception, Some(Measured::Count { .. })) => {
438                            changes.push(removed("the dimension's kind changed"));
439                        }
440                    }
441                }
442            }
443        }
444        Tightened { debt, changes }
445    }
446
447    /// Parse a debt file.
448    ///
449    /// # Errors
450    ///
451    /// [`DebtError::Shape`] when the text is not a mapping of this schema,
452    /// and [`DebtError::Malformed`] naming the gate and path of the first
453    /// entry that is not of the expected shape. Neither falls back to the
454    /// empty debt: a file that quietly stops applying turns a green commit
455    /// into a false pass.
456    pub fn parse(text: &str) -> Result<Self, DebtError> {
457        let value: yaml_serde::Value =
458            yaml_serde::from_str(text).map_err(|error| DebtError::Shape(error.to_string()))?;
459        let Some(top) = value.as_mapping() else {
460            return Err(DebtError::Shape(
461                "the document is not a mapping".to_string(),
462            ));
463        };
464        let mut debt = Self::default();
465        let mut schema = None;
466        for (key, value) in top {
467            let Some(key) = key.as_str() else {
468                return Err(DebtError::Shape(format!("a key is not a string: {key:?}")));
469            };
470            if key == "schema_version" {
471                schema = value.as_u64();
472                if schema != Some(SCHEMA_VERSION) {
473                    return Err(DebtError::Shape(format!(
474                        "schema_version must be {SCHEMA_VERSION}, found {value:?}"
475                    )));
476                }
477                continue;
478            }
479            let Some(gate) = BUDGET_GATES.iter().copied().find(|g| g.to_string() == key) else {
480                return Err(DebtError::Malformed {
481                    gate: key.to_string(),
482                    path: "-".to_string(),
483                    detail: "not a budget gate; the budget gates are adr-word-cap, agents-digest-size, chapter-size-cap, and spec-size-cap".to_string(),
484                });
485            };
486            parse_gate(&mut debt, gate, key, value)?;
487        }
488        if schema.is_none() {
489            return Err(DebtError::Shape("schema_version is missing".to_string()));
490        }
491        Ok(debt)
492    }
493
494    /// Read the debt an instance carries.
495    ///
496    /// An absent file is the empty debt and never an error: nothing delivers
497    /// the file, so absence is the ordinary state.
498    ///
499    /// # Errors
500    ///
501    /// [`DebtError::TwoFormats`] when the legacy list sits beside the file,
502    /// and the parse errors of [`Self::parse`].
503    pub fn read(root: &Utf8Path) -> Result<Self, DebtError> {
504        let presence = Presence::at(root);
505        if presence.dimensional && presence.legacy {
506            return Err(DebtError::TwoFormats);
507        }
508        if !presence.dimensional {
509            return Ok(Self::default());
510        }
511        let text = std::fs::read_to_string(root.join(DEBT_PATH))
512            .map_err(|error| DebtError::Shape(error.to_string()))?;
513        Self::parse(&text)
514    }
515
516    /// The file's text, in the one shape this binary writes.
517    #[must_use]
518    pub fn render(&self) -> String {
519        let mut out = String::new();
520        out.push_str("# Inherited budget violations this project carries.\n");
521        out.push_str("#\n");
522        out.push_str("# A ceiling is judged instead of the budget and only comes down: run\n");
523        out.push_str(
524            "# `sdd debt tighten --apply` after a document shrinks. An exception clears\n",
525        );
526        out.push_str("# once the condition is corrected. Nothing here can be widened.\n");
527        let _ = writeln!(out, "schema_version: {SCHEMA_VERSION}");
528        for (gate, paths) in &self.entries {
529            if paths.is_empty() {
530                continue;
531            }
532            let _ = writeln!(out, "\n{gate}:");
533            for (path, dims) in paths {
534                let _ = writeln!(out, "  {}:", quoted(path));
535                for (dimension, recorded) in dims {
536                    match recorded {
537                        Recorded::Ceiling(ceiling) => {
538                            let _ = writeln!(out, "    {dimension}:\n      ceiling: {ceiling}");
539                        }
540                        Recorded::Exception => {
541                            let _ = writeln!(out, "    {dimension}: true");
542                        }
543                    }
544                }
545            }
546        }
547        out
548    }
549}
550
551/// A path as a YAML scalar that reads back as itself.
552fn quoted(path: &str) -> String {
553    format!("'{}'", path.replace('\'', "''"))
554}
555
556fn parse_gate(
557    debt: &mut Debt,
558    gate: GateId,
559    key: &str,
560    value: &yaml_serde::Value,
561) -> Result<(), DebtError> {
562    let malformed = |path: &str, detail: String| DebtError::Malformed {
563        gate: key.to_string(),
564        path: path.to_string(),
565        detail,
566    };
567    if value.is_null() {
568        return Ok(());
569    }
570    let Some(paths) = value.as_mapping() else {
571        return Err(malformed("-", "not a mapping of paths".to_string()));
572    };
573    for (path, dims) in paths {
574        let Some(path) = path.as_str() else {
575            return Err(malformed(
576                "-",
577                format!("a path key is not a string: {path:?}"),
578            ));
579        };
580        let Some(dims) = dims.as_mapping() else {
581            return Err(malformed(path, "not a mapping of dimensions".to_string()));
582        };
583        for (name, recorded) in dims {
584            let Some(name) = name.as_str() else {
585                return Err(malformed(
586                    path,
587                    format!("a dimension key is not a string: {name:?}"),
588                ));
589            };
590            let Some(spec) = dimension_spec(gate, name) else {
591                let known: Vec<&str> = dimensions(gate).iter().map(|d| d.name).collect();
592                return Err(malformed(
593                    path,
594                    format!(
595                        "`{name}` is not a dimension of {gate}; it measures {}",
596                        known.join(", ")
597                    ),
598                ));
599            };
600            let value = match spec.kind {
601                Kind::Count => recorded
602                    .as_mapping()
603                    .and_then(|m| m.get("ceiling"))
604                    .and_then(yaml_serde::Value::as_u64)
605                    .and_then(|n| usize::try_from(n).ok())
606                    .map(Recorded::Ceiling)
607                    .ok_or_else(|| {
608                        malformed(path, format!("`{name}` must carry `ceiling: <count>`"))
609                    })?,
610                Kind::Flag => match recorded.as_bool() {
611                    Some(true) => Recorded::Exception,
612                    _ => {
613                        return Err(malformed(
614                            path,
615                            format!(
616                                "`{name}` must be `true`; a corrected exception is removed rather than set false"
617                            ),
618                        ));
619                    }
620                },
621            };
622            debt.record(gate, path, spec.name, value);
623        }
624    }
625    Ok(())
626}
627
628#[cfg(test)]
629mod tests {
630    use super::*;
631
632    const SAMPLE: &str = "schema_version: 1\n\nspec-size-cap:\n  _docs/specs/SPEC-legacy.md:\n    authored_lines:\n      ceiling: 417\n    missing_toc: true\n\nchapter-size-cap:\n  method/legacy.md:\n    lines:\n      ceiling: 417\n";
633
634    fn sample() -> Debt {
635        Debt::parse(SAMPLE).expect("the sample parses")
636    }
637
638    #[test]
639    fn the_sample_parses_and_renders_back_to_itself() {
640        let debt = sample();
641        assert_eq!(
642            debt.recorded(
643                GateId::SpecSizeCap,
644                "./_docs/specs/SPEC-legacy.md",
645                "authored_lines"
646            ),
647            Some(Recorded::Ceiling(417))
648        );
649        assert_eq!(
650            debt.recorded(
651                GateId::SpecSizeCap,
652                "_docs/specs/SPEC-legacy.md",
653                "missing_toc"
654            ),
655            Some(Recorded::Exception)
656        );
657        assert_eq!(Debt::parse(&debt.render()).unwrap(), debt);
658    }
659
660    #[test]
661    fn an_absent_file_is_the_empty_debt() {
662        let dir = tempfile::tempdir().unwrap();
663        let root = camino::Utf8PathBuf::from_path_buf(dir.path().to_path_buf()).unwrap();
664        let debt = Debt::read(&root).expect("absence is not an error");
665        assert!(debt.is_empty());
666    }
667
668    #[test]
669    fn both_formats_present_is_an_error_naming_migrate() {
670        let dir = tempfile::tempdir().unwrap();
671        let root = camino::Utf8PathBuf::from_path_buf(dir.path().to_path_buf()).unwrap();
672        std::fs::create_dir_all(root.join(".spec-driven-docs")).unwrap();
673        std::fs::write(root.join(DEBT_PATH), SAMPLE).unwrap();
674        std::fs::write(root.join(LEGACY_DEBT_PATH), "method/legacy.md\n").unwrap();
675        let error = Debt::read(&root).unwrap_err();
676        assert_eq!(error, DebtError::TwoFormats);
677        assert!(error.to_string().contains("sdd debt migrate --apply"));
678    }
679
680    #[test]
681    fn a_malformed_file_is_an_error_naming_the_gate_and_path() {
682        let error = Debt::parse(
683            "schema_version: 1\nchapter-size-cap:\n  method/a.md:\n    words:\n      ceiling: 3\n",
684        )
685        .unwrap_err();
686        assert!(
687            matches!(&error, DebtError::Malformed { gate, path, .. } if gate == "chapter-size-cap" && path == "method/a.md"),
688            "{error}"
689        );
690        let error = Debt::parse("schema_version: 1\nno-personal-path:\n  a.md: {}\n").unwrap_err();
691        assert!(matches!(&error, DebtError::Malformed { gate, .. } if gate == "no-personal-path"));
692        let error =
693            Debt::parse("schema_version: 1\nspec-size-cap:\n  a.md:\n    missing_toc: false\n")
694                .unwrap_err();
695        assert!(error.to_string().contains("must be `true`"), "{error}");
696        assert!(matches!(
697            Debt::parse("chapter-size-cap: {}\n").unwrap_err(),
698            DebtError::Shape(_)
699        ));
700        assert!(matches!(
701            Debt::parse("schema_version: 2\n").unwrap_err(),
702            DebtError::Shape(_)
703        ));
704        assert!(matches!(
705            Debt::parse("- a\n").unwrap_err(),
706            DebtError::Shape(_)
707        ));
708    }
709
710    #[test]
711    fn baseline_records_every_violating_dimension_and_nothing_else() {
712        let debt = Debt::baseline(&[
713            Measurement::count(GateId::ChapterSizeCap, "./method/a.md", "lines", 250, 200),
714            Measurement::count(GateId::ChapterSizeCap, "./method/b.md", "lines", 200, 200),
715            Measurement::flag(
716                GateId::SpecSizeCap,
717                "_docs/specs/SPEC-a.md",
718                "missing_toc",
719                true,
720            ),
721            Measurement::flag(
722                GateId::SpecSizeCap,
723                "_docs/specs/SPEC-b.md",
724                "missing_toc",
725                false,
726            ),
727        ]);
728        assert_eq!(
729            debt.recorded(GateId::ChapterSizeCap, "method/a.md", "lines"),
730            Some(Recorded::Ceiling(250))
731        );
732        assert_eq!(
733            debt.recorded(GateId::ChapterSizeCap, "method/b.md", "lines"),
734            None
735        );
736        assert_eq!(
737            debt.recorded(GateId::SpecSizeCap, "_docs/specs/SPEC-a.md", "missing_toc"),
738            Some(Recorded::Exception)
739        );
740        assert_eq!(
741            debt.recorded(GateId::SpecSizeCap, "_docs/specs/SPEC-b.md", "missing_toc"),
742            None
743        );
744    }
745
746    #[test]
747    fn tighten_lowers_a_ceiling_to_the_measurement() {
748        let tightened = sample().tighten(&[
749            Measurement::count(
750                GateId::ChapterSizeCap,
751                "./method/legacy.md",
752                "lines",
753                300,
754                200,
755            ),
756            Measurement::count(
757                GateId::SpecSizeCap,
758                "_docs/specs/SPEC-legacy.md",
759                "authored_lines",
760                417,
761                300,
762            ),
763            Measurement::flag(
764                GateId::SpecSizeCap,
765                "_docs/specs/SPEC-legacy.md",
766                "missing_toc",
767                true,
768            ),
769        ]);
770        assert_eq!(
771            tightened
772                .debt
773                .recorded(GateId::ChapterSizeCap, "method/legacy.md", "lines"),
774            Some(Recorded::Ceiling(300))
775        );
776        assert_eq!(
777            tightened.changes,
778            vec![Change::Lowered {
779                gate: GateId::ChapterSizeCap,
780                path: "method/legacy.md".to_string(),
781                dimension: "lines",
782                from: 417,
783                to: 300,
784            }]
785        );
786    }
787
788    #[test]
789    fn tighten_never_raises_a_ceiling() {
790        let tightened = sample().tighten(&[
791            Measurement::count(
792                GateId::ChapterSizeCap,
793                "method/legacy.md",
794                "lines",
795                500,
796                200,
797            ),
798            Measurement::count(
799                GateId::SpecSizeCap,
800                "_docs/specs/SPEC-legacy.md",
801                "authored_lines",
802                417,
803                300,
804            ),
805            Measurement::flag(
806                GateId::SpecSizeCap,
807                "_docs/specs/SPEC-legacy.md",
808                "missing_toc",
809                true,
810            ),
811        ]);
812        assert_eq!(
813            tightened
814                .debt
815                .recorded(GateId::ChapterSizeCap, "method/legacy.md", "lines"),
816            Some(Recorded::Ceiling(417)),
817            "the ceiling moved on a document that grew"
818        );
819        assert!(matches!(
820            tightened.changes.as_slice(),
821            [Change::Grew {
822                ceiling: 417,
823                measured: 500,
824                ..
825            }]
826        ));
827    }
828
829    #[test]
830    fn tighten_clears_a_corrected_exception_and_never_reinstates_one() {
831        let tightened = sample().tighten(&[
832            Measurement::count(
833                GateId::ChapterSizeCap,
834                "method/legacy.md",
835                "lines",
836                417,
837                200,
838            ),
839            Measurement::count(
840                GateId::SpecSizeCap,
841                "_docs/specs/SPEC-legacy.md",
842                "authored_lines",
843                417,
844                300,
845            ),
846            Measurement::flag(
847                GateId::SpecSizeCap,
848                "_docs/specs/SPEC-legacy.md",
849                "missing_toc",
850                false,
851            ),
852        ]);
853        assert_eq!(
854            tightened.debt.recorded(
855                GateId::SpecSizeCap,
856                "_docs/specs/SPEC-legacy.md",
857                "missing_toc"
858            ),
859            None
860        );
861        // The condition returns. Tightening records nothing new: the gate
862        // fails on it as a fresh violation, and only a baseline could have
863        // accepted it.
864        let again = tightened.debt.tighten(&[
865            Measurement::count(
866                GateId::ChapterSizeCap,
867                "method/legacy.md",
868                "lines",
869                417,
870                200,
871            ),
872            Measurement::count(
873                GateId::SpecSizeCap,
874                "_docs/specs/SPEC-legacy.md",
875                "authored_lines",
876                417,
877                300,
878            ),
879            Measurement::flag(
880                GateId::SpecSizeCap,
881                "_docs/specs/SPEC-legacy.md",
882                "missing_toc",
883                true,
884            ),
885        ]);
886        assert_eq!(
887            again.debt.recorded(
888                GateId::SpecSizeCap,
889                "_docs/specs/SPEC-legacy.md",
890                "missing_toc"
891            ),
892            None
893        );
894        assert!(again.changes.is_empty());
895    }
896
897    #[test]
898    fn a_ceiling_reached_by_the_budget_removes_the_entry() {
899        let tightened = sample().tighten(&[
900            Measurement::count(
901                GateId::ChapterSizeCap,
902                "method/legacy.md",
903                "lines",
904                200,
905                200,
906            ),
907            Measurement::count(
908                GateId::SpecSizeCap,
909                "_docs/specs/SPEC-legacy.md",
910                "authored_lines",
911                300,
912                300,
913            ),
914            Measurement::flag(
915                GateId::SpecSizeCap,
916                "_docs/specs/SPEC-legacy.md",
917                "missing_toc",
918                true,
919            ),
920        ]);
921        assert_eq!(
922            tightened
923                .debt
924                .recorded(GateId::ChapterSizeCap, "method/legacy.md", "lines"),
925            None
926        );
927        assert_eq!(
928            tightened.debt.recorded(
929                GateId::SpecSizeCap,
930                "_docs/specs/SPEC-legacy.md",
931                "authored_lines"
932            ),
933            None
934        );
935        assert_eq!(
936            tightened.debt.recorded(
937                GateId::SpecSizeCap,
938                "_docs/specs/SPEC-legacy.md",
939                "missing_toc"
940            ),
941            Some(Recorded::Exception)
942        );
943    }
944
945    #[test]
946    fn an_unmeasured_path_leaves_the_file() {
947        let tightened = sample().tighten(&[]);
948        assert!(tightened.debt.is_empty());
949        assert_eq!(tightened.changes.len(), 3);
950        assert!(tightened.changes.iter().all(|change| matches!(
951            change,
952            Change::Removed { reason, .. } if reason == "the gate measures no such path"
953        )));
954    }
955
956    #[test]
957    fn the_legacy_list_is_read_as_written_and_never_trimmed() {
958        assert_eq!(
959            legacy_list("# exempt\nmethod/a.md\n./method/b.md\n\n method/c.md \n"),
960            vec![
961                "method/a.md".to_string(),
962                "method/b.md".to_string(),
963                " method/c.md ".to_string()
964            ]
965        );
966    }
967
968    #[test]
969    fn an_empty_debt_renders_no_gate() {
970        let rendered = Debt::default().render();
971        assert!(rendered.contains("schema_version: 1"));
972        assert!(!rendered.contains("chapter-size-cap"));
973        assert!(Debt::parse(&rendered).unwrap().is_empty());
974    }
975
976    #[test]
977    fn a_path_needing_quotes_reads_back() {
978        let mut debt = Debt::default();
979        debt.record(
980            GateId::ChapterSizeCap,
981            "./it's/*.md",
982            "lines",
983            Recorded::Ceiling(3),
984        );
985        let parsed = Debt::parse(&debt.render()).unwrap();
986        assert_eq!(
987            parsed.recorded(GateId::ChapterSizeCap, "it's/*.md", "lines"),
988            Some(Recorded::Ceiling(3))
989        );
990    }
991}