Skip to main content

robonix_cli/
manifest.rs

1// SPDX-License-Identifier: MulanPSL-2.0
2// Manifest parsing and validation for robonix-cli.
3//
4// New (dev-packaging) spec: `package_manifest.yaml` with a single top-level
5// `build` + `start` shell string, a list of `capabilities`, optional
6// `depends`. One package = one `start` body — no `nodes: [...]` list, no
7// `-n` flag.
8//
9// Legacy (pre-dev-packaging) spec still accepted for backward compatibility:
10//   - filename `robonix_manifest.yaml`
11//   - `package.id` (used as identity if `name` is missing)
12//   - `build: { script: <path> }` instead of top-level `build: <string>`
13//   - `nodes: [{id, type, start}, ...]` instead of top-level `start: <string>`
14//     → node `start` blocks are concatenated and run as a single background
15//       process group (best-effort; use the new spec for deterministic order).
16//
17// `node` / `runtime` terminology is gone from the spec — "node" is deprecated
18// in favour of "capability"; "runtime" is "system".
19
20use anyhow::{Context, Result};
21use serde::Deserialize;
22use std::path::{Path, PathBuf};
23
24/// Preferred per-package manifest filename. Legacy `robonix_manifest.yaml`
25/// is also accepted by [`detect_manifest_path`].
26pub const MANIFEST_FILE: &str = "package_manifest.yaml";
27pub const LEGACY_MANIFEST_FILE: &str = "robonix_manifest.yaml";
28
29#[derive(Debug, Clone)]
30pub struct DetectedManifest {
31    pub path: PathBuf,
32    pub manifest: Manifest,
33}
34
35#[derive(Debug, Clone)]
36pub struct PackageSummary {
37    pub name: String,
38    pub version: String,
39    pub capabilities: Vec<String>,
40    pub depends: Vec<String>,
41}
42
43#[derive(Debug, Clone, Default)]
44pub struct Manifest {
45    pub manifest_version: u32,
46    pub package: Package,
47    pub build: String,
48    pub start: String,
49    pub capabilities: Vec<CapabilityRef>,
50    pub depends: Vec<DependsRef>,
51    /// True iff the manifest was parsed from legacy fields (id/nodes/build.script).
52    /// `rbnx` prints a deprecation warning in this case.
53    pub is_legacy: bool,
54}
55
56#[derive(Debug, Clone, Default, Deserialize)]
57pub struct Package {
58    /// New spec: `package.name`. Legacy spec allowed a separate `package.id`
59    /// — if present and `name` is missing, we fall back to `id`.
60    #[serde(default)]
61    pub name: String,
62    #[serde(default)]
63    pub id: Option<String>,
64    #[serde(default)]
65    pub version: String,
66    #[serde(default)]
67    pub vendor: String,
68    #[serde(default)]
69    pub description: String,
70    #[serde(default)]
71    pub license: String,
72}
73
74#[derive(Debug, Clone, Deserialize)]
75pub struct CapabilityRef {
76    /// Contract id (matches one of the TOMLs under `capabilities/`).
77    pub name: String,
78    /// Optional path to a package-local TOML that overrides / defines
79    /// this capability (for experimental providers not yet in the official
80    /// capabilities directory). Relative to the package root.
81    #[serde(default, alias = "definition")]
82    pub path: Option<String>,
83}
84
85/// One entry under a package's `depends:` list. Models a *source / lib*
86/// dependency (think Linux kernel module SOFT_DEPS) — i.e. another
87/// package whose codegen output / Python package this package needs at
88/// build or import time. NOT a boot-order dependency.
89///
90/// `name` is required (the depended-on package's `package.name`).
91/// Exactly one of `path` / `url` should be set:
92///   - `path`: filesystem path relative to this package's manifest dir
93///   - `url`:  git URL (cloned to `<pkg>/rbnx-build/deps/<name>/` on first build)
94///     Neither set means "expect it to already be installed / on PYTHONPATH".
95#[derive(Debug, Clone, Deserialize)]
96pub struct DependsRef {
97    pub name: String,
98    #[serde(default)]
99    pub path: Option<String>,
100    #[serde(default)]
101    pub url: Option<String>,
102    #[serde(default)]
103    pub branch: Option<String>,
104}
105
106// ── raw shape accepting both old and new ────────────────────────────────
107
108#[derive(Debug, Clone, Deserialize, Default)]
109struct RawManifest {
110    #[serde(rename = "manifestVersion", default)]
111    manifest_version: u32,
112    #[serde(default)]
113    package: Package,
114    /// Accept either a plain shell string (new) or `{ script: <path> }` (legacy).
115    #[serde(default)]
116    build: Option<BuildField>,
117    /// New: top-level shell string.
118    #[serde(default)]
119    start: Option<String>,
120    /// Legacy: list of nodes, each with its own `start` block.
121    #[serde(default)]
122    nodes: Vec<LegacyNode>,
123    #[serde(default)]
124    capabilities: Vec<CapabilityRef>,
125    #[serde(default)]
126    depends: Vec<DependsRef>,
127    /// Legacy `provider_id:` field. Removed from spec — accepted by serde but
128    /// ignored. rbnx-boot now discovers the provider_id by polling atlas for any provider
129    /// that registered after `start.sh` spawned. Future warning: emit deprecation.
130    #[serde(default)]
131    #[allow(dead_code)]
132    provider_id: Option<String>,
133}
134
135#[derive(Debug, Clone, Deserialize)]
136#[serde(untagged)]
137enum BuildField {
138    Shell(String),
139    Script { script: String },
140}
141
142#[derive(Debug, Clone, Deserialize)]
143struct LegacyNode {
144    #[serde(default)]
145    #[allow(dead_code)]
146    id: String,
147    #[serde(default, rename = "type")]
148    #[allow(dead_code)]
149    node_type: Option<String>,
150    #[serde(default)]
151    start: String,
152}
153
154pub fn detect_manifest_path(package_root: &Path) -> Result<PathBuf> {
155    let new_path = package_root.join(MANIFEST_FILE);
156    if new_path.exists() {
157        return Ok(new_path);
158    }
159    let legacy = package_root.join(LEGACY_MANIFEST_FILE);
160    if legacy.exists() {
161        return Ok(legacy);
162    }
163    anyhow::bail!("Package does not have {MANIFEST_FILE} (or legacy {LEGACY_MANIFEST_FILE})")
164}
165
166pub fn detect_and_load(package_root: &Path) -> Result<DetectedManifest> {
167    let path = detect_manifest_path(package_root)?;
168    let manifest = load_from_path(&path)?;
169    Ok(DetectedManifest { path, manifest })
170}
171
172pub fn load_from_path(manifest_path: &Path) -> Result<Manifest> {
173    let content = std::fs::read_to_string(manifest_path)
174        .with_context(|| format!("Failed to read manifest: {}", manifest_path.display()))?;
175    let raw: RawManifest = serde_yaml::from_str(&content)
176        .with_context(|| format!("Failed to parse manifest: {}", manifest_path.display()))?;
177    Ok(normalize(raw, manifest_path))
178}
179
180fn normalize(raw: RawManifest, manifest_path: &Path) -> Manifest {
181    let mut is_legacy = false;
182    let filename_is_legacy = manifest_path
183        .file_name()
184        .and_then(|n| n.to_str())
185        .map(|s| s == LEGACY_MANIFEST_FILE)
186        .unwrap_or(false);
187
188    // package.name fallback to package.id (legacy used id as canonical name).
189    let mut package = raw.package;
190    if package.name.trim().is_empty()
191        && let Some(id) = &package.id
192        && !id.trim().is_empty()
193    {
194        package.name = id.clone();
195        is_legacy = true;
196    }
197
198    // build: string (new) or { script } (legacy).
199    let build = match raw.build {
200        Some(BuildField::Shell(s)) => s,
201        Some(BuildField::Script { script }) => {
202            is_legacy = true;
203            format!("bash {script}")
204        }
205        None => String::new(),
206    };
207
208    // start: top-level string (new) or concatenate nodes (legacy).
209    let start = match (raw.start, raw.nodes.is_empty()) {
210        (Some(s), _) => s,
211        (None, false) => {
212            is_legacy = true;
213            // Concatenate legacy node start blocks. Each block gets wrapped
214            // in a subshell and backgrounded; a `wait` at the end keeps
215            // `rbnx start` alive until all nodes exit. This is a best-effort
216            // port — for deterministic deploys, migrate to the new spec.
217            let parts: Vec<String> = raw
218                .nodes
219                .iter()
220                .filter(|n| !n.start.trim().is_empty())
221                .map(|n| format!("( {} ) &", n.start.trim()))
222                .collect();
223            if parts.is_empty() {
224                String::new()
225            } else {
226                format!("{}\nwait", parts.join("\n"))
227            }
228        }
229        (None, true) => String::new(),
230    };
231
232    if filename_is_legacy {
233        is_legacy = true;
234    }
235
236    Manifest {
237        manifest_version: raw.manifest_version,
238        package,
239        build,
240        start,
241        capabilities: raw.capabilities,
242        depends: raw.depends,
243        is_legacy,
244    }
245}
246
247impl Manifest {
248    pub fn validate_and_summarize(&self) -> Result<PackageSummary> {
249        if self.manifest_version == 0 {
250            anyhow::bail!("Invalid 'manifestVersion': must be >= 1");
251        }
252        let p = &self.package;
253        for (name, val) in [
254            ("package.name", &p.name),
255            ("package.version", &p.version),
256            ("package.vendor", &p.vendor),
257            ("package.description", &p.description),
258            ("package.license", &p.license),
259        ] {
260            if val.trim().is_empty() {
261                anyhow::bail!("Missing '{name}' in manifest");
262            }
263        }
264        // `build` remains optional — packages that ship pre-built binaries
265        // can omit it. `start` is required for `rbnx start` to do anything.
266        if self.start.trim().is_empty() {
267            anyhow::bail!(
268                "manifest.start is required (shell string, run at package root). \
269                 If migrating from the legacy spec with `nodes: [...]`, either \
270                 concatenate their start blocks into one top-level `start:` or \
271                 split into multiple packages."
272            );
273        }
274
275        if self.is_legacy {
276            log::warn!(
277                "package '{}' uses legacy manifest fields (package.id / nodes[] / \
278                 build.script / robonix_manifest.yaml). These still work but are \
279                 deprecated — migrate to the new spec (package_manifest.yaml with \
280                 top-level build/start strings + capabilities).",
281                self.package.name
282            );
283        }
284
285        Ok(PackageSummary {
286            name: p.name.clone(),
287            version: p.version.clone(),
288            capabilities: self.capabilities.iter().map(|c| c.name.clone()).collect(),
289            depends: self.depends.iter().map(|d| d.name.clone()).collect(),
290        })
291    }
292}