1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
//! Plan printing and diff display helpers.
use super::helpers::*;
use crate::core::{codegen, resolver, types};
use std::path::Path;
/// Format the action symbol for a plan change.
fn action_symbol(action: &types::PlanAction) -> String {
match action {
types::PlanAction::Create => green("+"),
types::PlanAction::Update => yellow("~"),
types::PlanAction::Destroy => red("-"),
types::PlanAction::NoOp => dim(" "),
}
}
/// Format the description text for a plan change with color.
fn action_desc(action: &types::PlanAction, description: &str) -> String {
match action {
types::PlanAction::Create => green(description),
types::PlanAction::Update => yellow(description),
types::PlanAction::Destroy => red(description),
types::PlanAction::NoOp => dim(description),
}
}
/// Print a single plan change entry, including optional content diff.
fn print_plan_change(change: &types::PlannedChange, config: Option<&types::ForjarConfig>) {
println!(
" {} {}",
action_symbol(&change.action),
action_desc(&change.action, &change.description)
);
// FJ-255/274: Show content diff for file resources on create/update
if let Some(cfg) = config {
if matches!(
change.action,
types::PlanAction::Create | types::PlanAction::Update
) {
if let Some(resource) = cfg.resources.get(&change.resource_id) {
if let Some(ref content) = resource.content {
let old_content = if matches!(change.action, types::PlanAction::Update) {
resource
.path
.as_ref()
.and_then(|p| std::fs::read_to_string(p).ok())
} else {
None
};
print_content_diff(content, &change.action, old_content.as_deref());
}
}
}
}
}
/// Format a count with color: non-zero values get colored, zero stays plain.
fn colored_count(count: u32, color_fn: fn(&str) -> String) -> String {
if count > 0 {
color_fn(&count.to_string())
} else {
count.to_string()
}
}
/// How many locked resources carry observed state that `plan` did not consult.
///
/// forjar#342. `plan` compares the config to the LOCK; `drift` compares the
/// lock to the HOST. Both are correct for the question they answer, and they
/// disagree about the same machine — on intel, `plan` reported 52 changes while
/// `drift` found 28 drifted resources, and neither number contains the other.
///
/// This counts what plan is BLIND TO, not what is wrong. A resource is counted
/// when its lock entry holds an observation from the target, because that is
/// exactly the state a lock-relative diff cannot range over. It says nothing
/// about whether that resource has drifted — establishing that requires
/// reaching the machine, which is `drift`'s job and deliberately not plan's.
pub(crate) fn unconsulted_observations(
locks: &std::collections::HashMap<String, types::StateLock>,
) -> usize {
locks
.values()
.flat_map(|l| l.resources.values())
.filter(|rl| rl.observed_state().is_some())
.count()
}
/// State the quantifier this report ranges over.
///
/// SILENCE IS THE BUG. `plan` printing `0 to change` reads as "nothing is
/// wrong", when it means "nothing in the lock disagrees with the config" — and
/// the mutated-file case proves those are different claims. Naming the blind
/// spot converts an absence into a statement.
///
/// Produced for a CLEAN plan too, on purpose: the dangerous case is precisely
/// the one where plan has nothing to report. A disclosure that only appeared
/// alongside pending changes would be missing exactly when it is needed.
///
/// forjar#342: this is a VALUE, not a `println!`. It was written as a
/// side-effecting printer, so the only way to consume it was to be inside
/// `print_plan` — which is why the disclosure reached the TTY rendering and
/// structurally could not reach `plan --json` or the MCP/HTTP `plan` verb.
/// Returning `None` at zero is the contract's biconditional, not an
/// optimisation: an unconditional banner is noise, and noise is how a warning
/// stops being read.
pub(crate) fn scope_disclosure(unconsulted: usize) -> Option<String> {
if unconsulted == 0 {
return None;
}
Some(format!(
"This plan is lock-relative: it compares the config to the lock, and \
did not\ncontact any machine. {unconsulted} locked resource(s) carry \
state observed on a\ntarget that this plan did not consult — run \
`forjar drift` for what the machines\nactually hold."
))
}
/// Print the disclosure, when there is one.
fn print_scope_disclosure(unconsulted: usize) {
if let Some(msg) = scope_disclosure(unconsulted) {
println!("\n{msg}");
}
}
/// Name what this plan did not MEASURE.
///
/// forjar#497. `determine_present_action` lets an observed probe override a
/// matching config hash and falls through to the hash comparison when there is
/// no probe, so "probed, nothing stale" and "never probed" both plan `NoOp`.
/// The probe is only taken for machines this host answers for, so every
/// converged task on an SSH target has read as `unchanged` however far its
/// declared inputs moved. The action is deliberately unchanged — rebuilding
/// every unprobed resource would break f(f(x)) = f(x) at the plan level — so
/// the disclosure is the whole fix.
///
/// A VALUE, not a `println!`, for the forjar#342 reason directly above: a
/// side-effecting printer can only be consumed from inside `print_plan`, which
/// is exactly how the first disclosure failed to reach `--json` and the verb
/// surface. `None` when the census is empty is the contract's biconditional,
/// not an optimisation.
///
/// The count is of what the plan is BLIND to. It must never read as "N
/// drifted": whether those inputs moved is precisely what was not measured.
pub(crate) fn unprobed_disclosure(unprobed: &[types::UnprobedResource]) -> Option<String> {
if unprobed.is_empty() {
return None;
}
let mut entries = String::new();
for u in unprobed {
if !entries.is_empty() {
entries.push_str("; ");
}
entries.push_str(&format!("{}@{} ({})", u.resource_id, u.machine, u.reason));
}
Some(format!(
"This plan did not measure the declared build inputs or artifacts of {} \
converged\nresource(s) — `unchanged` for them is config-relative only: \
{entries}. Run\n`forjar apply --refresh` to re-check them on their machines, \
or `forjar drift` for what\nthe machines actually hold.",
unprobed.len()
))
}
/// Print the unprobed census, when there is one.
fn print_unprobed_disclosure(unprobed: &[types::UnprobedResource]) {
if let Some(msg) = unprobed_disclosure(unprobed) {
println!("\n{msg}");
}
}
/// The plan's disclosures as ONE value: what it did not consult (forjar#342)
/// and what it did not measure (forjar#497), folded the way every surface
/// must fold them. `None` iff both are empty. `plan --json` and the MCP layer
/// call this rather than composing the two themselves, so the fold cannot
/// drift between surfaces.
pub(crate) fn plan_disclosure(
unconsulted: usize,
unprobed: &[types::UnprobedResource],
) -> Option<String> {
crate::core::unattended::merge_disclosures(
scope_disclosure(unconsulted),
unprobed_disclosure(unprobed),
)
}
/// Print the plan summary line.
fn print_plan_summary(plan: &types::ExecutionPlan) {
println!(
"Plan: {} to add, {} to change, {} to destroy, {} unchanged.",
colored_count(plan.to_create, green),
colored_count(plan.to_update, yellow),
colored_count(plan.to_destroy, red),
plan.unchanged
);
}
/// Display a plan to stdout.
/// If `config` is Some, show content diff for file resources (FJ-255).
pub(crate) fn print_plan(
plan: &types::ExecutionPlan,
machine_filter: Option<&str>,
config: Option<&types::ForjarConfig>,
unconsulted: usize,
) {
println!("Planning: {} ({} resources)", plan.name, plan.changes.len());
println!();
let mut current_machine = String::new();
for change in &plan.changes {
if let Some(filter) = machine_filter {
if change.machine != filter {
continue;
}
}
if change.machine != current_machine {
current_machine.clone_from(&change.machine);
println!("{current_machine}:");
}
print_plan_change(change, config);
}
println!();
print_plan_summary(plan);
print_scope_disclosure(unconsulted);
print_unprobed_disclosure(&plan.unprobed);
}
/// FJ-255/274: Print a content diff block for a file resource.
/// For Creates: shows all new lines with `+` prefix.
/// For Updates with known old content (FJ-274): shows unified diff.
/// Limited to 50 lines; truncated with "[... N more lines]".
pub(crate) fn print_content_diff(
content: &str,
action: &types::PlanAction,
old_content: Option<&str>,
) {
// FJ-274: For updates with old content, show unified diff
if matches!(action, types::PlanAction::Update) {
if let Some(old) = old_content {
print_unified_diff(old, content);
return;
}
}
let lines: Vec<&str> = content.lines().collect();
let prefix = match action {
types::PlanAction::Create => "+",
types::PlanAction::Update => "~",
_ => " ",
};
let max_lines = 50;
let show = lines.len().min(max_lines);
println!(" ---");
for line in &lines[..show] {
println!(" {prefix} {line}");
}
if lines.len() > max_lines {
println!(" [... {} more lines]", lines.len() - max_lines);
}
println!(" ---");
}
/// FJ-274: Print a simple unified diff between old and new content.
pub(crate) fn print_unified_diff(old: &str, new: &str) {
let old_lines: Vec<&str> = old.lines().collect();
let new_lines: Vec<&str> = new.lines().collect();
let max_lines = 50;
let mut shown = 0;
println!(" ---");
// Simple line-by-line comparison
let max_len = old_lines.len().max(new_lines.len());
for i in 0..max_len {
if shown >= max_lines {
println!(" [... {} more lines]", max_len - shown);
break;
}
match (old_lines.get(i), new_lines.get(i)) {
(Some(o), Some(n)) if o == n => {
println!(" {o}");
shown += 1;
}
(Some(o), Some(n)) => {
println!(" {} {}", red("-"), o);
println!(" {} {}", green("+"), n);
shown += 2;
}
(Some(o), None) => {
println!(" {} {}", red("-"), o);
shown += 1;
}
(None, Some(n)) => {
println!(" {} {}", green("+"), n);
shown += 1;
}
(None, None) => break,
}
}
println!(" ---");
}
/// Export generated scripts (check, apply, state_query) to a directory for auditing.
/// Templates (params, secrets, machine refs) are resolved before export.
pub(crate) fn export_scripts(config: &types::ForjarConfig, dir: &Path) -> Result<(), String> {
std::fs::create_dir_all(dir)
.map_err(|e| format!("cannot create output dir {}: {}", dir.display(), e))?;
let mut count = 0;
for (id, resource) in &config.resources {
// Resolve templates (params, secrets, machine refs) before codegen
let resolved =
resolver::resolve_resource_templates(resource, &config.params, &config.machines)?;
// Sanitize resource ID for filesystem (replace / with --)
let safe_id = id.replace('/', "--");
// FJ-297: Build metadata header for exported scripts
let machine_str = match &resource.machine {
types::MachineTarget::Single(m) => m.clone(),
types::MachineTarget::Multiple(ms) => ms.join(","),
};
let mut header = format!(
"# forjar: {} ({})\n# machine: {}\n# type: {}\n",
id, config.name, machine_str, resource.resource_type
);
if let Some(ref rg) = resource.resource_group {
header.push_str(&format!("# group: {rg}\n"));
}
if !resource.tags.is_empty() {
header.push_str(&format!("# tags: {}\n", resource.tags.join(", ")));
}
if !resource.depends_on.is_empty() {
header.push_str(&format!(
"# depends_on: {}\n",
resource.depends_on.join(", ")
));
}
if let Ok(script) = codegen::check_script(&resolved) {
let path = dir.join(format!("{safe_id}.check.sh"));
std::fs::write(&path, format!("{header}{script}"))
.map_err(|e| format!("write {}: {}", path.display(), e))?;
count += 1;
}
if let Ok(script) = codegen::apply_script(&resolved) {
let path = dir.join(format!("{safe_id}.apply.sh"));
std::fs::write(&path, format!("{header}{script}"))
.map_err(|e| format!("write {}: {}", path.display(), e))?;
count += 1;
}
if let Ok(script) = codegen::state_query_script(&resolved) {
let path = dir.join(format!("{safe_id}.state_query.sh"));
std::fs::write(&path, format!("{header}{script}"))
.map_err(|e| format!("write {}: {}", path.display(), e))?;
count += 1;
}
}
println!("Exported {} scripts to {}", count, dir.display());
Ok(())
}