repolith_core/manifest.rs
1//! Repolith manifest schema and parser.
2//!
3//! The manifest (typically `repolith.toml` at the repo root) is the user-facing
4//! contract: it declares which upstream nodes to track and which actions to run
5//! against each.
6//!
7//! Parse via [`Manifest::from_toml`], which both deserializes and validates.
8//!
9//! [`Manifest::from_toml`]: crate::manifest::Manifest::from_toml
10
11use crate::types::ActionId;
12use serde::{Deserialize, Serialize};
13use std::path::PathBuf;
14use thiserror::Error;
15
16/// Top-level `[orchestrator]` section.
17#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
18pub struct OrchestratorMeta {
19 /// Manifest schema version, expected to match `~0.1` (semver tilde range).
20 pub schema_version: String,
21 /// Human-readable name for the orchestrator instance (e.g. `"anatta-rs"`).
22 pub name: String,
23}
24
25/// A single `[[node]]` entry — one upstream repository to manage.
26///
27/// **`git` and `path` semantics depend on the action kind**:
28///
29/// - For `kind = "git-clone"` actions, `git` is the **source** URL
30/// (required) and `path` is the **destination** working tree
31/// (optional, defaults to `./{node.id}`). Both fields can — and
32/// typically do — coexist on the same node.
33/// - For `kind = "cargo-install"` actions, `path` (when set) is the
34/// **source** crate root that wins over `git`. If only `git` is
35/// set, cargo is invoked with `--git <url>`. If both are set, `path`
36/// takes precedence — the assumption being you've already cloned via
37/// a sibling `git-clone` action.
38///
39/// The validator only requires that **at least one** of `git`/`path`
40/// is set — the per-action interpretation above lives in the CLI's
41/// manifest factory, not here.
42#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
43pub struct NodeEntry {
44 /// Unique identifier for the node within the manifest.
45 pub id: String,
46 /// Remote git URL. Required when [`Self::path`] is `None` and the
47 /// node has a `git-clone` action; required as the cargo-install
48 /// fallback when `path` is absent. URLs must use one of the
49 /// allowlisted schemes (`https://`, `http://`, `ssh://`, `git@`,
50 /// `file://`) and must not start with `-` — see the validator.
51 pub git: Option<String>,
52 /// Local path. For `git-clone` it's the destination working tree;
53 /// for `cargo-install` it's the source crate root (preferred over
54 /// `git`). Tilde expansion (`~/...`) is applied by the CLI before
55 /// the path reaches `git` / `cargo`.
56 pub path: Option<PathBuf>,
57 /// Ordered list of actions to run for this node.
58 #[serde(default, rename = "action")]
59 pub actions: Vec<ActionEntry>,
60}
61
62/// Root manifest document — deserialized from `repolith.toml`.
63///
64/// `[[attached]]` blocks are accepted in the TOML for forward
65/// compatibility but currently ignored (no field is collected). The
66/// `attached` schema is reserved for an M2 `template_apply` action and
67/// will land with that feature.
68#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
69pub struct Manifest {
70 /// `[orchestrator]` metadata (schema version, instance name).
71 pub orchestrator: OrchestratorMeta,
72 /// All `[[node]]` entries declared in the manifest.
73 #[serde(default, rename = "node")]
74 pub nodes: Vec<NodeEntry>,
75}
76
77/// Tagged variant of `[[node.action]]`, dispatched by the TOML `kind` field
78/// (kebab-case: `"git-clone"`, `"cargo-install"`, `"docker"`, `"repolith"`).
79#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
80#[serde(tag = "kind", rename_all = "kebab-case")]
81pub enum ActionEntry {
82 /// `kind = "git-clone"` — fetch the node's source.
83 GitClone,
84 /// `kind = "cargo-install"` — `cargo install` from the node's source tree.
85 CargoInstall {
86 /// Crate name (TOML key `crate`). Defaults to the node's `id` when absent.
87 ///
88 /// This is the **binary** target name (`cargo install --bin`), which
89 /// may differ from the package that contains it — see [`Self::CargoInstall::package`].
90 #[serde(default, rename = "crate")]
91 crate_name: Option<String>,
92 /// Package to select from the source, when the source contains more
93 /// than one (`cargo install [CRATE]`, the positional argument).
94 ///
95 /// Needed for git repositories that ship several packages with
96 /// binaries — test fixtures, tool workspaces — where cargo refuses
97 /// to guess. **No implicit default**: deriving it from
98 /// [`Self::CargoInstall::crate_name`] would break the common case
99 /// where the binary name differs from the package name. Absent
100 /// means no positional argument, i.e. cargo's own resolution.
101 #[serde(default)]
102 package: Option<String>,
103 /// Cargo profile to build with (`--profile`), e.g. `"dev"` for a
104 /// debug build. Absent means cargo's default, which is release.
105 ///
106 /// A debug build skips optimisation and is dramatically faster to
107 /// produce — the right choice when iterating on a local `path`
108 /// source. The binary is larger and slower at run time; that is the
109 /// trade the field exists to make explicit.
110 #[serde(default)]
111 profile: Option<String>,
112 /// Cargo features to enable.
113 #[serde(default)]
114 features: Vec<String>,
115 /// Target install directory. Defaults to `~/.repolith/bin` when absent.
116 #[serde(default)]
117 install_to: Option<PathBuf>,
118 },
119 /// `kind = "docker"` — `docker build` an image from the node's checkout.
120 ///
121 /// Build-only by design: running containers is process supervision,
122 /// which repolith explicitly does not do (see README "What it is NOT").
123 Docker {
124 /// Image tag to apply (`-t`). Required; validated against the
125 /// docker reference charset and the no-leading-dash rule.
126 tag: String,
127 /// Dockerfile path relative to the build context. Defaults to
128 /// `Dockerfile`. Must stay inside the node's checkout: relative,
129 /// no `..` component, no leading dash, no control characters.
130 #[serde(default)]
131 dockerfile: Option<PathBuf>,
132 /// Build context directory relative to the node's `path`.
133 /// Defaults to the node's `path` itself. Same containment rules
134 /// as `dockerfile`.
135 #[serde(default)]
136 context: Option<PathBuf>,
137 },
138 /// `kind = "repolith"` — federation: the node's checkout contains its
139 /// own `repolith.toml`, executed as a nested plan
140 /// (orchestrator-of-orchestrators).
141 ///
142 /// The nested stack keeps its **own local cache**, exactly as if
143 /// `repolith sync` had been run in that directory by hand. Cycle
144 /// detection and a maximum federation depth are enforced by the
145 /// executing action (`repolith-engine::federation`).
146 Repolith {
147 /// Child manifest path relative to the node's `path`. Defaults to
148 /// `repolith.toml`. Must stay inside the node's checkout: relative,
149 /// no `..` component, no leading dash, no control characters.
150 #[serde(default)]
151 manifest: Option<PathBuf>,
152 },
153}
154
155/// Errors that can occur while parsing or validating a manifest.
156#[derive(Debug, Error)]
157pub enum ManifestError {
158 /// Underlying TOML deserialization failed (syntax error, type mismatch, unknown variant).
159 #[error("toml parse: {0}")]
160 Toml(#[from] toml::de::Error),
161 /// `orchestrator.schema_version` does not satisfy the `~0.1` requirement.
162 #[error("schema version {got}: requires ~0.1")]
163 SchemaMismatch {
164 /// The offending version string from the manifest.
165 got: String,
166 },
167 /// Two or more `[[node]]` entries share the same `id`.
168 #[error("duplicate node id: {0}")]
169 DuplicateNodeId(String),
170 /// A `[[node]]` declares neither a `git` URL nor a local `path`.
171 #[error("node {0} has neither `git` nor `path`")]
172 NodeMissingSource(String),
173 /// A user-supplied URL would be interpreted as a CLI flag by the
174 /// downstream `git` / `cargo` invocation (CWE-88, argument injection).
175 #[error("invalid URL in node `{node}`: {reason}")]
176 InvalidUrl {
177 /// Node id that owns the offending URL.
178 node: String,
179 /// Why the URL was rejected (leading dash, unknown scheme, …).
180 reason: String,
181 },
182 /// A user-supplied identifier (crate name, feature name) would be
183 /// interpreted as a CLI flag or break shell argument parsing.
184 #[error("invalid argument in node `{node}`: {reason}")]
185 InvalidArg {
186 /// Node id that owns the offending field.
187 node: String,
188 /// Why the value was rejected.
189 reason: String,
190 },
191}
192
193/// URL schemes the orchestrator considers safe to hand to `git` and `cargo`.
194/// Anything else is rejected at parse time so a malicious manifest can't
195/// reach the subprocess layer.
196const ALLOWED_URL_SCHEMES: &[&str] = &["https://", "http://", "ssh://", "git@", "file://"];
197
198/// Reject strings that `git` / `cargo` would interpret as a CLI flag
199/// because they begin with `-`. The check is intentionally permissive
200/// for everything else — the goal is defense against trivial argument
201/// injection, not validation of the entire URL grammar.
202fn check_no_leading_dash(value: &str, node: &str, field: &str) -> Result<(), ManifestError> {
203 if value.starts_with('-') {
204 return Err(ManifestError::InvalidArg {
205 node: node.to_string(),
206 reason: format!("`{field}` starts with `-`, would be parsed as a CLI flag"),
207 });
208 }
209 Ok(())
210}
211
212/// Reject node ids that would let the manifest escape the workspace
213/// when used to construct a default clone path (`./{id}`):
214///
215/// - Empty ids (no directory).
216/// - Path separators `/` and `\`.
217/// - Drive-letter / alternate-data-stream separator `:` — on Windows
218/// `id = "C:foo"` makes `PathBuf::from("./C:foo")` resolve drive-relative
219/// and escape the workspace.
220/// - Control characters — NUL truncates at the syscall layer (the path
221/// actually opened on Unix is `./foo` not `./foo\0bar`); newlines confuse
222/// downstream tooling.
223/// - The literal `.` / `..` path-traversal tokens.
224fn check_node_id_safe(id: &str) -> Result<(), ManifestError> {
225 if id.is_empty() {
226 return Err(ManifestError::InvalidArg {
227 node: id.to_string(),
228 reason: "node id is empty".to_string(),
229 });
230 }
231 if let Some(c) = id.chars().find(|c| matches!(c, '/' | '\\' | ':')) {
232 return Err(ManifestError::InvalidArg {
233 node: id.to_string(),
234 reason: format!(
235 "node id `{id}` contains path separator `{c}` — would create nested, escaping, or drive-relative directories"
236 ),
237 });
238 }
239 if let Some(c) = id.chars().find(|c| c.is_control()) {
240 return Err(ManifestError::InvalidArg {
241 node: id.to_string(),
242 reason: format!("node id contains control character U+{:04X}", c as u32),
243 });
244 }
245 if id == "." || id == ".." {
246 return Err(ManifestError::InvalidArg {
247 node: id.to_string(),
248 reason: format!("node id `{id}` is a path-traversal token"),
249 });
250 }
251 Ok(())
252}
253
254/// Validate `url` against [`ALLOWED_URL_SCHEMES`], the no-leading-dash rule
255/// (including for host and path components — so `ssh://-oProxyCommand=evil@h/r`
256/// is rejected), and the no-control-characters rule. Empty URLs are rejected
257/// to keep `git ls-remote ""` from producing confusing diagnostics.
258fn check_url(url: &str, node: &str) -> Result<(), ManifestError> {
259 if url.is_empty() {
260 return Err(ManifestError::InvalidUrl {
261 node: node.to_string(),
262 reason: "URL is empty".into(),
263 });
264 }
265 if let Some(c) = url.chars().find(|c| c.is_control()) {
266 return Err(ManifestError::InvalidUrl {
267 node: node.to_string(),
268 reason: format!("URL contains control character U+{:04X}", c as u32),
269 });
270 }
271 if url.starts_with('-') {
272 return Err(ManifestError::InvalidUrl {
273 node: node.to_string(),
274 reason: format!("`{url}` starts with `-`, would be parsed as a CLI flag"),
275 });
276 }
277 let scheme = ALLOWED_URL_SCHEMES
278 .iter()
279 .find(|s| url.starts_with(*s))
280 .ok_or_else(|| ManifestError::InvalidUrl {
281 node: node.to_string(),
282 reason: format!("scheme not in allowlist {ALLOWED_URL_SCHEMES:?} (got `{url}`)"),
283 })?;
284
285 // For RFC-3986 URL schemes, parse and check host + each path segment for
286 // a leading `-`. This catches `ssh://-oProxyCommand=evil@host/r.git` and
287 // friends, which `starts_with('-')` on the whole URL misses.
288 // `git@host:org/repo` is SCP-style — not a valid URL — so we walk it
289 // manually instead.
290 if *scheme == "git@" {
291 check_scp_url(url, node)
292 } else {
293 check_rfc_url(url, node)
294 }
295}
296
297fn check_rfc_url(url: &str, node: &str) -> Result<(), ManifestError> {
298 let parsed = url::Url::parse(url).map_err(|e| ManifestError::InvalidUrl {
299 node: node.to_string(),
300 reason: format!("URL parse failed: {e}"),
301 })?;
302 // Userinfo: `ssh://-oProxyCommand=evil@host/r.git` parses with user
303 // `-oProxyCommand=evil`, which git/ssh can be tricked into treating as
304 // a flag depending on version.
305 let user = parsed.username();
306 if user.starts_with('-') {
307 return Err(ManifestError::InvalidUrl {
308 node: node.to_string(),
309 reason: format!("URL userinfo `{user}` starts with `-`"),
310 });
311 }
312 if let Some(host) = parsed.host_str()
313 && host.starts_with('-')
314 {
315 return Err(ManifestError::InvalidUrl {
316 node: node.to_string(),
317 reason: format!("host `{host}` starts with `-`"),
318 });
319 }
320 if let Some(segments) = parsed.path_segments() {
321 for s in segments {
322 if s.starts_with('-') {
323 return Err(ManifestError::InvalidUrl {
324 node: node.to_string(),
325 reason: format!("path segment `{s}` starts with `-`"),
326 });
327 }
328 }
329 }
330 Ok(())
331}
332
333fn check_scp_url(url: &str, node: &str) -> Result<(), ManifestError> {
334 let after_at = &url["git@".len()..];
335 let (host, path) = after_at.split_once(':').unwrap_or((after_at, ""));
336 if host.is_empty() || host.starts_with('-') {
337 return Err(ManifestError::InvalidUrl {
338 node: node.to_string(),
339 reason: format!("host `{host}` is empty or starts with `-`"),
340 });
341 }
342 for segment in path.split('/') {
343 if segment.starts_with('-') {
344 return Err(ManifestError::InvalidUrl {
345 node: node.to_string(),
346 reason: format!("path segment `{segment}` starts with `-`"),
347 });
348 }
349 }
350 Ok(())
351}
352
353/// Lexical containment check for `docker` action paths (`dockerfile`,
354/// `context`), mirroring [`check_node_id_safe`]. Runs at parse time with
355/// no filesystem access, so it can only guarantee the *lexical* form —
356/// symlink escapes are caught at run time by the action's canonicalize
357/// check (defense in depth).
358///
359/// Rejected:
360/// - Absolute paths — the whole point of these fields is "inside the
361/// node's checkout".
362/// - Any `..` component — lexical directory traversal.
363/// - A leading `-` on the path — `docker` would parse it as a flag
364/// (CWE-88), even behind `-f`/`--`.
365/// - Control characters — NUL truncates at the syscall layer, newlines
366/// confuse downstream tooling.
367fn check_contained_path(
368 path: &std::path::Path,
369 node: &str,
370 field: &str,
371) -> Result<(), ManifestError> {
372 let s = path.to_string_lossy();
373 if s.is_empty() {
374 return Err(ManifestError::InvalidArg {
375 node: node.to_string(),
376 reason: format!("`{field}` is empty"),
377 });
378 }
379 if let Some(c) = s.chars().find(|c| c.is_control()) {
380 return Err(ManifestError::InvalidArg {
381 node: node.to_string(),
382 reason: format!("`{field}` contains control character U+{:04X}", c as u32),
383 });
384 }
385 if s.starts_with('-') {
386 return Err(ManifestError::InvalidArg {
387 node: node.to_string(),
388 reason: format!("`{field}` `{s}` starts with `-`, would be parsed as a CLI flag"),
389 });
390 }
391 if path.is_absolute() {
392 return Err(ManifestError::InvalidArg {
393 node: node.to_string(),
394 reason: format!("`{field}` `{s}` is absolute — must stay inside the node's checkout"),
395 });
396 }
397 if path
398 .components()
399 .any(|c| matches!(c, std::path::Component::ParentDir))
400 {
401 return Err(ManifestError::InvalidArg {
402 node: node.to_string(),
403 reason: format!(
404 "`{field}` `{s}` contains `..` — path traversal outside the node's checkout"
405 ),
406 });
407 }
408 Ok(())
409}
410
411/// Validate a `docker` action image tag: non-empty, no leading dash
412/// (CWE-88), and restricted to the docker reference charset
413/// (`[a-zA-Z0-9._:/-]`) so the value can never smuggle whitespace or
414/// shell-relevant bytes into the `-t` argument.
415fn check_docker_tag(tag: &str, node: &str) -> Result<(), ManifestError> {
416 if tag.is_empty() {
417 return Err(ManifestError::InvalidArg {
418 node: node.to_string(),
419 reason: "`tag` is empty".to_string(),
420 });
421 }
422 if tag.starts_with('-') {
423 return Err(ManifestError::InvalidArg {
424 node: node.to_string(),
425 reason: format!("`tag` `{tag}` starts with `-`, would be parsed as a CLI flag"),
426 });
427 }
428 if let Some(c) = tag
429 .chars()
430 .find(|c| !(c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | ':' | '/' | '-')))
431 {
432 return Err(ManifestError::InvalidArg {
433 node: node.to_string(),
434 reason: format!("`tag` `{tag}` contains `{c}` — allowed charset is [a-zA-Z0-9._:/-]"),
435 });
436 }
437 Ok(())
438}
439
440/// Argv-injection sweep + per-kind requirements for one action.
441///
442/// Split out of [`Manifest::validate`] so that function stays under the
443/// workspace's `clippy::too_many_lines` threshold — same reason
444/// `classify` lives outside `Plan::compute`.
445/// Argv guards for `cargo-install`: every field here ends up on cargo's
446/// command line, so each one is checked for flag smuggling and for the
447/// separators cargo would split on.
448fn check_cargo_install(
449 node: &NodeEntry,
450 crate_name: Option<&String>,
451 package: Option<&String>,
452 profile: Option<&String>,
453 features: &[String],
454) -> Result<(), ManifestError> {
455 if let Some(name) = crate_name {
456 check_no_leading_dash(name, &node.id, "crate")?;
457 if name.contains(',') {
458 return Err(ManifestError::InvalidArg {
459 node: node.id.clone(),
460 reason: format!(
461 "`crate` name `{name}` contains `,` which would split cargo's --features list"
462 ),
463 });
464 }
465 }
466 // `package` becomes cargo's positional argument, so
467 // the same argv guards apply. A leading dash would be
468 // read as a flag; `@` would be parsed as the version
469 // spec of `CRATE@VER` and silently select something
470 // else.
471 if let Some(pkg) = package {
472 check_no_leading_dash(pkg, &node.id, "package")?;
473 if pkg.is_empty() {
474 return Err(ManifestError::InvalidArg {
475 node: node.id.clone(),
476 reason: "`package` is empty".to_string(),
477 });
478 }
479 if pkg.contains('@') {
480 return Err(ManifestError::InvalidArg {
481 node: node.id.clone(),
482 reason: format!(
483 "`package` `{pkg}` contains `@`, which cargo parses as a version spec (`CRATE@VER`)"
484 ),
485 });
486 }
487 }
488 // `profile` reaches cargo as `--profile <NAME>`. Cargo profile
489 // names are identifier-like, so anything outside that charset is
490 // either a typo or an attempt to smuggle an argument.
491 if let Some(prof) = profile {
492 check_no_leading_dash(prof, &node.id, "profile")?;
493 if prof.is_empty() {
494 return Err(ManifestError::InvalidArg {
495 node: node.id.clone(),
496 reason: "`profile` is empty".to_string(),
497 });
498 }
499 if let Some(c) = prof
500 .chars()
501 .find(|c| !(c.is_ascii_alphanumeric() || matches!(c, '_' | '-')))
502 {
503 return Err(ManifestError::InvalidArg {
504 node: node.id.clone(),
505 reason: format!(
506 "`profile` `{prof}` contains `{c}` — allowed charset is [a-zA-Z0-9_-]"
507 ),
508 });
509 }
510 }
511 for feature in features {
512 check_no_leading_dash(feature, &node.id, "features entry")?;
513 if feature.contains(',') {
514 return Err(ManifestError::InvalidArg {
515 node: node.id.clone(),
516 reason: format!(
517 "feature `{feature}` contains `,` which would split cargo's --features list"
518 ),
519 });
520 }
521 }
522 Ok(())
523}
524
525fn check_action(node: &NodeEntry, action: &ActionEntry) -> Result<(), ManifestError> {
526 match action {
527 ActionEntry::CargoInstall {
528 crate_name,
529 package,
530 profile,
531 features,
532 ..
533 } => check_cargo_install(
534 node,
535 crate_name.as_ref(),
536 package.as_ref(),
537 profile.as_ref(),
538 features,
539 )?,
540 ActionEntry::Docker {
541 tag,
542 dockerfile,
543 context,
544 } => {
545 check_docker_tag(tag, &node.id)?;
546 if let Some(df) = dockerfile {
547 check_contained_path(df, &node.id, "dockerfile")?;
548 }
549 if let Some(ctx) = context {
550 check_contained_path(ctx, &node.id, "context")?;
551 }
552 // A docker build needs a checkout to build from.
553 if node.path.is_none() {
554 return Err(ManifestError::InvalidArg {
555 node: node.id.clone(),
556 reason: "docker action requires `path` on the node (build context base)"
557 .to_string(),
558 });
559 }
560 }
561 ActionEntry::Repolith { manifest } => {
562 if let Some(m) = manifest {
563 check_contained_path(m, &node.id, "manifest")?;
564 }
565 // Federation descends into the node's checkout.
566 if node.path.is_none() {
567 return Err(ManifestError::InvalidArg {
568 node: node.id.clone(),
569 reason: "repolith action requires `path` on the node (child stack root)"
570 .to_string(),
571 });
572 }
573 }
574 ActionEntry::GitClone => {}
575 }
576 Ok(())
577}
578
579impl Manifest {
580 /// Parse and validate a manifest from a TOML source string.
581 ///
582 /// Returns a fully-validated [`Manifest`] on success. Validation covers
583 /// schema version range, node-id uniqueness, and the per-node "must have
584 /// `git` or `path`" rule.
585 ///
586 /// # Errors
587 /// See [`ManifestError`] for the validation rules and the corresponding variants.
588 pub fn from_toml(src: &str) -> Result<Self, ManifestError> {
589 let m: Manifest = toml::from_str(src)?;
590 m.validate()?;
591 Ok(m)
592 }
593
594 /// Run all post-deserialize semantic checks. Called by [`Self::from_toml`].
595 fn validate(&self) -> Result<(), ManifestError> {
596 // schema_version is in 0.1.x range
597 let req = semver::VersionReq::parse("~0.1").unwrap();
598 let v = semver::Version::parse(&format!("{}.0", self.orchestrator.schema_version))
599 .map_err(|_| ManifestError::SchemaMismatch {
600 got: self.orchestrator.schema_version.clone(),
601 })?;
602 if !req.matches(&v) {
603 return Err(ManifestError::SchemaMismatch {
604 got: self.orchestrator.schema_version.clone(),
605 });
606 }
607 // Per-node checks: id uniqueness, source presence, then the
608 // argument-injection sweep on every user-controlled string field
609 // that ultimately reaches a subprocess.
610 let mut seen = std::collections::HashSet::new();
611 for n in &self.nodes {
612 check_node_id_safe(&n.id)?;
613 if !seen.insert(&n.id) {
614 return Err(ManifestError::DuplicateNodeId(n.id.clone()));
615 }
616 if n.git.is_none() && n.path.is_none() {
617 return Err(ManifestError::NodeMissingSource(n.id.clone()));
618 }
619 if let Some(url) = &n.git {
620 check_url(url, &n.id)?;
621 }
622 for action in &n.actions {
623 check_action(n, action)?;
624 }
625 }
626 Ok(())
627 }
628
629 /// Enumerate every action across every node as a stable [`ActionId`].
630 ///
631 /// Each id has the shape `"{node.id}::{action-kind}::{index}"`, where
632 /// `index` is the action's position within its node (0-based). Ordering
633 /// matches manifest declaration order; downstream consumers (Plan, Cache)
634 /// rely on this for deterministic builds.
635 #[must_use]
636 pub fn action_ids(&self) -> Vec<ActionId> {
637 self.nodes
638 .iter()
639 .flat_map(|n| {
640 n.actions
641 .iter()
642 .enumerate()
643 .map(move |(i, a)| ActionId(format!("{}::{}::{}", n.id, action_kind(a), i)))
644 })
645 .collect()
646 }
647}
648
649/// Kebab-case discriminator string for an [`ActionEntry`], matching
650/// the TOML `kind` tag. Public so the CLI's manifest factory uses the
651/// same single source of truth — adding a new action variant therefore
652/// only needs touching this match plus the variant declaration.
653#[must_use]
654pub fn action_kind(a: &ActionEntry) -> &'static str {
655 match a {
656 ActionEntry::GitClone => "git-clone",
657 ActionEntry::CargoInstall { .. } => "cargo-install",
658 ActionEntry::Docker { .. } => "docker",
659 ActionEntry::Repolith { .. } => "repolith",
660 }
661}