1use 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 #[serde(default, skip_serializing_if = "Option::is_none")]
28 pub api: Option<Api>,
29 #[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 Self {
51 framework: "axum".to_string(),
52 database: "postgres".to_string(),
53 orm: "sqlx".to_string(),
54 }
55 }
56}
57
58#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
61pub struct Api {
62 pub version: String,
64 pub prefix: String,
66}
67
68impl Api {
69 pub const DEFAULT_PREFIX: &'static str = "/api";
71
72 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 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#[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 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 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 pub fn model_name(&self) -> String {
148 self.model.clone().unwrap_or_else(|| model_name(&self.name))
149 }
150
151 pub fn model_spec(&self) -> Result<ModelSpec> {
155 crate::validation::validate_identifier(&self.name, "module")?;
156 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#[derive(Debug, Clone, Deserialize)]
168struct LegacyModules {
169 #[serde(default)]
170 list: Vec<String>,
171}
172
173#[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 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 pub fn from_toml(text: &str) -> Result<Self> {
206 let raw: RawManifest = toml::from_str(text).context("failed to parse gize.toml")?;
207 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 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 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 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 pub fn to_toml(&self) -> Result<String> {
273 toml::to_string_pretty(self).context("failed to serialize manifest")
274 }
275
276 pub fn module(&self, name: &str) -> Option<&Module> {
278 self.modules.iter().find(|m| m.name == name)
279 }
280
281 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 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 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")); 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 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 assert_eq!(m.modules[0].name, "products");
431 assert_eq!(m.modules[1].name, "users");
432 assert!(m.modules[0].fields.is_empty());
433 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 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 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 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 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 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 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 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 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}