1use anyhow::{Context, Result};
21use robonix_scribe::warn;
22use serde::Deserialize;
23use std::collections::{HashMap, HashSet};
24use std::path::{Path, PathBuf};
25
26pub 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
32pub 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
46pub 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 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
78pub 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
154pub 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 pub is_legacy: bool,
256}
257
258#[derive(Debug, Clone, Default, Deserialize)]
259pub struct Package {
260 #[serde(default)]
263 pub name: String,
264 #[serde(default)]
265 pub id: Option<String>,
266 #[serde(default)]
267 pub version: String,
268 #[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 pub name: String,
287 #[serde(default, alias = "definition")]
291 pub path: Option<String>,
292}
293
294#[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#[derive(Debug, Clone, Deserialize, Default)]
318struct RawManifest {
319 #[serde(rename = "manifestVersion", default)]
320 manifest_version: u32,
321 #[serde(default)]
322 package: Package,
323 #[serde(default)]
325 build: Option<BuildField>,
326 #[serde(default)]
328 start: Option<String>,
329 #[serde(default)]
331 stop: Option<String>,
332 #[serde(default)]
334 nodes: Vec<LegacyNode>,
335 #[serde(default)]
336 capabilities: Vec<CapabilityRef>,
337 #[serde(default)]
338 depends: Vec<DependsRef>,
339 #[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
366pub 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 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 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 let start = match (raw.start, raw.nodes.is_empty()) {
447 (Some(s), _) => s,
448 (None, false) => {
449 is_legacy = true;
450 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 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 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 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 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}