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
//! FJ-2723 / FJ-2724 (PMAT-199): resource selection for `apply` and `make`.
//!
//! Split out of `apply.rs` to keep it under the 500-line limit. Three ways to
//! narrow an apply live here, and they are not interchangeable:
//!
//! * `reject_empty_selection` — a selector naming nothing is a mistake in the
//! invocation, not a request to do nothing.
//! * `apply_goal_closure` — `make`-style: the goals plus everything they need.
//! Downward-closed, so it can never strand a prerequisite.
//! * `apply_filters` — `--subset`/`--exclude` pattern filters, which CAN cut a
//! resource out from under a dependent. That is why `make` does not use them.
use crate::core::{resolver, types};
/// FJ-2723 (PMAT-199): a selector that matches nothing is an error, not a no-op.
///
/// `forjar apply -r <typo>` used to print `0 converged, 0 unchanged` and exit
/// 0. Every signal said success while nothing had been applied — the same
/// silent-green shape as a check that always passes, and worse in CI, where the
/// exit code is the only thing anyone reads. A selector naming something that
/// does not exist is a mistake in the invocation, and saying so costs one line.
pub(crate) fn reject_empty_selection(
config: &types::ForjarConfig,
resource_filter: Option<&str>,
tag_filter: Option<&str>,
group_filter: Option<&str>,
) -> Result<(), String> {
if let Some(id) = resource_filter {
if !config.resources.contains_key(id) {
let mut known: Vec<&str> = config.resources.keys().map(String::as_str).collect();
known.sort_unstable();
return Err(format!(
"--resource '{id}' matches no resource in this config. Known: {}",
known.join(", ")
));
}
}
if let Some(tag) = tag_filter {
if !config
.resources
.values()
.any(|r| r.tags.iter().any(|t| t == tag))
{
return Err(format!("--tag '{tag}' matches no resource in this config"));
}
}
if let Some(group) = group_filter {
if !config
.resources
.values()
.any(|r| r.resource_group.as_deref() == Some(group))
{
return Err(format!(
"--group '{group}' matches no resource in this config"
));
}
}
Ok(())
}
/// FJ-2724 (PMAT-199): prune the config to the goals' prerequisite closure.
///
/// This is what makes `forjar make <goal>` mean what `make <goal>` means. The
/// prune happens after param overrides and before every other filter, so
/// `make a --exclude 'x-*'` composes.
///
/// Pruning is safe here in a way `--subset` is not: a `depends_on` closure is
/// downward-closed, so the pruned config can never execute a resource whose
/// prerequisites were dropped. It also cannot produce a spurious Destroy — the
/// plan iterates the execution order derived from `config.resources`, so lock
/// entries with no config resource are simply never visited.
pub(crate) fn apply_goal_closure(
config: &mut types::ForjarConfig,
goals: &[String],
verbose: bool,
) -> Result<(), String> {
if goals.is_empty() {
return Ok(());
}
let keep = resolver::goal_closure(config, goals)?;
let before = config.resources.len();
config.resources.retain(|id, _| keep.contains(id));
if verbose {
eprintln!(
"Goals {:?}: {} of {} resources in the prerequisite closure",
goals,
config.resources.len(),
before
);
}
Ok(())
}
/// Apply subset and exclude filters to config.
pub(crate) fn apply_filters(
config: &mut types::ForjarConfig,
subset: Option<&str>,
exclude: Option<&str>,
verbose: bool,
) -> Result<(), String> {
if let Some(pattern) = subset {
let count = super::apply_gates::filter_subset(&mut config.resources, pattern)?;
if verbose {
eprintln!("Subset filter '{pattern}': {count} resources selected");
}
}
if let Some(pattern) = exclude {
let removed = super::apply_gates::filter_exclude(&mut config.resources, pattern);
if verbose {
eprintln!(
"Exclude filter '{}': removed {} resources ({} remaining)",
pattern,
removed,
config.resources.len()
);
}
}
Ok(())
}
/// FJ-2725 (PMAT-199): remove phony resources that were not explicitly requested.
///
/// A phony resource names an ACTION (`clean`, `test`, `all`), not a file. It has
/// no artifact, so there is nothing to observe and nothing to converge to.
/// forjar's answer is goal-only: a phony resource participates in an apply only
/// when it is named as a goal, and then it runs unconditionally.
///
/// # Why not "runs on every apply", the naive reading of make
///
/// `planner::propagation` promotes every NoOp dependent of a changed resource,
/// so an always-changed phony would rebuild its entire transitive closure on
/// every single apply. It would also mean `forjar plan` could never again print
/// "0 to change" for any config containing one, breaking the
/// `idempotent-apply-v1` plan-fixed-point contract and poisoning every drift
/// lane that reads a non-empty plan as drift. make does not have this problem
/// because it decides per target from the filesystem rather than by propagating
/// dirtiness along edges.
///
/// # Why not "phony prerequisites auto-run when reached", make's real rule
///
/// That is not convergent here. Let `build` depend on phony `clean`, which
/// deletes build's artifacts: apply #2 sees the outputs missing, plans `build`,
/// pulls in `clean`, deletes them again — `f(f(x)) != f(x)`, permanently. And
/// it would not even work: probes are computed once before planning, so a
/// prerequisite's side effects during an apply are invisible to that apply's
/// plan.
///
/// Goal-only makes the plan action a constant function of the config —
/// independent of lock, probe and history — so idempotency holds by
/// construction rather than by argument. Dropping the edge to an unrequested
/// phony resource IS the "ordering only, never auto-run" rule.
pub(crate) fn strip_unrequested_phony(config: &mut types::ForjarConfig, goals: &[String]) {
let dropped: Vec<String> = config
.resources
.iter()
.filter(|(id, r)| r.phony && !goals.iter().any(|g| g == *id))
.map(|(id, _)| id.clone())
.collect();
if dropped.is_empty() {
return;
}
for id in &dropped {
config.resources.shift_remove(id);
}
// Scrub edges to the removed resources so the DAG stays well-formed.
for resource in config.resources.values_mut() {
resource.depends_on.retain(|d| !dropped.contains(d));
resource.triggers.retain(|t| !dropped.contains(t));
resource.restart_on.retain(|t| !dropped.contains(t));
}
}