spec_driven_docs/commands/
debt.rs1use camino::{Utf8Path, Utf8PathBuf};
14
15use crate::adapters::fs::{remove_within, write_within};
16use crate::cli::debt::{DebtArgs, DebtVerb, DebtVerbArgs};
17use crate::context::AppContext;
18use crate::domain::debt::{
19 Change, DEBT_PATH, Debt, DebtError, LEGACY_DEBT_PATH, Presence, Recorded,
20};
21use crate::domain::gate_id::GateId;
22use crate::error::AppError;
23use crate::output;
24
25const DRY_RUN: &str = "DRY RUN: no files written";
26
27fn resolve_target(ctx: &AppContext, target: &Utf8Path) -> Result<Utf8PathBuf, AppError> {
28 if target.is_absolute() {
29 Ok(target.to_path_buf())
30 } else if target == "." {
31 Ok(ctx.cwd.clone())
32 } else {
33 Err(AppError::Usage("target must be absolute or .".to_string()))
34 }
35}
36
37fn print_debt(debt: &Debt) {
39 for line in debt.render().lines() {
40 output::line(line);
41 }
42}
43
44fn write_debt(root: &Utf8Path, debt: &Debt) -> Result<(), AppError> {
47 let path = root.join(DEBT_PATH);
48 if debt.is_empty() {
49 if path.is_file() {
50 remove_within(root, Utf8Path::new(DEBT_PATH))?;
51 output::line(format!("OK removed {DEBT_PATH}; nothing remains recorded"));
52 } else {
53 output::line("OK nothing to record; no debt file is needed");
54 }
55 return Ok(());
56 }
57 write_within(root, Utf8Path::new(DEBT_PATH), debt.render().as_bytes())?;
58 output::line(format!("OK wrote {DEBT_PATH}"));
59 Ok(())
60}
61
62fn baseline(root: &Utf8Path, apply: bool) -> Result<(), AppError> {
63 let presence = Presence::at(root);
64 if presence.dimensional {
65 return Err(DebtError::AlreadyBaselined.into());
66 }
67 if presence.legacy {
68 return Err(DebtError::LegacyBlocksBaseline.into());
69 }
70 let debt = Debt::baseline(&crate::services::budget::measure_all(root)?);
71 if debt.is_empty() {
72 output::line("OK no budget violation to record; no debt file is needed");
73 return Ok(());
74 }
75 print_debt(&debt);
76 if !apply {
77 output::line(DRY_RUN);
78 return Ok(());
79 }
80 write_debt(root, &debt)
81}
82
83fn legacy_entries(root: &Utf8Path) -> Result<Vec<String>, AppError> {
85 Ok(crate::domain::debt::legacy_list(&std::fs::read_to_string(
86 root.join(LEGACY_DEBT_PATH),
87 )?))
88}
89
90fn migrate(root: &Utf8Path, apply: bool) -> Result<(), AppError> {
91 if !Presence::at(root).legacy {
92 return Err(DebtError::NothingToMigrate.into());
93 }
94 let listed = legacy_entries(root)?;
95 let measured = crate::services::budget::measure_gate(root, GateId::ChapterSizeCap)?;
100 let mut debt = Debt::default();
101 for path in &listed {
102 match measured
103 .iter()
104 .find(|m| crate::domain::debt::normalize(&m.path) == *path)
105 {
106 Some(m) if m.violates() => {
107 if let crate::domain::debt::Measured::Count { value, .. } = m.value {
108 debt.record(
109 GateId::ChapterSizeCap,
110 path,
111 "lines",
112 Recorded::Ceiling(value),
113 );
114 output::line(format!("{path}: ceiling fixed at {value} lines"));
115 }
116 }
117 Some(_) => output::line(format!("{path}: now fits; dropped")),
118 None => output::line(format!("{path}: the gate measures no such path; dropped")),
119 }
120 }
121 if !apply {
122 print_debt(&debt);
123 output::line(DRY_RUN);
124 return Ok(());
125 }
126 write_debt(root, &debt)?;
130 remove_within(root, Utf8Path::new(LEGACY_DEBT_PATH))?;
131 output::line(format!("OK removed {LEGACY_DEBT_PATH}"));
132 Ok(())
133}
134
135fn describe(change: &Change) -> String {
136 match change {
137 Change::Lowered {
138 gate,
139 path,
140 dimension,
141 from,
142 to,
143 } => format!("{gate}: {path}: {dimension}: ceiling {from} -> {to}"),
144 Change::Removed {
145 gate,
146 path,
147 dimension,
148 reason,
149 } => format!("{gate}: {path}: {dimension}: removed, {reason}"),
150 Change::Grew {
151 gate,
152 path,
153 dimension,
154 ceiling,
155 measured,
156 } => format!(
157 "{gate}: {path}: {dimension}: measured {measured} above the ceiling of {ceiling}; unchanged, the gate fails on it"
158 ),
159 }
160}
161
162fn tighten(root: &Utf8Path, apply: bool) -> Result<(), AppError> {
163 let debt = Debt::read(root)?;
164 if debt.is_empty() {
165 output::line("OK nothing recorded; nothing to tighten");
166 return Ok(());
167 }
168 let tightened = debt.tighten(&crate::services::budget::measure_all(root)?);
169 for change in &tightened.changes {
170 output::line(describe(change));
171 }
172 let moved = tightened
173 .changes
174 .iter()
175 .any(|change| !matches!(change, Change::Grew { .. }));
176 if !moved {
177 output::line("OK every ceiling is at its measurement; nothing to tighten");
178 return Ok(());
179 }
180 if !apply {
181 print_debt(&tightened.debt);
182 output::line(DRY_RUN);
183 return Ok(());
184 }
185 write_debt(root, &tightened.debt)
186}
187
188pub fn run(ctx: &AppContext, args: DebtArgs) -> Result<(), AppError> {
196 let (verb, DebtVerbArgs { target, apply }) = match args.verb {
197 DebtVerb::Baseline(args) => (baseline as fn(&Utf8Path, bool) -> _, args),
198 DebtVerb::Migrate(args) => (migrate as fn(&Utf8Path, bool) -> _, args),
199 DebtVerb::Tighten(args) => (tighten as fn(&Utf8Path, bool) -> _, args),
200 };
201 let root = resolve_target(ctx, &target)?;
202 verb(&root, apply)
203}