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 let mut tokens = self.fields.clone();
134 tokens.extend(self.belongs_to.iter().map(Relation::to_token));
135 ModelSpec::parse(model_name(&self.name), &tokens)
136 .with_context(|| format!("module `{}` has an invalid field definition", self.name))
137 }
138}
139
140#[derive(Debug, Clone, Deserialize)]
142struct LegacyModules {
143 #[serde(default)]
144 list: Vec<String>,
145}
146
147#[derive(Debug, Deserialize)]
150struct RawManifest {
151 project: Project,
152 #[serde(default)]
153 stack: Stack,
154 #[serde(default)]
155 features: Features,
156 #[serde(default)]
157 api: Option<Api>,
158 #[serde(default, rename = "module")]
159 module: Vec<Module>,
160 #[serde(default)]
161 modules: Option<LegacyModules>,
162}
163
164impl Manifest {
165 pub fn new(project_name: impl Into<String>) -> Self {
167 Self {
168 project: Project {
169 name: project_name.into(),
170 },
171 stack: Stack::default(),
172 features: Features::default(),
173 api: None,
174 modules: Vec::new(),
175 }
176 }
177
178 pub fn from_toml(text: &str) -> Result<Self> {
180 let raw: RawManifest = toml::from_str(text).context("failed to parse gize.toml")?;
181 let mut modules = raw.module;
183 if modules.is_empty() {
184 if let Some(legacy) = raw.modules {
185 modules = legacy.list.into_iter().map(Module::named).collect();
186 }
187 }
188 modules.sort_by(|a, b| a.name.cmp(&b.name));
189 Ok(Self {
190 project: raw.project,
191 stack: raw.stack,
192 features: raw.features,
193 api: raw.api,
194 modules,
195 })
196 }
197
198 pub fn to_toml(&self) -> Result<String> {
200 toml::to_string_pretty(self).context("failed to serialize manifest")
201 }
202
203 pub fn module(&self, name: &str) -> Option<&Module> {
205 self.modules.iter().find(|m| m.name == name)
206 }
207
208 pub fn add_module(&mut self, name: impl Into<String>) -> bool {
212 let name = name.into();
213 if self.modules.iter().any(|m| m.name == name) {
214 return false;
215 }
216 self.modules.push(Module::named(name));
217 self.modules.sort_by(|a, b| a.name.cmp(&b.name));
218 true
219 }
220
221 pub fn modules_in_dependency_order(&self) -> Result<Vec<&Module>> {
226 use std::collections::{HashMap, HashSet};
227
228 let by_name: HashMap<&str, &Module> =
229 self.modules.iter().map(|m| (m.name.as_str(), m)).collect();
230 let mut ordered = Vec::new();
231 let mut done: HashSet<&str> = HashSet::new();
232 let mut on_stack: HashSet<&str> = HashSet::new();
233
234 fn visit<'a>(
235 m: &'a Module,
236 by_name: &HashMap<&str, &'a Module>,
237 done: &mut HashSet<&'a str>,
238 on_stack: &mut HashSet<&'a str>,
239 ordered: &mut Vec<&'a Module>,
240 ) -> Result<()> {
241 if done.contains(m.name.as_str()) {
242 return Ok(());
243 }
244 if !on_stack.insert(m.name.as_str()) {
245 anyhow::bail!("cyclic belongs_to relationship involving `{}`", m.name);
246 }
247 for r in &m.belongs_to {
248 if r.target != m.name {
249 if let Some(target) = by_name.get(r.target.as_str()) {
250 visit(target, by_name, done, on_stack, ordered)?;
251 }
252 }
253 }
254 on_stack.remove(m.name.as_str());
255 done.insert(m.name.as_str());
256 ordered.push(m);
257 Ok(())
258 }
259
260 for m in &self.modules {
261 visit(m, &by_name, &mut done, &mut on_stack, &mut ordered)?;
262 }
263 Ok(ordered)
264 }
265
266 pub fn upsert_module(&mut self, module: Module) -> bool {
271 match self.modules.iter_mut().find(|m| m.name == module.name) {
272 Some(existing) => {
273 *existing = module;
274 false
275 }
276 None => {
277 self.modules.push(module);
278 self.modules.sort_by(|a, b| a.name.cmp(&b.name));
279 true
280 }
281 }
282 }
283}
284
285#[cfg(test)]
286mod tests {
287 use super::*;
288
289 #[test]
290 fn roundtrips_through_toml() {
291 let mut m = Manifest::new("shop");
292 m.features.admin = true;
293 m.upsert_module(Module {
294 name: "products".to_string(),
295 fields: vec!["name:String".to_string(), "price:i32".to_string()],
296 belongs_to: vec![],
297 });
298 let text = m.to_toml().unwrap();
299 let parsed = Manifest::from_toml(&text).unwrap();
300 assert_eq!(m, parsed);
301 }
302
303 #[test]
304 fn roundtrips_a_relationship() {
305 let mut m = Manifest::new("blog");
306 m.upsert_module(Module {
307 name: "posts".to_string(),
308 fields: vec!["title:String".to_string()],
309 belongs_to: vec![Relation {
310 field: "author".to_string(),
311 target: "users".to_string(),
312 }],
313 });
314 let text = m.to_toml().unwrap();
315 assert!(text.contains("[[module.belongs_to]]"));
316 assert_eq!(m, Manifest::from_toml(&text).unwrap());
317 }
318
319 #[test]
320 fn adding_module_is_idempotent() {
321 let mut m = Manifest::new("shop");
322 assert!(m.add_module("users"));
323 assert!(!m.add_module("users"));
324 assert_eq!(m.modules.len(), 1);
325 assert_eq!(m.modules[0].name, "users");
326 }
327
328 #[test]
329 fn upsert_replaces_shape_without_duplicating() {
330 let mut m = Manifest::new("shop");
331 assert!(m.add_module("products")); assert!(!m.upsert_module(Module {
333 name: "products".to_string(),
334 fields: vec!["name:String".to_string()],
335 belongs_to: vec![],
336 }));
337 assert_eq!(m.modules.len(), 1);
338 assert_eq!(m.modules[0].fields, vec!["name:String".to_string()]);
339 }
340
341 #[test]
342 fn reads_legacy_names_only_manifest() {
343 let text = r#"
345 [project]
346 name = "shop"
347
348 [modules]
349 list = ["products", "users"]
350 "#;
351 let m = Manifest::from_toml(text).unwrap();
352 assert_eq!(m.modules.len(), 2);
353 assert_eq!(m.modules[0].name, "products");
355 assert_eq!(m.modules[1].name, "users");
356 assert!(m.modules[0].fields.is_empty());
357 let upgraded = m.to_toml().unwrap();
359 assert!(upgraded.contains("[[module]]"));
360 assert!(!upgraded.contains("list ="));
361 }
362
363 #[test]
364 fn orders_modules_so_targets_precede_dependents() {
365 let mut m = Manifest::new("blog");
366 m.upsert_module(Module {
368 name: "comments".to_string(),
369 fields: vec!["body:String".to_string()],
370 belongs_to: vec![Relation {
371 field: "post".to_string(),
372 target: "posts".to_string(),
373 }],
374 });
375 m.upsert_module(Module {
376 name: "posts".to_string(),
377 fields: vec!["title:String".to_string()],
378 belongs_to: vec![Relation {
379 field: "author".to_string(),
380 target: "users".to_string(),
381 }],
382 });
383 m.upsert_module(Module::named("users"));
384
385 let ordered: Vec<&str> = m
386 .modules_in_dependency_order()
387 .unwrap()
388 .iter()
389 .map(|m| m.name.as_str())
390 .collect();
391 let pos = |n: &str| ordered.iter().position(|x| *x == n).unwrap();
392 assert!(
393 pos("users") < pos("posts"),
394 "users before posts: {ordered:?}"
395 );
396 assert!(
397 pos("posts") < pos("comments"),
398 "posts before comments: {ordered:?}"
399 );
400 }
401
402 #[test]
403 fn dependency_ordering_detects_cycles() {
404 let mut m = Manifest::new("loop");
405 m.upsert_module(Module {
406 name: "a".to_string(),
407 fields: vec![],
408 belongs_to: vec![Relation {
409 field: "b".to_string(),
410 target: "b".to_string(),
411 }],
412 });
413 m.upsert_module(Module {
414 name: "b".to_string(),
415 fields: vec![],
416 belongs_to: vec![Relation {
417 field: "a".to_string(),
418 target: "a".to_string(),
419 }],
420 });
421 assert!(m.modules_in_dependency_order().is_err());
422 }
423
424 #[test]
425 fn api_version_normalizes_and_builds_mount_path() {
426 assert_eq!(Api::from_version("1").version, "v1");
428 assert_eq!(Api::from_version("v1").version, "v1");
429 assert_eq!(Api::from_version("V2").version, "v2");
430 assert_eq!(Api::from_version(" 3 ").version, "v3");
431 assert_eq!(Api::from_version("1").mount_path(), "/api/v1");
432 }
433
434 #[test]
435 fn api_section_roundtrips_and_is_omitted_when_absent() {
436 let mut m = Manifest::new("shop");
438 m.api = Some(Api::from_version("1"));
439 let text = m.to_toml().unwrap();
440 assert!(text.contains("[api]"));
441 assert!(text.contains("version = \"v1\""));
442 assert_eq!(m, Manifest::from_toml(&text).unwrap());
443
444 let bare = Manifest::new("shop");
446 assert!(!bare.to_toml().unwrap().contains("[api]"));
447 }
448
449 #[test]
450 fn parses_minimal_manifest() {
451 let text = r#"
452 [project]
453 name = "blog"
454 "#;
455 let m = Manifest::from_toml(text).unwrap();
456 assert_eq!(m.project.name, "blog");
457 assert_eq!(m.stack.framework, "axum");
459 assert!(m.modules.is_empty());
460 }
461
462 #[test]
463 fn empty_project_omits_modules_section() {
464 let m = Manifest::new("empty");
465 let text = m.to_toml().unwrap();
466 assert!(!text.contains("[[module]]"));
467 assert!(!text.contains("[modules]"));
468 }
469}