use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use crate::naming::model_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 = "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(),
fields: Vec::new(),
belongs_to: Vec::new(),
}
}
pub fn model_spec(&self) -> Result<ModelSpec> {
let mut tokens = self.fields.clone();
tokens.extend(self.belongs_to.iter().map(Relation::to_token));
ModelSpec::parse(model_name(&self.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));
Ok(Self {
project: raw.project,
stack: raw.stack,
features: raw.features,
api: raw.api,
modules,
})
}
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(),
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(),
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(),
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(),
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(),
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(),
fields: vec![],
belongs_to: vec![Relation {
field: "b".to_string(),
target: "b".to_string(),
}],
});
m.upsert_module(Module {
name: "b".to_string(),
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]"));
}
}