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 robonix_scribe::warn;
22use serde::Deserialize;
23use std::collections::{HashMap, HashSet};
24use std::path::{Path, PathBuf};
25
26/// Preferred per-package manifest filename. Legacy `robonix_manifest.yaml`
27/// is also accepted by [`detect_manifest_path`].
28pub const MANIFEST_FILE: &str = "package_manifest.yaml";
29pub const LEGACY_MANIFEST_FILE: &str = "robonix_manifest.yaml";
30pub const SHARED_LIFECYCLE_DRIVER_CONTRACT: &str = "robonix/lifecycle/driver";
31
32/// Cache directory name for a URL-backed deploy package.
33///
34/// The name comes from the repository, not the configured provider id: one
35/// repository can supply more than one configured instance.
36pub fn deploy_repo_dir_name(url: &str) -> String {
37    url.trim_end_matches('/')
38        .trim_end_matches(".git")
39        .rsplit('/')
40        .next()
41        .filter(|segment| !segment.is_empty())
42        .unwrap_or("pkg")
43        .to_string()
44}
45
46/// Apply deployment `env:` and expand `$VAR` / `${VAR}` in every scalar.
47///
48/// `rbnx` and Soma call this same entry point before parsing a deployment so
49/// they cannot disagree about paths, selected manifests, or package config.
50pub fn prepare_deployment_manifest(
51    mut root: serde_yaml::Value,
52    robonix_source_path: Option<&Path>,
53) -> Result<serde_yaml::Value> {
54    if std::env::var_os("ROBONIX_SOURCE_PATH").is_none()
55        && let Some(path) = robonix_source_path
56    {
57        // This runs before package child processes start.
58        unsafe { std::env::set_var("ROBONIX_SOURCE_PATH", path) };
59    }
60    let env: HashMap<String, String> = root
61        .get("env")
62        .cloned()
63        .map(serde_yaml::from_value)
64        .transpose()
65        .context("parse top-level env")?
66        .unwrap_or_default();
67    let expanded: Vec<(&String, String)> = env
68        .iter()
69        .map(|(key, value)| (key, expand_deployment_env(value)))
70        .collect();
71    for (key, value) in expanded {
72        unsafe { std::env::set_var(key, value) };
73    }
74    expand_deployment_yaml(&mut root);
75    Ok(root)
76}
77
78/// Validate package instance identities in one deployment.
79///
80/// Every primitive, service, and skill entry ``name`` becomes an Atlas provider
81/// id. The ids must therefore be non-empty, whitespace-normalized, and unique
82/// across package sections. Built-in system processes resolve their provider
83/// ids through component-specific configuration and are checked against the
84/// live Atlas registry when package startup begins.
85pub fn validate_deployment_instance_names(root: &serde_yaml::Value) -> Result<()> {
86    let mut seen = HashSet::new();
87    let mut record = |name: &str, location: &str| -> Result<()> {
88        let trimmed = name.trim();
89        if trimmed.is_empty() {
90            anyhow::bail!("{location} must declare a non-empty `name`");
91        }
92        if name != trimmed {
93            anyhow::bail!("{location} `name` must not contain leading or trailing whitespace");
94        }
95        if !seen.insert(name.to_string()) {
96            anyhow::bail!(
97                "duplicate deployment instance name '{name}' at {location}; \
98                 every primitive, service, and skill instance must be unique"
99            );
100        }
101        Ok(())
102    };
103
104    for section in ["primitive", "service", "skill"] {
105        let Some(entries) = root.get(section) else {
106            continue;
107        };
108        let entries = entries
109            .as_sequence()
110            .ok_or_else(|| anyhow::anyhow!("deployment `{section}` must be a list"))?;
111        for (index, entry) in entries.iter().enumerate() {
112            let name = entry
113                .get("name")
114                .and_then(serde_yaml::Value::as_str)
115                .unwrap_or("");
116            record(name, &format!("{section}[{index}]"))?;
117        }
118    }
119    Ok(())
120}
121
122pub fn expand_deployment_env(s: &str) -> String {
123    let mut out = String::with_capacity(s.len());
124    let bytes = s.as_bytes();
125    let mut i = 0;
126    while i < bytes.len() {
127        if bytes[i] == b'$' && i + 1 < bytes.len() {
128            if bytes[i + 1] == b'{' {
129                if let Some(end) = s[i + 2..].find('}') {
130                    out.push_str(&std::env::var(&s[i + 2..i + 2 + end]).unwrap_or_default());
131                    i = i + 2 + end + 1;
132                    continue;
133                }
134            } else if bytes[i + 1].is_ascii_alphabetic() || bytes[i + 1] == b'_' {
135                let start = i + 1;
136                let mut end = start;
137                while end < bytes.len()
138                    && (bytes[end].is_ascii_alphanumeric() || bytes[end] == b'_')
139                {
140                    end += 1;
141                }
142                out.push_str(&std::env::var(&s[start..end]).unwrap_or_default());
143                i = end;
144                continue;
145            }
146        }
147        let ch = s[i..].chars().next().expect("non-empty by loop guard");
148        out.push(ch);
149        i += ch.len_utf8();
150    }
151    out
152}
153
154/// Split deployment-owned package fields from a non-builtin `system:`
155/// package's runtime configuration.
156///
157/// The canonical shape mirrors primitive/service/skill entries:
158/// `manifest:` selects the package manifest and nested `config:` is delivered
159/// through Driver(CMD_INIT). Historical system entries put config keys beside
160/// `manifest`; keep accepting those keys with a migration warning. If both
161/// forms are present, nested `config:` wins on duplicate keys so deployments
162/// can migrate incrementally without changing runtime values.
163pub fn split_system_package_config(
164    value: &serde_yaml::Value,
165) -> Result<(Option<String>, serde_yaml::Value)> {
166    let Some(source) = value.as_mapping() else {
167        return Ok((None, value.clone()));
168    };
169    let mut fields = source.clone();
170    let manifest_key = serde_yaml::Value::String("manifest".to_string());
171    let config_key = serde_yaml::Value::String("config".to_string());
172    let manifest = match fields.remove(&manifest_key) {
173        Some(raw_manifest) => Some(
174            raw_manifest
175                .as_str()
176                .map(str::trim)
177                .filter(|name| !name.is_empty())
178                .ok_or_else(|| {
179                    anyhow::anyhow!("system package `manifest` must be a non-empty string")
180                })?
181                .to_string(),
182        ),
183        None => None,
184    };
185
186    let nested = fields.remove(&config_key);
187    if nested.is_none() {
188        if !fields.is_empty() {
189            warn!(
190                "system package uses deprecated flat runtime config keys; move them under `config:` (flat keys remain supported for compatibility)"
191            );
192        }
193        return Ok((manifest, serde_yaml::Value::Mapping(fields)));
194    }
195
196    let nested = nested.expect("checked as present above");
197    let nested = match nested {
198        serde_yaml::Value::Null => serde_yaml::Mapping::new(),
199        serde_yaml::Value::Mapping(mapping) => mapping,
200        _ => anyhow::bail!("system package `config` must be a mapping"),
201    };
202    if !fields.is_empty() {
203        warn!(
204            "system package mixes deprecated flat runtime config keys with nested `config:`; nested values take precedence"
205        );
206    }
207    for (key, value) in nested {
208        fields.insert(key, value);
209    }
210    Ok((manifest, serde_yaml::Value::Mapping(fields)))
211}
212
213fn expand_deployment_yaml(value: &mut serde_yaml::Value) {
214    match value {
215        serde_yaml::Value::String(s) => *s = expand_deployment_env(s),
216        serde_yaml::Value::Sequence(sequence) => {
217            for item in sequence {
218                expand_deployment_yaml(item);
219            }
220        }
221        serde_yaml::Value::Mapping(mapping) => {
222            for (_, item) in mapping.iter_mut() {
223                expand_deployment_yaml(item);
224            }
225        }
226        _ => {}
227    }
228}
229
230#[derive(Debug, Clone)]
231pub struct DetectedManifest {
232    pub path: PathBuf,
233    pub manifest: Manifest,
234}
235
236#[derive(Debug, Clone)]
237pub struct PackageSummary {
238    pub name: String,
239    pub version: String,
240    pub capabilities: Vec<String>,
241    pub depends: Vec<String>,
242}
243
244#[derive(Debug, Clone, Default)]
245pub struct Manifest {
246    pub manifest_version: u32,
247    pub package: Package,
248    pub build: String,
249    pub start: String,
250    pub stop: String,
251    pub capabilities: Vec<CapabilityRef>,
252    pub depends: Vec<DependsRef>,
253    /// True iff the manifest was parsed from legacy fields (id/nodes/build.script).
254    /// `rbnx` prints a deprecation warning in this case.
255    pub is_legacy: bool,
256}
257
258#[derive(Debug, Clone, Default, Deserialize)]
259pub struct Package {
260    /// New spec: `package.name`. Legacy spec allowed a separate `package.id`
261    /// — if present and `name` is missing, we fall back to `id`.
262    #[serde(default)]
263    pub name: String,
264    #[serde(default)]
265    pub id: Option<String>,
266    #[serde(default)]
267    pub version: String,
268    /// Legacy publisher label retained for manifest compatibility. It is not
269    /// used as package identity or Catalog metadata; new manifests should use
270    /// `name`, `tags`, and `maintainers` instead.
271    #[serde(default)]
272    pub vendor: String,
273    #[serde(default)]
274    pub description: String,
275    #[serde(default)]
276    pub tags: Vec<String>,
277    #[serde(default)]
278    pub maintainers: Vec<String>,
279    #[serde(default)]
280    pub license: String,
281}
282
283#[derive(Debug, Clone, Deserialize)]
284pub struct CapabilityRef {
285    /// Contract id (matches one of the TOMLs under `capabilities/`).
286    pub name: String,
287    /// Optional path to a package-local TOML that overrides / defines
288    /// this capability (for experimental providers not yet in the official
289    /// capabilities directory). Relative to the package root.
290    #[serde(default, alias = "definition")]
291    pub path: Option<String>,
292}
293
294/// One entry under a package's `depends:` list. Models a *source / lib*
295/// dependency (think Linux kernel module SOFT_DEPS) — i.e. another
296/// package whose codegen output / Python package this package needs at
297/// build or import time. NOT a boot-order dependency.
298///
299/// `name` is required (the depended-on package's `package.name`).
300/// Exactly one of `path` / `url` should be set:
301///   - `path`: filesystem path relative to this package's manifest dir
302///   - `url`:  git URL (cloned to `<pkg>/rbnx-build/deps/<name>/` on first build)
303///     Neither set means "expect it to already be installed / on PYTHONPATH".
304#[derive(Debug, Clone, Deserialize)]
305pub struct DependsRef {
306    pub name: String,
307    #[serde(default)]
308    pub path: Option<String>,
309    #[serde(default)]
310    pub url: Option<String>,
311    #[serde(default)]
312    pub branch: Option<String>,
313}
314
315// ── raw shape accepting both old and new ────────────────────────────────
316
317#[derive(Debug, Clone, Deserialize, Default)]
318struct RawManifest {
319    #[serde(rename = "manifestVersion", default)]
320    manifest_version: u32,
321    #[serde(default)]
322    package: Package,
323    /// Accept either a plain shell string (new) or `{ script: <path> }` (legacy).
324    #[serde(default)]
325    build: Option<BuildField>,
326    /// New: top-level shell string.
327    #[serde(default)]
328    start: Option<String>,
329    /// Optional package-owned cleanup command, symmetric with `start`.
330    #[serde(default)]
331    stop: Option<String>,
332    /// Legacy: list of nodes, each with its own `start` block.
333    #[serde(default)]
334    nodes: Vec<LegacyNode>,
335    #[serde(default)]
336    capabilities: Vec<CapabilityRef>,
337    #[serde(default)]
338    depends: Vec<DependsRef>,
339    /// Legacy `provider_id:` field. Removed from spec — accepted by serde but
340    /// ignored. rbnx-boot now discovers the provider_id by polling atlas for any provider
341    /// that registered after `start.sh` spawned. Future warning: emit deprecation.
342    #[serde(default)]
343    #[allow(dead_code)]
344    provider_id: Option<String>,
345}
346
347#[derive(Debug, Clone, Deserialize)]
348#[serde(untagged)]
349enum BuildField {
350    Shell(String),
351    Script { script: String },
352}
353
354#[derive(Debug, Clone, Deserialize)]
355struct LegacyNode {
356    #[serde(default)]
357    #[allow(dead_code)]
358    id: String,
359    #[serde(default, rename = "type")]
360    #[allow(dead_code)]
361    node_type: Option<String>,
362    #[serde(default)]
363    start: String,
364}
365
366/// Locate a package's manifest file inside `package_root`.
367///
368/// `override_name` lets a deploy entry select a non-default manifest file
369/// (the `manifest:` field on a deploy `PackageEntry`) so one package can
370/// ship per-deployment-target variants — e.g. `package_manifest.yaml`
371/// (x86 + docker), `package_manifest.jetson-native.yaml`, and
372/// `package_manifest.jetson-docker.yaml`, each with its own build/start —
373/// without changing the manifest schema itself. When set, the named file
374/// MUST exist (a typo'd target should fail loud, not silently fall back to
375/// the default manifest and build the wrong thing). When `None`, the
376/// default `package_manifest.yaml` (then legacy) is used as before.
377pub fn detect_manifest_path(package_root: &Path, override_name: Option<&str>) -> Result<PathBuf> {
378    if let Some(name) = override_name {
379        let p = package_root.join(name);
380        if p.is_file() {
381            return Ok(p);
382        }
383        anyhow::bail!(
384            "manifest override `{name}` not found in {} — the deploy entry's \
385             `manifest:` field names a file the package does not ship",
386            package_root.display()
387        );
388    }
389    let new_path = package_root.join(MANIFEST_FILE);
390    if new_path.exists() {
391        return Ok(new_path);
392    }
393    let legacy = package_root.join(LEGACY_MANIFEST_FILE);
394    if legacy.exists() {
395        return Ok(legacy);
396    }
397    anyhow::bail!("Package does not have {MANIFEST_FILE} (or legacy {LEGACY_MANIFEST_FILE})")
398}
399
400pub fn detect_and_load(
401    package_root: &Path,
402    override_name: Option<&str>,
403) -> Result<DetectedManifest> {
404    let path = detect_manifest_path(package_root, override_name)?;
405    let manifest = load_from_path(&path)?;
406    Ok(DetectedManifest { path, manifest })
407}
408
409pub fn load_from_path(manifest_path: &Path) -> Result<Manifest> {
410    let content = std::fs::read_to_string(manifest_path)
411        .with_context(|| format!("Failed to read manifest: {}", manifest_path.display()))?;
412    let raw: RawManifest = serde_yaml::from_str(&content)
413        .with_context(|| format!("Failed to parse manifest: {}", manifest_path.display()))?;
414    Ok(normalize(raw, manifest_path))
415}
416
417fn normalize(raw: RawManifest, manifest_path: &Path) -> Manifest {
418    let mut is_legacy = false;
419    let filename_is_legacy = manifest_path
420        .file_name()
421        .and_then(|n| n.to_str())
422        .map(|s| s == LEGACY_MANIFEST_FILE)
423        .unwrap_or(false);
424
425    // package.name fallback to package.id (legacy used id as canonical name).
426    let mut package = raw.package;
427    if package.name.trim().is_empty()
428        && let Some(id) = &package.id
429        && !id.trim().is_empty()
430    {
431        package.name = id.clone();
432        is_legacy = true;
433    }
434
435    // build: string (new) or { script } (legacy).
436    let build = match raw.build {
437        Some(BuildField::Shell(s)) => s,
438        Some(BuildField::Script { script }) => {
439            is_legacy = true;
440            format!("bash {script}")
441        }
442        None => String::new(),
443    };
444
445    // start: top-level string (new) or concatenate nodes (legacy).
446    let start = match (raw.start, raw.nodes.is_empty()) {
447        (Some(s), _) => s,
448        (None, false) => {
449            is_legacy = true;
450            // Concatenate legacy node start blocks. Each block gets wrapped
451            // in a subshell and backgrounded; a `wait` at the end keeps
452            // `rbnx start` alive until all nodes exit. This is a best-effort
453            // port — for deterministic deploys, migrate to the new spec.
454            let parts: Vec<String> = raw
455                .nodes
456                .iter()
457                .filter(|n| !n.start.trim().is_empty())
458                .map(|n| format!("( {} ) &", n.start.trim()))
459                .collect();
460            if parts.is_empty() {
461                String::new()
462            } else {
463                format!("{}\nwait", parts.join("\n"))
464            }
465        }
466        (None, true) => String::new(),
467    };
468
469    let stop = raw.stop.unwrap_or_default();
470
471    if filename_is_legacy {
472        is_legacy = true;
473    }
474
475    Manifest {
476        manifest_version: raw.manifest_version,
477        package,
478        build,
479        start,
480        stop,
481        capabilities: raw.capabilities,
482        depends: raw.depends,
483        is_legacy,
484    }
485}
486
487impl Manifest {
488    /// Return the Driver written in `capabilities:`, if any.
489    pub fn explicit_lifecycle_driver_contract(&self) -> Result<Option<&str>> {
490        let drivers = self
491            .capabilities
492            .iter()
493            .filter(|capability| capability.name.ends_with("/driver"))
494            .map(|capability| capability.name.as_str())
495            .collect::<Vec<_>>();
496        if drivers.len() > 1 {
497            anyhow::bail!(
498                "package '{}' declares multiple lifecycle Driver contracts: {}; declare at most one shared or legacy Driver, never both",
499                self.package.name,
500                drivers.join(", ")
501            );
502        }
503        Ok(drivers.first().copied())
504    }
505
506    /// Resolve the lifecycle contract selected by this package manifest.
507    ///
508    /// Omission selects the shared Driver so every current provider has a
509    /// managed lifecycle. An explicitly declared legacy namespace Driver is
510    /// preserved exactly. Runtime launchers may select the exact legacy
511    /// namespace Driver when an older generated package lacks the shared
512    /// binding; if neither binding exists, startup fails.
513    pub fn selected_lifecycle_driver_contract(&self) -> Result<&str> {
514        Ok(self
515            .explicit_lifecycle_driver_contract()?
516            .unwrap_or(SHARED_LIFECYCLE_DRIVER_CONTRACT))
517    }
518
519    pub fn validate_and_summarize(&self) -> Result<PackageSummary> {
520        if self.manifest_version == 0 {
521            anyhow::bail!("Invalid 'manifestVersion': must be >= 1");
522        }
523        let p = &self.package;
524        for (name, val) in [
525            ("package.name", &p.name),
526            ("package.version", &p.version),
527            ("package.description", &p.description),
528            ("package.license", &p.license),
529        ] {
530            if val.trim().is_empty() {
531                anyhow::bail!("Missing '{name}' in manifest");
532            }
533        }
534        // `build` remains optional — packages that ship pre-built binaries
535        // can omit it. `start` is required for `rbnx start` to do anything.
536        if self.start.trim().is_empty() {
537            anyhow::bail!(
538                "manifest.start is required (shell string, run at package root). \
539                 If migrating from the legacy spec with `nodes: [...]`, either \
540                 concatenate their start blocks into one top-level `start:` or \
541                 split into multiple packages."
542            );
543        }
544
545        // Validate lifecycle selection in every package entry point (build,
546        // install, start and validate), before any provider is launched.
547        self.selected_lifecycle_driver_contract()?;
548
549        if self.is_legacy {
550            warn!(
551                "package '{}' uses legacy manifest fields (package.id / nodes[] / \
552                 build.script / robonix_manifest.yaml). These still work but are \
553                 deprecated — migrate to the new spec (package_manifest.yaml with \
554                 top-level build/start strings + capabilities).",
555                self.package.name
556            );
557        }
558
559        Ok(PackageSummary {
560            name: p.name.clone(),
561            version: p.version.clone(),
562            capabilities: self.capabilities.iter().map(|c| c.name.clone()).collect(),
563            depends: self.depends.iter().map(|d| d.name.clone()).collect(),
564        })
565    }
566}
567
568#[cfg(test)]
569mod tests {
570    use super::*;
571
572    #[test]
573    fn package_vendor_is_backward_compatible() {
574        let raw: RawManifest = serde_yaml::from_str(
575            r#"
576manifestVersion: 1
577package:
578  name: com.robonix.system.scene
579  version: 0.1.0
580  vendor: robonix
581  description: Legacy package metadata example.
582  license: MulanPSL-2.0
583start: bash scripts/start.sh
584capabilities: []
585"#,
586        )
587        .expect("legacy package.vendor must parse");
588
589        let manifest = normalize(raw, Path::new("package_manifest.yaml"));
590
591        assert_eq!(manifest.package.vendor, "robonix");
592        manifest
593            .validate_and_summarize()
594            .expect("package.vendor must not invalidate an otherwise valid manifest");
595    }
596
597    #[test]
598    fn system_package_manifest_is_separate_from_runtime_config() {
599        let value: serde_yaml::Value = serde_yaml::from_str(
600            r#"
601manifest: package_manifest.jetson-native.yaml
602config:
603  camera_provider_id: front_camera
604  web_port: 50107
605"#,
606        )
607        .unwrap();
608
609        let (manifest, config) = split_system_package_config(&value).unwrap();
610
611        assert_eq!(
612            manifest.as_deref(),
613            Some("package_manifest.jetson-native.yaml")
614        );
615        assert_eq!(config["camera_provider_id"], "front_camera");
616        assert_eq!(config["web_port"], 50107);
617        assert!(config.get("manifest").is_none());
618    }
619
620    #[test]
621    fn legacy_system_package_config_is_unchanged() {
622        let value: serde_yaml::Value = serde_yaml::from_str(
623            r#"
624camera_provider_id: front_camera
625web_port: 50107
626"#,
627        )
628        .unwrap();
629
630        let (manifest, config) = split_system_package_config(&value).unwrap();
631
632        assert!(manifest.is_none());
633        assert_eq!(config, value);
634    }
635
636    #[test]
637    fn nested_system_config_wins_during_incremental_migration() {
638        let value: serde_yaml::Value = serde_yaml::from_str(
639            r#"
640manifest: package_manifest.jetson-native.yaml
641camera_provider_id: legacy_camera
642legacy_only: kept
643config:
644  camera_provider_id: front_camera
645  web_port: 50107
646"#,
647        )
648        .unwrap();
649
650        let (manifest, config) = split_system_package_config(&value).unwrap();
651
652        assert_eq!(
653            manifest.as_deref(),
654            Some("package_manifest.jetson-native.yaml")
655        );
656        assert_eq!(config["camera_provider_id"], "front_camera");
657        assert_eq!(config["legacy_only"], "kept");
658        assert_eq!(config["web_port"], 50107);
659        assert!(config.get("manifest").is_none());
660        assert!(config.get("config").is_none());
661    }
662
663    #[test]
664    fn system_package_config_rejects_non_mapping_values() {
665        let value: serde_yaml::Value = serde_yaml::from_str("config: front_camera\n").unwrap();
666        let error = split_system_package_config(&value).unwrap_err();
667        assert!(error.to_string().contains("`config` must be a mapping"));
668    }
669
670    #[test]
671    fn system_package_manifest_rejects_non_string_values() {
672        let value: serde_yaml::Value = serde_yaml::from_str("manifest: 42\n").unwrap();
673        let error = split_system_package_config(&value).unwrap_err();
674        assert!(error.to_string().contains("must be a non-empty string"));
675    }
676
677    #[test]
678    fn deployment_instance_names_are_unique_across_sections() {
679        let valid: serde_yaml::Value = serde_yaml::from_str(
680            r#"
681system:
682  scene: {}
683primitive:
684  - name: front_camera
685    path: camera
686  - name: wrist_camera
687    path: camera
688service:
689  - name: navigation
690    path: navigation
691"#,
692        )
693        .unwrap();
694        validate_deployment_instance_names(&valid).unwrap();
695
696        let duplicate: serde_yaml::Value = serde_yaml::from_str(
697            r#"
698system:
699  scene: {}
700primitive:
701  - name: scene
702    path: camera
703service:
704  - name: scene
705    path: scene
706"#,
707        )
708        .unwrap();
709        let error = validate_deployment_instance_names(&duplicate)
710            .unwrap_err()
711            .to_string();
712        assert!(error.contains("duplicate deployment instance name 'scene'"));
713    }
714
715    #[test]
716    fn deployment_instance_names_reject_outer_whitespace() {
717        let manifest: serde_yaml::Value = serde_yaml::from_str(
718            r#"
719primitive:
720  - name: " front_camera "
721    path: camera
722"#,
723        )
724        .unwrap();
725        let error = validate_deployment_instance_names(&manifest)
726            .unwrap_err()
727            .to_string();
728        assert!(error.contains("must not contain leading or trailing whitespace"));
729    }
730
731    #[test]
732    fn deployment_package_instance_name_is_required() {
733        let manifest: serde_yaml::Value = serde_yaml::from_str(
734            r#"
735primitive:
736  - path: camera
737"#,
738        )
739        .unwrap();
740        let error = validate_deployment_instance_names(&manifest)
741            .unwrap_err()
742            .to_string();
743        assert!(error.contains("primitive[0] must declare a non-empty `name`"));
744    }
745
746    #[test]
747    fn lifecycle_driver_omission_selects_shared_and_explicit_legacy_is_preserved() {
748        let manifest_with = |capabilities: Vec<&str>| Manifest {
749            package: Package {
750                name: "test.package".to_string(),
751                ..Package::default()
752            },
753            capabilities: capabilities
754                .into_iter()
755                .map(|name| CapabilityRef {
756                    name: name.to_string(),
757                    path: None,
758                })
759                .collect(),
760            ..Manifest::default()
761        };
762
763        assert_eq!(
764            manifest_with(vec![])
765                .selected_lifecycle_driver_contract()
766                .unwrap(),
767            SHARED_LIFECYCLE_DRIVER_CONTRACT
768        );
769        assert_eq!(
770            manifest_with(vec![SHARED_LIFECYCLE_DRIVER_CONTRACT])
771                .selected_lifecycle_driver_contract()
772                .unwrap(),
773            SHARED_LIFECYCLE_DRIVER_CONTRACT
774        );
775        assert_eq!(
776            manifest_with(vec!["robonix/primitive/camera/driver"])
777                .selected_lifecycle_driver_contract()
778                .unwrap(),
779            "robonix/primitive/camera/driver"
780        );
781
782        let error = manifest_with(vec![
783            SHARED_LIFECYCLE_DRIVER_CONTRACT,
784            "robonix/primitive/camera/driver",
785        ])
786        .selected_lifecycle_driver_contract()
787        .unwrap_err()
788        .to_string();
789        assert!(error.contains("never both"));
790    }
791}