use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use crate::naming::{model_name, table_name};
use crate::{ModelSpec, Relation};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct Manifest {
pub project: Project,
#[serde(default)]
pub stack: Stack,
#[serde(default)]
pub features: Features,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub api: Option<Api>,
#[serde(default, rename = "module", skip_serializing_if = "Vec::is_empty")]
pub modules: Vec<Module>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct Project {
pub name: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct Stack {
pub framework: String,
pub database: String,
pub orm: String,
}
impl Default for Stack {
fn default() -> Self {
Self {
framework: "axum".to_string(),
database: "postgres".to_string(),
orm: "sqlx".to_string(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct Api {
pub version: String,
pub prefix: String,
}
impl Api {
pub const DEFAULT_PREFIX: &'static str = "/api";
pub fn from_version(raw: &str) -> Self {
let raw = raw.trim();
let digits = raw.strip_prefix(['v', 'V']).unwrap_or(raw);
Self {
version: format!("v{digits}"),
prefix: Self::DEFAULT_PREFIX.to_string(),
}
}
pub fn mount_path(&self) -> String {
format!("{}/{}", self.prefix.trim_end_matches('/'), self.version)
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
pub struct Features {
#[serde(default)]
pub authentication: bool,
#[serde(default)]
pub admin: bool,
#[serde(default)]
pub openapi: bool,
#[serde(default)]
pub websocket: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
pub struct Module {
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub fields: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub belongs_to: Vec<Relation>,
}
impl Module {
pub fn named(name: impl Into<String>) -> Self {
Self {
name: name.into(),
model: None,
fields: Vec::new(),
belongs_to: Vec::new(),
}
}
pub fn for_model(model: &ModelSpec) -> Self {
let name = table_name(&model.name);
let derived = model_name(&name);
Self {
model: (derived != model.name).then(|| model.name.clone()),
name,
fields: model.to_field_tokens(),
belongs_to: model.relations.clone(),
}
}
pub fn model_name(&self) -> String {
self.model.clone().unwrap_or_else(|| model_name(&self.name))
}
pub fn model_spec(&self) -> Result<ModelSpec> {
crate::validation::validate_identifier(&self.name, "module")?;
let mut tokens = self.fields.clone();
tokens.extend(self.belongs_to.iter().map(Relation::to_token));
ModelSpec::parse(self.model_name(), &tokens)
.with_context(|| format!("module `{}` has an invalid field definition", self.name))
}
}
#[derive(Debug, Clone, Deserialize)]
struct LegacyModules {
#[serde(default)]
list: Vec<String>,
}
#[derive(Debug, Deserialize)]
struct RawManifest {
project: Project,
#[serde(default)]
stack: Stack,
#[serde(default)]
features: Features,
#[serde(default)]
api: Option<Api>,
#[serde(default, rename = "module")]
module: Vec<Module>,
#[serde(default)]
modules: Option<LegacyModules>,
}
impl Manifest {
pub fn new(project_name: impl Into<String>) -> Self {
Self {
project: Project {
name: project_name.into(),
},
stack: Stack::default(),
features: Features::default(),
api: None,
modules: Vec::new(),
}
}
pub fn from_toml(text: &str) -> Result<Self> {
let raw: RawManifest = toml::from_str(text).context("failed to parse gize.toml")?;
let mut modules = raw.module;
if modules.is_empty() {
if let Some(legacy) = raw.modules {
modules = legacy.list.into_iter().map(Module::named).collect();
}
}
modules.sort_by(|a, b| a.name.cmp(&b.name));
let manifest = Self {
project: raw.project,
stack: raw.stack,
features: raw.features,
api: raw.api,
modules,
};
Ok(manifest)
}
pub fn validate(&self) -> Result<()> {
let mut names = std::collections::BTreeSet::new();
for module in &self.modules {
anyhow::ensure!(
names.insert(&module.name),
"duplicate module `{}`",
module.name
);
crate::validation::validate_identifier(&module.name, "module")?;
if let Some(model) = &module.model {
anyhow::ensure!(
table_name(model) == module.name,
"module `{}` in gize.toml records model `{model}`, whose table would be `{}`; \
the model of `{}` must be named so its table is `{}`",
module.name,
table_name(model),
module.name,
module.name
);
}
if !module.fields.is_empty() || !module.belongs_to.is_empty() {
let dialect = crate::Dialect::from_database(&self.stack.database);
crate::validation::validate_sql_identifier_for_dialect(
&module.name,
"table",
dialect,
)
.and_then(|()| module.model_spec()?.validate_for_dialect(dialect))
.with_context(|| {
format!(
"module `{}` in gize.toml cannot be generated; rename the name reported \
below in gize.toml (and rename an existing table/column with a named migration: \
`gize make migration <name>`)",
module.name
)
})?;
}
}
Ok(())
}
pub fn to_toml(&self) -> Result<String> {
toml::to_string_pretty(self).context("failed to serialize manifest")
}
pub fn module(&self, name: &str) -> Option<&Module> {
self.modules.iter().find(|m| m.name == name)
}
pub fn add_module(&mut self, name: impl Into<String>) -> bool {
let name = name.into();
if self.modules.iter().any(|m| m.name == name) {
return false;
}
self.modules.push(Module::named(name));
self.modules.sort_by(|a, b| a.name.cmp(&b.name));
true
}
pub fn modules_in_dependency_order(&self) -> Result<Vec<&Module>> {
use std::collections::{HashMap, HashSet};
let by_name: HashMap<&str, &Module> =
self.modules.iter().map(|m| (m.name.as_str(), m)).collect();
let mut ordered = Vec::new();
let mut done: HashSet<&str> = HashSet::new();
let mut on_stack: HashSet<&str> = HashSet::new();
fn visit<'a>(
m: &'a Module,
by_name: &HashMap<&str, &'a Module>,
done: &mut HashSet<&'a str>,
on_stack: &mut HashSet<&'a str>,
ordered: &mut Vec<&'a Module>,
) -> Result<()> {
if done.contains(m.name.as_str()) {
return Ok(());
}
if !on_stack.insert(m.name.as_str()) {
anyhow::bail!("cyclic belongs_to relationship involving `{}`", m.name);
}
for r in &m.belongs_to {
if r.target != m.name {
if let Some(target) = by_name.get(r.target.as_str()) {
visit(target, by_name, done, on_stack, ordered)?;
}
}
}
on_stack.remove(m.name.as_str());
done.insert(m.name.as_str());
ordered.push(m);
Ok(())
}
for m in &self.modules {
visit(m, &by_name, &mut done, &mut on_stack, &mut ordered)?;
}
Ok(ordered)
}
pub fn upsert_module(&mut self, module: Module) -> bool {
match self.modules.iter_mut().find(|m| m.name == module.name) {
Some(existing) => {
*existing = module;
false
}
None => {
self.modules.push(module);
self.modules.sort_by(|a, b| a.name.cmp(&b.name));
true
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn roundtrips_through_toml() {
let mut m = Manifest::new("shop");
m.features.admin = true;
m.upsert_module(Module {
name: "products".to_string(),
model: None,
fields: vec!["name:String".to_string(), "price:i32".to_string()],
belongs_to: vec![],
});
let text = m.to_toml().unwrap();
let parsed = Manifest::from_toml(&text).unwrap();
assert_eq!(m, parsed);
}
#[test]
fn roundtrips_a_relationship() {
let mut m = Manifest::new("blog");
m.upsert_module(Module {
name: "posts".to_string(),
model: None,
fields: vec!["title:String".to_string()],
belongs_to: vec![Relation {
field: "author".to_string(),
target: "users".to_string(),
}],
});
let text = m.to_toml().unwrap();
assert!(text.contains("[[module.belongs_to]]"));
assert_eq!(m, Manifest::from_toml(&text).unwrap());
}
#[test]
fn adding_module_is_idempotent() {
let mut m = Manifest::new("shop");
assert!(m.add_module("users"));
assert!(!m.add_module("users"));
assert_eq!(m.modules.len(), 1);
assert_eq!(m.modules[0].name, "users");
}
#[test]
fn upsert_replaces_shape_without_duplicating() {
let mut m = Manifest::new("shop");
assert!(m.add_module("products")); assert!(!m.upsert_module(Module {
name: "products".to_string(),
model: None,
fields: vec!["name:String".to_string()],
belongs_to: vec![],
}));
assert_eq!(m.modules.len(), 1);
assert_eq!(m.modules[0].fields, vec!["name:String".to_string()]);
}
#[test]
fn reads_legacy_names_only_manifest() {
let text = r#"
[project]
name = "shop"
[modules]
list = ["products", "users"]
"#;
let m = Manifest::from_toml(text).unwrap();
assert_eq!(m.modules.len(), 2);
assert_eq!(m.modules[0].name, "products");
assert_eq!(m.modules[1].name, "users");
assert!(m.modules[0].fields.is_empty());
let upgraded = m.to_toml().unwrap();
assert!(upgraded.contains("[[module]]"));
assert!(!upgraded.contains("list ="));
}
#[test]
fn orders_modules_so_targets_precede_dependents() {
let mut m = Manifest::new("blog");
m.upsert_module(Module {
name: "comments".to_string(),
model: None,
fields: vec!["body:String".to_string()],
belongs_to: vec![Relation {
field: "post".to_string(),
target: "posts".to_string(),
}],
});
m.upsert_module(Module {
name: "posts".to_string(),
model: None,
fields: vec!["title:String".to_string()],
belongs_to: vec![Relation {
field: "author".to_string(),
target: "users".to_string(),
}],
});
m.upsert_module(Module::named("users"));
let ordered: Vec<&str> = m
.modules_in_dependency_order()
.unwrap()
.iter()
.map(|m| m.name.as_str())
.collect();
let pos = |n: &str| ordered.iter().position(|x| *x == n).unwrap();
assert!(
pos("users") < pos("posts"),
"users before posts: {ordered:?}"
);
assert!(
pos("posts") < pos("comments"),
"posts before comments: {ordered:?}"
);
}
#[test]
fn dependency_ordering_detects_cycles() {
let mut m = Manifest::new("loop");
m.upsert_module(Module {
name: "a".to_string(),
model: None,
fields: vec![],
belongs_to: vec![Relation {
field: "b".to_string(),
target: "b".to_string(),
}],
});
m.upsert_module(Module {
name: "b".to_string(),
model: None,
fields: vec![],
belongs_to: vec![Relation {
field: "a".to_string(),
target: "a".to_string(),
}],
});
assert!(m.modules_in_dependency_order().is_err());
}
#[test]
fn api_version_normalizes_and_builds_mount_path() {
assert_eq!(Api::from_version("1").version, "v1");
assert_eq!(Api::from_version("v1").version, "v1");
assert_eq!(Api::from_version("V2").version, "v2");
assert_eq!(Api::from_version(" 3 ").version, "v3");
assert_eq!(Api::from_version("1").mount_path(), "/api/v1");
}
#[test]
fn api_section_roundtrips_and_is_omitted_when_absent() {
let mut m = Manifest::new("shop");
m.api = Some(Api::from_version("1"));
let text = m.to_toml().unwrap();
assert!(text.contains("[api]"));
assert!(text.contains("version = \"v1\""));
assert_eq!(m, Manifest::from_toml(&text).unwrap());
let bare = Manifest::new("shop");
assert!(!bare.to_toml().unwrap().contains("[api]"));
}
#[test]
fn parses_minimal_manifest() {
let text = r#"
[project]
name = "blog"
"#;
let m = Manifest::from_toml(text).unwrap();
assert_eq!(m.project.name, "blog");
assert_eq!(m.stack.framework, "axum");
assert!(m.modules.is_empty());
}
#[test]
fn empty_project_omits_modules_section() {
let m = Manifest::new("empty");
let text = m.to_toml().unwrap();
assert!(!text.contains("[[module]]"));
assert!(!text.contains("[modules]"));
}
#[test]
fn loads_legacy_modules_but_rejects_invalid_generation() {
for body in [
"[[module]]\nname='posts'\nfields=['id:String']",
"[[module]]\nname='posts'\nfields=['name:String','name:i32']",
"[[module]]\nname='posts'\nfields=['author_id:Uuid']\n[[module.belongs_to]]\nfield='author'\ntarget='users'",
"[[module]]\nname='posts'\n[[module.belongs_to]]\nfield='author'\ntarget='../users'",
"[[module]]\nname='../posts'",
"[[module]]\nname='posts'\n[[module]]\nname='posts'",
"[modules]\nlist=['mod']",
"[[module]]\nname='movies'\nmodel='Film'\nfields=['title:String']",
"[[module]]\nname='movies'\nmodel='movie'\nfields=['title:String']",
] {
let text = format!("[project]\nname='test'\n{body}");
let manifest = Manifest::from_toml(&text).unwrap();
assert!(manifest.validate().is_err(), "accepted {body}");
assert!(manifest.to_toml().is_ok());
}
}
#[test]
fn records_the_model_name_only_when_it_does_not_round_trip() {
let spec = |name: &str| ModelSpec::parse(name, &["title:String".to_string()]).unwrap();
let movie = Module::for_model(&spec("Movie"));
assert_eq!(movie.name, "movies");
assert_eq!(movie.model.as_deref(), Some("Movie"));
assert_eq!(movie.model_spec().unwrap().name, "Movie");
for name in ["Post", "Category", "Box", "BlogPost", "Status"] {
let module = Module::for_model(&spec(name));
assert_eq!(module.model, None, "{name}");
assert_eq!(module.model_spec().unwrap().name, name);
}
let mut m = Manifest::new("films");
m.upsert_module(movie.clone());
let text = m.to_toml().unwrap();
assert!(text.contains("model = \"Movie\""));
let back = Manifest::from_toml(&text).unwrap();
back.validate().unwrap();
assert_eq!(back.module("movies"), Some(&movie));
let legacy = Manifest::from_toml(
"[project]\nname='blog'\n[[module]]\nname='posts'\nfields=['title:String']",
)
.unwrap();
assert_eq!(legacy.modules[0].model_spec().unwrap().name, "Post");
}
}