1use 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
19pub type Derived = (Vec<Operation>, BTreeMap<Sha256, Vec<u8>>);
21
22pub 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 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
86pub 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
121fn 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
165fn 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}