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