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