Skip to main content

spec_driven_docs/plan/
derive.rs

1//! Turn one computed target state into the operations that reach it.
2//!
3//! What a release lands into a target is one computation, in the
4//! installer. Deriving it a second time here would be two places for one
5//! rule to drift, so the planner takes the installer's answer and says
6//! what kind of write each destination is.
7
8use std::collections::BTreeMap;
9
10use camino::{Utf8Path, Utf8PathBuf};
11
12use crate::domain::ownership::Sha256;
13use crate::domain::paths::{AGENTS_DIGEST_PATH, HOOKS_CONFIG_PATH, MANIFEST_PATH, PRUNABLE_ROOTS};
14use crate::domain::profile::{ProfileId, resolve_destination};
15use crate::domain::projection::Declaration;
16use crate::error::AppError;
17use crate::plan::operation::{Class, Operation, TargetPath};
18
19/// Every write one target state implies, with the bytes each one lands.
20pub type Derived = (Vec<Operation>, BTreeMap<Sha256, Vec<u8>>);
21
22/// Every write one target state implies, and the bytes each one lands.
23///
24/// A destination that already holds the bytes is not an operation: a plan
25/// that listed it would tell an operator something would change when
26/// nothing would.
27///
28/// # Errors
29///
30/// [`AppError::Refused`] for a destination no operation may name, and I/O
31/// errors reading what the target holds now.
32pub fn operations_for(
33    target: &Utf8Path,
34    files: &[(Utf8PathBuf, Vec<u8>)],
35    declaration: &Declaration,
36    profile: ProfileId,
37    recorded_managed: &[(String, Sha256)],
38) -> Result<Derived, AppError> {
39    let adopted = adopted_destinations(declaration, profile);
40    let mut operations = Vec::new();
41    let mut blobs = BTreeMap::new();
42    for (destination, bytes) in files {
43        let path = TargetPath::new(destination.as_str())
44            .map_err(|error| AppError::Refused(error.to_string()))?;
45        let after = Sha256::of(bytes);
46        let before = std::fs::read(target.join(destination))
47            .ok()
48            .map(|held| Sha256::of(&held));
49        if before.as_ref() == Some(&after) {
50            continue;
51        }
52        blobs.insert(after.clone(), bytes.clone());
53        operations.push(match destination.as_str() {
54            MANIFEST_PATH => Operation::WriteRecord {
55                path,
56                before,
57                after,
58            },
59            // A marked region is spliced into a file the project owns, so
60            // the operation says which region rather than claiming the
61            // whole file.
62            HOOKS_CONFIG_PATH | AGENTS_DIGEST_PATH => Operation::SpliceBlock {
63                marker: destination.to_string(),
64                path,
65                before,
66                after,
67            },
68            held if adopted.contains(&held.to_string()) => Operation::WriteFile {
69                path,
70                class: Class::Adopted,
71                before,
72                after,
73            },
74            _ => Operation::WriteFile {
75                path,
76                class: Class::Managed,
77                before,
78                after,
79            },
80        });
81    }
82    operations.extend(removals(target, files, recorded_managed)?);
83    Ok((operations, blobs))
84}
85
86/// The debt file the operator asked the landing to record.
87///
88/// The same bytes `sdd debt baseline --apply` writes, from the same
89/// measurements, so the two can never disagree about what a ceiling is.
90/// A target that already carries a debt file keeps it: a baseline never
91/// widens one, and a landing is not the place to argue with that.
92///
93/// # Errors
94///
95/// [`AppError::Refused`] for a destination no operation may name.
96pub fn debt_operation(
97    target: &Utf8Path,
98    measured: &[crate::domain::debt::Measurement],
99) -> Result<Option<(Operation, Vec<u8>)>, AppError> {
100    use crate::domain::paths::DEBT_PATH;
101
102    if target.join(DEBT_PATH).exists() {
103        return Ok(None);
104    }
105    let debt = crate::domain::debt::Debt::baseline(measured);
106    if debt.is_empty() {
107        return Ok(None);
108    }
109    let bytes = debt.render().into_bytes();
110    let path = TargetPath::new(DEBT_PATH).map_err(|error| AppError::Refused(error.to_string()))?;
111    Ok(Some((
112        Operation::WriteDebt {
113            path,
114            before: None,
115            after: Sha256::of(&bytes),
116        },
117        bytes,
118    )))
119}
120
121/// Every managed file the record holds that this release no longer lands.
122///
123/// Only managed, and only under a root the canon owns. An adopted file the
124/// release stops seeding stays: the project owns it from the moment it
125/// lands, and a version moving is not permission to take it back. A file
126/// whose bytes are not the ones the record vouches for stays too, because
127/// the operator edited it and a removal would be a silent loss.
128fn removals(
129    target: &Utf8Path,
130    files: &[(Utf8PathBuf, Vec<u8>)],
131    recorded_managed: &[(String, Sha256)],
132) -> Result<Vec<Operation>, AppError> {
133    let landing: Vec<&str> = files
134        .iter()
135        .map(|(destination, _)| destination.as_str())
136        .collect();
137    let mut removals = Vec::new();
138    for (destination, recorded) in recorded_managed {
139        if landing.contains(&destination.as_str()) {
140            continue;
141        }
142        if !PRUNABLE_ROOTS
143            .iter()
144            .any(|prefix| destination.starts_with(prefix))
145        {
146            continue;
147        }
148        let path =
149            TargetPath::new(destination).map_err(|error| AppError::Refused(error.to_string()))?;
150        let Ok(held) = std::fs::read(target.join(destination)) else {
151            continue;
152        };
153        let found = Sha256::of(&held);
154        if &found != recorded {
155            continue;
156        }
157        removals.push(Operation::RemoveOwnedFile {
158            path,
159            before: found,
160        });
161    }
162    Ok(removals)
163}
164
165/// Every destination one profile adopts, resolved.
166fn adopted_destinations(declaration: &Declaration, profile: ProfileId) -> Vec<String> {
167    let Some(docs_root) = declaration.docs_root(profile) else {
168        return Vec::new();
169    };
170    declaration
171        .adopted
172        .iter()
173        .map(|projection| resolve_destination(&projection.destination, docs_root).to_string())
174        .collect()
175}
176
177#[cfg(test)]
178mod tests {
179    #![allow(
180        clippy::unwrap_used,
181        reason = "a test panics as its failure signal, not as control flow"
182    )]
183
184    use super::*;
185
186    #[test]
187    fn each_destination_takes_the_operation_its_kind_implies() {
188        let dir = tempfile::tempdir().unwrap();
189        let target = Utf8PathBuf::from(dir.path().to_str().unwrap());
190        let declaration = &crate::domain::profile::DECLARATION;
191        let files = vec![
192            (Utf8PathBuf::from(MANIFEST_PATH), b"record".to_vec()),
193            (Utf8PathBuf::from(HOOKS_CONFIG_PATH), b"hooks".to_vec()),
194            (Utf8PathBuf::from(AGENTS_DIGEST_PATH), b"digest".to_vec()),
195            (
196                Utf8PathBuf::from("docs/specs/SPEC-instance.md"),
197                b"seed".to_vec(),
198            ),
199            (
200                Utf8PathBuf::from(".spec-driven-docs/markdownlint/adr.markdownlint-cli2.jsonc"),
201                b"config".to_vec(),
202            ),
203        ];
204        let (operations, blobs) =
205            operations_for(&target, &files, declaration, ProfileId::Codebase, &[]).unwrap();
206        let kinds: Vec<&str> = operations.iter().map(Operation::kind).collect();
207        assert_eq!(
208            kinds,
209            [
210                "write-record",
211                "splice-block",
212                "splice-block",
213                "write-file",
214                "write-file"
215            ]
216        );
217        assert!(matches!(
218            operations[3],
219            Operation::WriteFile {
220                class: Class::Adopted,
221                ..
222            }
223        ));
224        assert!(matches!(
225            operations[4],
226            Operation::WriteFile {
227                class: Class::Managed,
228                ..
229            }
230        ));
231        assert_eq!(blobs.len(), 5);
232    }
233
234    #[test]
235    fn a_destination_that_already_holds_the_bytes_is_no_operation() {
236        let dir = tempfile::tempdir().unwrap();
237        let target = Utf8PathBuf::from(dir.path().to_str().unwrap());
238        crate::adapters::fs::write_file(&target.join("a.md"), b"same").unwrap();
239        let files = vec![(Utf8PathBuf::from("a.md"), b"same".to_vec())];
240        let (operations, blobs) = operations_for(
241            &target,
242            &files,
243            &crate::domain::profile::DECLARATION,
244            ProfileId::Codebase,
245            &[],
246        )
247        .unwrap();
248        assert!(operations.is_empty());
249        assert!(blobs.is_empty());
250    }
251
252    #[test]
253    fn a_managed_file_the_release_dropped_is_taken_back() {
254        let dir = tempfile::tempdir().unwrap();
255        let target = Utf8PathBuf::from(dir.path().to_str().unwrap());
256        let dropped = ".spec-driven-docs/markdownlint/retired.jsonc";
257        crate::adapters::fs::write_file(&target.join(dropped), b"old").unwrap();
258        let recorded = vec![(dropped.to_string(), Sha256::of(b"old"))];
259        let (operations, _) = operations_for(
260            &target,
261            &[],
262            &crate::domain::profile::DECLARATION,
263            ProfileId::Codebase,
264            &recorded,
265        )
266        .unwrap();
267        assert_eq!(operations.len(), 1);
268        assert_eq!(operations[0].kind(), "remove-owned-file");
269        assert_eq!(operations[0].path().as_str(), dropped);
270    }
271
272    #[test]
273    fn an_edited_dropped_file_and_one_outside_the_owned_roots_both_stay() {
274        let dir = tempfile::tempdir().unwrap();
275        let target = Utf8PathBuf::from(dir.path().to_str().unwrap());
276        let edited = ".spec-driven-docs/markdownlint/retired.jsonc";
277        let adopted = "docs/specs/SPEC-retired.md";
278        crate::adapters::fs::write_file(&target.join(edited), b"mine now").unwrap();
279        crate::adapters::fs::write_file(&target.join(adopted), b"seed").unwrap();
280        let recorded = vec![
281            (edited.to_string(), Sha256::of(b"old")),
282            (adopted.to_string(), Sha256::of(b"seed")),
283        ];
284        let (operations, _) = operations_for(
285            &target,
286            &[],
287            &crate::domain::profile::DECLARATION,
288            ProfileId::Codebase,
289            &recorded,
290        )
291        .unwrap();
292        assert!(
293            operations.is_empty(),
294            "an edited file or one the project owns was taken back: {operations:?}"
295        );
296    }
297
298    #[test]
299    fn a_destination_no_operation_may_name_refuses() {
300        let dir = tempfile::tempdir().unwrap();
301        let target = Utf8PathBuf::from(dir.path().to_str().unwrap());
302        let files = vec![(Utf8PathBuf::from("../escape.md"), b"x".to_vec())];
303        assert!(
304            operations_for(
305                &target,
306                &files,
307                &crate::domain::profile::DECLARATION,
308                ProfileId::Codebase,
309                &[],
310            )
311            .is_err()
312        );
313    }
314}