use super::conditions;
use super::resolver;
use super::types::*;
use crate::tripwire::hasher;
pub fn plan(
config: &ForjarConfig,
execution_order: &[String],
locks: &std::collections::HashMap<String, StateLock>,
tag_filter: Option<&str>,
) -> ExecutionPlan {
let locks = apply_moved_blocks(&config.moved, locks);
let mut changes = Vec::with_capacity(execution_order.len());
let mut to_create = 0u32;
let mut to_update = 0u32;
let mut to_destroy = 0u32;
let mut unchanged = 0u32;
for resource_id in execution_order {
let resource = match config.resources.get(resource_id) {
Some(r) => r,
None => continue,
};
if !passes_tag_filter(resource, tag_filter) {
continue;
}
let resolved = resolve_or_fallback(resource_id, resource, config);
for machine_name in resource.machine.iter() {
if !passes_machine_filters(resource, machine_name, resource_id, config) {
continue;
}
let action = determine_action(resource_id, &resolved, machine_name, &locks);
let description = describe_action(resource_id, resource, &action);
match action {
PlanAction::Create => to_create += 1,
PlanAction::Update => to_update += 1,
PlanAction::Destroy => to_destroy += 1,
PlanAction::NoOp => unchanged += 1,
}
changes.push(PlannedChange {
resource_id: resource_id.clone(),
machine: machine_name.to_owned(),
resource_type: resource.resource_type.clone(),
action,
description,
});
}
}
ExecutionPlan {
name: config.name.clone(),
changes,
execution_order: execution_order.to_vec(),
to_create,
to_update,
to_destroy,
unchanged,
}
}
fn passes_tag_filter(resource: &Resource, tag_filter: Option<&str>) -> bool {
match tag_filter {
Some(tag) => resource.tags.iter().any(|t| t == tag),
None => true,
}
}
fn resolve_or_fallback(resource_id: &str, resource: &Resource, config: &ForjarConfig) -> Resource {
resolver::resolve_resource_templates(resource, &config.params, &config.machines).unwrap_or_else(
|e| {
eprintln!("warning: template resolution failed for {resource_id}: {e}");
resource.clone()
},
)
}
fn passes_machine_filters(
resource: &Resource,
machine_name: &str,
resource_id: &str,
config: &ForjarConfig,
) -> bool {
if !resource.arch.is_empty() {
if let Some(machine) = config.machines.get(machine_name) {
if !resource.arch.contains(&machine.arch) {
return false;
}
}
}
if let Some(ref when_expr) = resource.when {
if let Some(machine) = config.machines.get(machine_name) {
match conditions::evaluate_when(when_expr, &config.params, machine) {
Ok(false) => return false,
Err(e) => {
eprintln!(
"warning: when condition failed for {resource_id} on {machine_name}: {e}"
);
return false;
}
Ok(true) => {} }
}
}
true
}
fn default_state(resource_type: &ResourceType) -> &'static str {
match resource_type {
ResourceType::Package => "present",
ResourceType::File => "file",
ResourceType::Service => "running",
ResourceType::Mount => "mounted",
ResourceType::User
| ResourceType::Docker
| ResourceType::Pepita
| ResourceType::Network
| ResourceType::Cron
| ResourceType::Model
| ResourceType::Gpu
| ResourceType::Task
| ResourceType::Recipe
| ResourceType::WasmBundle
| ResourceType::Image
| ResourceType::Build
| ResourceType::GithubRelease => "present",
}
}
fn determine_action(
resource_id: &str,
resource: &Resource,
machine_name: &str,
locks: &std::collections::HashMap<String, StateLock>,
) -> PlanAction {
let state = resource
.state
.as_deref()
.unwrap_or_else(|| default_state(&resource.resource_type));
if state == "absent" {
let action = determine_absent_action(resource_id, machine_name, locks);
if action == PlanAction::Destroy {
if let Some(ref lifecycle) = resource.lifecycle {
if lifecycle.prevent_destroy {
eprintln!("warning: {resource_id} has prevent_destroy — skipping destroy");
return PlanAction::NoOp;
}
}
}
return action;
}
determine_present_action(resource_id, resource, machine_name, locks)
}
fn determine_absent_action(
resource_id: &str,
machine_name: &str,
locks: &std::collections::HashMap<String, StateLock>,
) -> PlanAction {
if let Some(lock) = locks.get(machine_name) {
if lock.resources.contains_key(resource_id) {
return PlanAction::Destroy;
}
}
PlanAction::NoOp
}
fn determine_present_action(
resource_id: &str,
resource: &Resource,
machine_name: &str,
locks: &std::collections::HashMap<String, StateLock>,
) -> PlanAction {
let lock = match locks.get(machine_name) {
Some(l) => l,
None => return PlanAction::Create,
};
let rl = match lock.resources.get(resource_id) {
Some(r) => r,
None => return PlanAction::Create,
};
if rl.status != ResourceStatus::Converged {
return PlanAction::Update; }
let desired_hash = hash_desired_state(resource);
let result = if rl.hash == desired_hash {
PlanAction::NoOp
} else {
PlanAction::Update
};
debug_assert!(
rl.status != ResourceStatus::Converged
|| rl.hash != desired_hash
|| result == PlanAction::NoOp,
"idempotency violation: converged resource with matching hash must be NoOp"
);
result
}
fn push_opt<'a>(components: &mut Vec<&'a str>, field: &'a Option<String>) {
if let Some(ref val) = *field {
components.push(val);
}
}
fn push_list<'a>(components: &mut Vec<&'a str>, items: &'a [String]) {
for item in items {
components.push(item);
}
}
fn collect_core_fields<'a>(components: &mut Vec<&'a str>, resource: &'a Resource) {
push_opt(components, &resource.state);
push_opt(components, &resource.provider);
push_list(components, &resource.packages);
push_opt(components, &resource.path);
push_opt(components, &resource.content);
push_opt(components, &resource.source);
push_opt(components, &resource.name);
push_opt(components, &resource.owner);
push_opt(components, &resource.group);
push_opt(components, &resource.mode);
push_opt(components, &resource.fs_type);
push_opt(components, &resource.options);
push_opt(components, &resource.target);
push_opt(components, &resource.version);
}
fn collect_phase2_fields<'a>(components: &mut Vec<&'a str>, resource: &'a Resource) {
push_opt(components, &resource.image);
push_opt(components, &resource.command);
push_opt(components, &resource.schedule);
push_opt(components, &resource.restart);
push_opt(components, &resource.port);
push_opt(components, &resource.protocol);
push_opt(components, &resource.action);
push_opt(components, &resource.from_addr);
push_opt(components, &resource.shell);
push_opt(components, &resource.home);
if let Some(ref enabled) = resource.enabled {
components.push(if *enabled { "enabled" } else { "disabled" });
}
push_list(components, &resource.ports);
push_list(components, &resource.environment);
push_list(components, &resource.volumes);
push_list(components, &resource.restart_on);
}
pub fn hash_desired_state(resource: &Resource) -> String {
let type_str = resource.resource_type.to_string();
let mut components: Vec<&str> = vec![&type_str];
collect_core_fields(&mut components, resource);
collect_phase2_fields(&mut components, resource);
let joined = components.join("\0");
let result = hasher::hash_string(&joined);
debug_assert_eq!(
result,
hasher::hash_string(&joined),
"hash_desired_state: determinism violated"
);
result
}
fn describe_action(resource_id: &str, resource: &Resource, action: &PlanAction) -> String {
match action {
PlanAction::Create => match resource.resource_type {
ResourceType::Package => {
let pkgs = resource.packages.join(", ");
format!("{resource_id}: install {pkgs}")
}
ResourceType::File => {
let path = resource.path.as_deref().unwrap_or("?");
format!("{resource_id}: create {path}")
}
ResourceType::Service => {
let name = resource.name.as_deref().unwrap_or("?");
let verb = match resource.state.as_deref() {
Some("stopped") => "stop",
_ => "start",
};
format!("{resource_id}: {verb} {name}")
}
ResourceType::Mount => {
let path = resource.path.as_deref().unwrap_or("?");
format!("{resource_id}: mount {path}")
}
ResourceType::User
| ResourceType::Docker
| ResourceType::Pepita
| ResourceType::Network
| ResourceType::Cron
| ResourceType::Model
| ResourceType::Gpu
| ResourceType::Task
| ResourceType::Recipe
| ResourceType::WasmBundle
| ResourceType::Image
| ResourceType::Build
| ResourceType::GithubRelease => format!("{resource_id}: create"),
},
PlanAction::Update => format!("{resource_id}: update (state changed)"),
PlanAction::Destroy => format!("{resource_id}: destroy"),
PlanAction::NoOp => format!("{resource_id}: no changes"),
}
}
fn apply_moved_blocks(
moved: &[crate::core::types::MovedEntry],
locks: &std::collections::HashMap<String, StateLock>,
) -> std::collections::HashMap<String, StateLock> {
if moved.is_empty() {
return locks.clone();
}
let mut result = std::collections::HashMap::new();
for (machine, lock) in locks {
let mut new_lock = lock.clone();
for entry in moved {
if let Some(rl) = new_lock.resources.swap_remove(&entry.from) {
new_lock.resources.insert(entry.to.clone(), rl);
eprintln!(
"info: moved {} → {} in state for {}",
entry.from, entry.to, machine
);
}
}
result.insert(machine.clone(), new_lock);
}
result
}
pub mod minimal_changeset;
pub mod proof_obligation;
pub mod reversibility;
pub mod sat_deps;
pub mod why;
#[cfg(test)]
mod tests_advanced;
#[cfg(test)]
mod tests_describe;
#[cfg(test)]
mod tests_determine;
#[cfg(test)]
mod tests_filter;
#[cfg(test)]
mod tests_hash;
#[cfg(test)]
mod tests_hash_b;
#[cfg(test)]
mod tests_helpers;
#[cfg(test)]
mod tests_lifecycle;
#[cfg(test)]
mod tests_plan;
#[cfg(test)]
mod tests_proof_cov;
#[cfg(test)]
mod tests_reversibility;
#[cfg(test)]
mod tests_when;
#[cfg(test)]
mod tests_why;
#[cfg(test)]
mod tests_why_cov;