1use std::collections::BTreeMap;
21use std::fmt::Write as _;
22
23use camino::Utf8Path;
24
25use crate::domain::gate_id::GateId;
26
27pub const DEBT_PATH: &str = ".spec-driven-docs/debt.yaml";
29pub const LEGACY_DEBT_PATH: &str = ".spec-driven-docs/chapter-size-debt.txt";
31pub const SCHEMA_VERSION: u64 = 1;
33
34pub const BUDGET_GATES: &[GateId] = &[
36 GateId::AdrWordCap,
37 GateId::AgentsDigestSize,
38 GateId::ChapterSizeCap,
39 GateId::SpecSizeCap,
40];
41
42#[derive(Debug, Clone, Copy, PartialEq, Eq)]
44pub enum Kind {
45 Count,
47 Flag,
49}
50
51#[derive(Debug, Clone, Copy, PartialEq, Eq)]
54pub struct DimensionSpec {
55 pub name: &'static str,
57 pub kind: Kind,
59 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#[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
92pub enum Recorded {
93 Ceiling(usize),
96 Exception,
98}
99
100#[derive(Debug, Clone, Copy, PartialEq, Eq)]
102pub enum Measured {
103 Count {
105 value: usize,
107 budget: usize,
109 },
110 Flag(bool),
112}
113
114#[derive(Debug, Clone, PartialEq, Eq)]
116pub struct Measurement {
117 pub gate: GateId,
119 pub path: String,
121 pub dimension: &'static str,
123 pub value: Measured,
125}
126
127impl Measurement {
128 #[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 #[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 #[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#[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#[must_use]
187pub fn normalize(path: &str) -> String {
188 path.trim_start_matches("./").to_string()
189}
190
191#[derive(Debug, thiserror::Error, PartialEq, Eq)]
193pub enum DebtError {
194 #[error("{DEBT_PATH} does not parse: {0}")]
196 Shape(String),
197 #[error("{DEBT_PATH}: {gate}: {path}: {detail}")]
199 Malformed {
200 gate: String,
202 path: String,
204 detail: String,
206 },
207 #[error(
209 "both {LEGACY_DEBT_PATH} and {DEBT_PATH} are present; run 'sdd debt migrate --apply' to finish the migration"
210 )]
211 TwoFormats,
212 #[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 #[error("{LEGACY_DEBT_PATH} is present; run 'sdd debt migrate --apply' before a baseline")]
219 LegacyBlocksBaseline,
220 #[error("{LEGACY_DEBT_PATH} is absent; there is no legacy list to migrate")]
222 NothingToMigrate,
223}
224
225#[derive(Debug, Clone, Copy, PartialEq, Eq)]
227pub struct Presence {
228 pub dimensional: bool,
230 pub legacy: bool,
232}
233
234impl Presence {
235 #[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#[derive(Debug, Clone, Default, PartialEq, Eq)]
249pub struct Debt {
250 entries: Entries,
251}
252
253#[derive(Debug, Clone, PartialEq, Eq)]
255pub enum Change {
256 Lowered {
258 gate: GateId,
260 path: String,
262 dimension: &'static str,
264 from: usize,
266 to: usize,
268 },
269 Removed {
271 gate: GateId,
273 path: String,
275 dimension: &'static str,
277 reason: String,
279 },
280 Grew {
283 gate: GateId,
285 path: String,
287 dimension: &'static str,
289 ceiling: usize,
291 measured: usize,
293 },
294}
295
296#[derive(Debug, Clone, PartialEq, Eq)]
298pub struct Tightened {
299 pub debt: Debt,
301 pub changes: Vec<Change>,
303}
304
305impl Debt {
306 #[must_use]
308 pub fn is_empty(&self) -> bool {
309 self.entries.values().all(BTreeMap::is_empty)
310 }
311
312 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 #[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 #[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 #[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 #[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 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 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 #[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
554fn 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 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}