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//!
7//! Since the Alpha (ADR-009 revision) the manifest is *rich*: each module records its
8//! fields and relationships, so the project can be reconciled (`gize sync`) and rebuilt from
9//! the manifest alone. The legacy `[modules] list = [...]` form (names only) is still read
10//! for backward compatibility and rewritten into the `[[module]]` form on the next write.
11
12use anyhow::{Context, Result};
13use serde::{Deserialize, Serialize};
14
15use crate::naming::model_name;
16use crate::{ModelSpec, Relation};
17
18#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
19pub struct Manifest {
20    pub project: Project,
21    #[serde(default)]
22    pub stack: Stack,
23    #[serde(default)]
24    pub features: Features,
25    /// Modules that make up the app, in the rich `[[module]]` form. Kept sorted by name for
26    /// deterministic output. Skipped when empty so a bare project has a clean manifest.
27    #[serde(default, rename = "module", skip_serializing_if = "Vec::is_empty")]
28    pub modules: Vec<Module>,
29}
30
31#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
32pub struct Project {
33    pub name: String,
34}
35
36#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
37pub struct Stack {
38    pub framework: String,
39    pub database: String,
40    pub orm: String,
41}
42
43impl Default for Stack {
44    fn default() -> Self {
45        // MVP defaults (ADR-002 / ADR-003).
46        Self {
47            framework: "axum".to_string(),
48            database: "postgres".to_string(),
49            orm: "sqlx".to_string(),
50        }
51    }
52}
53
54#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
55pub struct Features {
56    #[serde(default)]
57    pub authentication: bool,
58    #[serde(default)]
59    pub admin: bool,
60    #[serde(default)]
61    pub openapi: bool,
62}
63
64/// One application module as recorded in `gize.toml` (ADR-009 revision).
65///
66/// `name` is the module/table name as used on disk (snake_case, e.g. `users`, `products`).
67/// `fields` reuse the CLI's `name:Type` grammar verbatim (see [`crate::field`]) so the
68/// manifest and the command line share one definition of a model. `belongs_to` records the
69/// module's 1-N relationships (ADR-014).
70#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
71pub struct Module {
72    pub name: String,
73    #[serde(default, skip_serializing_if = "Vec::is_empty")]
74    pub fields: Vec<String>,
75    #[serde(default, skip_serializing_if = "Vec::is_empty")]
76    pub belongs_to: Vec<Relation>,
77}
78
79impl Module {
80    /// A module with just a name and no declared shape (used by `gize make app`, and when
81    /// upgrading a legacy names-only manifest).
82    pub fn named(name: impl Into<String>) -> Self {
83        Self {
84            name: name.into(),
85            fields: Vec::new(),
86            belongs_to: Vec::new(),
87        }
88    }
89
90    /// Recover the model definition this module describes, for regenerating its code during
91    /// `gize sync` (ADR-009 revision). The struct name is derived from the module/table name
92    /// (`posts` -> `Post`); the fields are re-parsed from their `name:Type` tokens.
93    pub fn model_spec(&self) -> Result<ModelSpec> {
94        // Feed the scalar field tokens plus a `name:belongs_to:target` token per relationship,
95        // so `ModelSpec::parse` reconstructs the same fields (incl. FK columns) it produced
96        // at `make crud` time.
97        let mut tokens = self.fields.clone();
98        tokens.extend(self.belongs_to.iter().map(Relation::to_token));
99        ModelSpec::parse(model_name(&self.name), &tokens)
100            .with_context(|| format!("module `{}` has an invalid field definition", self.name))
101    }
102}
103
104/// The legacy `[modules] list = [...]` table (names only), read for backward compatibility.
105#[derive(Debug, Clone, Deserialize)]
106struct LegacyModules {
107    #[serde(default)]
108    list: Vec<String>,
109}
110
111/// Wire format for parsing: accepts both the rich `[[module]]` array and the legacy
112/// `[modules]` table so old manifests keep loading (ADR-009 revision).
113#[derive(Debug, Deserialize)]
114struct RawManifest {
115    project: Project,
116    #[serde(default)]
117    stack: Stack,
118    #[serde(default)]
119    features: Features,
120    #[serde(default, rename = "module")]
121    module: Vec<Module>,
122    #[serde(default)]
123    modules: Option<LegacyModules>,
124}
125
126impl Manifest {
127    /// Create a fresh manifest for a new project with MVP defaults.
128    pub fn new(project_name: impl Into<String>) -> Self {
129        Self {
130            project: Project {
131                name: project_name.into(),
132            },
133            stack: Stack::default(),
134            features: Features::default(),
135            modules: Vec::new(),
136        }
137    }
138
139    /// Parse a manifest from TOML text, accepting both the rich and legacy module forms.
140    pub fn from_toml(text: &str) -> Result<Self> {
141        let raw: RawManifest = toml::from_str(text).context("failed to parse gize.toml")?;
142        // Prefer the rich `[[module]]` form; fall back to the legacy names-only list.
143        let mut modules = raw.module;
144        if modules.is_empty() {
145            if let Some(legacy) = raw.modules {
146                modules = legacy.list.into_iter().map(Module::named).collect();
147            }
148        }
149        modules.sort_by(|a, b| a.name.cmp(&b.name));
150        Ok(Self {
151            project: raw.project,
152            stack: raw.stack,
153            features: raw.features,
154            modules,
155        })
156    }
157
158    /// Serialize the manifest to TOML text (always in the rich `[[module]]` form).
159    pub fn to_toml(&self) -> Result<String> {
160        toml::to_string_pretty(self).context("failed to serialize manifest")
161    }
162
163    /// Find a module by name.
164    pub fn module(&self, name: &str) -> Option<&Module> {
165        self.modules.iter().find(|m| m.name == name)
166    }
167
168    /// Register a module by name only, keeping the list sorted and unique. Returns `true` if
169    /// it was newly added (idempotent — ADR-012 safety model). Used by `gize make app`, which
170    /// has no fields yet. Does not touch an existing module's declared shape.
171    pub fn add_module(&mut self, name: impl Into<String>) -> bool {
172        let name = name.into();
173        if self.modules.iter().any(|m| m.name == name) {
174            return false;
175        }
176        self.modules.push(Module::named(name));
177        self.modules.sort_by(|a, b| a.name.cmp(&b.name));
178        true
179    }
180
181    /// The modules ordered so every `belongs_to` target comes before the module that
182    /// references it (ADR-014). Used when creating migrations so a foreign key's target table
183    /// already exists. Errors on a dependency cycle. Targets that are not themselves modules
184    /// (pre-existing tables) impose no ordering.
185    pub fn modules_in_dependency_order(&self) -> Result<Vec<&Module>> {
186        use std::collections::{HashMap, HashSet};
187
188        let by_name: HashMap<&str, &Module> =
189            self.modules.iter().map(|m| (m.name.as_str(), m)).collect();
190        let mut ordered = Vec::new();
191        let mut done: HashSet<&str> = HashSet::new();
192        let mut on_stack: HashSet<&str> = HashSet::new();
193
194        fn visit<'a>(
195            m: &'a Module,
196            by_name: &HashMap<&str, &'a Module>,
197            done: &mut HashSet<&'a str>,
198            on_stack: &mut HashSet<&'a str>,
199            ordered: &mut Vec<&'a Module>,
200        ) -> Result<()> {
201            if done.contains(m.name.as_str()) {
202                return Ok(());
203            }
204            if !on_stack.insert(m.name.as_str()) {
205                anyhow::bail!("cyclic belongs_to relationship involving `{}`", m.name);
206            }
207            for r in &m.belongs_to {
208                if r.target != m.name {
209                    if let Some(target) = by_name.get(r.target.as_str()) {
210                        visit(target, by_name, done, on_stack, ordered)?;
211                    }
212                }
213            }
214            on_stack.remove(m.name.as_str());
215            done.insert(m.name.as_str());
216            ordered.push(m);
217            Ok(())
218        }
219
220        for m in &self.modules {
221            visit(m, &by_name, &mut done, &mut on_stack, &mut ordered)?;
222        }
223        Ok(ordered)
224    }
225
226    /// Insert or replace a module's full declaration (name + fields + relationships),
227    /// keeping the list sorted. Returns `true` if the module was newly added, `false` if an
228    /// existing entry was replaced. Used by `gize make model`/`make crud`, which know the
229    /// module's shape, and by `gize sync`.
230    pub fn upsert_module(&mut self, module: Module) -> bool {
231        match self.modules.iter_mut().find(|m| m.name == module.name) {
232            Some(existing) => {
233                *existing = module;
234                false
235            }
236            None => {
237                self.modules.push(module);
238                self.modules.sort_by(|a, b| a.name.cmp(&b.name));
239                true
240            }
241        }
242    }
243}
244
245#[cfg(test)]
246mod tests {
247    use super::*;
248
249    #[test]
250    fn roundtrips_through_toml() {
251        let mut m = Manifest::new("shop");
252        m.features.admin = true;
253        m.upsert_module(Module {
254            name: "products".to_string(),
255            fields: vec!["name:String".to_string(), "price:i32".to_string()],
256            belongs_to: vec![],
257        });
258        let text = m.to_toml().unwrap();
259        let parsed = Manifest::from_toml(&text).unwrap();
260        assert_eq!(m, parsed);
261    }
262
263    #[test]
264    fn roundtrips_a_relationship() {
265        let mut m = Manifest::new("blog");
266        m.upsert_module(Module {
267            name: "posts".to_string(),
268            fields: vec!["title:String".to_string()],
269            belongs_to: vec![Relation {
270                field: "author".to_string(),
271                target: "users".to_string(),
272            }],
273        });
274        let text = m.to_toml().unwrap();
275        assert!(text.contains("[[module.belongs_to]]"));
276        assert_eq!(m, Manifest::from_toml(&text).unwrap());
277    }
278
279    #[test]
280    fn adding_module_is_idempotent() {
281        let mut m = Manifest::new("shop");
282        assert!(m.add_module("users"));
283        assert!(!m.add_module("users"));
284        assert_eq!(m.modules.len(), 1);
285        assert_eq!(m.modules[0].name, "users");
286    }
287
288    #[test]
289    fn upsert_replaces_shape_without_duplicating() {
290        let mut m = Manifest::new("shop");
291        assert!(m.add_module("products")); // empty shape first
292        assert!(!m.upsert_module(Module {
293            name: "products".to_string(),
294            fields: vec!["name:String".to_string()],
295            belongs_to: vec![],
296        }));
297        assert_eq!(m.modules.len(), 1);
298        assert_eq!(m.modules[0].fields, vec!["name:String".to_string()]);
299    }
300
301    #[test]
302    fn reads_legacy_names_only_manifest() {
303        // A gize.toml written before the ADR-009 revision.
304        let text = r#"
305            [project]
306            name = "shop"
307
308            [modules]
309            list = ["products", "users"]
310        "#;
311        let m = Manifest::from_toml(text).unwrap();
312        assert_eq!(m.modules.len(), 2);
313        // Sorted, and carried as empty-shaped modules.
314        assert_eq!(m.modules[0].name, "products");
315        assert_eq!(m.modules[1].name, "users");
316        assert!(m.modules[0].fields.is_empty());
317        // Re-serializing upgrades it to the rich form (no more `[modules] list`).
318        let upgraded = m.to_toml().unwrap();
319        assert!(upgraded.contains("[[module]]"));
320        assert!(!upgraded.contains("list ="));
321    }
322
323    #[test]
324    fn orders_modules_so_targets_precede_dependents() {
325        let mut m = Manifest::new("blog");
326        // Declared out of dependency order and alphabetically: comments -> posts -> users.
327        m.upsert_module(Module {
328            name: "comments".to_string(),
329            fields: vec!["body:String".to_string()],
330            belongs_to: vec![Relation {
331                field: "post".to_string(),
332                target: "posts".to_string(),
333            }],
334        });
335        m.upsert_module(Module {
336            name: "posts".to_string(),
337            fields: vec!["title:String".to_string()],
338            belongs_to: vec![Relation {
339                field: "author".to_string(),
340                target: "users".to_string(),
341            }],
342        });
343        m.upsert_module(Module::named("users"));
344
345        let ordered: Vec<&str> = m
346            .modules_in_dependency_order()
347            .unwrap()
348            .iter()
349            .map(|m| m.name.as_str())
350            .collect();
351        let pos = |n: &str| ordered.iter().position(|x| *x == n).unwrap();
352        assert!(
353            pos("users") < pos("posts"),
354            "users before posts: {ordered:?}"
355        );
356        assert!(
357            pos("posts") < pos("comments"),
358            "posts before comments: {ordered:?}"
359        );
360    }
361
362    #[test]
363    fn dependency_ordering_detects_cycles() {
364        let mut m = Manifest::new("loop");
365        m.upsert_module(Module {
366            name: "a".to_string(),
367            fields: vec![],
368            belongs_to: vec![Relation {
369                field: "b".to_string(),
370                target: "b".to_string(),
371            }],
372        });
373        m.upsert_module(Module {
374            name: "b".to_string(),
375            fields: vec![],
376            belongs_to: vec![Relation {
377                field: "a".to_string(),
378                target: "a".to_string(),
379            }],
380        });
381        assert!(m.modules_in_dependency_order().is_err());
382    }
383
384    #[test]
385    fn parses_minimal_manifest() {
386        let text = r#"
387            [project]
388            name = "blog"
389        "#;
390        let m = Manifest::from_toml(text).unwrap();
391        assert_eq!(m.project.name, "blog");
392        // defaults fill in the stack
393        assert_eq!(m.stack.framework, "axum");
394        assert!(m.modules.is_empty());
395    }
396
397    #[test]
398    fn empty_project_omits_modules_section() {
399        let m = Manifest::new("empty");
400        let text = m.to_toml().unwrap();
401        assert!(!text.contains("[[module]]"));
402        assert!(!text.contains("[modules]"));
403    }
404}