Skip to main content

weft_core/app/
config.rs

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    /// 角色→模型路由(planner/implementer/reviewer/integrator)。激活该 scene 时
258    /// 写入 KV `team:role_routing`,使切场景即切团队各角色用的模型。空则不改路由。
259    pub role_routing: HashMap<String, crate::config::RoleModel>,
260    /// 会话默认工作区目录。激活该 scene 时作为新会话的默认 workspace_root。空则用系统默认。
261    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}