1use anyhow::{Context, Result};
2use serde::{Deserialize, Serialize};
3use std::collections::HashMap;
4use std::path::{Path, PathBuf};
5
6fn string_is_empty(value: &str) -> bool {
7 value.trim().is_empty()
8}
9
10fn scene_metadata_is_empty(metadata: &AppSceneMetadata) -> bool {
11 metadata.created_by.trim().is_empty()
12 && metadata.created_at.is_none()
13 && metadata.updated_at.is_none()
14}
15
16#[derive(Debug, Clone, Serialize, Deserialize, Default)]
17pub struct AppIdentity {
18 pub name: String,
19 pub version: String,
20 pub display_name: String,
21 pub description: String,
22}
23
24#[derive(Debug, Clone, Serialize, Deserialize, Default)]
25pub struct AppRequires {
26 #[serde(default)]
27 pub capabilities: Vec<String>,
28}
29
30#[derive(Debug, Clone, Serialize, Deserialize, Default)]
31pub struct AppFeatureSet {
32 #[serde(default)]
33 pub default_enabled: Vec<String>,
34 #[serde(default)]
35 pub optional: Vec<String>,
36}
37
38#[derive(Debug, Clone, Serialize, Deserialize, Default)]
39pub struct AppFeatureBinding {
40 #[serde(default)]
41 pub requires: Vec<String>,
42 #[serde(default)]
43 pub packages: Vec<String>,
44}
45
46#[derive(Debug, Clone, Serialize, Deserialize, Default)]
47pub struct AppBindingConfig {
48 pub provider: String,
49 #[serde(default)]
50 pub mutable: bool,
51 #[serde(default)]
52 pub allowed: Vec<String>,
53}
54
55#[derive(Debug, Clone, Serialize, Deserialize, Default)]
56pub struct AppValidation {
57 #[serde(default)]
58 pub checks: Vec<String>,
59}
60
61#[derive(Debug, Clone, Serialize, Deserialize, Default)]
62pub struct AppUpgrade {
63 pub strategy: Option<String>,
64}
65
66#[derive(Debug, Clone, Serialize, Deserialize, Default)]
67pub struct AppManifest {
68 #[serde(default)]
69 pub schema_version: u32,
70 #[serde(default)]
71 pub app: AppIdentity,
72 #[serde(default)]
73 pub requires: AppRequires,
74 #[serde(default)]
75 pub bindings: HashMap<String, HashMap<String, AppBindingConfig>>,
76 #[serde(default)]
77 pub features: AppFeatureSet,
78 #[serde(default)]
79 pub feature_bindings: HashMap<String, AppFeatureBinding>,
80 #[serde(default)]
81 pub validation: AppValidation,
82 #[serde(default)]
83 pub upgrade: AppUpgrade,
84}
85
86pub type ProductPackageDeclaration = AppManifest;
87
88#[derive(Debug, Clone, Serialize, Deserialize, Default)]
89struct RawProductIdentity {
90 #[serde(default)]
91 name: String,
92 #[serde(default)]
93 version: String,
94 #[serde(default)]
95 display_name: String,
96 #[serde(default)]
97 description: String,
98}
99
100#[derive(Debug, Clone, Serialize, Deserialize, Default)]
101struct RawProductPackageMeta {
102 #[serde(default)]
103 kind: String,
104}
105
106#[derive(Debug, Clone, Serialize, Deserialize, Default)]
107struct RawProductPackageDeclaration {
108 #[serde(default)]
109 schema_version: u32,
110 #[serde(default)]
111 identity: RawProductIdentity,
112 #[serde(default)]
113 package: RawProductPackageMeta,
114 #[serde(default)]
115 requires: AppRequires,
116 #[serde(default)]
117 bindings: HashMap<String, AppBindingConfig>,
118 #[serde(default)]
119 features: AppFeatureSet,
120 #[serde(default)]
121 feature_bindings: HashMap<String, AppFeatureBinding>,
122 #[serde(default)]
123 validation: AppValidation,
124 #[serde(default)]
125 upgrade: AppUpgrade,
126}
127
128impl AppManifest {
129 pub fn flattened_bindings(&self) -> HashMap<String, AppBindingConfig> {
130 let mut result = HashMap::new();
131
132 for (section, entries) in &self.bindings {
133 for (name, binding) in entries {
134 result.insert(format!("{}.{}", section, name), binding.clone());
135 }
136 }
137
138 result
139 }
140}
141
142#[derive(Debug, Clone, Serialize, Deserialize, Default)]
143pub struct AppRuntimeConfig {
144 #[serde(default)]
145 pub profile: String,
146 #[serde(default)]
147 pub scene: String,
148 #[serde(default)]
149 pub workspace: String,
150 #[serde(default)]
151 pub default_model: String,
152}
153
154#[derive(Debug, Clone, Serialize, Deserialize, Default)]
155pub struct AppSecretsConfig {
156 #[serde(default)]
157 pub llm_api_key_ref: Option<String>,
158 #[serde(default)]
159 pub telegram_bot_token_ref: Option<String>,
160}
161
162#[derive(Debug, Clone, Serialize, Deserialize, Default)]
163pub struct AppFeaturesConfig {
164 #[serde(default)]
165 pub enabled: Vec<String>,
166 #[serde(default)]
167 pub disabled: Vec<String>,
168 #[serde(default)]
169 pub channels_enabled: bool,
170 #[serde(default)]
171 pub mcp_enabled: bool,
172 #[serde(default)]
173 pub skills_enabled: bool,
174 #[serde(default)]
175 pub enable_chat: bool,
176 #[serde(default)]
177 pub enable_tools: bool,
178 #[serde(default)]
179 pub enable_memory: bool,
180}
181
182#[derive(Debug, Clone, Serialize, Deserialize, Default)]
183pub struct AppBindingOverride {
184 #[serde(default)]
185 pub provider: String,
186 #[serde(default)]
187 pub reason: String,
188 #[serde(default)]
189 pub package: String,
190 #[serde(default)]
191 pub version: String,
192 #[serde(default)]
193 pub sha512: String,
194}
195
196#[derive(Debug, Clone, Serialize, Deserialize, Default)]
197pub struct AppSceneBindingPin {
198 #[serde(default)]
199 pub capability: String,
200 #[serde(default)]
201 pub package: String,
202 #[serde(default)]
203 pub provider: String,
204 #[serde(default)]
205 pub version: String,
206 #[serde(default)]
207 pub sha512: String,
208 #[serde(default)]
209 pub source: String,
210 #[serde(default)]
211 pub reason: String,
212}
213
214#[derive(Debug, Clone, Serialize, Deserialize, Default)]
215pub struct AppScenePackagePin {
216 #[serde(default)]
217 pub package: String,
218 #[serde(default)]
219 pub version: String,
220 #[serde(default)]
221 pub sha512: String,
222 #[serde(default)]
223 pub source: String,
224 #[serde(default)]
225 pub reason: String,
226}
227
228#[derive(Debug, Clone, Serialize, Deserialize, Default)]
229pub struct AppSceneFeatureSelection {
230 #[serde(default)]
231 pub enabled: Vec<String>,
232 #[serde(default)]
233 pub disabled: Vec<String>,
234}
235
236#[derive(Debug, Clone, Serialize, Deserialize, Default)]
237pub struct AppSceneMetadata {
238 #[serde(default, skip_serializing_if = "string_is_empty")]
239 pub created_by: String,
240 #[serde(default, skip_serializing_if = "Option::is_none")]
241 pub created_at: Option<u64>,
242 #[serde(default, skip_serializing_if = "Option::is_none")]
243 pub updated_at: Option<u64>,
244}
245
246#[derive(Debug, Clone, Default)]
247pub struct AppSceneConfig {
248 pub schema_version: u32,
249 pub name: String,
250 pub description: String,
251 pub profile: String,
252 pub base_generation: Option<u64>,
253 pub enabled_features: Vec<String>,
254 pub disabled_features: Vec<String>,
255 pub binding_pins: Vec<AppSceneBindingPin>,
256 pub package_pins: Vec<AppScenePackagePin>,
257 pub role_routing: HashMap<String, crate::config::RoleModel>,
260 pub workspace: String,
262 pub metadata: AppSceneMetadata,
263}
264
265#[derive(Debug, Clone, Serialize, Deserialize, Default)]
266struct AppSceneConfigSerde {
267 #[serde(default)]
268 schema_version: u32,
269 #[serde(default)]
270 name: String,
271 #[serde(default)]
272 description: String,
273 #[serde(default)]
274 profile: String,
275 #[serde(default)]
276 base_generation: Option<u64>,
277 #[serde(default)]
278 features: AppSceneFeatureSelection,
279 #[serde(default)]
280 bindings: Vec<AppSceneBindingPin>,
281 #[serde(default)]
282 packages: Vec<AppScenePackagePin>,
283 #[serde(default)]
284 enabled_features: Vec<String>,
285 #[serde(default)]
286 disabled_features: Vec<String>,
287 #[serde(default, rename = "binding_pins")]
288 binding_pins: Vec<AppSceneBindingPin>,
289 #[serde(default, rename = "package_pins")]
290 package_pins: Vec<AppScenePackagePin>,
291 #[serde(default, skip_serializing_if = "scene_metadata_is_empty")]
292 metadata: AppSceneMetadata,
293 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
294 role_routing: HashMap<String, crate::config::RoleModel>,
295 #[serde(default, skip_serializing_if = "String::is_empty")]
296 workspace: String,
297}
298
299impl From<AppSceneConfigSerde> for AppSceneConfig {
300 fn from(value: AppSceneConfigSerde) -> Self {
301 let enabled_features = if value.features.enabled.is_empty() {
302 value.enabled_features
303 } else {
304 value.features.enabled
305 };
306 let disabled_features = if value.features.disabled.is_empty() {
307 value.disabled_features
308 } else {
309 value.features.disabled
310 };
311 let binding_pins = if value.bindings.is_empty() {
312 value.binding_pins
313 } else {
314 value.bindings
315 };
316 let package_pins = if value.packages.is_empty() {
317 value.package_pins
318 } else {
319 value.packages
320 };
321
322 Self {
323 schema_version: value.schema_version,
324 name: value.name,
325 description: value.description,
326 profile: value.profile,
327 base_generation: value.base_generation,
328 enabled_features,
329 disabled_features,
330 binding_pins,
331 package_pins,
332 role_routing: value.role_routing,
333 workspace: value.workspace,
334 metadata: value.metadata,
335 }
336 }
337}
338
339impl From<&AppSceneConfig> for AppSceneConfigSerde {
340 fn from(value: &AppSceneConfig) -> Self {
341 Self {
342 schema_version: value.schema_version,
343 name: value.name.clone(),
344 description: value.description.clone(),
345 profile: value.profile.clone(),
346 base_generation: value.base_generation,
347 features: AppSceneFeatureSelection {
348 enabled: value.enabled_features.clone(),
349 disabled: value.disabled_features.clone(),
350 },
351 bindings: value.binding_pins.clone(),
352 packages: value.package_pins.clone(),
353 enabled_features: Vec::new(),
354 disabled_features: Vec::new(),
355 binding_pins: Vec::new(),
356 package_pins: Vec::new(),
357 metadata: value.metadata.clone(),
358 role_routing: value.role_routing.clone(),
359 workspace: value.workspace.clone(),
360 }
361 }
362}
363
364impl Serialize for AppSceneConfig {
365 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
366 where
367 S: serde::Serializer,
368 {
369 AppSceneConfigSerde::from(self).serialize(serializer)
370 }
371}
372
373impl<'de> Deserialize<'de> for AppSceneConfig {
374 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
375 where
376 D: serde::Deserializer<'de>,
377 {
378 AppSceneConfigSerde::deserialize(deserializer).map(Into::into)
379 }
380}
381
382#[derive(Debug, Clone, Serialize, Deserialize, Default)]
383pub struct AppPackageOverride {
384 #[serde(default)]
385 pub enabled: Vec<String>,
386 #[serde(default)]
387 pub disabled: Vec<String>,
388}
389
390#[derive(Debug, Clone, Serialize, Deserialize, Default)]
391pub struct AppConfigFile {
392 #[serde(default)]
393 pub schema_version: u32,
394 #[serde(default)]
395 pub app_runtime: AppRuntimeConfig,
396 #[serde(default)]
397 pub secrets: AppSecretsConfig,
398 #[serde(default)]
399 pub features: AppFeaturesConfig,
400 #[serde(default)]
401 pub packages: AppPackageOverride,
402 #[serde(default)]
403 pub binding_overrides: HashMap<String, AppBindingOverride>,
404 #[serde(default)]
405 pub active_scene: String,
406 #[serde(default)]
407 pub scenes: HashMap<String, AppSceneConfig>,
408}
409
410pub type InstanceConfig = AppConfigFile;
411
412#[derive(Debug, Clone, Serialize, Deserialize, Default)]
413pub struct AppLockEvidence {
414 #[serde(default)]
415 pub digest: String,
416 #[serde(default)]
417 pub signature: String,
418 #[serde(default)]
419 pub source_authority: String,
420 #[serde(default)]
421 pub source_public_keys: Vec<String>,
422}
423
424#[derive(Debug, Clone, Serialize, Deserialize, Default)]
425pub struct AppLockPackageReason {
426 #[serde(default)]
427 pub layer: String,
428 #[serde(default)]
429 pub source: String,
430 #[serde(default)]
431 pub message: String,
432}
433
434#[derive(Debug, Clone, Serialize, Deserialize, Default)]
435pub struct AppLockNotes {
436 #[serde(default)]
437 pub message: String,
438}
439
440#[derive(Debug, Clone, Serialize, Deserialize, Default)]
441pub struct AppLockPackage {
442 #[serde(default)]
443 pub name: String,
444 #[serde(default)]
445 pub version: String,
446 #[serde(default)]
447 pub runtime: String,
448 #[serde(default)]
449 pub sha512: String,
450 #[serde(default)]
451 pub source: String,
452 #[serde(default)]
453 pub trusted: bool,
454 #[serde(default)]
455 pub signature: String,
456 #[serde(default)]
457 pub source_authority: String,
458 #[serde(default)]
459 pub source_public_keys: Vec<String>,
460 #[serde(default)]
461 pub package_kind: String,
462 #[serde(default)]
463 pub manifest_digest: String,
464 #[serde(default)]
465 pub artifact_digest: String,
466 #[serde(default)]
467 pub artifact_set_id: String,
468 #[serde(default)]
469 pub store_object_id: String,
470 #[serde(default)]
471 pub store_path: String,
472 #[serde(default)]
473 pub closure_id: String,
474 #[serde(default)]
475 pub entry_kind: String,
476 #[serde(default)]
477 pub runtime_provider: String,
478 #[serde(default)]
479 pub provides: Vec<String>,
480 #[serde(default)]
481 pub requires: Vec<String>,
482 #[serde(default)]
483 pub roles: Vec<String>,
484 #[serde(default)]
485 pub capabilities: Vec<String>,
486 #[serde(default)]
487 pub features: Vec<String>,
488 #[serde(default)]
489 pub default_enabled_features: Vec<String>,
490 #[serde(default)]
491 pub evidence: AppLockEvidence,
492 #[serde(default)]
493 pub reasons: Vec<AppLockPackageReason>,
494}
495
496#[derive(Debug, Clone, Serialize, Deserialize, Default)]
497pub struct AppLockBinding {
498 #[serde(default)]
499 pub capability: String,
500 #[serde(default)]
501 pub provider: String,
502 #[serde(default)]
503 pub package: String,
504 #[serde(default)]
505 pub mutable: bool,
506 #[serde(default)]
507 pub package_version: String,
508 #[serde(default)]
509 pub package_sha512: String,
510 #[serde(default)]
511 pub binding_source: String,
512}
513
514#[derive(Debug, Clone, Serialize, Deserialize, Default)]
515pub struct AppLockFeature {
516 #[serde(default)]
517 pub name: String,
518 #[serde(default)]
519 pub resolved: bool,
520 #[serde(default)]
521 pub packages: Vec<String>,
522 #[serde(default)]
523 pub capabilities: Vec<String>,
524}
525
526#[derive(Debug, Clone, Serialize, Deserialize, Default)]
527pub struct AppLockInputSnapshot {
528 #[serde(default)]
529 pub declaration_schema_version: u32,
530 #[serde(default)]
531 pub config_schema_version: u32,
532 #[serde(default)]
533 pub declaration_digest: String,
534 #[serde(default)]
535 pub config_digest: String,
536 #[serde(default)]
537 pub scene_digest: String,
538 #[serde(default)]
539 pub package_index_digest: String,
540 #[serde(default)]
541 pub package_server_snapshot_digest: String,
542}
543
544#[derive(Debug, Clone, Serialize, Deserialize, Default)]
545pub struct AppLockAssembly {
546 #[serde(default)]
547 pub enabled_features: Vec<String>,
548 #[serde(default)]
549 pub selected_packages: Vec<String>,
550 #[serde(default)]
551 pub scene: String,
552 #[serde(default)]
553 pub scene_digest: String,
554 #[serde(default)]
555 pub binding_set_id: String,
556 #[serde(default)]
557 pub closure_id: String,
558 #[serde(default)]
559 pub closure_digest: String,
560}
561
562#[derive(Debug, Clone, Serialize, Deserialize, Default)]
563pub struct AppLockBindingSource {
564 #[serde(default)]
565 pub capability: String,
566 #[serde(default)]
567 pub source: String,
568 #[serde(default)]
569 pub package: String,
570}
571
572#[derive(Debug, Clone, Serialize, Deserialize, Default)]
573pub struct AppLockFile {
574 #[serde(default)]
575 pub lock_version: u32,
576 #[serde(default)]
577 pub app: String,
578 #[serde(default)]
579 pub generation: u32,
580 #[serde(default)]
581 pub status: String,
582 #[serde(default)]
583 pub profile: String,
584 #[serde(default)]
585 pub scene: String,
586 #[serde(default)]
587 pub scene_digest: String,
588 #[serde(default)]
589 pub binding_set_id: String,
590 #[serde(default)]
591 pub closure_id: String,
592 #[serde(default)]
593 pub closure_digest: String,
594 #[serde(default)]
595 pub store_generation_id: String,
596 #[serde(default)]
597 pub trust_level: String,
598 #[serde(default)]
599 pub dev_unsealed: bool,
600 #[serde(default)]
601 pub features: Vec<AppLockFeature>,
602 #[serde(default)]
603 pub inputs: AppLockInputSnapshot,
604 #[serde(default)]
605 pub assembly: AppLockAssembly,
606 #[serde(default)]
607 pub packages: Vec<AppLockPackage>,
608 #[serde(default)]
609 pub bindings: Vec<AppLockBinding>,
610 #[serde(default)]
611 pub binding_sources: Vec<AppLockBindingSource>,
612 #[serde(default)]
613 pub notes: AppLockNotes,
614}
615
616pub type InstanceLock = AppLockFile;
617
618fn discover_repo_root(base_dir: &Path) -> Option<PathBuf> {
619 base_dir.ancestors().find_map(|ancestor| {
620 let has_packages = ancestor.join("packages").exists();
621 if has_packages {
622 Some(ancestor.to_path_buf())
623 } else {
624 None
625 }
626 })
627}
628
629fn logical_name(base_dir: &Path) -> Option<String> {
630 if base_dir.join("package.toml").exists() {
631 if let Ok(content) = std::fs::read_to_string(base_dir.join("package.toml")) {
632 if let Ok(raw) = toml::from_str::<RawProductPackageDeclaration>(&content) {
633 let name = raw.identity.name.trim();
634 if !name.is_empty() {
635 return Some(name.to_string());
636 }
637 }
638 }
639 }
640
641 match base_dir.file_name().and_then(|name| name.to_str()) {
642 Some(".weft") | None => None,
643 Some(name) => Some(name.to_string()),
644 }
645}
646
647fn preferred_product_package_dir(base_dir: &Path) -> Option<PathBuf> {
648 if base_dir.join("package.toml").exists() {
649 return Some(base_dir.to_path_buf());
650 }
651
652 let repo_root = discover_repo_root(base_dir)?;
653 let name = logical_name(base_dir)?;
654 let package_dir = repo_root.join("packages").join(&name);
655 if package_dir.join("package.toml").exists() {
656 Some(package_dir)
657 } else {
658 None
659 }
660}
661
662fn preferred_instance_dir(base_dir: &Path) -> Option<PathBuf> {
663 if base_dir.join("config.toml").exists() || base_dir.join("lock.toml").exists() {
664 return Some(base_dir.to_path_buf());
665 }
666
667 let repo_root = discover_repo_root(base_dir)?;
668 let name = logical_name(base_dir)?;
669 let instance_dir = repo_root.join(".weft").join(&name);
670 if instance_dir.join("config.toml").exists() || instance_dir.join("lock.toml").exists() {
671 Some(instance_dir)
672 } else {
673 None
674 }
675}
676
677fn desired_instance_dir(base_dir: &Path) -> Option<PathBuf> {
678 preferred_instance_dir(base_dir).or_else(|| {
679 let repo_root = discover_repo_root(base_dir)?;
680 let name = logical_name(base_dir)?;
681 Some(repo_root.join(".weft").join(name))
682 })
683}
684
685fn parse_product_package_declaration(path: &Path) -> Result<ProductPackageDeclaration> {
686 let content = std::fs::read_to_string(path)
687 .with_context(|| format!("Failed to read {}", path.display()))?;
688
689 if let Ok(raw) = toml::from_str::<RawProductPackageDeclaration>(&content) {
690 if !raw.identity.name.trim().is_empty()
691 && (raw.package.kind.trim().is_empty() || raw.package.kind == "product")
692 {
693 return Ok(ProductPackageDeclaration {
694 schema_version: raw.schema_version,
695 app: AppIdentity {
696 name: raw.identity.name,
697 version: raw.identity.version,
698 display_name: raw.identity.display_name,
699 description: raw.identity.description,
700 },
701 requires: raw.requires,
702 bindings: inflate_flat_bindings(raw.bindings),
703 features: raw.features,
704 feature_bindings: raw.feature_bindings,
705 validation: raw.validation,
706 upgrade: raw.upgrade,
707 });
708 }
709 }
710
711 toml::from_str(&content).with_context(|| format!("Failed to parse {}", path.display()))
712}
713
714pub fn load_product_package_declaration_from_path(
715 path: &Path,
716) -> Result<ProductPackageDeclaration> {
717 parse_product_package_declaration(path)
718}
719
720pub fn load_instance_config_from_path(path: &Path) -> Result<InstanceConfig> {
721 let content = std::fs::read_to_string(path)
722 .with_context(|| format!("Failed to read {}", path.display()))?;
723 let mut config: InstanceConfig =
724 toml::from_str(&content).with_context(|| format!("Failed to parse {}", path.display()))?;
725
726 if let Some(instance_dir) = path.parent() {
727 merge_scene_files(instance_dir, &mut config)?;
728 }
729
730 Ok(config)
731}
732
733pub fn load_instance_lock_from_path(path: &Path) -> Result<InstanceLock> {
734 let content = std::fs::read_to_string(path)
735 .with_context(|| format!("Failed to read {}", path.display()))?;
736 toml::from_str(&content).with_context(|| format!("Failed to parse {}", path.display()))
737}
738
739pub fn load_scene_config_from_path(path: &Path) -> Result<AppSceneConfig> {
740 let content = std::fs::read_to_string(path)
741 .with_context(|| format!("Failed to read {}", path.display()))?;
742 toml::from_str(&content).with_context(|| format!("Failed to parse {}", path.display()))
743}
744
745pub fn save_scene_config_to_path(path: &Path, scene: &AppSceneConfig) -> Result<()> {
746 if let Some(parent) = path.parent() {
747 std::fs::create_dir_all(parent)
748 .with_context(|| format!("Failed to create {}", parent.display()))?;
749 }
750 let content =
751 toml::to_string_pretty(scene).with_context(|| "Failed to serialize scene file")?;
752 std::fs::write(path, content).with_context(|| format!("Failed to write {}", path.display()))
753}
754
755pub fn save_instance_config_to_path(path: &Path, config: &InstanceConfig) -> Result<()> {
756 if let Some(parent) = path.parent() {
757 std::fs::create_dir_all(parent)
758 .with_context(|| format!("Failed to create {}", parent.display()))?;
759 }
760 let content =
761 toml::to_string_pretty(config).with_context(|| "Failed to serialize config file")?;
762 std::fs::write(path, content).with_context(|| format!("Failed to write {}", path.display()))
763}
764
765pub fn save_instance_lock_to_path(path: &Path, lock: &InstanceLock) -> Result<()> {
766 if let Some(parent) = path.parent() {
767 std::fs::create_dir_all(parent)
768 .with_context(|| format!("Failed to create {}", parent.display()))?;
769 }
770 let content = toml::to_string_pretty(lock).with_context(|| "Failed to serialize lock file")?;
771 std::fs::write(path, content).with_context(|| format!("Failed to write {}", path.display()))
772}
773
774fn inflate_flat_bindings(
775 bindings: HashMap<String, AppBindingConfig>,
776) -> HashMap<String, HashMap<String, AppBindingConfig>> {
777 let mut nested = HashMap::new();
778
779 for (capability, binding) in bindings {
780 let (section, name) = capability
781 .rsplit_once('.')
782 .map(|(section, name)| (section.to_string(), name.to_string()))
783 .unwrap_or_else(|| (capability.clone(), String::from("default")));
784 nested
785 .entry(section)
786 .or_insert_with(HashMap::new)
787 .insert(name, binding);
788 }
789
790 nested
791}
792
793fn merge_scene_files(instance_dir: &Path, config: &mut InstanceConfig) -> Result<()> {
794 let scenes_dir = instance_dir.join("scenes");
795 if !scenes_dir.exists() {
796 return Ok(());
797 }
798
799 let mut scene_paths = std::fs::read_dir(&scenes_dir)
800 .with_context(|| format!("Failed to read {}", scenes_dir.display()))?
801 .filter_map(|entry| entry.ok().map(|entry| entry.path()))
802 .filter(|path| path.extension().and_then(|ext| ext.to_str()) == Some("toml"))
803 .collect::<Vec<_>>();
804 scene_paths.sort();
805
806 for scene_path in scene_paths {
807 let mut scene = load_scene_config_from_path(&scene_path)?;
808 if scene.name.trim().is_empty() {
809 scene.name = scene_path
810 .file_stem()
811 .and_then(|stem| stem.to_str())
812 .unwrap_or_default()
813 .to_string();
814 }
815
816 if scene.name.trim().is_empty() {
817 continue;
818 }
819
820 if config.scenes.contains_key(&scene.name) {
821 tracing::warn!(
822 scene = %scene.name,
823 path = %scene_path.display(),
824 "scene file overrides embedded config scene"
825 );
826 }
827
828 config.scenes.insert(scene.name.clone(), scene);
829 }
830
831 Ok(())
832}
833
834pub fn product_package_declaration_path(base_dir: &Path) -> PathBuf {
835 preferred_product_package_dir(base_dir)
836 .map(|dir| dir.join("package.toml"))
837 .unwrap_or_else(|| base_dir.join("package.toml"))
838}
839
840pub fn instance_config_path(base_dir: &Path) -> PathBuf {
841 preferred_instance_dir(base_dir)
842 .map(|dir| dir.join("config.toml"))
843 .filter(|path| path.exists())
844 .unwrap_or_else(|| base_dir.join("config.toml"))
845}
846
847pub fn instance_lock_path(base_dir: &Path) -> PathBuf {
848 preferred_instance_dir(base_dir)
849 .map(|dir| dir.join("lock.toml"))
850 .filter(|path| path.exists())
851 .unwrap_or_else(|| base_dir.join("lock.toml"))
852}
853
854impl AppLockPackage {
855 pub fn identity(&self) -> String {
856 if !self.name.trim().is_empty() {
857 self.name.clone()
858 } else {
859 self.runtime_provider.clone()
860 }
861 }
862}
863
864pub fn load_app_manifest(app_dir: &Path) -> Result<AppManifest> {
865 let path = app_dir.join("app.toml");
866 let content = std::fs::read_to_string(&path)
867 .with_context(|| format!("Failed to read {}", path.display()))?;
868 toml::from_str(&content).with_context(|| format!("Failed to parse {}", path.display()))
869}
870
871pub fn load_product_package_declaration(app_dir: &Path) -> Result<ProductPackageDeclaration> {
872 let path = product_package_declaration_path(app_dir);
873 load_product_package_declaration_from_path(&path)
874}
875
876pub fn load_app_config(app_dir: &Path) -> Result<AppConfigFile> {
877 let path = app_dir.join("app.config.toml");
878 let content = std::fs::read_to_string(&path)
879 .with_context(|| format!("Failed to read {}", path.display()))?;
880 toml::from_str(&content).with_context(|| format!("Failed to parse {}", path.display()))
881}
882
883pub fn load_instance_config(app_dir: &Path) -> Result<InstanceConfig> {
884 let path = instance_config_path(app_dir);
885 load_instance_config_from_path(&path)
886}
887
888pub fn load_app_lock(app_dir: &Path) -> Result<AppLockFile> {
889 let path = app_dir.join("app.lock");
890 let content = std::fs::read_to_string(&path)
891 .with_context(|| format!("Failed to read {}", path.display()))?;
892 toml::from_str(&content).with_context(|| format!("Failed to parse {}", path.display()))
893}
894
895pub fn load_instance_lock(app_dir: &Path) -> Result<InstanceLock> {
896 let path = instance_lock_path(app_dir);
897 load_instance_lock_from_path(&path)
898}
899
900pub fn save_app_lock(app_dir: &Path, lock: &AppLockFile) -> Result<()> {
901 let path = app_dir.join("app.lock");
902 let content = toml::to_string_pretty(lock).with_context(|| "Failed to serialize lock file")?;
903 std::fs::write(&path, content).with_context(|| format!("Failed to write {}", path.display()))
904}
905
906pub fn save_instance_lock(app_dir: &Path, lock: &InstanceLock) -> Result<()> {
907 let path = desired_instance_dir(app_dir)
908 .unwrap_or_else(|| app_dir.to_path_buf())
909 .join("lock.toml");
910 save_instance_lock_to_path(&path, lock)
911}
912
913#[cfg(test)]
914mod tests {
915 use std::collections::HashMap;
916 use super::{
917 instance_config_path, instance_lock_path, load_instance_config, load_instance_lock,
918 load_product_package_declaration, product_package_declaration_path, AppConfigFile,
919 AppLockFile, AppLockPackage, AppLockPackageReason, AppSceneBindingPin, AppSceneConfig,
920 AppSceneMetadata, AppScenePackagePin,
921 };
922
923 fn temp_root(name: &str) -> std::path::PathBuf {
924 let unique = format!(
925 "weft-phase1-{}-{}",
926 name,
927 std::time::SystemTime::now()
928 .duration_since(std::time::UNIX_EPOCH)
929 .expect("system time after epoch")
930 .as_nanos()
931 );
932 std::env::temp_dir().join(unique)
933 }
934
935 fn weft_claw_package_dir() -> std::path::PathBuf {
936 std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
937 .join("..")
938 .join("packages")
939 .join("official")
940 .join("weft-claw")
941 }
942
943 fn weft_claw_instance_dir() -> std::path::PathBuf {
944 std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
945 .join("..")
946 .join(".weft")
947 .join("weft-claw")
948 }
949
950 #[test]
951 fn product_package_declaration_loader_reads_package_manifest() {
952 let declaration = load_product_package_declaration(&weft_claw_package_dir())
953 .expect("product package declaration remains readable from package.toml");
954
955 assert_eq!(declaration.app.name, "weft-claw");
956 assert_eq!(declaration.app.version, "0.1.0");
957 }
958
959 #[test]
960 fn weft_code_product_package_manifest_parses_with_repo_loader() {
961 let path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
962 .join("..")
963 .join("packages")
964 .join("weft-code")
965 .join("package.toml");
966
967 let declaration = super::load_product_package_declaration_from_path(&path)
968 .expect("weft-code product package manifest should parse");
969
970 assert_eq!(declaration.app.name, "weft-code");
971 assert_eq!(declaration.app.version, "0.1.0");
972 assert!(declaration
973 .requires
974 .capabilities
975 .iter()
976 .any(|capability| capability == "agent.runtime"));
977 assert!(declaration.features.default_enabled.is_empty());
978 assert!(declaration
979 .flattened_bindings()
980 .contains_key("weft_code.runtime"));
981 }
982
983 #[test]
984 fn instance_config_loader_reads_instance_config_path() {
985 let config = load_instance_config(&weft_claw_instance_dir())
986 .expect("instance config remains readable from config.toml");
987
988 assert_eq!(config.app_runtime.profile, "developer");
989 assert!(config
990 .features
991 .enabled
992 .iter()
993 .all(|feature| !feature.starts_with("weft-claw-") || !feature.ends_with("-feature")));
994 }
995
996 #[test]
997 fn instance_lock_path_defaults_to_instance_lock_location() {
998 assert_eq!(
999 instance_lock_path(&weft_claw_instance_dir()),
1000 weft_claw_instance_dir().join("lock.toml")
1001 );
1002 }
1003
1004 #[test]
1005 fn product_package_loader_reads_package_toml_only() {
1006 let root = temp_root("product-package");
1007 let package_dir = root.join("packages").join("demo");
1008 std::fs::create_dir_all(&package_dir).expect("package dir created");
1009 std::fs::write(
1010 package_dir.join("package.toml"),
1011 "schema_version = 2\n[identity]\nname='demo'\nversion='product'\ndisplay_name='Product'\ndescription='product'\n[package]\nkind='product'\n[requires]\ncapabilities=[]\n",
1012 )
1013 .expect("product package declaration written");
1014
1015 let declaration = load_product_package_declaration(&package_dir)
1016 .expect("package declaration should load from package.toml");
1017 assert_eq!(
1018 product_package_declaration_path(&package_dir),
1019 package_dir.join("package.toml")
1020 );
1021 assert_eq!(declaration.app.version, "product");
1022
1023 let _ = std::fs::remove_dir_all(root);
1024 }
1025
1026 #[test]
1027 fn instance_config_loader_reads_instance_path_only() {
1028 let root = temp_root("instance-config");
1029 let instance_dir = root.join(".weft").join("demo");
1030 std::fs::create_dir_all(&instance_dir).expect("instance dir created");
1031 std::fs::write(
1032 instance_dir.join("config.toml"),
1033 "schema_version = 2\n[app_runtime]\nprofile='developer'\n",
1034 )
1035 .expect("instance config written");
1036
1037 let config = load_instance_config(&instance_dir)
1038 .expect("instance config should load from config.toml");
1039 assert_eq!(
1040 instance_config_path(&instance_dir),
1041 instance_dir.join("config.toml")
1042 );
1043 assert_eq!(config.app_runtime.profile, "developer");
1044
1045 let _ = std::fs::remove_dir_all(root);
1046 }
1047
1048 #[test]
1049 fn instance_lock_loader_reads_instance_path_only() {
1050 let root = temp_root("instance-lock");
1051 let instance_dir = root.join(".weft").join("demo");
1052 std::fs::create_dir_all(&instance_dir).expect("instance dir created");
1053 std::fs::write(
1054 instance_dir.join("lock.toml"),
1055 "lock_version = 2\napp='demo'\ngeneration=2\nstatus='instance'\n",
1056 )
1057 .expect("instance lock written");
1058
1059 let lock =
1060 load_instance_lock(&instance_dir).expect("instance lock should load from lock.toml");
1061 assert_eq!(
1062 instance_lock_path(&instance_dir),
1063 instance_dir.join("lock.toml")
1064 );
1065 assert_eq!(lock.generation, 2);
1066 assert_eq!(lock.status, "instance");
1067
1068 let _ = std::fs::remove_dir_all(root);
1069 }
1070
1071 #[test]
1072 fn legacy_embedded_scene_config_still_deserializes() {
1073 let config: AppConfigFile = toml::from_str(
1074 r#"
1075schema_version = 2
1076active_scene = 'team'
1077
1078[scenes.team]
1079name = 'team'
1080description = 'Legacy embedded scene.'
1081profile = 'developer'
1082base_generation = 18
1083enabled_features = ['feature-a']
1084disabled_features = ['feature-b']
1085
1086[[scenes.team.binding_pins]]
1087capability = 'team.delegate'
1088package = 'agent-runtime'
1089provider = 'agent-runtime'
1090
1091[[scenes.team.package_pins]]
1092package = 'agent-runtime'
1093version = '0.1.0'
1094source = 'local-index'
1095"#,
1096 )
1097 .expect("legacy embedded scenes should deserialize");
1098
1099 let scene = config.scenes.get("team").expect("team scene present");
1100 assert_eq!(scene.name, "team");
1101 assert_eq!(scene.schema_version, 0);
1102 assert_eq!(scene.enabled_features, vec!["feature-a"]);
1103 assert_eq!(scene.disabled_features, vec!["feature-b"]);
1104 assert_eq!(scene.binding_pins.len(), 1);
1105 assert_eq!(scene.package_pins.len(), 1);
1106 assert!(scene.metadata.created_by.is_empty());
1107 assert_eq!(scene.metadata.created_at, None);
1108 assert_eq!(scene.metadata.updated_at, None);
1109 }
1110
1111 #[test]
1112 fn instance_scene_file_overrides_embedded_scene_config() {
1113 let root = temp_root("scene-merge");
1114 let instance_dir = root.join(".weft").join("demo");
1115 let scenes_dir = instance_dir.join("scenes");
1116 std::fs::create_dir_all(&scenes_dir).expect("scene dir created");
1117 std::fs::write(
1118 instance_dir.join("config.toml"),
1119 r#"
1120schema_version = 2
1121active_scene = 'team'
1122
1123[scenes.team]
1124name = 'team'
1125description = 'embedded'
1126enabled_features = ['embedded-feature']
1127"#,
1128 )
1129 .expect("config written");
1130 std::fs::write(
1131 scenes_dir.join("team.toml"),
1132 r#"
1133schema_version = 1
1134name = 'team'
1135description = 'scene file'
1136profile = 'developer'
1137
1138[features]
1139enabled = ['file-feature']
1140
1141[metadata]
1142created_by = 'cli'
1143created_at = 1710000000
1144updated_at = 1710001000
1145"#,
1146 )
1147 .expect("scene file written");
1148
1149 let config = load_instance_config(&instance_dir).expect("instance config should load");
1150 let scene = config.scenes.get("team").expect("scene file merged");
1151
1152 assert_eq!(scene.schema_version, 1);
1153 assert_eq!(scene.description, "scene file");
1154 assert_eq!(scene.profile, "developer");
1155 assert_eq!(scene.enabled_features, vec!["file-feature"]);
1156 assert_eq!(scene.metadata.created_by, "cli");
1157 assert_eq!(scene.metadata.created_at, Some(1710000000));
1158 assert_eq!(scene.metadata.updated_at, Some(1710001000));
1159
1160 let _ = std::fs::remove_dir_all(root);
1161 }
1162
1163 #[test]
1164 fn new_scene_schema_round_trips() {
1165 let scene = AppSceneConfig {
1166 schema_version: 1,
1167 name: "team".into(),
1168 description: "Enable team delegation.".into(),
1169 profile: "developer".into(),
1170 base_generation: Some(18),
1171 enabled_features: vec!["feature-a".into()],
1172 disabled_features: vec!["feature-b".into()],
1173 binding_pins: vec![AppSceneBindingPin {
1174 capability: "team.delegate".into(),
1175 package: "agent-runtime".into(),
1176 provider: "agent-runtime".into(),
1177 version: "0.1.0".into(),
1178 sha512: "sha512:abc".into(),
1179 source: "local-index".into(),
1180 reason: "Pin team delegate provider.".into(),
1181 }],
1182 package_pins: vec![AppScenePackagePin {
1183 package: "agent-runtime".into(),
1184 version: "0.1.0".into(),
1185 sha512: "sha512:abc".into(),
1186 source: "local-index".into(),
1187 reason: "Required by team scene.".into(),
1188 }],
1189 metadata: AppSceneMetadata {
1190 created_by: "cli".into(),
1191 created_at: Some(1710000000),
1192 updated_at: Some(1710001000),
1193 },
1194 role_routing: HashMap::new(),
1195 workspace: String::new(),
1196 };
1197
1198 let content = toml::to_string_pretty(&scene).expect("scene should serialize");
1199 assert!(content.contains("[features]"));
1200 assert!(content.contains("[[bindings]]"));
1201 assert!(content.contains("[[packages]]"));
1202 assert!(content.contains("[metadata]"));
1203
1204 let parsed: AppSceneConfig = toml::from_str(&content).expect("scene should deserialize");
1205 assert_eq!(parsed.schema_version, 1);
1206 assert_eq!(parsed.name, "team");
1207 assert_eq!(parsed.base_generation, Some(18));
1208 assert_eq!(parsed.enabled_features, vec!["feature-a"]);
1209 assert_eq!(parsed.disabled_features, vec!["feature-b"]);
1210 assert_eq!(parsed.binding_pins.len(), 1);
1211 assert_eq!(parsed.package_pins.len(), 1);
1212 assert_eq!(parsed.metadata.created_by, "cli");
1213 assert_eq!(parsed.metadata.created_at, Some(1710000000));
1214 assert_eq!(parsed.metadata.updated_at, Some(1710001000));
1215 }
1216
1217 #[test]
1218 fn legacy_lock_without_generation_metadata_still_deserializes() {
1219 let lock: AppLockFile = toml::from_str(
1220 r#"
1221lock_version = 1
1222app = 'demo'
1223generation = 7
1224status = 'active'
1225profile = 'developer'
1226
1227[assembly]
1228enabled_features = []
1229selected_packages = []
1230
1231[[packages]]
1232name = 'agent-runtime'
1233version = '0.1.0'
1234"#,
1235 )
1236 .expect("legacy lock should deserialize");
1237
1238 assert_eq!(lock.scene, "");
1239 assert_eq!(lock.scene_digest, "");
1240 assert_eq!(lock.binding_set_id, "");
1241 assert_eq!(lock.closure_id, "");
1242 assert_eq!(lock.closure_digest, "");
1243 assert_eq!(lock.store_generation_id, "");
1244 assert_eq!(lock.trust_level, "");
1245 assert!(!lock.dev_unsealed);
1246 assert_eq!(lock.packages.len(), 1);
1247 assert!(lock.packages[0].reasons.is_empty());
1248 }
1249
1250 #[test]
1251 fn new_lock_generation_metadata_round_trips() {
1252 let lock = AppLockFile {
1253 lock_version: 2,
1254 app: "demo".into(),
1255 generation: 20,
1256 status: "verified".into(),
1257 profile: "developer".into(),
1258 scene: "team".into(),
1259 scene_digest: "sha256:scene".into(),
1260 binding_set_id: "binding-set:sha256:bindings".into(),
1261 closure_id: "closure:sha256:closure".into(),
1262 closure_digest: "sha256:closure".into(),
1263 store_generation_id: "store-gen:sha256:store".into(),
1264 trust_level: "verified".into(),
1265 dev_unsealed: false,
1266 inputs: super::AppLockInputSnapshot {
1267 declaration_schema_version: 1,
1268 config_schema_version: 1,
1269 declaration_digest: "sha256:declaration".into(),
1270 config_digest: "sha256:config".into(),
1271 scene_digest: "sha256:scene".into(),
1272 package_index_digest: "sha256:index".into(),
1273 package_server_snapshot_digest: "sha256:server".into(),
1274 },
1275 assembly: super::AppLockAssembly {
1276 enabled_features: vec!["feature-a".into()],
1277 selected_packages: vec!["agent-runtime".into()],
1278 scene: "team".into(),
1279 scene_digest: "sha256:scene".into(),
1280 binding_set_id: "binding-set:sha256:bindings".into(),
1281 closure_id: "closure:sha256:closure".into(),
1282 closure_digest: "sha256:closure".into(),
1283 },
1284 packages: vec![AppLockPackage {
1285 name: "agent-runtime".into(),
1286 version: "0.1.0".into(),
1287 source: "local-index".into(),
1288 sha512: "sha512:abc".into(),
1289 manifest_digest: "sha256:manifest".into(),
1290 artifact_digest: "sha256:artifact".into(),
1291 artifact_set_id: "artifact-set:sha256:artifact".into(),
1292 store_object_id: "store:sha512:artifact".into(),
1293 store_path: ".weft/store/sha512-abc-agent-runtime-0.1.0".into(),
1294 closure_id: "closure:sha256:closure".into(),
1295 runtime_provider: "agent-runtime".into(),
1296 provides: vec!["team.delegate".into()],
1297 reasons: vec![AppLockPackageReason {
1298 layer: "scene".into(),
1299 source: "team".into(),
1300 message: "Scene pins team.delegate.".into(),
1301 }],
1302 ..Default::default()
1303 }],
1304 bindings: vec![super::AppLockBinding {
1305 capability: "team.delegate".into(),
1306 provider: "agent-runtime".into(),
1307 package: "agent-runtime".into(),
1308 mutable: false,
1309 package_version: "0.1.0".into(),
1310 package_sha512: "sha512:abc".into(),
1311 binding_source: "scene".into(),
1312 }],
1313 ..Default::default()
1314 };
1315
1316 let content = toml::to_string_pretty(&lock).expect("lock should serialize");
1317 assert!(content.contains("scene_digest = \"sha256:scene\""));
1318 assert!(content.contains("[[packages.reasons]]"));
1319
1320 let parsed: AppLockFile = toml::from_str(&content).expect("lock should deserialize");
1321 assert_eq!(parsed.scene, "team");
1322 assert_eq!(parsed.scene_digest, "sha256:scene");
1323 assert_eq!(parsed.binding_set_id, "binding-set:sha256:bindings");
1324 assert_eq!(parsed.closure_id, "closure:sha256:closure");
1325 assert_eq!(parsed.closure_digest, "sha256:closure");
1326 assert_eq!(parsed.store_generation_id, "store-gen:sha256:store");
1327 assert_eq!(parsed.trust_level, "verified");
1328 assert!(!parsed.dev_unsealed);
1329 assert_eq!(parsed.inputs.scene_digest, "sha256:scene");
1330 assert_eq!(parsed.assembly.scene_digest, "sha256:scene");
1331 assert_eq!(parsed.packages[0].reasons.len(), 1);
1332 assert_eq!(parsed.packages[0].reasons[0].layer, "scene");
1333 }
1334}