Skip to main content

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}