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, table_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/// `model` is the struct name, recorded only when it cannot be derived back from `name`
104/// (`Movie` -> `movies` would read back as `Movy`); see [`Module::model_name`].
105/// `fields` reuse the CLI's `name:Type` grammar verbatim (see [`crate::field`]) so the
106/// manifest and the command line share one definition of a model. `belongs_to` records the
107/// module's 1-N relationships (ADR-014).
108#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
109pub struct Module {
110    pub name: String,
111    #[serde(default, skip_serializing_if = "Option::is_none")]
112    pub model: Option<String>,
113    #[serde(default, skip_serializing_if = "Vec::is_empty")]
114    pub fields: Vec<String>,
115    #[serde(default, skip_serializing_if = "Vec::is_empty")]
116    pub belongs_to: Vec<Relation>,
117}
118
119impl Module {
120    /// A module with just a name and no declared shape (used by `gize make app`, and when
121    /// upgrading a legacy names-only manifest).
122    pub fn named(name: impl Into<String>) -> Self {
123        Self {
124            name: name.into(),
125            model: None,
126            fields: Vec::new(),
127            belongs_to: Vec::new(),
128        }
129    }
130
131    /// The module recorded for `model`, as `gize make crud` writes it to the manifest. The
132    /// struct name is stored only when deriving it from the table name would not give it back,
133    /// so manifests of models that round-trip (`Post` -> `posts`) stay as they were.
134    pub fn for_model(model: &ModelSpec) -> Self {
135        let name = table_name(&model.name);
136        let derived = model_name(&name);
137        Self {
138            model: (derived != model.name).then(|| model.name.clone()),
139            name,
140            fields: model.to_field_tokens(),
141            belongs_to: model.relations.clone(),
142        }
143    }
144
145    /// The model's struct name: the recorded `model`, or else derived from the module/table
146    /// name (`posts` -> `Post`), which is what manifests written before `model` existed rely on.
147    pub fn model_name(&self) -> String {
148        self.model.clone().unwrap_or_else(|| model_name(&self.name))
149    }
150
151    /// Recover the model definition this module describes, for regenerating its code during
152    /// `gize sync` (ADR-009 revision). The struct name comes from [`Module::model_name`]; the
153    /// fields are re-parsed from their `name:Type` tokens.
154    pub fn model_spec(&self) -> Result<ModelSpec> {
155        crate::validation::validate_identifier(&self.name, "module")?;
156        // Feed the scalar field tokens plus a `name:belongs_to:target` token per relationship,
157        // so `ModelSpec::parse` reconstructs the same fields (incl. FK columns) it produced
158        // at `make crud` time.
159        let mut tokens = self.fields.clone();
160        tokens.extend(self.belongs_to.iter().map(Relation::to_token));
161        ModelSpec::parse(self.model_name(), &tokens)
162            .with_context(|| format!("module `{}` has an invalid field definition", self.name))
163    }
164}
165
166/// The legacy `[modules] list = [...]` table (names only), read for backward compatibility.
167#[derive(Debug, Clone, Deserialize)]
168struct LegacyModules {
169    #[serde(default)]
170    list: Vec<String>,
171}
172
173/// Wire format for parsing: accepts both the rich `[[module]]` array and the legacy
174/// `[modules]` table so old manifests keep loading (ADR-009 revision).
175#[derive(Debug, Deserialize)]
176struct RawManifest {
177    project: Project,
178    #[serde(default)]
179    stack: Stack,
180    #[serde(default)]
181    features: Features,
182    #[serde(default)]
183    api: Option<Api>,
184    #[serde(default, rename = "module")]
185    module: Vec<Module>,
186    #[serde(default)]
187    modules: Option<LegacyModules>,
188}
189
190impl Manifest {
191    /// Create a fresh manifest for a new project with MVP defaults.
192    pub fn new(project_name: impl Into<String>) -> Self {
193        Self {
194            project: Project {
195                name: project_name.into(),
196            },
197            stack: Stack::default(),
198            features: Features::default(),
199            api: None,
200            modules: Vec::new(),
201        }
202    }
203
204    /// Parse a manifest from TOML text, accepting both the rich and legacy module forms.
205    pub fn from_toml(text: &str) -> Result<Self> {
206        let raw: RawManifest = toml::from_str(text).context("failed to parse gize.toml")?;
207        // Prefer the rich `[[module]]` form; fall back to the legacy names-only list.
208        let mut modules = raw.module;
209        if modules.is_empty() {
210            if let Some(legacy) = raw.modules {
211                modules = legacy.list.into_iter().map(Module::named).collect();
212            }
213        }
214        modules.sort_by(|a, b| a.name.cmp(&b.name));
215        let manifest = Self {
216            project: raw.project,
217            stack: raw.stack,
218            features: raw.features,
219            api: raw.api,
220            modules,
221        };
222        Ok(manifest)
223    }
224
225    /// Validate every module before any consumer can write a partial generation plan.
226    pub fn validate(&self) -> Result<()> {
227        let mut names = std::collections::BTreeSet::new();
228        for module in &self.modules {
229            anyhow::ensure!(
230                names.insert(&module.name),
231                "duplicate module `{}`",
232                module.name
233            );
234            crate::validation::validate_identifier(&module.name, "module")?;
235            if let Some(model) = &module.model {
236                // The templates derive the table, routes and paths from the struct name, so a
237                // recorded name must lead back to this module.
238                anyhow::ensure!(
239                    table_name(model) == module.name,
240                    "module `{}` in gize.toml records model `{model}`, whose table would be `{}`; \
241                     the model of `{}` must be named so its table is `{}`",
242                    module.name,
243                    table_name(model),
244                    module.name,
245                    module.name
246                );
247            }
248            if !module.fields.is_empty() || !module.belongs_to.is_empty() {
249                let dialect = crate::Dialect::from_database(&self.stack.database);
250                // Every generation command validates the whole manifest, so an older module
251                // can block work on a new one: say which module and how to get unblocked.
252                crate::validation::validate_sql_identifier_for_dialect(
253                    &module.name,
254                    "table",
255                    dialect,
256                )
257                .and_then(|()| module.model_spec()?.validate_for_dialect(dialect))
258                .with_context(|| {
259                    format!(
260                        "module `{}` in gize.toml cannot be generated; rename the name reported \
261                         below in gize.toml (and rename an existing table/column with a named migration: \
262                         `gize make migration <name>`)",
263                        module.name
264                    )
265                })?;
266            }
267        }
268        Ok(())
269    }
270
271    /// Serialize the manifest to TOML text (always in the rich `[[module]]` form).
272    pub fn to_toml(&self) -> Result<String> {
273        toml::to_string_pretty(self).context("failed to serialize manifest")
274    }
275
276    /// Find a module by name.
277    pub fn module(&self, name: &str) -> Option<&Module> {
278        self.modules.iter().find(|m| m.name == name)
279    }
280
281    /// Register a module by name only, keeping the list sorted and unique. Returns `true` if
282    /// it was newly added (idempotent — ADR-012 safety model). Used by `gize make app`, which
283    /// has no fields yet. Does not touch an existing module's declared shape.
284    pub fn add_module(&mut self, name: impl Into<String>) -> bool {
285        let name = name.into();
286        if self.modules.iter().any(|m| m.name == name) {
287            return false;
288        }
289        self.modules.push(Module::named(name));
290        self.modules.sort_by(|a, b| a.name.cmp(&b.name));
291        true
292    }
293
294    /// The modules ordered so every `belongs_to` target comes before the module that
295    /// references it (ADR-014). Used when creating migrations so a foreign key's target table
296    /// already exists. Errors on a dependency cycle. Targets that are not themselves modules
297    /// (pre-existing tables) impose no ordering.
298    pub fn modules_in_dependency_order(&self) -> Result<Vec<&Module>> {
299        use std::collections::{HashMap, HashSet};
300
301        let by_name: HashMap<&str, &Module> =
302            self.modules.iter().map(|m| (m.name.as_str(), m)).collect();
303        let mut ordered = Vec::new();
304        let mut done: HashSet<&str> = HashSet::new();
305        let mut on_stack: HashSet<&str> = HashSet::new();
306
307        fn visit<'a>(
308            m: &'a Module,
309            by_name: &HashMap<&str, &'a Module>,
310            done: &mut HashSet<&'a str>,
311            on_stack: &mut HashSet<&'a str>,
312            ordered: &mut Vec<&'a Module>,
313        ) -> Result<()> {
314            if done.contains(m.name.as_str()) {
315                return Ok(());
316            }
317            if !on_stack.insert(m.name.as_str()) {
318                anyhow::bail!("cyclic belongs_to relationship involving `{}`", m.name);
319            }
320            for r in &m.belongs_to {
321                if r.target != m.name {
322                    if let Some(target) = by_name.get(r.target.as_str()) {
323                        visit(target, by_name, done, on_stack, ordered)?;
324                    }
325                }
326            }
327            on_stack.remove(m.name.as_str());
328            done.insert(m.name.as_str());
329            ordered.push(m);
330            Ok(())
331        }
332
333        for m in &self.modules {
334            visit(m, &by_name, &mut done, &mut on_stack, &mut ordered)?;
335        }
336        Ok(ordered)
337    }
338
339    /// Insert or replace a module's full declaration (name + fields + relationships),
340    /// keeping the list sorted. Returns `true` if the module was newly added, `false` if an
341    /// existing entry was replaced. Used by `gize make model`/`make crud`, which know the
342    /// module's shape, and by `gize sync`.
343    pub fn upsert_module(&mut self, module: Module) -> bool {
344        match self.modules.iter_mut().find(|m| m.name == module.name) {
345            Some(existing) => {
346                *existing = module;
347                false
348            }
349            None => {
350                self.modules.push(module);
351                self.modules.sort_by(|a, b| a.name.cmp(&b.name));
352                true
353            }
354        }
355    }
356}
357
358#[cfg(test)]
359mod tests {
360    use super::*;
361
362    #[test]
363    fn roundtrips_through_toml() {
364        let mut m = Manifest::new("shop");
365        m.features.admin = true;
366        m.upsert_module(Module {
367            name: "products".to_string(),
368            model: None,
369            fields: vec!["name:String".to_string(), "price:i32".to_string()],
370            belongs_to: vec![],
371        });
372        let text = m.to_toml().unwrap();
373        let parsed = Manifest::from_toml(&text).unwrap();
374        assert_eq!(m, parsed);
375    }
376
377    #[test]
378    fn roundtrips_a_relationship() {
379        let mut m = Manifest::new("blog");
380        m.upsert_module(Module {
381            name: "posts".to_string(),
382            model: None,
383            fields: vec!["title:String".to_string()],
384            belongs_to: vec![Relation {
385                field: "author".to_string(),
386                target: "users".to_string(),
387            }],
388        });
389        let text = m.to_toml().unwrap();
390        assert!(text.contains("[[module.belongs_to]]"));
391        assert_eq!(m, Manifest::from_toml(&text).unwrap());
392    }
393
394    #[test]
395    fn adding_module_is_idempotent() {
396        let mut m = Manifest::new("shop");
397        assert!(m.add_module("users"));
398        assert!(!m.add_module("users"));
399        assert_eq!(m.modules.len(), 1);
400        assert_eq!(m.modules[0].name, "users");
401    }
402
403    #[test]
404    fn upsert_replaces_shape_without_duplicating() {
405        let mut m = Manifest::new("shop");
406        assert!(m.add_module("products")); // empty shape first
407        assert!(!m.upsert_module(Module {
408            name: "products".to_string(),
409            model: None,
410            fields: vec!["name:String".to_string()],
411            belongs_to: vec![],
412        }));
413        assert_eq!(m.modules.len(), 1);
414        assert_eq!(m.modules[0].fields, vec!["name:String".to_string()]);
415    }
416
417    #[test]
418    fn reads_legacy_names_only_manifest() {
419        // A gize.toml written before the ADR-009 revision.
420        let text = r#"
421            [project]
422            name = "shop"
423
424            [modules]
425            list = ["products", "users"]
426        "#;
427        let m = Manifest::from_toml(text).unwrap();
428        assert_eq!(m.modules.len(), 2);
429        // Sorted, and carried as empty-shaped modules.
430        assert_eq!(m.modules[0].name, "products");
431        assert_eq!(m.modules[1].name, "users");
432        assert!(m.modules[0].fields.is_empty());
433        // Re-serializing upgrades it to the rich form (no more `[modules] list`).
434        let upgraded = m.to_toml().unwrap();
435        assert!(upgraded.contains("[[module]]"));
436        assert!(!upgraded.contains("list ="));
437    }
438
439    #[test]
440    fn orders_modules_so_targets_precede_dependents() {
441        let mut m = Manifest::new("blog");
442        // Declared out of dependency order and alphabetically: comments -> posts -> users.
443        m.upsert_module(Module {
444            name: "comments".to_string(),
445            model: None,
446            fields: vec!["body:String".to_string()],
447            belongs_to: vec![Relation {
448                field: "post".to_string(),
449                target: "posts".to_string(),
450            }],
451        });
452        m.upsert_module(Module {
453            name: "posts".to_string(),
454            model: None,
455            fields: vec!["title:String".to_string()],
456            belongs_to: vec![Relation {
457                field: "author".to_string(),
458                target: "users".to_string(),
459            }],
460        });
461        m.upsert_module(Module::named("users"));
462
463        let ordered: Vec<&str> = m
464            .modules_in_dependency_order()
465            .unwrap()
466            .iter()
467            .map(|m| m.name.as_str())
468            .collect();
469        let pos = |n: &str| ordered.iter().position(|x| *x == n).unwrap();
470        assert!(
471            pos("users") < pos("posts"),
472            "users before posts: {ordered:?}"
473        );
474        assert!(
475            pos("posts") < pos("comments"),
476            "posts before comments: {ordered:?}"
477        );
478    }
479
480    #[test]
481    fn dependency_ordering_detects_cycles() {
482        let mut m = Manifest::new("loop");
483        m.upsert_module(Module {
484            name: "a".to_string(),
485            model: None,
486            fields: vec![],
487            belongs_to: vec![Relation {
488                field: "b".to_string(),
489                target: "b".to_string(),
490            }],
491        });
492        m.upsert_module(Module {
493            name: "b".to_string(),
494            model: None,
495            fields: vec![],
496            belongs_to: vec![Relation {
497                field: "a".to_string(),
498                target: "a".to_string(),
499            }],
500        });
501        assert!(m.modules_in_dependency_order().is_err());
502    }
503
504    #[test]
505    fn api_version_normalizes_and_builds_mount_path() {
506        // Both `1` and `v1` normalize to the same `v1` segment.
507        assert_eq!(Api::from_version("1").version, "v1");
508        assert_eq!(Api::from_version("v1").version, "v1");
509        assert_eq!(Api::from_version("V2").version, "v2");
510        assert_eq!(Api::from_version(" 3 ").version, "v3");
511        assert_eq!(Api::from_version("1").mount_path(), "/api/v1");
512    }
513
514    #[test]
515    fn api_section_roundtrips_and_is_omitted_when_absent() {
516        // Present: the `[api]` table survives a round-trip.
517        let mut m = Manifest::new("shop");
518        m.api = Some(Api::from_version("1"));
519        let text = m.to_toml().unwrap();
520        assert!(text.contains("[api]"));
521        assert!(text.contains("version = \"v1\""));
522        assert_eq!(m, Manifest::from_toml(&text).unwrap());
523
524        // Absent: no `[api]` table is written, keeping unversioned manifests clean.
525        let bare = Manifest::new("shop");
526        assert!(!bare.to_toml().unwrap().contains("[api]"));
527    }
528
529    #[test]
530    fn parses_minimal_manifest() {
531        let text = r#"
532            [project]
533            name = "blog"
534        "#;
535        let m = Manifest::from_toml(text).unwrap();
536        assert_eq!(m.project.name, "blog");
537        // defaults fill in the stack
538        assert_eq!(m.stack.framework, "axum");
539        assert!(m.modules.is_empty());
540    }
541
542    #[test]
543    fn empty_project_omits_modules_section() {
544        let m = Manifest::new("empty");
545        let text = m.to_toml().unwrap();
546        assert!(!text.contains("[[module]]"));
547        assert!(!text.contains("[modules]"));
548    }
549    #[test]
550    fn loads_legacy_modules_but_rejects_invalid_generation() {
551        for body in [
552            "[[module]]\nname='posts'\nfields=['id:String']",
553            "[[module]]\nname='posts'\nfields=['name:String','name:i32']",
554            "[[module]]\nname='posts'\nfields=['author_id:Uuid']\n[[module.belongs_to]]\nfield='author'\ntarget='users'",
555            "[[module]]\nname='posts'\n[[module.belongs_to]]\nfield='author'\ntarget='../users'",
556            "[[module]]\nname='../posts'",
557            "[[module]]\nname='posts'\n[[module]]\nname='posts'",
558            "[modules]\nlist=['mod']",
559            "[[module]]\nname='movies'\nmodel='Film'\nfields=['title:String']",
560            "[[module]]\nname='movies'\nmodel='movie'\nfields=['title:String']",
561        ] {
562            let text = format!("[project]\nname='test'\n{body}");
563            let manifest = Manifest::from_toml(&text).unwrap();
564            assert!(manifest.validate().is_err(), "accepted {body}");
565            assert!(manifest.to_toml().is_ok());
566        }
567    }
568
569    #[test]
570    fn records_the_model_name_only_when_it_does_not_round_trip() {
571        let spec = |name: &str| ModelSpec::parse(name, &["title:String".to_string()]).unwrap();
572
573        // `movies` reads back as `Movy`, so the struct name is recorded and wins.
574        let movie = Module::for_model(&spec("Movie"));
575        assert_eq!(movie.name, "movies");
576        assert_eq!(movie.model.as_deref(), Some("Movie"));
577        assert_eq!(movie.model_spec().unwrap().name, "Movie");
578
579        // Names that round-trip keep the manifest as it was before `model` existed.
580        for name in ["Post", "Category", "Box", "BlogPost", "Status"] {
581            let module = Module::for_model(&spec(name));
582            assert_eq!(module.model, None, "{name}");
583            assert_eq!(module.model_spec().unwrap().name, name);
584        }
585
586        let mut m = Manifest::new("films");
587        m.upsert_module(movie.clone());
588        let text = m.to_toml().unwrap();
589        assert!(text.contains("model = \"Movie\""));
590        let back = Manifest::from_toml(&text).unwrap();
591        back.validate().unwrap();
592        assert_eq!(back.module("movies"), Some(&movie));
593
594        // A manifest written before `model` existed still derives the name.
595        let legacy = Manifest::from_toml(
596            "[project]\nname='blog'\n[[module]]\nname='posts'\nfields=['title:String']",
597        )
598        .unwrap();
599        assert_eq!(legacy.modules[0].model_spec().unwrap().name, "Post");
600    }
601}