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