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, rename = "module", skip_serializing_if = "Vec::is_empty")]
28 pub modules: Vec<Module>,
29}
30
31#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
32pub struct Project {
33 pub name: String,
34}
35
36#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
37pub struct Stack {
38 pub framework: String,
39 pub database: String,
40 pub orm: String,
41}
42
43impl Default for Stack {
44 fn default() -> Self {
45 Self {
47 framework: "axum".to_string(),
48 database: "postgres".to_string(),
49 orm: "sqlx".to_string(),
50 }
51 }
52}
53
54#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
55pub struct Features {
56 #[serde(default)]
57 pub authentication: bool,
58 #[serde(default)]
59 pub admin: bool,
60 #[serde(default)]
61 pub openapi: bool,
62}
63
64#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
71pub struct Module {
72 pub name: String,
73 #[serde(default, skip_serializing_if = "Vec::is_empty")]
74 pub fields: Vec<String>,
75 #[serde(default, skip_serializing_if = "Vec::is_empty")]
76 pub belongs_to: Vec<Relation>,
77}
78
79impl Module {
80 pub fn named(name: impl Into<String>) -> Self {
83 Self {
84 name: name.into(),
85 fields: Vec::new(),
86 belongs_to: Vec::new(),
87 }
88 }
89
90 pub fn model_spec(&self) -> Result<ModelSpec> {
94 let mut tokens = self.fields.clone();
98 tokens.extend(self.belongs_to.iter().map(Relation::to_token));
99 ModelSpec::parse(model_name(&self.name), &tokens)
100 .with_context(|| format!("module `{}` has an invalid field definition", self.name))
101 }
102}
103
104#[derive(Debug, Clone, Deserialize)]
106struct LegacyModules {
107 #[serde(default)]
108 list: Vec<String>,
109}
110
111#[derive(Debug, Deserialize)]
114struct RawManifest {
115 project: Project,
116 #[serde(default)]
117 stack: Stack,
118 #[serde(default)]
119 features: Features,
120 #[serde(default, rename = "module")]
121 module: Vec<Module>,
122 #[serde(default)]
123 modules: Option<LegacyModules>,
124}
125
126impl Manifest {
127 pub fn new(project_name: impl Into<String>) -> Self {
129 Self {
130 project: Project {
131 name: project_name.into(),
132 },
133 stack: Stack::default(),
134 features: Features::default(),
135 modules: Vec::new(),
136 }
137 }
138
139 pub fn from_toml(text: &str) -> Result<Self> {
141 let raw: RawManifest = toml::from_str(text).context("failed to parse gize.toml")?;
142 let mut modules = raw.module;
144 if modules.is_empty() {
145 if let Some(legacy) = raw.modules {
146 modules = legacy.list.into_iter().map(Module::named).collect();
147 }
148 }
149 modules.sort_by(|a, b| a.name.cmp(&b.name));
150 Ok(Self {
151 project: raw.project,
152 stack: raw.stack,
153 features: raw.features,
154 modules,
155 })
156 }
157
158 pub fn to_toml(&self) -> Result<String> {
160 toml::to_string_pretty(self).context("failed to serialize manifest")
161 }
162
163 pub fn module(&self, name: &str) -> Option<&Module> {
165 self.modules.iter().find(|m| m.name == name)
166 }
167
168 pub fn add_module(&mut self, name: impl Into<String>) -> bool {
172 let name = name.into();
173 if self.modules.iter().any(|m| m.name == name) {
174 return false;
175 }
176 self.modules.push(Module::named(name));
177 self.modules.sort_by(|a, b| a.name.cmp(&b.name));
178 true
179 }
180
181 pub fn modules_in_dependency_order(&self) -> Result<Vec<&Module>> {
186 use std::collections::{HashMap, HashSet};
187
188 let by_name: HashMap<&str, &Module> =
189 self.modules.iter().map(|m| (m.name.as_str(), m)).collect();
190 let mut ordered = Vec::new();
191 let mut done: HashSet<&str> = HashSet::new();
192 let mut on_stack: HashSet<&str> = HashSet::new();
193
194 fn visit<'a>(
195 m: &'a Module,
196 by_name: &HashMap<&str, &'a Module>,
197 done: &mut HashSet<&'a str>,
198 on_stack: &mut HashSet<&'a str>,
199 ordered: &mut Vec<&'a Module>,
200 ) -> Result<()> {
201 if done.contains(m.name.as_str()) {
202 return Ok(());
203 }
204 if !on_stack.insert(m.name.as_str()) {
205 anyhow::bail!("cyclic belongs_to relationship involving `{}`", m.name);
206 }
207 for r in &m.belongs_to {
208 if r.target != m.name {
209 if let Some(target) = by_name.get(r.target.as_str()) {
210 visit(target, by_name, done, on_stack, ordered)?;
211 }
212 }
213 }
214 on_stack.remove(m.name.as_str());
215 done.insert(m.name.as_str());
216 ordered.push(m);
217 Ok(())
218 }
219
220 for m in &self.modules {
221 visit(m, &by_name, &mut done, &mut on_stack, &mut ordered)?;
222 }
223 Ok(ordered)
224 }
225
226 pub fn upsert_module(&mut self, module: Module) -> bool {
231 match self.modules.iter_mut().find(|m| m.name == module.name) {
232 Some(existing) => {
233 *existing = module;
234 false
235 }
236 None => {
237 self.modules.push(module);
238 self.modules.sort_by(|a, b| a.name.cmp(&b.name));
239 true
240 }
241 }
242 }
243}
244
245#[cfg(test)]
246mod tests {
247 use super::*;
248
249 #[test]
250 fn roundtrips_through_toml() {
251 let mut m = Manifest::new("shop");
252 m.features.admin = true;
253 m.upsert_module(Module {
254 name: "products".to_string(),
255 fields: vec!["name:String".to_string(), "price:i32".to_string()],
256 belongs_to: vec![],
257 });
258 let text = m.to_toml().unwrap();
259 let parsed = Manifest::from_toml(&text).unwrap();
260 assert_eq!(m, parsed);
261 }
262
263 #[test]
264 fn roundtrips_a_relationship() {
265 let mut m = Manifest::new("blog");
266 m.upsert_module(Module {
267 name: "posts".to_string(),
268 fields: vec!["title:String".to_string()],
269 belongs_to: vec![Relation {
270 field: "author".to_string(),
271 target: "users".to_string(),
272 }],
273 });
274 let text = m.to_toml().unwrap();
275 assert!(text.contains("[[module.belongs_to]]"));
276 assert_eq!(m, Manifest::from_toml(&text).unwrap());
277 }
278
279 #[test]
280 fn adding_module_is_idempotent() {
281 let mut m = Manifest::new("shop");
282 assert!(m.add_module("users"));
283 assert!(!m.add_module("users"));
284 assert_eq!(m.modules.len(), 1);
285 assert_eq!(m.modules[0].name, "users");
286 }
287
288 #[test]
289 fn upsert_replaces_shape_without_duplicating() {
290 let mut m = Manifest::new("shop");
291 assert!(m.add_module("products")); assert!(!m.upsert_module(Module {
293 name: "products".to_string(),
294 fields: vec!["name:String".to_string()],
295 belongs_to: vec![],
296 }));
297 assert_eq!(m.modules.len(), 1);
298 assert_eq!(m.modules[0].fields, vec!["name:String".to_string()]);
299 }
300
301 #[test]
302 fn reads_legacy_names_only_manifest() {
303 let text = r#"
305 [project]
306 name = "shop"
307
308 [modules]
309 list = ["products", "users"]
310 "#;
311 let m = Manifest::from_toml(text).unwrap();
312 assert_eq!(m.modules.len(), 2);
313 assert_eq!(m.modules[0].name, "products");
315 assert_eq!(m.modules[1].name, "users");
316 assert!(m.modules[0].fields.is_empty());
317 let upgraded = m.to_toml().unwrap();
319 assert!(upgraded.contains("[[module]]"));
320 assert!(!upgraded.contains("list ="));
321 }
322
323 #[test]
324 fn orders_modules_so_targets_precede_dependents() {
325 let mut m = Manifest::new("blog");
326 m.upsert_module(Module {
328 name: "comments".to_string(),
329 fields: vec!["body:String".to_string()],
330 belongs_to: vec![Relation {
331 field: "post".to_string(),
332 target: "posts".to_string(),
333 }],
334 });
335 m.upsert_module(Module {
336 name: "posts".to_string(),
337 fields: vec!["title:String".to_string()],
338 belongs_to: vec![Relation {
339 field: "author".to_string(),
340 target: "users".to_string(),
341 }],
342 });
343 m.upsert_module(Module::named("users"));
344
345 let ordered: Vec<&str> = m
346 .modules_in_dependency_order()
347 .unwrap()
348 .iter()
349 .map(|m| m.name.as_str())
350 .collect();
351 let pos = |n: &str| ordered.iter().position(|x| *x == n).unwrap();
352 assert!(
353 pos("users") < pos("posts"),
354 "users before posts: {ordered:?}"
355 );
356 assert!(
357 pos("posts") < pos("comments"),
358 "posts before comments: {ordered:?}"
359 );
360 }
361
362 #[test]
363 fn dependency_ordering_detects_cycles() {
364 let mut m = Manifest::new("loop");
365 m.upsert_module(Module {
366 name: "a".to_string(),
367 fields: vec![],
368 belongs_to: vec![Relation {
369 field: "b".to_string(),
370 target: "b".to_string(),
371 }],
372 });
373 m.upsert_module(Module {
374 name: "b".to_string(),
375 fields: vec![],
376 belongs_to: vec![Relation {
377 field: "a".to_string(),
378 target: "a".to_string(),
379 }],
380 });
381 assert!(m.modules_in_dependency_order().is_err());
382 }
383
384 #[test]
385 fn parses_minimal_manifest() {
386 let text = r#"
387 [project]
388 name = "blog"
389 "#;
390 let m = Manifest::from_toml(text).unwrap();
391 assert_eq!(m.project.name, "blog");
392 assert_eq!(m.stack.framework, "axum");
394 assert!(m.modules.is_empty());
395 }
396
397 #[test]
398 fn empty_project_omits_modules_section() {
399 let m = Manifest::new("empty");
400 let text = m.to_toml().unwrap();
401 assert!(!text.contains("[[module]]"));
402 assert!(!text.contains("[modules]"));
403 }
404}