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