1use anyhow::{Context, Result};
21use serde::Deserialize;
22use std::path::{Path, PathBuf};
23
24pub 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 pub is_legacy: bool,
54}
55
56#[derive(Debug, Clone, Default, Deserialize)]
57pub struct Package {
58 #[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 pub name: String,
78 #[serde(default, alias = "definition")]
82 pub path: Option<String>,
83}
84
85#[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#[derive(Debug, Clone, Deserialize, Default)]
109struct RawManifest {
110 #[serde(rename = "manifestVersion", default)]
111 manifest_version: u32,
112 #[serde(default)]
113 package: Package,
114 #[serde(default)]
116 build: Option<BuildField>,
117 #[serde(default)]
119 start: Option<String>,
120 #[serde(default)]
122 nodes: Vec<LegacyNode>,
123 #[serde(default)]
124 capabilities: Vec<CapabilityRef>,
125 #[serde(default)]
126 depends: Vec<DependsRef>,
127 #[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 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 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 let start = match (raw.start, raw.nodes.is_empty()) {
210 (Some(s), _) => s,
211 (None, false) => {
212 is_legacy = true;
213 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 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}