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