use std::collections::BTreeMap;
use camino::{Utf8Path, Utf8PathBuf};
use crate::domain::ownership::Sha256;
use crate::error::AppError;
use crate::plan::Plan;
use crate::plan::operation::Operation;
use crate::plan::readiness::{Readiness, Requirement};
use crate::plan::store::{
Disposition, OperationOutcome, PostconditionOutcome, RESULT_SCHEMA, Result as ApplyResult,
Store,
};
use crate::transaction::journal::{self, Entry, Journal};
use crate::transaction::stage::Stage;
pub struct Request<'a> {
pub store: &'a Store,
pub target: &'a Utf8Path,
pub stored: &'a Plan,
pub recomputed: &'a Plan,
pub bundle: &'a dyn crate::release::ReleaseBundle,
pub now: String,
}
#[must_use]
pub fn moved(stored: &Plan, recomputed: &Plan) -> Vec<String> {
let mut moved = Vec::new();
if stored.classification != recomputed.classification {
moved.push(format!(
"the target is now {} and the plan described {}",
recomputed.classification, stored.classification
));
}
if stored.desired_state.release_sha256 != recomputed.desired_state.release_sha256 {
moved.push("the release the plan resolved is no longer the one it resolved".to_string());
}
let record = |plan: &Plan| {
plan.observed_state
.installation
.as_ref()
.map(|installation| installation.record_sha256.to_string())
};
if record(stored) != record(recomputed) {
moved.push("the instance record changed".to_string());
}
let declaration = |plan: &Plan| {
plan.observed_state
.installation
.as_ref()
.and_then(|installation| installation.declaration_sha256.clone())
};
if declaration(stored) != declaration(recomputed) {
moved.push("the project's declaration changed".to_string());
}
for operation in &stored.operations {
if matches!(operation, Operation::WriteRecord { .. }) {
continue;
}
let found = recomputed
.operations
.iter()
.find(|other| other.path() == operation.path());
match found {
Some(other) if other == operation => {}
Some(_) => moved.push(format!(
"{} no longer needs what the plan described",
operation.path()
)),
None => moved.push(format!(
"{} is no longer part of the plan",
operation.path()
)),
}
}
for operation in &recomputed.operations {
if matches!(operation, Operation::WriteRecord { .. }) {
continue;
}
if !stored
.operations
.iter()
.any(|other| other.path() == operation.path())
{
moved.push(format!("{} is newly part of the plan", operation.path()));
}
}
if selected_answers(stored) != selected_answers(recomputed) {
moved.push("a selected decision changed".to_string());
}
moved
}
fn selected_answers(plan: &Plan) -> BTreeMap<&str, &str> {
plan.decisions
.iter()
.filter_map(|decision| {
decision
.selected
.as_deref()
.map(|answer| (decision.id.as_str(), answer))
})
.collect()
}
fn refuse(reason: &str) -> AppError {
AppError::Refused(reason.to_string())
}
pub fn apply(request: &Request<'_>) -> std::result::Result<ApplyResult, AppError> {
let Request {
store,
target,
stored,
recomputed,
bundle,
now,
} = request;
let directory = store.directory(&stored.identity.plan_id);
journal::recover(&directory.journal)?;
let mut differences = Vec::new();
if stored.input_fingerprint != recomputed.input_fingerprint {
differences = moved(stored, recomputed);
if differences.is_empty() {
differences.push(format!(
"the plan's inputs no longer hash to {}",
stored.identity.plan_id
));
}
}
if !differences.is_empty() {
let result = terminal(
stored,
now,
Disposition::Invalidated,
&format!(
"the plan no longer describes the target: {}",
differences.join("; ")
),
);
store.record(stored, &result)?;
return Err(refuse(&result.reason));
}
match stored.readiness {
Readiness::Ready => {}
Readiness::NeedsDecision => {
let waiting: Vec<&str> = stored
.decisions
.iter()
.filter(|decision| decision.selected.is_none())
.map(|decision| decision.id.as_str())
.collect();
return Err(refuse(&format!(
"the plan waits on a decision: {}; answer it with --set and plan again",
waiting.join(", ")
)));
}
Readiness::Blocked => {
let blocked: Vec<&str> = stored
.preconditions
.iter()
.filter(|precondition| {
precondition.requirement == Requirement::Required
&& !precondition.evaluation.is_satisfied()
})
.map(|precondition| precondition.id.as_str())
.collect();
return Err(refuse(&format!(
"the plan is blocked by {}",
blocked.join(", ")
)));
}
}
if stored.operations.is_empty() {
let postconditions = prove(target, stored, *bundle);
let failed: Vec<&PostconditionOutcome> =
postconditions.iter().filter(|held| !held.held).collect();
let (disposition, reason) = failed.first().map_or_else(
|| {
(
Disposition::Succeeded,
"the target already holds what the plan describes".to_string(),
)
},
|first| {
(
Disposition::Retryable,
format!(
"apply aborted: the postcondition {} did not hold: {}",
first.id,
first.detail.clone().unwrap_or_default()
),
)
},
);
let result = ApplyResult {
postconditions,
..terminal(stored, now, disposition, &reason)
};
store.record(stored, &result)?;
if disposition == Disposition::Succeeded {
return Ok(result);
}
return Err(refuse(&result.reason));
}
execute(store, target, stored, *bundle, now)
}
fn execute(
store: &Store,
target: &Utf8Path,
plan: &Plan,
bundle: &dyn crate::release::ReleaseBundle,
now: &str,
) -> std::result::Result<ApplyResult, AppError> {
let directory = store.directory(&plan.identity.plan_id);
let (entries, planned) = stage_every_operation(store, target, plan)?;
let mut journal = Journal::begin(&directory.journal, &directory.blobs, entries)?;
let mut outcomes = Vec::new();
let mut affected = Vec::new();
let mut notes: Vec<String> = Vec::new();
for ((destination, bytes), operation) in planned.iter().zip(ordered(plan)) {
let done = contained(target, operation.path().as_path())
.and_then(|()| {
bytes.as_ref().map_or_else(
|| remove(target, destination),
|bytes| {
Stage::write(destination, bytes)
.and_then(|scratch| Stage::replace(&scratch, destination))
.map(|()| None)
},
)
})
.and_then(|note| journal.mark_done(destination).map(|()| note));
let note = match done {
Ok(note) => note,
Err(cause) => {
let reason = format!("{} could not be written: {cause}", operation.path());
return Err(undo(store, plan, &journal, now, &reason, None));
}
};
if let Some(note) = note {
notes.push(note);
}
outcomes.push(OperationOutcome {
kind: operation.kind().to_string(),
path: operation.path().as_str().to_string(),
applied: true,
refusal: None,
});
affected.push(operation.path().as_str().to_string());
}
let postconditions = prove(target, plan, bundle);
if let Some(first) = postconditions.iter().find(|held| !held.held) {
let reason = format!(
"apply aborted: the postcondition {} did not hold: {}",
first.id,
first.detail.clone().unwrap_or_default()
);
return Err(undo(
store,
plan,
&journal,
now,
&reason,
Some(postconditions),
));
}
if let Err(cause) = journal.finish()
&& directory.journal.exists()
{
let reason = format!("the journal could not be closed: {cause}");
return Err(undo(store, plan, &journal, now, &reason, None));
}
let reason = if notes.is_empty() {
"every operation landed".to_string()
} else {
format!("every operation landed; {}", notes.join("; "))
};
let result = ApplyResult {
operations: outcomes,
postconditions,
affected,
..terminal(plan, now, Disposition::Succeeded, &reason)
};
if let Err(cause) = store.record(plan, &result) {
return Err(AppError::Unrecovered(format!(
"the landing succeeded and its result could not be recorded: {cause}"
)));
}
Ok(result)
}
type Staged = (Utf8PathBuf, Option<Vec<u8>>);
fn stage_every_operation(
store: &Store,
target: &Utf8Path,
plan: &Plan,
) -> std::result::Result<(Vec<Entry>, Vec<Staged>), AppError> {
let directory = store.directory(&plan.identity.plan_id);
let stage = Stage::new(&directory.blobs)?;
let mut entries = Vec::new();
let mut planned: Vec<Staged> = Vec::new();
for operation in ordered(plan) {
contained(target, operation.path().as_path())?;
let destination = target.join(operation.path().as_path());
let before = stage.back_up(&destination)?;
match operation.after() {
Some(after) => {
let bytes = store.blob(&plan.identity.plan_id, after)?;
entries.push(Entry::write(destination.clone(), before, after.clone()));
planned.push((destination, Some(bytes)));
}
None => {
if let Some(before) = before {
entries.push(Entry::remove(destination.clone(), before));
planned.push((destination, None));
}
}
}
}
Ok((entries, planned))
}
fn undo(
store: &Store,
plan: &Plan,
journal: &Journal,
now: &str,
reason: &str,
postconditions: Option<Vec<PostconditionOutcome>>,
) -> AppError {
let restored = journal.roll_back();
let disposition = if restored.is_ok() {
Disposition::Retryable
} else {
Disposition::RecoveryRequired
};
let result = ApplyResult {
postconditions: postconditions.unwrap_or_default(),
..terminal(plan, now, disposition, reason)
};
let _ = store.record(plan, &result);
restored.err().map_or_else(
|| refuse(&format!("{reason}; the target was put back")),
|failure| {
AppError::Unrecovered(format!(
"{reason}; the target could not be put back: {failure}"
))
},
)
}
fn remove(
target: &Utf8Path,
destination: &Utf8Path,
) -> std::result::Result<Option<String>, AppError> {
match std::fs::remove_file(destination) {
Ok(()) => {
crate::transaction::sync_parent(destination).map_err(AppError::Io)?;
Ok(sweep_emptied_parent(target, destination))
}
Err(source) if source.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(source) => Err(AppError::Io(source)),
}
}
fn sweep_emptied_parent(target: &Utf8Path, destination: &Utf8Path) -> Option<String> {
let parent = destination.parent()?;
let relative = parent.strip_prefix(target).ok()?;
let owned = crate::domain::paths::PRUNABLE_ROOTS
.iter()
.any(|root| relative.as_str().starts_with(root.trim_end_matches('/')));
if !owned
|| crate::domain::paths::PRUNABLE_ROOTS
.iter()
.any(|root| relative.as_str() == root.trim_end_matches('/'))
{
return None;
}
if std::fs::read_dir(parent).is_ok_and(|mut entries| entries.next().is_none()) {
if let Err(cause) = std::fs::remove_dir(parent) {
return Some(format!(
"{relative} is empty and could not be removed: {cause}; remove it by hand"
));
}
}
None
}
fn contained(target: &Utf8Path, relative: &Utf8Path) -> std::result::Result<(), AppError> {
crate::adapters::fs::check_destination(target, relative).map_err(|refusal| {
AppError::Refused(match refusal {
crate::adapters::fs::DestinationRefusal::SymlinkEscape => {
format!("destination escapes the target through a symlink: {relative}")
}
crate::adapters::fs::DestinationRefusal::FileBlocksDirectory(blocked) => {
format!("a file blocks a directory the plan needs: {blocked}")
}
crate::adapters::fs::DestinationRefusal::NotARegularFile => {
format!("destination exists and is not a regular file: {relative}")
}
})
})
}
fn ordered(plan: &Plan) -> Vec<&Operation> {
let mut ordered: Vec<&Operation> = plan
.operations
.iter()
.filter(|operation| !matches!(operation, Operation::WriteRecord { .. }))
.collect();
ordered.extend(
plan.operations
.iter()
.filter(|operation| matches!(operation, Operation::WriteRecord { .. })),
);
ordered
}
fn prove(
target: &Utf8Path,
plan: &Plan,
bundle: &dyn crate::release::ReleaseBundle,
) -> Vec<PostconditionOutcome> {
plan.postconditions
.iter()
.map(|postcondition| match postcondition.id.as_str() {
"record-matches-the-tree" => {
let wrong: Vec<String> = plan
.operations
.iter()
.filter_map(|operation| {
let destination = target.join(operation.path().as_path());
let found = std::fs::read(&destination)
.ok()
.map(|bytes| Sha256::of(&bytes));
(found.as_ref() != operation.after()).then(|| operation.path().to_string())
})
.collect();
PostconditionOutcome {
id: postcondition.id.clone(),
held: wrong.is_empty(),
detail: (!wrong.is_empty()).then(|| {
format!(
"these destinations do not hold the plan's digest: {}",
wrong.join(", ")
)
}),
}
}
"verification-passes" => {
let report = crate::services::verifier::verify(target, bundle);
let detail = match &report {
Ok(report) if report.failures == 0 => None,
Ok(report) => Some(format!(
"sdd verify reports {} failure(s): {}",
report.failures,
report.lines.join("; ")
)),
Err(source) => Some(format!("sdd verify could not run: {source}")),
};
PostconditionOutcome {
id: postcondition.id.clone(),
held: detail.is_none(),
detail,
}
}
other => PostconditionOutcome {
id: other.to_string(),
held: false,
detail: Some(format!("{other} has no check behind it in this engine")),
},
})
.collect()
}
fn terminal(plan: &Plan, now: &str, disposition: Disposition, reason: &str) -> ApplyResult {
ApplyResult {
schema: RESULT_SCHEMA.to_string(),
plan_id: plan.identity.plan_id.clone(),
fingerprint: plan.input_fingerprint.clone(),
result_id: format!(
"{}-{}",
now.replace([':', '.'], "-"),
disposition_slug(disposition)
),
disposition,
finished_at: now.to_string(),
operations: Vec::new(),
postconditions: Vec::new(),
recovery_required: disposition == Disposition::RecoveryRequired,
affected: Vec::new(),
reason: reason.to_string(),
}
}
const fn disposition_slug(disposition: Disposition) -> &'static str {
match disposition {
Disposition::Succeeded => "succeeded",
Disposition::Invalidated => "invalidated",
Disposition::Retryable => "retryable",
Disposition::RecoveryRequired => "recovery-required",
}
}
#[cfg(test)]
mod tests {
#![allow(
clippy::unwrap_used,
reason = "a test panics as its failure signal, not as control flow"
)]
use super::*;
use crate::plan::operation::{Class, TargetPath};
fn write(path: &str, after: &[u8]) -> Operation {
Operation::WriteFile {
path: TargetPath::new(path).unwrap(),
class: Class::Managed,
before: None,
after: Sha256::of(after),
}
}
fn record(path: &str, after: &[u8]) -> Operation {
Operation::WriteRecord {
path: TargetPath::new(path).unwrap(),
before: None,
after: Sha256::of(after),
}
}
fn plan_with(operations: Vec<Operation>) -> Plan {
let mut plan = crate::plan::planner::plan(&crate::plan::planner::Inputs {
observation: &crate::plan::observe::Observation {
repository: crate::plan::observe::Repository {
root: Utf8PathBuf::from("/nowhere"),
version_controlled: true,
empty: true,
},
installation: None,
invalid: None,
host: crate::plan::observe::Host {
offline: true,
cache_root: None,
},
corpus: crate::plan::observe::Corpus::default(),
},
declaration: &crate::domain::profile::DECLARATION,
candidate: &BTreeMap::new(),
baseline: None,
selector: "embedded".to_string(),
release: "0.0.0".to_string(),
release_sha256: Sha256::of(b"release"),
provenance: "native".to_string(),
registry_checksum: None,
yanked: false,
compatibility: None,
interval: None,
briefing: None,
proposed: None,
selections: &crate::plan::decision::Selections::new(),
budget: &[],
reserve: &[],
declared: None,
declarations_settled: false,
now: "2026-09-12T00:00:00Z".to_string(),
});
plan.operations = operations;
plan
}
#[test]
fn the_record_is_written_last() {
let plan = plan_with(vec![
record(".spec-driven-docs/manifest.json", b"record"),
write("a.md", b"a"),
write("b.md", b"b"),
]);
let order: Vec<&str> = ordered(&plan)
.iter()
.map(|operation| operation.path().as_str())
.collect();
assert_eq!(order, ["a.md", "b.md", ".spec-driven-docs/manifest.json"]);
}
#[test]
fn nothing_moved_reports_no_difference() {
let plan = plan_with(vec![write("a.md", b"a")]);
assert!(moved(&plan, &plan).is_empty());
}
#[test]
fn a_changed_operation_is_named_by_its_destination() {
let one = plan_with(vec![write("a.md", b"a")]);
let two = plan_with(vec![write("a.md", b"different")]);
let differences = moved(&one, &two);
assert_eq!(differences.len(), 1);
assert!(differences[0].contains("a.md"), "{differences:?}");
}
#[test]
fn an_added_or_dropped_operation_is_named() {
let one = plan_with(vec![write("a.md", b"a")]);
let two = plan_with(vec![write("a.md", b"a"), write("b.md", b"b")]);
assert!(moved(&one, &two).iter().any(|held| held.contains("newly")));
assert!(
moved(&two, &one)
.iter()
.any(|held| held.contains("no longer part"))
);
}
}