Skip to main content

weft_core/package/
config.rs

1use anyhow::{Context, Result};
2use serde::{Deserialize, Serialize};
3use std::collections::HashMap;
4use std::path::Path;
5
6/// Top-level package.toml structure.
7#[derive(Debug, Clone, Serialize, Deserialize)]
8pub struct PackageManifest {
9    #[serde(default)]
10    pub identity: Option<PackageIdentity>,
11    #[serde(rename = "package_info")]
12    pub package_info: PackageDescriptor,
13    #[serde(default)]
14    pub capability: Option<PackageCapability>,
15    #[serde(default)]
16    pub package: Option<PackageMeta>,
17    #[serde(default)]
18    pub runtime_contract: Option<PackageRuntimeContract>,
19    #[serde(default)]
20    pub lifecycle: Option<PackageLifecycle>,
21    #[serde(default)]
22    pub permissions: PackagePermissions,
23    #[serde(default)]
24    pub dependencies: HashMap<String, String>,
25    #[serde(default)]
26    pub config: HashMap<String, PackageConfigField>,
27}
28
29#[derive(Debug, Clone, Default, Serialize, Deserialize)]
30pub struct PackageCapability {
31    #[serde(default)]
32    pub provides: Vec<String>,
33}
34
35#[derive(Debug, Clone, Default, Serialize, Deserialize)]
36pub struct PackageIdentity {
37    #[serde(default)]
38    pub name: String,
39    #[serde(default)]
40    pub version: String,
41    #[serde(default)]
42    pub description: String,
43}
44
45/// [package_info] section — identity and entry point.
46#[derive(Debug, Clone, Serialize, Deserialize)]
47pub struct PackageDescriptor {
48    pub name: String,
49    pub version: String,
50    pub description: String,
51    #[serde(default = "default_entry")]
52    pub entry: String,
53    /// Capabilities this package provides (e.g., ["chat_channel", "tool", "transform"])
54    #[serde(default)]
55    pub provides: Vec<String>,
56    /// Chat endpoint path if this package provides chat_channel capability
57    pub chat_endpoint: Option<String>,
58}
59
60fn default_entry() -> String {
61    "package.wasm".into()
62}
63
64#[derive(Debug, Clone, Default, Serialize, Deserialize)]
65pub struct PackageMeta {
66    #[serde(default)]
67    pub entry: Option<String>,
68    #[serde(default)]
69    pub runtime: Option<String>,
70    /// Package kind, e.g. "product" (capability composition, no executable entry),
71    /// "foundation", "provider". Absent for most leaf packages.
72    #[serde(default)]
73    pub kind: Option<String>,
74    #[serde(default)]
75    pub api_version: Option<String>,
76    #[serde(default)]
77    pub native_allowed: bool,
78    #[serde(default)]
79    pub expected_digest: Option<String>,
80    #[serde(default)]
81    pub chat_endpoint: Option<String>,
82    #[serde(default)]
83    pub provides: Vec<String>,
84    #[serde(default)]
85    pub priority: Option<i32>,
86    #[serde(default)]
87    pub assets: Vec<String>,
88}
89
90#[derive(Debug, Clone, Default, Serialize, Deserialize)]
91pub struct PackageRuntimeContract {
92    #[serde(default)]
93    pub startup_mode: Option<String>,
94    #[serde(default)]
95    pub communication: Vec<String>,
96    #[serde(default)]
97    pub config_delivery: Vec<String>,
98    #[serde(default)]
99    pub readiness_probe: Option<String>,
100    #[serde(default)]
101    pub liveness_probe: Option<String>,
102    #[serde(default)]
103    pub stop_mode: Option<String>,
104    #[serde(default)]
105    pub restart_policy: Option<String>,
106}
107
108/// [config.*] section — schema for package configuration fields.
109#[derive(Debug, Clone, Serialize, Deserialize)]
110pub struct PackageConfigField {
111    #[serde(rename = "type")]
112    pub field_type: String, // string, number, boolean, select
113    #[serde(default)]
114    pub required: bool,
115    #[serde(default)]
116    pub secret: bool,
117    pub default: Option<serde_json::Value>,
118    pub description: Option<String>,
119    #[serde(default)]
120    pub options: Option<Vec<String>>, // for select type
121    pub min: Option<f64>,
122    pub max: Option<f64>,
123}
124
125/// [permissions] section — capability declarations.
126#[derive(Debug, Clone, Default, Serialize, Deserialize)]
127pub struct PackagePermissions {
128    #[serde(default)]
129    pub process: bool,
130    #[serde(default)]
131    pub network: bool,
132    #[serde(default)]
133    pub auth: bool,
134    #[serde(default)]
135    pub storage: bool,
136    #[serde(default)]
137    pub pipeline: bool,
138    #[serde(default)]
139    pub routes: bool,
140    #[serde(default)]
141    pub events: bool,
142    #[serde(default)]
143    pub log: bool,
144    #[serde(default)]
145    pub ui: bool,
146    #[serde(default)]
147    pub config: bool,
148    #[serde(default)]
149    pub scheduler: bool,
150}
151
152#[derive(Debug, Clone, Default, Serialize, Deserialize)]
153pub struct PackageLifecycle {
154    #[serde(default)]
155    pub healthcheck: Option<String>,
156}
157
158fn clean_optional_string(value: Option<String>) -> Option<String> {
159    value.and_then(|entry| {
160        let trimmed = entry.trim();
161        if trimmed.is_empty() {
162            None
163        } else {
164            Some(trimmed.to_string())
165        }
166    })
167}
168
169impl PackageManifest {
170    pub fn resolved_entry(&self) -> Option<String> {
171        clean_optional_string(
172            self.package
173                .as_ref()
174                .and_then(|package| package.entry.clone())
175                .or_else(|| Some(self.package_info.entry.clone())),
176        )
177    }
178
179    pub fn runtime_kind(&self) -> String {
180        if let Some(runtime) = clean_optional_string(
181            self.package
182                .as_ref()
183                .and_then(|package| package.runtime.clone()),
184        ) {
185            return runtime;
186        }
187
188        match self.resolved_entry() {
189            Some(entry) if entry.ends_with(".wasm") => "wasm".into(),
190            Some(_) => "service".into(),
191            None => "wasm".into(),
192        }
193    }
194
195    pub fn resolved_chat_endpoint(&self) -> Option<String> {
196        clean_optional_string(
197            self.package
198                .as_ref()
199                .and_then(|package| package.chat_endpoint.clone())
200                .or_else(|| self.package_info.chat_endpoint.clone()),
201        )
202    }
203
204    pub fn resolved_provides(&self) -> Vec<String> {
205        let mut provides = self.package_info.provides.clone();
206        if let Some(capability) = &self.capability {
207            provides.extend(capability.provides.clone());
208        }
209        if let Some(package) = &self.package {
210            provides.extend(package.provides.clone());
211        }
212        provides.retain(|value| !value.trim().is_empty());
213        provides.sort();
214        provides.dedup();
215        provides
216    }
217
218    /// True if this is a product package (`[package] kind = "product"`), which
219    /// composes capabilities via [requires]/[bindings] and has no executable
220    /// entry of its own — so wasm/entry checks do not apply.
221    pub fn is_product(&self) -> bool {
222        self.package
223            .as_ref()
224            .and_then(|package| package.kind.as_deref())
225            .map(|kind| kind.eq_ignore_ascii_case("product"))
226            .unwrap_or(false)
227    }
228
229    pub fn resolved_healthcheck(&self) -> Option<String> {
230        clean_optional_string(
231            self.runtime_contract
232                .as_ref()
233                .and_then(|contract| contract.readiness_probe.clone())
234                .or_else(|| {
235                    self.lifecycle
236                        .as_ref()
237                        .and_then(|lifecycle| lifecycle.healthcheck.clone())
238                }),
239        )
240    }
241}
242
243/// Load and parse a package.toml from a package directory.
244pub fn load_manifest(package_dir: &Path) -> Result<PackageManifest> {
245    let manifest_path = package_dir.join("package.toml");
246    let content = std::fs::read_to_string(&manifest_path)
247        .with_context(|| format!("Failed to read {}", manifest_path.display()))?;
248
249    let has_package_section = content.lines().any(|line| line.trim() == "[package_info]");
250    let mut manifest: PackageManifest = if has_package_section {
251        toml::from_str(&content)
252            .with_context(|| format!("Failed to parse {}", manifest_path.display()))?
253    } else {
254        #[derive(Debug, Clone, Default, Serialize, Deserialize)]
255        struct IdentityOnlyManifest {
256            #[serde(default)]
257            identity: Option<PackageIdentity>,
258            #[serde(default)]
259            capability: Option<PackageCapability>,
260            #[serde(default)]
261            package: Option<PackageMeta>,
262            #[serde(default)]
263            runtime_contract: Option<PackageRuntimeContract>,
264            #[serde(default)]
265            lifecycle: Option<PackageLifecycle>,
266            #[serde(default)]
267            permissions: PackagePermissions,
268            #[serde(default)]
269            dependencies: HashMap<String, String>,
270            #[serde(default)]
271            config: HashMap<String, PackageConfigField>,
272        }
273
274        let raw: IdentityOnlyManifest = toml::from_str(&content)
275            .with_context(|| format!("Failed to parse {}", manifest_path.display()))?;
276        PackageManifest {
277            identity: raw.identity,
278            package_info: PackageDescriptor {
279                name: String::new(),
280                version: String::new(),
281                description: String::new(),
282                entry: default_entry(),
283                provides: Vec::new(),
284                chat_endpoint: None,
285            },
286            capability: raw.capability,
287            package: raw.package,
288            runtime_contract: raw.runtime_contract,
289            lifecycle: raw.lifecycle,
290            permissions: raw.permissions,
291            dependencies: raw.dependencies,
292            config: raw.config,
293        }
294    };
295
296    if manifest.package_info.name.trim().is_empty() {
297        if let Some(identity) = &manifest.identity {
298            manifest.package_info.name = identity.name.clone();
299            manifest.package_info.version = identity.version.clone();
300            manifest.package_info.description = identity.description.clone();
301            if manifest.package_info.entry.trim().is_empty() {
302                manifest.package_info.entry = default_entry();
303            }
304        }
305    }
306
307    Ok(manifest)
308}
309
310#[cfg(test)]
311mod tests {
312    use super::*;
313
314    #[test]
315    fn test_parse_package_manifest() {
316        let toml_str = r#"
317[package_info]
318name = "weft-claw"
319version = "0.1.0"
320description = "Weft Claw AI agent orchestration"
321entry = "backend.ts"
322
323[permissions]
324process = true
325network = true
326auth = true
327storage = true
328pipeline = false
329routes = true
330events = true
331log = true
332ui = true
333config = true
334scheduler = true
335
336[ui]
337title = "Weft Claw"
338icon = "robot"
339entry = "ui/index.html"
340"#;
341        let manifest: PackageManifest = toml::from_str(toml_str).unwrap();
342        assert_eq!(manifest.package_info.name, "weft-claw");
343        assert_eq!(manifest.package_info.version, "0.1.0");
344        assert_eq!(manifest.package_info.entry, "backend.ts"); // explicit entry in toml
345        assert!(manifest.permissions.process);
346        assert!(manifest.permissions.network);
347        assert!(!manifest.permissions.pipeline);
348    }
349
350    #[test]
351    fn test_parse_minimal_manifest() {
352        let toml_str = r#"
353[package_info]
354name = "simple"
355version = "0.1.0"
356description = "A simple package"
357"#;
358        let manifest: PackageManifest = toml::from_str(toml_str).unwrap();
359        assert_eq!(manifest.package_info.name, "simple");
360        assert_eq!(manifest.package_info.entry, "package.wasm"); // default entry
361        assert!(!manifest.permissions.process);
362    }
363
364    #[test]
365    fn test_parse_identity_manifest() {
366        let toml_str = r#"
367[identity]
368name = "companion-core"
369version = "0.1.0"
370description = "Companion runtime"
371
372[package]
373entry = "server.py"
374runtime = "service"
375"#;
376
377        let temp = tempfile::tempdir().unwrap();
378        let path = temp.path().join("package.toml");
379        std::fs::write(&path, toml_str).unwrap();
380        let loaded = load_manifest(temp.path()).unwrap();
381        assert_eq!(loaded.package_info.name, "companion-core");
382        assert_eq!(loaded.package_info.version, "0.1.0");
383        assert_eq!(loaded.package_info.description, "Companion runtime");
384        assert_eq!(loaded.resolved_entry().as_deref(), Some("server.py"));
385    }
386
387    #[test]
388    fn test_parse_modern_service_manifest() {
389        let toml_str = r#"
390[package_info]
391name = "ticker-watch"
392version = "0.1.0"
393description = "Ticker watcher"
394
395[package]
396entry = "dist/server.js"
397runtime = "service"
398chat_endpoint = "/chat"
399
400[runtime_contract]
401startup_mode = "persistent"
402communication = ["http"]
403config_delivery = ["config_file"]
404readiness_probe = "http://127.0.0.1:43111/health"
405restart_policy = "host_managed"
406
407[lifecycle]
408healthcheck = "http://127.0.0.1:43111/health"
409"#;
410        let manifest: PackageManifest = toml::from_str(toml_str).unwrap();
411        assert_eq!(manifest.resolved_entry().as_deref(), Some("dist/server.js"));
412        assert_eq!(manifest.runtime_kind(), "service");
413        assert_eq!(manifest.resolved_chat_endpoint().as_deref(), Some("/chat"));
414        assert_eq!(
415            manifest.resolved_healthcheck().as_deref(),
416            Some("http://127.0.0.1:43111/health")
417        );
418    }
419
420    #[test]
421    fn test_resolved_provides_includes_package_values() {
422        let toml_str = r#"
423[package_info]
424name = "companion-core"
425version = "0.1.0"
426description = "Companion"
427
428[package]
429entry = "server.py"
430runtime = "service"
431provides = ["chat_channel", "companion.turn.handle"]
432"#;
433        let manifest: PackageManifest = toml::from_str(toml_str).unwrap();
434        assert!(manifest
435            .resolved_provides()
436            .contains(&"chat_channel".to_string()));
437        assert!(manifest
438            .resolved_provides()
439            .contains(&"companion.turn.handle".to_string()));
440    }
441
442    #[test]
443    fn test_resolved_provides_includes_capability_section() {
444        let toml_str = r#"
445[identity]
446name = "companion-core"
447version = "0.1.0"
448description = "Companion runtime"
449
450[package]
451entry = "server.py"
452runtime = "service"
453
454[capability]
455provides = ["chat_channel", "companion.turn.handle"]
456"#;
457        let temp = tempfile::tempdir().unwrap();
458        let path = temp.path().join("package.toml");
459        std::fs::write(&path, toml_str).unwrap();
460        let loaded = load_manifest(temp.path()).unwrap();
461        assert!(loaded
462            .resolved_provides()
463            .contains(&"chat_channel".to_string()));
464        assert!(loaded
465            .resolved_provides()
466            .contains(&"companion.turn.handle".to_string()));
467    }
468
469    #[test]
470    fn test_load_current_official_package_manifests() {
471        let repo_root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("..");
472
473        let agent_core = load_manifest(
474            &repo_root
475                .join("packages")
476                .join("official")
477                .join("agent-core"),
478        )
479        .expect("agent-core manifest should load");
480        assert_eq!(agent_core.package_info.name, "agent-runtime");
481        assert_eq!(agent_core.runtime_kind(), "wasm");
482        assert!(agent_core
483            .resolved_provides()
484            .contains(&"agent.runtime".to_string()));
485
486        let weft_claw = load_manifest(
487            &repo_root
488                .join("packages")
489                .join("official")
490                .join("weft-claw"),
491        )
492        .expect("weft-claw manifest should load");
493        assert_eq!(weft_claw.package_info.name, "weft-claw");
494        assert_eq!(weft_claw.runtime_kind(), "wasm");
495        assert!(weft_claw
496            .resolved_provides()
497            .contains(&"weft_claw.turn".to_string()));
498    }
499}