1use std::collections::BTreeMap;
16
17use camino::{Utf8Path, Utf8PathBuf};
18
19use crate::domain::ownership::Sha256;
20use crate::error::AppError;
21use crate::plan::Plan;
22use crate::plan::operation::Operation;
23use crate::plan::readiness::{Readiness, Requirement};
24use crate::plan::store::{
25 Disposition, OperationOutcome, PostconditionOutcome, RESULT_SCHEMA, Result as ApplyResult,
26 Store,
27};
28use crate::transaction::journal::{self, Entry, Journal};
29use crate::transaction::stage::Stage;
30
31pub struct Request<'a> {
33 pub store: &'a Store,
35 pub target: &'a Utf8Path,
37 pub stored: &'a Plan,
39 pub recomputed: &'a Plan,
41 pub bundle: &'a dyn crate::release::ReleaseBundle,
43 pub now: String,
45}
46
47#[must_use]
52pub fn moved(stored: &Plan, recomputed: &Plan) -> Vec<String> {
53 let mut moved = Vec::new();
54 if stored.classification != recomputed.classification {
55 moved.push(format!(
56 "the target is now {} and the plan described {}",
57 recomputed.classification, stored.classification
58 ));
59 }
60 if stored.desired_state.release_sha256 != recomputed.desired_state.release_sha256 {
61 moved.push("the release the plan resolved is no longer the one it resolved".to_string());
62 }
63 let record = |plan: &Plan| {
64 plan.observed_state
65 .installation
66 .as_ref()
67 .map(|installation| installation.record_sha256.to_string())
68 };
69 if record(stored) != record(recomputed) {
70 moved.push("the instance record changed".to_string());
71 }
72 let declaration = |plan: &Plan| {
73 plan.observed_state
74 .installation
75 .as_ref()
76 .and_then(|installation| installation.declaration_sha256.clone())
77 };
78 if declaration(stored) != declaration(recomputed) {
79 moved.push("the project's declaration changed".to_string());
80 }
81 for operation in &stored.operations {
82 if matches!(operation, Operation::WriteRecord { .. }) {
86 continue;
87 }
88 let found = recomputed
89 .operations
90 .iter()
91 .find(|other| other.path() == operation.path());
92 match found {
93 Some(other) if other == operation => {}
94 Some(_) => moved.push(format!(
95 "{} no longer needs what the plan described",
96 operation.path()
97 )),
98 None => moved.push(format!(
99 "{} is no longer part of the plan",
100 operation.path()
101 )),
102 }
103 }
104 for operation in &recomputed.operations {
105 if matches!(operation, Operation::WriteRecord { .. }) {
106 continue;
107 }
108 if !stored
109 .operations
110 .iter()
111 .any(|other| other.path() == operation.path())
112 {
113 moved.push(format!("{} is newly part of the plan", operation.path()));
114 }
115 }
116 if selected_answers(stored) != selected_answers(recomputed) {
117 moved.push("a selected decision changed".to_string());
118 }
119 moved
120}
121
122fn selected_answers(plan: &Plan) -> BTreeMap<&str, &str> {
124 plan.decisions
125 .iter()
126 .filter_map(|decision| {
127 decision
128 .selected
129 .as_deref()
130 .map(|answer| (decision.id.as_str(), answer))
131 })
132 .collect()
133}
134
135fn refuse(reason: &str) -> AppError {
137 AppError::Refused(reason.to_string())
138}
139
140pub fn apply(request: &Request<'_>) -> std::result::Result<ApplyResult, AppError> {
148 let Request {
149 store,
150 target,
151 stored,
152 recomputed,
153 bundle,
154 now,
155 } = request;
156 let directory = store.directory(&stored.identity.plan_id);
157
158 journal::recover(&directory.journal)?;
161
162 let mut differences = Vec::new();
168 if stored.input_fingerprint != recomputed.input_fingerprint {
169 differences = moved(stored, recomputed);
170 if differences.is_empty() {
171 differences.push(format!(
172 "the plan's inputs no longer hash to {}",
173 stored.identity.plan_id
174 ));
175 }
176 }
177 if !differences.is_empty() {
178 let result = terminal(
179 stored,
180 now,
181 Disposition::Invalidated,
182 &format!(
183 "the plan no longer describes the target: {}",
184 differences.join("; ")
185 ),
186 );
187 store.record(stored, &result)?;
188 return Err(refuse(&result.reason));
189 }
190
191 match stored.readiness {
192 Readiness::Ready => {}
193 Readiness::NeedsDecision => {
194 let waiting: Vec<&str> = stored
195 .decisions
196 .iter()
197 .filter(|decision| decision.selected.is_none())
198 .map(|decision| decision.id.as_str())
199 .collect();
200 return Err(refuse(&format!(
201 "the plan waits on a decision: {}; answer it with --set and plan again",
202 waiting.join(", ")
203 )));
204 }
205 Readiness::Blocked => {
206 let blocked: Vec<&str> = stored
207 .preconditions
208 .iter()
209 .filter(|precondition| {
210 precondition.requirement == Requirement::Required
211 && !precondition.evaluation.is_satisfied()
212 })
213 .map(|precondition| precondition.id.as_str())
214 .collect();
215 return Err(refuse(&format!(
216 "the plan is blocked by {}",
217 blocked.join(", ")
218 )));
219 }
220 }
221
222 if stored.operations.is_empty() {
223 let postconditions = prove(target, stored, *bundle);
228 let failed: Vec<&PostconditionOutcome> =
229 postconditions.iter().filter(|held| !held.held).collect();
230 let (disposition, reason) = failed.first().map_or_else(
231 || {
232 (
233 Disposition::Succeeded,
234 "the target already holds what the plan describes".to_string(),
235 )
236 },
237 |first| {
238 (
239 Disposition::Retryable,
240 format!(
241 "apply aborted: the postcondition {} did not hold: {}",
242 first.id,
243 first.detail.clone().unwrap_or_default()
244 ),
245 )
246 },
247 );
248 let result = ApplyResult {
249 postconditions,
250 ..terminal(stored, now, disposition, &reason)
251 };
252 store.record(stored, &result)?;
253 if disposition == Disposition::Succeeded {
254 return Ok(result);
255 }
256 return Err(refuse(&result.reason));
257 }
258
259 execute(store, target, stored, *bundle, now)
260}
261
262fn execute(
264 store: &Store,
265 target: &Utf8Path,
266 plan: &Plan,
267 bundle: &dyn crate::release::ReleaseBundle,
268 now: &str,
269) -> std::result::Result<ApplyResult, AppError> {
270 let directory = store.directory(&plan.identity.plan_id);
271 let (entries, planned) = stage_every_operation(store, target, plan)?;
272
273 let mut journal = Journal::begin(&directory.journal, &directory.blobs, entries)?;
274 let mut outcomes = Vec::new();
275 let mut affected = Vec::new();
276 let mut notes: Vec<String> = Vec::new();
277 for ((destination, bytes), operation) in planned.iter().zip(ordered(plan)) {
278 let done = contained(target, operation.path().as_path())
283 .and_then(|()| {
284 bytes.as_ref().map_or_else(
285 || remove(target, destination),
286 |bytes| {
287 Stage::write(destination, bytes)
288 .and_then(|scratch| Stage::replace(&scratch, destination))
289 .map(|()| None)
290 },
291 )
292 })
293 .and_then(|note| journal.mark_done(destination).map(|()| note));
294 let note = match done {
295 Ok(note) => note,
296 Err(cause) => {
297 let reason = format!("{} could not be written: {cause}", operation.path());
298 return Err(undo(store, plan, &journal, now, &reason, None));
299 }
300 };
301 if let Some(note) = note {
302 notes.push(note);
303 }
304 outcomes.push(OperationOutcome {
305 kind: operation.kind().to_string(),
306 path: operation.path().as_str().to_string(),
307 applied: true,
308 refusal: None,
309 });
310 affected.push(operation.path().as_str().to_string());
311 }
312
313 let postconditions = prove(target, plan, bundle);
314 if let Some(first) = postconditions.iter().find(|held| !held.held) {
315 let reason = format!(
316 "apply aborted: the postcondition {} did not hold: {}",
317 first.id,
318 first.detail.clone().unwrap_or_default()
319 );
320 return Err(undo(
321 store,
322 plan,
323 &journal,
324 now,
325 &reason,
326 Some(postconditions),
327 ));
328 }
329
330 if let Err(cause) = journal.finish()
337 && directory.journal.exists()
338 {
339 let reason = format!("the journal could not be closed: {cause}");
340 return Err(undo(store, plan, &journal, now, &reason, None));
341 }
342
343 let reason = if notes.is_empty() {
344 "every operation landed".to_string()
345 } else {
346 format!("every operation landed; {}", notes.join("; "))
347 };
348 let result = ApplyResult {
349 operations: outcomes,
350 postconditions,
351 affected,
352 ..terminal(plan, now, Disposition::Succeeded, &reason)
353 };
354 if let Err(cause) = store.record(plan, &result) {
358 return Err(AppError::Unrecovered(format!(
359 "the landing succeeded and its result could not be recorded: {cause}"
360 )));
361 }
362 Ok(result)
363}
364
365type Staged = (Utf8PathBuf, Option<Vec<u8>>);
367
368fn stage_every_operation(
373 store: &Store,
374 target: &Utf8Path,
375 plan: &Plan,
376) -> std::result::Result<(Vec<Entry>, Vec<Staged>), AppError> {
377 let directory = store.directory(&plan.identity.plan_id);
378 let stage = Stage::new(&directory.blobs)?;
379 let mut entries = Vec::new();
380 let mut planned: Vec<Staged> = Vec::new();
381 for operation in ordered(plan) {
382 contained(target, operation.path().as_path())?;
388 let destination = target.join(operation.path().as_path());
389 let before = stage.back_up(&destination)?;
390 match operation.after() {
391 Some(after) => {
392 let bytes = store.blob(&plan.identity.plan_id, after)?;
393 entries.push(Entry::write(destination.clone(), before, after.clone()));
394 planned.push((destination, Some(bytes)));
395 }
396 None => {
397 if let Some(before) = before {
398 entries.push(Entry::remove(destination.clone(), before));
399 planned.push((destination, None));
400 }
401 }
402 }
403 }
404 Ok((entries, planned))
405}
406
407fn undo(
414 store: &Store,
415 plan: &Plan,
416 journal: &Journal,
417 now: &str,
418 reason: &str,
419 postconditions: Option<Vec<PostconditionOutcome>>,
420) -> AppError {
421 let restored = journal.roll_back();
422 let disposition = if restored.is_ok() {
423 Disposition::Retryable
424 } else {
425 Disposition::RecoveryRequired
426 };
427 let result = ApplyResult {
428 postconditions: postconditions.unwrap_or_default(),
429 ..terminal(plan, now, disposition, reason)
430 };
431 let _ = store.record(plan, &result);
432 restored.err().map_or_else(
433 || refuse(&format!("{reason}; the target was put back")),
434 |failure| {
435 AppError::Unrecovered(format!(
436 "{reason}; the target could not be put back: {failure}"
437 ))
438 },
439 )
440}
441
442fn remove(
451 target: &Utf8Path,
452 destination: &Utf8Path,
453) -> std::result::Result<Option<String>, AppError> {
454 match std::fs::remove_file(destination) {
455 Ok(()) => {
456 crate::transaction::sync_parent(destination).map_err(AppError::Io)?;
457 Ok(sweep_emptied_parent(target, destination))
458 }
459 Err(source) if source.kind() == std::io::ErrorKind::NotFound => Ok(None),
460 Err(source) => Err(AppError::Io(source)),
461 }
462}
463
464fn sweep_emptied_parent(target: &Utf8Path, destination: &Utf8Path) -> Option<String> {
466 let parent = destination.parent()?;
467 let relative = parent.strip_prefix(target).ok()?;
468 let owned = crate::domain::paths::PRUNABLE_ROOTS
469 .iter()
470 .any(|root| relative.as_str().starts_with(root.trim_end_matches('/')));
471 if !owned
472 || crate::domain::paths::PRUNABLE_ROOTS
473 .iter()
474 .any(|root| relative.as_str() == root.trim_end_matches('/'))
475 {
476 return None;
477 }
478 if std::fs::read_dir(parent).is_ok_and(|mut entries| entries.next().is_none()) {
479 if let Err(cause) = std::fs::remove_dir(parent) {
483 return Some(format!(
484 "{relative} is empty and could not be removed: {cause}; remove it by hand"
485 ));
486 }
487 }
488 None
489}
490
491fn contained(target: &Utf8Path, relative: &Utf8Path) -> std::result::Result<(), AppError> {
500 crate::adapters::fs::check_destination(target, relative).map_err(|refusal| {
501 AppError::Refused(match refusal {
502 crate::adapters::fs::DestinationRefusal::SymlinkEscape => {
503 format!("destination escapes the target through a symlink: {relative}")
504 }
505 crate::adapters::fs::DestinationRefusal::FileBlocksDirectory(blocked) => {
506 format!("a file blocks a directory the plan needs: {blocked}")
507 }
508 crate::adapters::fs::DestinationRefusal::NotARegularFile => {
509 format!("destination exists and is not a regular file: {relative}")
510 }
511 })
512 })
513}
514
515fn ordered(plan: &Plan) -> Vec<&Operation> {
521 let mut ordered: Vec<&Operation> = plan
522 .operations
523 .iter()
524 .filter(|operation| !matches!(operation, Operation::WriteRecord { .. }))
525 .collect();
526 ordered.extend(
527 plan.operations
528 .iter()
529 .filter(|operation| matches!(operation, Operation::WriteRecord { .. })),
530 );
531 ordered
532}
533
534fn prove(
536 target: &Utf8Path,
537 plan: &Plan,
538 bundle: &dyn crate::release::ReleaseBundle,
539) -> Vec<PostconditionOutcome> {
540 plan.postconditions
541 .iter()
542 .map(|postcondition| match postcondition.id.as_str() {
543 "record-matches-the-tree" => {
544 let wrong: Vec<String> = plan
545 .operations
546 .iter()
547 .filter_map(|operation| {
548 let destination = target.join(operation.path().as_path());
549 let found = std::fs::read(&destination)
550 .ok()
551 .map(|bytes| Sha256::of(&bytes));
552 (found.as_ref() != operation.after()).then(|| operation.path().to_string())
553 })
554 .collect();
555 PostconditionOutcome {
556 id: postcondition.id.clone(),
557 held: wrong.is_empty(),
558 detail: (!wrong.is_empty()).then(|| {
559 format!(
560 "these destinations do not hold the plan's digest: {}",
561 wrong.join(", ")
562 )
563 }),
564 }
565 }
566 "verification-passes" => {
567 let report = crate::services::verifier::verify(target, bundle);
568 let detail = match &report {
569 Ok(report) if report.failures == 0 => None,
570 Ok(report) => Some(format!(
571 "sdd verify reports {} failure(s): {}",
572 report.failures,
573 report.lines.join("; ")
574 )),
575 Err(source) => Some(format!("sdd verify could not run: {source}")),
576 };
577 PostconditionOutcome {
578 id: postcondition.id.clone(),
579 held: detail.is_none(),
580 detail,
581 }
582 }
583 other => PostconditionOutcome {
587 id: other.to_string(),
588 held: false,
589 detail: Some(format!("{other} has no check behind it in this engine")),
590 },
591 })
592 .collect()
593}
594
595fn terminal(plan: &Plan, now: &str, disposition: Disposition, reason: &str) -> ApplyResult {
597 ApplyResult {
598 schema: RESULT_SCHEMA.to_string(),
599 plan_id: plan.identity.plan_id.clone(),
600 fingerprint: plan.input_fingerprint.clone(),
601 result_id: format!(
602 "{}-{}",
603 now.replace([':', '.'], "-"),
604 disposition_slug(disposition)
605 ),
606 disposition,
607 finished_at: now.to_string(),
608 operations: Vec::new(),
609 postconditions: Vec::new(),
610 recovery_required: disposition == Disposition::RecoveryRequired,
611 affected: Vec::new(),
612 reason: reason.to_string(),
613 }
614}
615
616const fn disposition_slug(disposition: Disposition) -> &'static str {
617 match disposition {
618 Disposition::Succeeded => "succeeded",
619 Disposition::Invalidated => "invalidated",
620 Disposition::Retryable => "retryable",
621 Disposition::RecoveryRequired => "recovery-required",
622 }
623}
624
625#[cfg(test)]
626mod tests {
627 #![allow(
628 clippy::unwrap_used,
629 reason = "a test panics as its failure signal, not as control flow"
630 )]
631
632 use super::*;
633 use crate::plan::operation::{Class, TargetPath};
634
635 fn write(path: &str, after: &[u8]) -> Operation {
636 Operation::WriteFile {
637 path: TargetPath::new(path).unwrap(),
638 class: Class::Managed,
639 before: None,
640 after: Sha256::of(after),
641 }
642 }
643
644 fn record(path: &str, after: &[u8]) -> Operation {
645 Operation::WriteRecord {
646 path: TargetPath::new(path).unwrap(),
647 before: None,
648 after: Sha256::of(after),
649 }
650 }
651
652 fn plan_with(operations: Vec<Operation>) -> Plan {
653 let mut plan = crate::plan::planner::plan(&crate::plan::planner::Inputs {
654 observation: &crate::plan::observe::Observation {
655 repository: crate::plan::observe::Repository {
656 root: Utf8PathBuf::from("/nowhere"),
657 version_controlled: true,
658 empty: true,
659 },
660 installation: None,
661 invalid: None,
662 host: crate::plan::observe::Host {
663 offline: true,
664 cache_root: None,
665 },
666 corpus: crate::plan::observe::Corpus::default(),
667 },
668 declaration: &crate::domain::profile::DECLARATION,
669 candidate: &BTreeMap::new(),
670 baseline: None,
671 selector: "embedded".to_string(),
672 release: "0.0.0".to_string(),
673 release_sha256: Sha256::of(b"release"),
674 provenance: "native".to_string(),
675 registry_checksum: None,
676 yanked: false,
677 compatibility: None,
678 interval: None,
679 briefing: None,
680 proposed: None,
681 selections: &crate::plan::decision::Selections::new(),
682 budget: &[],
683 reserve: &[],
684 declared: None,
685 declarations_settled: false,
686 now: "2026-09-12T00:00:00Z".to_string(),
687 });
688 plan.operations = operations;
689 plan
690 }
691
692 #[test]
693 fn the_record_is_written_last() {
694 let plan = plan_with(vec![
695 record(".spec-driven-docs/manifest.json", b"record"),
696 write("a.md", b"a"),
697 write("b.md", b"b"),
698 ]);
699 let order: Vec<&str> = ordered(&plan)
700 .iter()
701 .map(|operation| operation.path().as_str())
702 .collect();
703 assert_eq!(order, ["a.md", "b.md", ".spec-driven-docs/manifest.json"]);
704 }
705
706 #[test]
707 fn nothing_moved_reports_no_difference() {
708 let plan = plan_with(vec![write("a.md", b"a")]);
709 assert!(moved(&plan, &plan).is_empty());
710 }
711
712 #[test]
713 fn a_changed_operation_is_named_by_its_destination() {
714 let one = plan_with(vec![write("a.md", b"a")]);
715 let two = plan_with(vec![write("a.md", b"different")]);
716 let differences = moved(&one, &two);
717 assert_eq!(differences.len(), 1);
718 assert!(differences[0].contains("a.md"), "{differences:?}");
719 }
720
721 #[test]
722 fn an_added_or_dropped_operation_is_named() {
723 let one = plan_with(vec![write("a.md", b"a")]);
724 let two = plan_with(vec![write("a.md", b"a"), write("b.md", b"b")]);
725 assert!(moved(&one, &two).iter().any(|held| held.contains("newly")));
726 assert!(
727 moved(&two, &one)
728 .iter()
729 .any(|held| held.contains("no longer part"))
730 );
731 }
732}