use super::apply_helpers::*;
use super::helpers::*;
use super::helpers_state::*;
use super::print_helpers::*;
use super::workspace::*;
use crate::core::plan_selectors::PlanSelectors;
use crate::core::{planner, resolver, types};
use std::path::Path;
#[allow(clippy::too_many_arguments)]
pub(crate) fn cmd_plan(
file: &Path,
state_dir: &Path,
machine_filter: Option<&str>,
resource_filter: Option<&str>,
tag_filter: Option<&str>,
json: bool,
verbose: bool,
output_dir: Option<&Path>,
env_file: Option<&Path>,
workspace: Option<&str>,
no_diff: bool,
target: Option<&str>,
cost: bool,
what_if: &[String],
plan_out: Option<&Path>,
why: bool,
group_filter: Option<&str>,
) -> Result<(), String> {
let mut config = parse_and_validate(file)?;
apply_what_if_overrides(&mut config, what_if)?;
if let Some(path) = env_file {
load_env_params(&mut config, path)?;
}
inject_workspace_param(&mut config, workspace);
resolver::resolve_data_sources(&mut config)?;
if let Some(target_id) = target {
let keep = collect_transitive_deps(&config, target_id)?;
config.resources.retain(|k, _| keep.contains(k));
}
if verbose {
eprintln!(
"Planning {} ({} machines, {} resources)",
config.name,
config.machines.len(),
config.resources.len()
);
}
let locks = load_machine_locks(&config, state_dir, machine_filter)?;
super::state_visibility::report(state_dir, &config, &locks);
super::apply_selection::strip_unrequested_phony(&mut config, &[]);
let selectors = PlanSelectors::new(machine_filter, resource_filter, tag_filter, group_filter);
let plan = super::plan_compute::plan_filtered(&config, &locks, &selectors)?;
if let Some(dir) = output_dir {
export_scripts(&config, dir)?;
}
if let Some(out_path) = plan_out {
super::plan_file::save_plan_file(&plan, &selectors, &config, file, state_dir, out_path)?;
println!("Plan saved to {}", out_path.display());
return Ok(());
}
if why {
print_why_explanation(&config, &locks, &plan.execution_order, tag_filter);
}
let unconsulted = super::print_helpers::unconsulted_observations(&locks);
if json {
super::plan_json::print_plan_json(&plan, &config, unconsulted)?;
} else {
print_plan(
&plan,
machine_filter,
if no_diff { None } else { Some(&config) },
unconsulted,
);
}
if cost && !plan.changes.is_empty() {
print_plan_cost(&plan);
}
Ok(())
}
fn apply_what_if_overrides(
config: &mut types::ForjarConfig,
what_if: &[String],
) -> Result<(), String> {
for kv in what_if {
if let Some((key, value)) = kv.split_once('=') {
config.params.insert(
key.to_string(),
serde_yaml_ng::Value::String(value.to_string()),
);
} else {
return Err(format!(
"invalid --what-if format '{kv}': expected KEY=VALUE"
));
}
}
if !what_if.is_empty() {
println!(
"{}",
dim(&format!(
"[what-if] Hypothetical params: {}",
what_if.join(", ")
))
);
}
Ok(())
}
fn type_weight(t: &types::ResourceType) -> u32 {
match t {
types::ResourceType::Package => 3,
types::ResourceType::Service => 3,
types::ResourceType::Mount => 4,
types::ResourceType::Docker | types::ResourceType::Pepita => 5,
types::ResourceType::User => 3,
types::ResourceType::Network => 2,
types::ResourceType::Gpu => 4,
types::ResourceType::Model => 5,
types::ResourceType::Cron => 2,
_ => 1, }
}
pub(crate) fn print_plan_cost(plan: &types::ExecutionPlan) {
let total_cost: u32 = plan
.changes
.iter()
.map(|c| type_weight(&c.resource_type))
.sum();
let destroy_cost: u32 = plan
.changes
.iter()
.filter(|c| c.action == types::PlanAction::Destroy)
.map(|c| type_weight(&c.resource_type) * 2) .sum();
println!(
"\nCost: {} total (create/update: {}, destroy: {})",
total_cost + destroy_cost,
total_cost,
destroy_cost
);
if destroy_cost > 10 {
println!(
" {} High destructive cost — consider --dry-run first",
red("!")
);
}
}
pub(crate) fn cmd_plan_compact(
file: &Path,
state_dir: &Path,
machine_filter: Option<&str>,
json: bool,
) -> Result<(), String> {
let config = parse_and_validate(file)?;
let execution_order = resolver::build_execution_order(&config)?;
let locks = load_machine_locks(&config, state_dir, machine_filter)?;
let plan = planner::plan(&config, &execution_order, &locks, None);
if json {
let compact: Vec<serde_json::Value> = plan
.changes
.iter()
.map(|c| {
serde_json::json!({
"resource": c.resource_id,
"action": format!("{:?}", c.action),
"machine": c.machine,
})
})
.collect();
println!(
"{}",
serde_json::to_string_pretty(&compact).unwrap_or_default()
);
} else {
for change in &plan.changes {
let icon = match change.action {
types::PlanAction::Create => green("+"),
types::PlanAction::Update => yellow("~"),
types::PlanAction::Destroy => red("-"),
types::PlanAction::NoOp => dim("="),
};
println!(" {} {} ({})", icon, change.resource_id, change.machine,);
}
println!(
"\n{} change(s)",
plan.changes
.iter()
.filter(|c| c.action != types::PlanAction::NoOp)
.count()
);
}
Ok(())
}
fn print_why_explanation(
config: &types::ForjarConfig,
locks: &std::collections::HashMap<String, types::StateLock>,
execution_order: &[String],
tag_filter: Option<&str>,
) {
println!("\n{}", bold("Change Explanations (--why):"));
let reasons = collect_why_reasons(config, locks, execution_order, tag_filter);
for reason in &reasons {
let icon = action_icon(&reason.action);
println!(" {} {} on {}", icon, reason.resource_id, reason.machine);
for r in &reason.reasons {
println!(" {}", dim(&format!("- {r}")));
}
}
println!();
}
fn collect_why_reasons(
config: &types::ForjarConfig,
locks: &std::collections::HashMap<String, types::StateLock>,
execution_order: &[String],
tag_filter: Option<&str>,
) -> Vec<crate::core::planner::why::ChangeReason> {
use crate::core::planner::why;
let mut results = Vec::new();
for resource_id in execution_order {
let Some(resource) = config.resources.get(resource_id) else {
continue;
};
if let Some(tag) = tag_filter {
if !resource.tags.iter().any(|t| t == tag) {
continue;
}
}
let resolved = crate::core::resolver::resolve_or_fallback(
resource_id,
resource,
&config.params,
&config.machines,
&config.secrets,
);
for machine_name in resource.machine.iter() {
let reason = why::explain_why(resource_id, &resolved, machine_name, locks);
if reason.action != types::PlanAction::NoOp {
results.push(reason);
}
}
}
results
}
fn action_icon(action: &types::PlanAction) -> String {
match action {
types::PlanAction::Create => green("+"),
types::PlanAction::Update => yellow("~"),
types::PlanAction::Destroy => red("-"),
types::PlanAction::NoOp => dim("="),
}
}
#[cfg(test)]
#[path = "plan_tests_selector_scope.rs"]
mod tests_selector_scope;