Skip to main content

evault_core/model/
manifest.rs

1//! Project manifest types.
2//!
3//! The on-disk manifest format is owned by `evault-manifest` (TOML), but the
4//! in-memory representation is part of the domain model so that services
5//! (e.g. materializer, runner) can consume it without depending on a
6//! particular file format.
7
8use serde::{Deserialize, Serialize};
9
10use crate::model::{Profile, ProjectId, VarId};
11
12/// In-memory snapshot of a project manifest.
13///
14/// A snapshot captures everything required to materialize a `.env` or inject
15/// env vars into a child process: which keys the project exposes, where each
16/// value comes from, and under which profile.
17#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
18pub struct ManifestSnapshot {
19    /// Identifier of the owning [`crate::model::Project`].
20    pub project_id: ProjectId,
21
22    /// Human-friendly project name (kept in the manifest for portability).
23    pub name: String,
24
25    /// Variable bindings declared by the project, across all profiles.
26    pub bindings: Vec<ManifestBinding>,
27}
28
29impl ManifestSnapshot {
30    /// Filter the bindings to those that apply for the given profile.
31    ///
32    /// Default-profile bindings are always included; named-profile bindings
33    /// override default ones when they share a key.
34    #[must_use]
35    pub fn effective_bindings(&self, profile: &Profile) -> Vec<&ManifestBinding> {
36        let mut by_key: std::collections::BTreeMap<&str, &ManifestBinding> =
37            std::collections::BTreeMap::new();
38        // First insert defaults...
39        for b in &self.bindings {
40            if b.profile.is_default() {
41                by_key.insert(b.key.as_str(), b);
42            }
43        }
44        // ...then override with profile-specific bindings.
45        for b in &self.bindings {
46            if b.profile == *profile {
47                by_key.insert(b.key.as_str(), b);
48            }
49        }
50        by_key.into_values().collect()
51    }
52}
53
54/// A single variable binding declared by a project.
55#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
56pub struct ManifestBinding {
57    /// Environment-variable name the project will see (e.g. `DATABASE_URL`).
58    pub key: String,
59
60    /// Source of the value.
61    pub source: BindingSource,
62
63    /// Profile this binding applies to.
64    pub profile: Profile,
65}
66
67/// Where a binding's value comes from.
68#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
69#[serde(tag = "kind", rename_all = "snake_case")]
70pub enum BindingSource {
71    /// Reference to a variable in the central registry.
72    Registry {
73        /// Identifier of the referenced [`crate::model::Var`].
74        var_id: VarId,
75    },
76    /// Literal value embedded in the manifest (use only for non-sensitive values).
77    Inline {
78        /// The literal value.
79        value: String,
80    },
81}
82
83#[cfg(test)]
84mod tests {
85    use super::*;
86
87    fn binding(key: &str, profile: Profile, value: &str) -> ManifestBinding {
88        ManifestBinding {
89            key: key.to_owned(),
90            source: BindingSource::Inline {
91                value: value.to_owned(),
92            },
93            profile,
94        }
95    }
96
97    #[test]
98    fn effective_default_profile_keeps_defaults() {
99        let snap = ManifestSnapshot {
100            project_id: ProjectId::new_v4(),
101            name: "x".into(),
102            bindings: vec![
103                binding("FOO", Profile::default_profile(), "1"),
104                binding("BAR", Profile::default_profile(), "2"),
105            ],
106        };
107        let effective = snap.effective_bindings(&Profile::default_profile());
108        assert_eq!(effective.len(), 2);
109    }
110
111    #[test]
112    fn named_profile_overrides_default_on_key() {
113        let snap = ManifestSnapshot {
114            project_id: ProjectId::new_v4(),
115            name: "x".into(),
116            bindings: vec![
117                binding("FOO", Profile::default_profile(), "default"),
118                binding("FOO", Profile::named("dev"), "dev"),
119                binding("BAR", Profile::default_profile(), "common"),
120            ],
121        };
122        let dev = snap.effective_bindings(&Profile::named("dev"));
123        // Two effective keys: FOO (overridden) and BAR (default).
124        assert_eq!(dev.len(), 2);
125        let foo = dev.iter().find(|b| b.key == "FOO").expect("FOO present");
126        match &foo.source {
127            BindingSource::Inline { value } => assert_eq!(value, "dev"),
128            BindingSource::Registry { .. } => panic!("unexpected source"),
129        }
130    }
131}