Skip to main content

gize_core/
manifest.rs

1//! The `gize.toml` project manifest (ADR-009).
2//!
3//! This is the declarative source of truth for a Gize project's shape. It is owned by the
4//! CLI and drives `gize sync`. Runtime configuration (DB URL, secrets) deliberately lives
5//! in the environment, not here.
6
7use anyhow::{Context, Result};
8use serde::{Deserialize, Serialize};
9
10#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
11pub struct Manifest {
12    pub project: Project,
13    #[serde(default)]
14    pub stack: Stack,
15    #[serde(default)]
16    pub features: Features,
17    #[serde(default)]
18    pub modules: Modules,
19}
20
21#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
22pub struct Project {
23    pub name: String,
24}
25
26#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
27pub struct Stack {
28    pub framework: String,
29    pub database: String,
30    pub orm: String,
31}
32
33impl Default for Stack {
34    fn default() -> Self {
35        // MVP defaults (ADR-002 / ADR-003).
36        Self {
37            framework: "axum".to_string(),
38            database: "postgres".to_string(),
39            orm: "sqlx".to_string(),
40        }
41    }
42}
43
44#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
45pub struct Features {
46    #[serde(default)]
47    pub authentication: bool,
48    #[serde(default)]
49    pub admin: bool,
50    #[serde(default)]
51    pub openapi: bool,
52}
53
54#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
55pub struct Modules {
56    #[serde(default)]
57    pub list: Vec<String>,
58}
59
60impl Manifest {
61    /// Create a fresh manifest for a new project with MVP defaults.
62    pub fn new(project_name: impl Into<String>) -> Self {
63        Self {
64            project: Project {
65                name: project_name.into(),
66            },
67            stack: Stack::default(),
68            features: Features::default(),
69            modules: Modules::default(),
70        }
71    }
72
73    /// Parse a manifest from TOML text.
74    pub fn from_toml(text: &str) -> Result<Self> {
75        toml::from_str(text).context("failed to parse gize.toml")
76    }
77
78    /// Serialize the manifest to TOML text.
79    pub fn to_toml(&self) -> Result<String> {
80        toml::to_string_pretty(self).context("failed to serialize manifest")
81    }
82
83    /// Register a module, keeping the list sorted and unique. Returns `true` if it was
84    /// newly added (idempotent — ADR-012 safety model).
85    pub fn add_module(&mut self, name: impl Into<String>) -> bool {
86        let name = name.into();
87        if self.modules.list.contains(&name) {
88            return false;
89        }
90        self.modules.list.push(name);
91        self.modules.list.sort();
92        true
93    }
94}
95
96#[cfg(test)]
97mod tests {
98    use super::*;
99
100    #[test]
101    fn roundtrips_through_toml() {
102        let mut m = Manifest::new("shop");
103        m.features.admin = true;
104        m.add_module("products");
105        let text = m.to_toml().unwrap();
106        let parsed = Manifest::from_toml(&text).unwrap();
107        assert_eq!(m, parsed);
108    }
109
110    #[test]
111    fn adding_module_is_idempotent() {
112        let mut m = Manifest::new("shop");
113        assert!(m.add_module("users"));
114        assert!(!m.add_module("users"));
115        assert_eq!(m.modules.list, vec!["users".to_string()]);
116    }
117
118    #[test]
119    fn parses_minimal_manifest() {
120        let text = r#"
121            [project]
122            name = "blog"
123        "#;
124        let m = Manifest::from_toml(text).unwrap();
125        assert_eq!(m.project.name, "blog");
126        // defaults fill in the stack
127        assert_eq!(m.stack.framework, "axum");
128    }
129}