1use 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};
28pub const SCHEMA_VERSION: u64 = 1;
30
31pub const BUDGET_GATES: &[GateId] = &[
33 GateId::AdrWordCap,
34 GateId::AgentsDigestSize,
35 GateId::ChapterSizeCap,
36 GateId::SpecSizeCap,
37];
38
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41pub enum Kind {
42 Count,
44 Flag,
46}
47
48#[derive(Debug, Clone, Copy, PartialEq, Eq)]
51pub struct DimensionSpec {
52 pub name: &'static str,
54 pub kind: Kind,
56 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#[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
89pub enum Recorded {
90 Ceiling(usize),
93 Exception,
95}
96
97#[derive(Debug, Clone, Copy, PartialEq, Eq)]
99pub enum Measured {
100 Count {
102 value: usize,
104 budget: usize,
106 },
107 Flag(bool),
109}
110
111#[derive(Debug, Clone, PartialEq, Eq)]
113pub struct Measurement {
114 pub gate: GateId,
116 pub path: String,
118 pub dimension: &'static str,
120 pub value: Measured,
122}
123
124impl Measurement {
125 #[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 #[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 #[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#[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#[must_use]
184pub fn normalize(path: &str) -> String {
185 path.trim_start_matches("./").to_string()
186}
187
188#[derive(Debug, thiserror::Error, PartialEq, Eq)]
190pub enum DebtError {
191 #[error("{DEBT_PATH} does not parse: {0}")]
193 Shape(String),
194 #[error("{DEBT_PATH}: {gate}: {path}: {detail}")]
196 Malformed {
197 gate: String,
199 path: String,
201 detail: String,
203 },
204 #[error(
206 "both {LEGACY_DEBT_PATH} and {DEBT_PATH} are present; run 'sdd debt migrate --apply' to finish the migration"
207 )]
208 TwoFormats,
209 #[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 #[error("{LEGACY_DEBT_PATH} is present; run 'sdd debt migrate --apply' before a baseline")]
216 LegacyBlocksBaseline,
217 #[error("{LEGACY_DEBT_PATH} is absent; there is no legacy list to migrate")]
219 NothingToMigrate,
220}
221
222#[derive(Debug, Clone, Copy, PartialEq, Eq)]
224pub struct Presence {
225 pub dimensional: bool,
227 pub legacy: bool,
229}
230
231impl Presence {
232 #[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#[derive(Debug, Clone, Default, PartialEq, Eq)]
246pub struct Debt {
247 entries: Entries,
248}
249
250#[derive(Debug, Clone, PartialEq, Eq)]
252pub enum Change {
253 Lowered {
255 gate: GateId,
257 path: String,
259 dimension: &'static str,
261 from: usize,
263 to: usize,
265 },
266 Removed {
268 gate: GateId,
270 path: String,
272 dimension: &'static str,
274 reason: String,
276 },
277 Grew {
280 gate: GateId,
282 path: String,
284 dimension: &'static str,
286 ceiling: usize,
288 measured: usize,
290 },
291}
292
293#[derive(Debug, Clone, PartialEq, Eq)]
295pub struct Tightened {
296 pub debt: Debt,
298 pub changes: Vec<Change>,
300}
301
302impl Debt {
303 #[must_use]
305 pub fn is_empty(&self) -> bool {
306 self.entries.values().all(BTreeMap::is_empty)
307 }
308
309 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 #[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 #[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 #[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 #[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 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 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 #[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
551fn 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 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}