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    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
37/// Measure one budget gate at a root, under the project's declaration.
38fn print_debt(debt: &Debt) {
39    for line in debt.render().lines() {
40        output::line(line);
41    }
42}
43
44/// Write the debt file, or remove it where the debt is empty: absence is
45/// the empty debt, and an empty file would be a second spelling of it.
46fn 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
83/// The legacy list's entries, read by the same parser the gate uses.
84fn 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    // The legacy list exempted chapters alone, so only the chapter gate's
96    // measurements can become entries. A path the list names that the gate
97    // does not measure, or that now fits, carried no live exemption and is
98    // reported rather than converted.
99    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    // The new file lands first, and the old list leaves only after it
127    // has. An interruption between the two leaves both present, which is a
128    // failure naming this verb, never a state where neither holds.
129    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
188/// Baseline, migrate, or tighten.
189///
190/// # Errors
191///
192/// [`AppError::Debt`] for a state the verb refuses or a file it cannot
193/// trust, [`AppError::Usage`] for a target the arguments cannot mean, and
194/// I/O errors when the tree cannot be read or written.
195pub 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}