use super::apply::*;
use super::apply_helpers::*;
use super::helpers::*;
use super::helpers_state::*;
use super::workspace::*;
use crate::core::{codegen, planner, resolver, state, types};
use crate::transport;
use crate::tripwire::hasher;
use std::path::Path;
pub(crate) fn cmd_apply_dry_run_graph(file: &Path) -> Result<(), String> {
let config = parse_and_validate(file)?;
let mut graph: Vec<(String, Vec<String>)> = Vec::new();
for (name, res) in &config.resources {
graph.push((name.clone(), res.depends_on.clone()));
}
graph.sort_by(|a, b| a.0.cmp(&b.0));
println!("Execution graph (dry run):");
println!(" {} resources", graph.len());
println!();
for (name, deps) in &graph {
if deps.is_empty() {
println!(" {name} (no dependencies — runs first)");
} else {
println!(" {} → depends on: {}", name, deps.join(", "));
}
}
Ok(())
}
pub(crate) fn cmd_apply_canary_machine(
file: &Path,
state_dir: &Path,
canary: &str,
params: &[String],
timeout: Option<u64>,
) -> Result<(), String> {
let config = parse_and_validate(file)?;
if !config.machines.contains_key(canary) {
return Err(format!(
"canary machine '{}' not found (available: {})",
canary,
config
.machines
.keys()
.cloned()
.collect::<Vec<_>>()
.join(", ")
));
}
println!("=== Canary: applying to '{canary}' first ===\n");
cmd_apply(
file,
state_dir,
Some(canary),
None,
None,
None,
false,
false,
false,
params,
false,
timeout,
false,
false,
None,
None,
false,
false,
None,
false,
false,
0,
true,
false,
None,
false,
None,
None,
None,
false,
None,
false,
None, false, None, &[],
)?;
println!("\n{} Canary '{}' succeeded.", green("✓"), canary);
let remaining: Vec<String> = config
.machines
.keys()
.filter(|k| *k != canary)
.cloned()
.collect();
if remaining.is_empty() {
println!("No remaining machines. Canary deploy complete.");
return Ok(());
}
println!(
"\n=== Fleet: applying to {} remaining machines ===\n",
remaining.len()
);
for machine_name in &remaining {
cmd_apply(
file,
state_dir,
Some(machine_name),
None,
None,
None,
false,
false,
false,
params,
false,
timeout,
false,
false,
None,
None,
false,
false,
None,
false,
false,
0,
true,
false,
None,
false,
None,
None,
None,
false,
None,
false,
None, false, None, &[],
)?;
}
println!(
"\n{} Fleet deploy complete ({} machines).",
green("✓"),
remaining.len() + 1
);
Ok(())
}
fn refreshed_live_hash(
machine: &types::Machine,
resource: &types::Resource,
config: &types::ForjarConfig,
timeout: Option<u64>,
) -> Option<String> {
let resolved = resolver::resolve_resource_templates_with_secrets(
resource,
&config.params,
&config.machines,
&config.secrets,
)
.unwrap_or_else(|_| resource.clone());
let query = codegen::state_query_script(&resolved).ok()?;
match transport::exec_script_timeout(machine, &query, timeout) {
Ok(out) if out.success() => Some(hasher::hash_string_or_sentinel(&out.stdout)),
_ => None,
}
}
fn refreshable_live_hash(
config: &types::ForjarConfig,
machine: &types::Machine,
id: &str,
rl: &types::ResourceLock,
timeout: Option<u64>,
) -> Option<String> {
if rl.status != types::ResourceStatus::Converged {
return None;
}
let resource = config.resources.get(id)?;
refreshed_live_hash(machine, resource, config, timeout)
}
fn observed_state_drifted(rl: &types::ResourceLock, hash: &str) -> bool {
let old_hash = rl.observed_state().unwrap_or("");
hash != old_hash
}
fn refresh_machine_lock(
config: &types::ForjarConfig,
machine: &types::Machine,
machine_name: &str,
lock: &types::StateLock,
timeout: Option<u64>,
verbose: bool,
) -> (types::StateLock, usize, usize) {
let mut updated_lock = lock.clone();
let mut refreshed = 0usize;
let mut drift_count = 0usize;
for (id, rl) in &lock.resources {
let Some(hash) = refreshable_live_hash(config, machine, id, rl, timeout) else {
continue;
};
if observed_state_drifted(rl, &hash) {
drift_count += 1;
if verbose {
eprintln!(" drift: {id} on {machine_name} (hash changed)");
}
}
if let Some(entry) = updated_lock.resources.get_mut(id) {
entry.set_observed_state(hash);
}
refreshed += 1;
}
(updated_lock, refreshed, drift_count)
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn cmd_refresh_only(
file: &Path,
state_dir: &Path,
machine_filter: Option<&str>,
verbose: bool,
timeout: Option<u64>,
env_file: Option<&Path>,
workspace: Option<&str>,
) -> Result<(), String> {
let mut config = parse_and_validate(file)?;
if let Some(path) = env_file {
load_env_params(&mut config, path)?;
}
inject_workspace_param(&mut config, workspace);
resolver::resolve_data_sources(&mut config)?;
let locks = load_machine_locks(&config, state_dir, machine_filter)?;
let mut refreshed = 0usize;
let mut drift_count = 0usize;
for (machine_name, lock) in &locks {
let Some(machine) = config.machines.get(machine_name) else {
continue;
};
let (updated_lock, machine_refreshed, machine_drift) =
refresh_machine_lock(&config, machine, machine_name, lock, timeout, verbose);
refreshed += machine_refreshed;
drift_count += machine_drift;
state::save_lock(state_dir, &updated_lock)?;
}
println!("Refresh complete: {refreshed} resources queried, {drift_count} drifted");
Ok(())
}
pub(crate) fn cmd_apply_dry_run_cost(
file: &Path,
state_dir: &Path,
machine: Option<&str>,
) -> Result<(), String> {
let config = parse_and_validate(file)?;
let order = resolver::build_execution_order(&config)?;
let locks = load_machine_locks(&config, state_dir, machine)?;
let plan = planner::plan(&config, &order, &locks, None);
let creates = plan
.changes
.iter()
.filter(|c| c.action == types::PlanAction::Create)
.count();
let updates = plan
.changes
.iter()
.filter(|c| c.action == types::PlanAction::Update)
.count();
let deletes = plan
.changes
.iter()
.filter(|c| c.action == types::PlanAction::Destroy)
.count();
let noops = plan
.changes
.iter()
.filter(|c| c.action == types::PlanAction::NoOp)
.count();
println!("Dry run cost estimate:\n");
println!(" Create: {creates}");
println!(" Update: {updates}");
println!(" Destroy: {deletes}");
println!(" No-op: {noops}");
println!(" ─────────────");
println!(" Total changes: {}", creates + updates + deletes);
Ok(())
}