1use 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 #[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)]
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 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 pub fn model_spec(&self) -> Result<ModelSpec> {
130 crate::validation::validate_identifier(&self.name, "module")?;
131 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#[derive(Debug, Clone, Deserialize)]
143struct LegacyModules {
144 #[serde(default)]
145 list: Vec<String>,
146}
147
148#[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 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 pub fn from_toml(text: &str) -> Result<Self> {
181 let raw: RawManifest = toml::from_str(text).context("failed to parse gize.toml")?;
182 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 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 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 pub fn to_toml(&self) -> Result<String> {
235 toml::to_string_pretty(self).context("failed to serialize manifest")
236 }
237
238 pub fn module(&self, name: &str) -> Option<&Module> {
240 self.modules.iter().find(|m| m.name == name)
241 }
242
243 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 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 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")); 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 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 assert_eq!(m.modules[0].name, "products");
390 assert_eq!(m.modules[1].name, "users");
391 assert!(m.modules[0].fields.is_empty());
392 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 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 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 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 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 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}