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