Skip to main content

spec_driven_docs/commands/
debt.rs

1//! `debt` subcommand: runtime-shape.
2//!
3//! Three verbs, one engine. Measurement, the tightening function, and the
4//! serialization are the same code, and each verb differs only in where its
5//! input comes from: `baseline` mints new exceptions from what the corpus
6//! measures today, `migrate` converts the exemptions a previous operator
7//! already accepted and nothing else, and `tighten` lowers what is recorded
8//! to what is measured. One verb switching between the first two on the
9//! state of the filesystem would let a request for a format conversion
10//! accept violations instead, so they stay two. Every write is atomic, and
11//! the legacy list leaves only after the new file lands.
12
13use 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
39/// Measure one budget gate at a root, under the project's declaration.
40fn 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
52/// Every measurement the four budget gates take at a root.
53pub(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
67/// Write the debt file, or remove it where the debt is empty: absence is
68/// the empty debt, and an empty file would be a second spelling of it.
69fn 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
106/// The legacy list's entries, read by the same parser the gate uses.
107fn 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    // The legacy list exempted chapters alone, so only the chapter gate's
119    // measurements can become entries. A path the list names that the gate
120    // does not measure, or that now fits, carried no live exemption and is
121    // reported rather than converted.
122    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    // The new file lands first, and the old list leaves only after it
150    // has. An interruption between the two leaves both present, which is a
151    // failure naming this verb, never a state where neither holds.
152    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
211/// Baseline, migrate, or tighten.
212///
213/// # Errors
214///
215/// [`AppError::Debt`] for a state the verb refuses or a file it cannot
216/// trust, [`AppError::Usage`] for a target the arguments cannot mean, and
217/// I/O errors when the tree cannot be read or written.
218pub 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}