grex_core/pack/validate/mod.rs
1//! Plan-phase validators for [`PackManifest`].
2//!
3//! Stage B of M3 introduces **plan-phase** validation — checks that run
4//! after [`crate::pack::parse`] succeeds but before any execute-time work
5//! (variable expansion, filesystem touches, child-pack traversal). The
6//! validators here operate on the already-parsed manifest in its
7//! pre-expansion, literal form.
8//!
9//! # Framework shape
10//!
11//! A [`Validator`] receives an immutable [`PackManifest`] and returns a
12//! `Vec<PackValidationError>` — never fail-first. [`run_all`] composes the
13//! default validator set and concatenates their findings so callers see
14//! the full diagnostic set in one pass. This slice ships one validator
15//! ([`DuplicateSymlinkValidator`]); subsequent M3 slices (cycle detect,
16//! cross-pack conflict, `depends_on` verification) plug into the same
17//! surface without touching orchestrator code.
18//!
19//! # Non-goals for this slice
20//!
21//! * No filesystem IO, no git, no platform probing.
22//! * No variable expansion — validators compare literal `dst` strings.
23//! * No cross-pack reasoning (later slices).
24
25use thiserror::Error;
26
27use super::PackManifest;
28use crate::tree::PackGraph;
29
30pub(crate) mod child_path;
31pub mod cycle;
32pub mod depends_on;
33pub mod dup_symlink;
34
35pub(crate) use child_path::{ChildPathValidator, DupChildPathValidator};
36pub use cycle::CycleValidator;
37pub use depends_on::DependsOnValidator;
38pub use dup_symlink::DuplicateSymlinkValidator;
39
40/// Errors raised by plan-phase validators.
41///
42/// Marked `#[non_exhaustive]` so future slices (slices 3–6) can add variants
43/// without breaking downstream `match` arms.
44#[non_exhaustive]
45#[derive(Debug, Clone, PartialEq, Eq, Error)]
46pub enum PackValidationError {
47 /// Two `symlink` actions within the same pack resolve to the same
48 /// literal `dst` string. `first` and `second` are indices in the
49 /// flattened action-walk order (see
50 /// [`PackManifest::iter_all_symlinks`]).
51 #[error("duplicate symlink dst `{dst}` (actions at indices {first} and {second})")]
52 DuplicateSymlinkDst {
53 /// Literal `dst` string (pre-expansion).
54 dst: String,
55 /// Global index of the earlier action.
56 first: usize,
57 /// Global index of the later action.
58 second: usize,
59 },
60
61 /// A cycle was detected in the assembled pack graph. `chain` lists the
62 /// pack names from the outermost node down to the recurrence.
63 #[error("cycle detected in pack graph: {chain:?}")]
64 GraphCycle {
65 /// Ordered chain of pack names that forms the cycle.
66 chain: Vec<String>,
67 },
68
69 /// A `depends_on` entry could not be resolved against any node in the
70 /// walked graph.
71 #[error("pack `{pack}` depends on `{required}` but no such pack exists in the graph")]
72 DependsOnUnsatisfied {
73 /// Name of the pack that declared the dependency.
74 pack: String,
75 /// The unresolved `depends_on` entry (a pack name or url).
76 required: String,
77 },
78
79 /// A `children[].path` value violates the bare-name rule
80 /// (`^[a-z][a-z0-9-]*$`, no separators, no `.` / `..`, no empty).
81 /// Enforced since v1.1.0 — see the `child_path` module's
82 /// `ChildPathValidator` (internal).
83 #[error("pack child `{child_name}` has invalid path `{path}`: {reason}")]
84 ChildPathInvalid {
85 /// Label of the offending child (its `path` field, or `url` as
86 /// fallback).
87 child_name: String,
88 /// The rejected literal `path` value.
89 path: String,
90 /// One-line explanation of which sub-rule failed.
91 reason: String,
92 },
93
94 /// Two or more `children[]` entries within the same parent
95 /// resolve to the same `effective_path()`. Without this gate the
96 /// second clone would silently overwrite the first's working
97 /// tree, or — once both have a `.git` — collide on the
98 /// dest-already-exists fast path and skip-fetch the wrong upstream.
99 /// Enforced since v1.1.0 — see the `child_path` module's
100 /// `DupChildPathValidator` (internal).
101 #[error("pack has duplicate children resolving to `{path}`: {urls:?}")]
102 ChildPathDuplicate {
103 /// The shared resolved path that two or more children claim.
104 path: String,
105 /// URLs of every colliding child, in declaration order.
106 urls: Vec<String>,
107 },
108}
109
110/// A single plan-phase validator.
111///
112/// Implementations run against a fully parsed manifest and return every
113/// problem they observe — never `Result`, because aggregation across
114/// validators is the point.
115pub trait Validator {
116 /// Stable human-readable identifier for diagnostics / allowlisting.
117 fn name(&self) -> &'static str;
118
119 /// Inspect `pack` and emit zero or more errors.
120 fn check(&self, pack: &PackManifest) -> Vec<PackValidationError>;
121}
122
123/// Run every default validator against `pack`, concatenating their
124/// findings.
125///
126/// The current default set is:
127///
128/// 1. [`DuplicateSymlinkValidator`] — two symlinks with the same literal
129/// `dst`.
130/// 2. `ChildPathValidator` (internal) — every `children[].path` matches
131/// the bare-name regex (since v1.1.0).
132/// 3. `DupChildPathValidator` (internal) — no two `children[]` entries
133/// within the same parent share an `effective_path()` (since v1.1.0).
134///
135/// Later slices extend this list; callers should prefer
136/// [`PackManifest::validate_plan`] over instantiating validators manually,
137/// so the default set stays discoverable.
138#[must_use]
139pub fn run_all(pack: &PackManifest) -> Vec<PackValidationError> {
140 let validators: [&dyn Validator; 3] =
141 [&DuplicateSymlinkValidator, &ChildPathValidator, &DupChildPathValidator];
142 let mut errs = Vec::new();
143 for v in validators {
144 errs.extend(v.check(pack));
145 }
146 errs
147}
148
149/// Plan-phase validator that operates on an assembled [`PackGraph`].
150///
151/// Separate trait from [`Validator`] on purpose: graph-level checks need
152/// the full graph, not a single manifest, and mixing the two into one
153/// trait would force every per-manifest validator to accept a graph it
154/// doesn't need. Two traits keep each call site's surface minimal and
155/// type-safe.
156pub trait GraphValidator {
157 /// Stable human-readable identifier.
158 fn name(&self) -> &'static str;
159
160 /// Inspect `graph` and emit zero or more errors.
161 fn check(&self, graph: &PackGraph) -> Vec<PackValidationError>;
162}
163
164/// Run every default [`GraphValidator`] against `graph`, concatenating
165/// their findings.
166///
167/// Current default set:
168///
169/// 1. [`CycleValidator`] — belt-and-suspenders for cycles the walker
170/// should have caught.
171/// 2. [`DependsOnValidator`] — verify every `depends_on` entry resolves.
172#[must_use]
173pub fn run_all_graph(graph: &PackGraph) -> Vec<PackValidationError> {
174 let validators: [&dyn GraphValidator; 2] = [&CycleValidator, &DependsOnValidator];
175 let mut errs = Vec::new();
176 for v in validators {
177 errs.extend(v.check(graph));
178 }
179 errs
180}
181
182impl PackGraph {
183 /// Run the default graph-validator set over `self`.
184 ///
185 /// Mirrors [`PackManifest::validate_plan`] at the graph surface. Kept
186 /// here (rather than in `tree::graph`) so the `tree` module does not
187 /// depend on `pack::validate`; the dependency direction stays
188 /// `validate -> tree` only.
189 ///
190 /// # Errors
191 ///
192 /// Returns the aggregated error list when any graph validator
193 /// flags a problem.
194 pub fn validate(&self) -> Result<(), Vec<PackValidationError>> {
195 let errs = run_all_graph(self);
196 if errs.is_empty() {
197 Ok(())
198 } else {
199 Err(errs)
200 }
201 }
202}