use std::collections::BTreeMap;
use std::fmt::Write as _;
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use crate::descriptor::{
FieldDescriptor, FieldSqlType, IndexKind, IndexSpec, IndexTarget, ModelDescriptor, PkType,
};
use super::target::GLOBAL_BUCKET_DIRNAME;
#[derive(Debug)]
pub enum DocsError {
Io { path: PathBuf, source: io::Error },
}
impl std::fmt::Display for DocsError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
DocsError::Io { path, source } => {
write!(f, "docs I/O at {}: {source}", path.display())
}
}
}
}
impl std::error::Error for DocsError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
DocsError::Io { source, .. } => Some(source),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DocsReport {
pub models_rendered: usize,
pub output_root: PathBuf,
pub written_files: Vec<PathBuf>,
}
pub fn generate_docs(
output_root: &Path,
intent: Option<&crate::intent::IntentFile>,
) -> Result<DocsReport, DocsError> {
generate_docs_with_provider(
&crate::migrate::InventoryDescriptorProvider::new(),
output_root,
intent,
)
}
pub fn generate_docs_with_provider(
p: &dyn crate::migrate::DescriptorProvider,
output_root: &Path,
intent: Option<&crate::intent::IntentFile>,
) -> Result<DocsReport, DocsError> {
let descriptors: Vec<&'static ModelDescriptor> = p.models();
render_inventory(&descriptors, output_root, intent)
}
pub fn render_inventory(
descriptors: &[&ModelDescriptor],
output_root: &Path,
intent: Option<&crate::intent::IntentFile>,
) -> Result<DocsReport, DocsError> {
fs::create_dir_all(output_root).map_err(|e| DocsError::Io {
path: output_root.to_path_buf(),
source: e,
})?;
let mut by_app: BTreeMap<String, BTreeMap<&str, &ModelDescriptor>> = BTreeMap::new();
for d in descriptors {
let app_dir = app_directory(d.app);
by_app.entry(app_dir).or_default().insert(d.type_name, *d);
}
let mut written: Vec<PathBuf> = Vec::with_capacity(descriptors.len());
let mut total = 0usize;
for (app_dir, models) in &by_app {
let app_path = output_root.join(app_dir);
fs::create_dir_all(&app_path).map_err(|e| DocsError::Io {
path: app_path.clone(),
source: e,
})?;
for desc in models.values() {
let body = render_model_page(desc, intent);
let file_path = app_path.join(model_filename(desc.type_name));
fs::write(&file_path, body.as_bytes()).map_err(|e| DocsError::Io {
path: file_path.clone(),
source: e,
})?;
written.push(file_path);
total += 1;
}
}
let readme_body = render_readme(&by_app);
let readme_path = output_root.join("README.md");
fs::write(&readme_path, readme_body.as_bytes()).map_err(|e| DocsError::Io {
path: readme_path.clone(),
source: e,
})?;
written.push(readme_path);
let written_set: std::collections::BTreeSet<&std::path::Path> =
written.iter().map(PathBuf::as_path).collect();
if let Ok(walk) = fs::read_dir(output_root) {
for entry in walk.flatten() {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("md") {
continue;
}
if !written_set.contains(path.as_path()) {
let _ = fs::remove_file(&path);
}
}
}
for app_dir_name in by_app.keys() {
let app_path = output_root.join(app_dir_name);
if let Ok(walk) = fs::read_dir(&app_path) {
for entry in walk.flatten() {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("md") {
continue;
}
if !written_set.contains(path.as_path()) {
let _ = fs::remove_file(&path);
}
}
}
}
Ok(DocsReport {
models_rendered: total,
output_root: output_root.to_path_buf(),
written_files: written,
})
}
pub fn render_model_page(
desc: &ModelDescriptor,
intent: Option<&crate::intent::IntentFile>,
) -> String {
let mut s = String::with_capacity(2048);
let _ = writeln!(s, "# {}\n", desc.type_name);
let _ = writeln!(s, "- **App:** {}", display_app(desc.app));
let _ = writeln!(s, "- **Table:** `{}`", desc.table_name);
let _ = writeln!(s, "- **PK kind:** {}", display_pk_type(&desc.pk_type));
if desc.is_through {
let _ = writeln!(s, "- **Through model:** yes");
}
if let Some(part) = &desc.partition_by {
let _ = writeln!(s, "- **Partitioned:** {}", display_partition(part));
}
if let Some(tk) = desc.tenant_key {
let _ = writeln!(s, "- **Tenant key:** `{tk}`");
}
if desc.has_outbox {
let _ = writeln!(s, "- **Outbox:** enabled");
}
if let Some(ttl) = desc.cache_ttl {
let _ = writeln!(s, "- **Cache TTL:** {ttl} seconds");
}
if let Some(idem) = desc.idempotency_key {
let _ = writeln!(s, "- **Idempotency key:** `{idem}`");
}
if let Some(rfm) = desc.renamed_from {
let _ = writeln!(s, "- **Renamed from:** `{rfm}`");
}
if let Some(moved) = desc.moved_from_app {
let _ = writeln!(s, "- **Moved from app:** `{moved}`");
}
let model_intent = intent.and_then(|i| i.models.get(desc.type_name));
if let Some(rationale) = crate::intent::resolve_model_rationale(desc.rationale, model_intent) {
let _ = writeln!(s, "\n> {rationale}");
}
s.push_str("\n## Fields\n\n");
s.push_str("| Name | SQL type | Nullable | Default | Notes |\n");
s.push_str("|------|----------|----------|---------|-------|\n");
for f in desc.fields {
let field_intent = model_intent.and_then(|m| m.fields.get(f.name));
let _ = writeln!(
s,
"| `{name}` | `{ty}` | {nullable} | {default} | {notes} |",
name = f.name,
ty = f.sql_type,
nullable = if f.nullable { "yes" } else { "no" },
default = render_field_default(f, desc),
notes = render_field_notes(f, field_intent),
);
}
if !desc.indexes.is_empty() {
s.push_str("\n## Indexes\n\n");
s.push_str("| Name | Kind | Method | Target | Notes |\n");
s.push_str("|------|------|--------|--------|-------|\n");
for idx in desc.indexes {
let _ = writeln!(
s,
"| `{name}` | {kind} | {method:?} | {target} | {notes} |",
name = idx.name,
kind = display_index_kind(idx.kind),
method = idx.index_type,
target = display_index_target(&idx.target),
notes = render_index_notes(idx),
);
}
}
let fk_rows: Vec<&FieldDescriptor> = desc
.fields
.iter()
.filter(|f| f.relation_kind.is_some())
.collect();
if !fk_rows.is_empty() {
s.push_str("\n## Relations\n\n");
s.push_str("| Column | Kind | Target | On delete |\n");
s.push_str("|--------|------|--------|-----------|\n");
for f in fk_rows {
let kind = match f.relation_kind {
Some(crate::relation::RelationKind::ForeignKey) => "ForeignKey",
Some(crate::relation::RelationKind::OneToOne) => "OneToOne",
None => "—",
};
let target = f.target_type_name.unwrap_or("—");
let on_delete = match f.on_delete {
Some(crate::relation::OnDelete::Cascade) => "CASCADE",
Some(crate::relation::OnDelete::Restrict) => "RESTRICT",
Some(crate::relation::OnDelete::SetNull) => "SET NULL",
Some(crate::relation::OnDelete::SetDefault) => "SET DEFAULT",
Some(crate::relation::OnDelete::Protect) => "RESTRICT (Protect)",
Some(crate::relation::OnDelete::DoNothing) => "NO ACTION",
None => "RESTRICT (default)",
};
let _ = writeln!(
s,
"| `{col}` | {kind} | `{target}` | {on_delete} |",
col = f.name,
);
}
}
s
}
fn render_readme(by_app: &BTreeMap<String, BTreeMap<&str, &ModelDescriptor>>) -> String {
let mut s = String::new();
s.push_str("# Djogi model reference\n\n");
s.push_str(
"Generated by `djogi docs` from the descriptor inventory. \
One page per registered model, grouped by app.\n\n",
);
if by_app.is_empty() {
s.push_str(
"_No models registered. Run `cargo build` so `#[model]` macros emit \
their descriptors, then re-run `djogi docs`._\n",
);
return s;
}
for (app_dir, models) in by_app {
let _ = writeln!(s, "## {app_dir}\n");
for desc in models.values() {
let _ = writeln!(
s,
"- [`{name}`](./{app_dir}/{filename})",
name = desc.type_name,
filename = model_filename(desc.type_name),
);
}
s.push('\n');
}
s
}
fn app_directory(app: Option<&'static str>) -> String {
match app {
Some(s) if !s.is_empty() => s.to_string(),
_ => GLOBAL_BUCKET_DIRNAME.to_string(),
}
}
fn display_app(app: Option<&'static str>) -> &'static str {
match app {
Some(s) if !s.is_empty() => s,
_ => "<global>",
}
}
fn display_pk_type(pk: &PkType) -> String {
match pk {
PkType::HeerId => "HeerId (ascending)".to_string(),
PkType::RanjId => "RanjId (ascending)".to_string(),
PkType::HeerIdDesc => "HeerId (recency-biased)".to_string(),
PkType::RanjIdDesc => "RanjId (recency-biased)".to_string(),
PkType::Serial => "Serial".to_string(),
PkType::None => "None".to_string(),
PkType::Composite(cols) => format!("Composite({})", cols.join(", ")),
PkType::Custom(c) => format!("Custom({})", c.type_name),
}
}
fn display_partition(part: &crate::descriptor::PartitionSpec) -> String {
match part {
crate::descriptor::PartitionSpec::Range { column } => format!("RANGE({column})"),
crate::descriptor::PartitionSpec::Hash { column, partitions } => {
format!("HASH({column}, {partitions} partitions)")
}
}
}
fn display_index_kind(kind: IndexKind) -> &'static str {
match kind {
IndexKind::NonUnique => "non-unique",
IndexKind::UniqueConstraint => "unique constraint",
IndexKind::UniqueIndex => "unique index",
}
}
fn display_index_target(target: &IndexTarget) -> String {
match target {
IndexTarget::Columns(cols) => {
let names: Vec<String> = cols.iter().map(|c| format!("`{}`", c.name)).collect();
names.join(", ")
}
IndexTarget::Expression(_) => "expression".to_string(),
}
}
fn render_field_default(f: &FieldDescriptor, parent: &ModelDescriptor) -> String {
if f.name == "id" {
match super::projection::pk_default_sql(&parent.pk_type) {
Some(sql) => format!("`{sql}`"),
None => "—".to_string(),
}
} else {
"—".to_string()
}
}
fn render_field_notes(
f: &FieldDescriptor,
field_intent: Option<&crate::intent::FieldIntent>,
) -> String {
let mut bits: Vec<String> = Vec::new();
if f.unique {
bits.push("UNIQUE".to_string());
}
if f.indexed {
bits.push("indexed".to_string());
}
if let Some(max) = f.max_length {
bits.push(format!("max {max} bytes"));
}
if let Some(rfm) = f.renamed_from {
bits.push(format!("renamed from `{rfm}`"));
}
if f.outbox_exclude {
bits.push("outbox-exclude".to_string());
}
if let Some(seq) = f.sequence_within {
bits.push(format!("sequence within `{seq}`"));
}
if let Some(rationale) = crate::intent::resolve_field_rationale(f.rationale, field_intent) {
bits.push(format!("_{rationale}_"));
}
if bits.is_empty() {
" ".to_string()
} else {
bits.join("; ")
}
}
fn render_index_notes(idx: &IndexSpec) -> String {
let mut bits: Vec<String> = Vec::new();
if let Some(p) = idx.predicate {
bits.push(format!("WHERE `{p}`"));
}
if !idx.include.is_empty() {
bits.push(format!("INCLUDE ({})", join_quoted(idx.include)));
}
if idx.nulls_not_distinct {
bits.push("NULLS NOT DISTINCT".to_string());
}
if idx.requires_out_of_transaction {
bits.push("CONCURRENTLY".to_string());
}
if let Some(ext) = idx.extension_dependency {
bits.push(format!("requires `{ext}` extension"));
}
if bits.is_empty() {
" ".to_string()
} else {
bits.join("; ")
}
}
fn join_quoted(items: &[&str]) -> String {
let mut s = String::new();
for (i, name) in items.iter().enumerate() {
if i > 0 {
s.push_str(", ");
}
s.push('`');
s.push_str(name);
s.push('`');
}
s
}
fn model_filename(type_name: &str) -> String {
let mut out = String::with_capacity(type_name.len() + 3);
for byte in type_name.bytes() {
if byte.is_ascii_alphanumeric() || byte == b'_' {
out.push(byte as char);
} else {
out.push('_');
}
}
out.push_str(".md");
out
}
#[allow(dead_code)]
fn display_sql_type(t: &FieldSqlType) -> String {
format!("{t}")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::descriptor::{
FieldDescriptor, FieldSqlType, IndexColumnSpec, IndexKind, IndexNullsOrder, IndexOrder,
IndexSpec, IndexTarget, IndexType, ModelDescriptor, PkType, field_descriptor,
model_descriptor,
};
use std::sync::atomic::{AtomicUsize, Ordering};
fn temp_root(tag: &str) -> PathBuf {
static COUNTER: AtomicUsize = AtomicUsize::new(0);
let n = COUNTER.fetch_add(1, Ordering::SeqCst);
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
let p = std::env::temp_dir().join(format!("djogi-docs-{tag}-{nanos}-{n}"));
fs::create_dir_all(&p).unwrap();
p
}
fn fixture_users() -> ModelDescriptor {
static FIELDS: &[FieldDescriptor] = &[
FieldDescriptor {
rationale: Some("primary key"),
..field_descriptor("id", FieldSqlType::BigInt, false)
},
FieldDescriptor {
unique: true,
max_length: Some(254),
..field_descriptor("email", FieldSqlType::Text, false)
},
];
static INDEXES: &[IndexSpec] = &[IndexSpec {
name: "users_email_uidx",
target: IndexTarget::Columns(&[IndexColumnSpec {
name: "email",
opclass: None,
order: IndexOrder::Asc,
nulls: IndexNullsOrder::Default,
}]),
kind: IndexKind::UniqueIndex,
index_type: IndexType::BTree,
predicate: Some("deleted_at IS NULL"),
include: &[],
nulls_not_distinct: false,
requires_out_of_transaction: false,
extension_dependency: None,
}];
ModelDescriptor {
tenant_key: Some("org_id"),
rationale: Some("Application user accounts."),
indexes: INDEXES,
app: Some("accounts"),
..model_descriptor("User", "users", PkType::HeerIdDesc, FIELDS)
}
}
fn fixture_global_post() -> ModelDescriptor {
static FIELDS: &[FieldDescriptor] = &[FieldDescriptor {
..field_descriptor("id", FieldSqlType::BigInt, false)
}];
ModelDescriptor {
..model_descriptor("Post", "posts", PkType::HeerId, FIELDS)
}
}
#[test]
fn render_model_page_includes_table_pk_and_field_table() {
let user = fixture_users();
let body = render_model_page(&user, None);
assert!(body.starts_with("# User\n"));
assert!(body.contains("**App:** accounts"));
assert!(body.contains("**Table:** `users`"));
assert!(body.contains("**PK kind:** HeerId (recency-biased)"));
assert!(body.contains("**Tenant key:** `org_id`"));
assert!(body.contains("| Name | SQL type | Nullable | Default | Notes |"));
assert!(body.contains("`email`"));
assert!(body.contains("UNIQUE"));
assert!(body.contains("max 254 bytes"));
assert!(body.contains("## Indexes"));
assert!(body.contains("`users_email_uidx`"));
assert!(body.contains("WHERE `deleted_at IS NULL`"));
}
#[test]
fn render_model_page_default_column_renders_pk_default_and_em_dash() {
let user = fixture_users(); let body = render_model_page(&user, None);
assert!(
body.contains("`heerid_next_desc()`"),
"PK default must render as `heerid_next_desc()` for HeerIdDesc; \
body was:\n{body}",
);
let email_row = body
.lines()
.find(|l| l.contains("`email`"))
.expect("email row");
assert!(
email_row.contains(" — "),
"non-PK rows must render Default as em-dash; got:\n{email_row}",
);
let post = fixture_global_post();
let body = render_model_page(&post, None);
assert!(
body.contains("`heerid_next()`"),
"PK default must render as `heerid_next()` for HeerId; \
body was:\n{body}",
);
}
#[test]
fn render_inventory_writes_per_app_directories_and_readme() {
let user = fixture_users();
let post = fixture_global_post();
let descriptors: Vec<&ModelDescriptor> = vec![&user, &post];
let root = temp_root("layout");
let report = render_inventory(&descriptors, &root, None).expect("render");
assert_eq!(report.models_rendered, 2);
let readme = fs::read_to_string(root.join("README.md")).unwrap();
assert!(readme.contains("# Djogi model reference"));
assert!(readme.contains("`User`"));
assert!(readme.contains("`Post`"));
let glb_idx = readme.find("## _global_").unwrap();
let acc_idx = readme.find("## accounts").unwrap();
assert!(
glb_idx < acc_idx,
"BTreeMap ASCII byte order — `_global_` sorts before `accounts`",
);
assert!(root.join("accounts/User.md").is_file());
assert!(root.join("_global_/Post.md").is_file());
let post_body = fs::read_to_string(root.join("_global_/Post.md")).unwrap();
assert!(post_body.contains("**App:** <global>"));
}
#[test]
fn render_inventory_is_byte_deterministic() {
let user = fixture_users();
let post = fixture_global_post();
let descriptors: Vec<&ModelDescriptor> = vec![&user, &post];
let root_a = temp_root("det_a");
let root_b = temp_root("det_b");
render_inventory(&descriptors, &root_a, None).unwrap();
render_inventory(&descriptors, &root_b, None).unwrap();
let user_a = fs::read(root_a.join("accounts/User.md")).unwrap();
let user_b = fs::read(root_b.join("accounts/User.md")).unwrap();
assert_eq!(user_a, user_b);
let readme_a = fs::read(root_a.join("README.md")).unwrap();
let readme_b = fs::read(root_b.join("README.md")).unwrap();
assert_eq!(readme_a, readme_b);
}
#[test]
fn render_inventory_handles_input_order_invariance() {
let user = fixture_users();
let post = fixture_global_post();
let order_a: Vec<&ModelDescriptor> = vec![&user, &post];
let order_b: Vec<&ModelDescriptor> = vec![&post, &user];
let root_a = temp_root("order_a");
let root_b = temp_root("order_b");
render_inventory(&order_a, &root_a, None).unwrap();
render_inventory(&order_b, &root_b, None).unwrap();
let readme_a = fs::read(root_a.join("README.md")).unwrap();
let readme_b = fs::read(root_b.join("README.md")).unwrap();
assert_eq!(readme_a, readme_b, "render must be input-order-invariant");
}
#[test]
fn render_inventory_against_empty_input_writes_sentinel_readme() {
let root = temp_root("empty");
let report = render_inventory(&[], &root, None).expect("render");
assert_eq!(report.models_rendered, 0);
let readme = fs::read_to_string(root.join("README.md")).unwrap();
assert!(readme.contains("No models registered"));
}
#[test]
fn model_filename_replaces_unsafe_bytes() {
assert_eq!(model_filename("User"), "User.md");
assert_eq!(model_filename("Order_Line"), "Order_Line.md");
assert_eq!(model_filename("Foo::Bar"), "Foo__Bar.md");
}
#[test]
fn app_directory_maps_global_bucket() {
assert_eq!(app_directory(None), GLOBAL_BUCKET_DIRNAME);
assert_eq!(app_directory(Some("")), GLOBAL_BUCKET_DIRNAME);
assert_eq!(app_directory(Some("billing")), "billing");
}
#[test]
fn display_pk_type_formats_known_kinds() {
assert_eq!(display_pk_type(&PkType::HeerId), "HeerId (ascending)");
assert_eq!(
display_pk_type(&PkType::HeerIdDesc),
"HeerId (recency-biased)"
);
assert_eq!(display_pk_type(&PkType::Serial), "Serial");
assert_eq!(
display_pk_type(&PkType::Composite(&["a", "b"])),
"Composite(a, b)"
);
}
fn fixture_post_no_rationale() -> ModelDescriptor {
static FIELDS: &[FieldDescriptor] = &[FieldDescriptor {
..field_descriptor("title", FieldSqlType::Text, false)
}];
ModelDescriptor {
..model_descriptor("Article", "articles", PkType::HeerId, FIELDS)
}
}
fn intent_with_article_rationale() -> crate::intent::IntentFile {
let mut field_intents = std::collections::BTreeMap::new();
field_intents.insert(
"title".to_string(),
crate::intent::FieldIntent {
rationale: "Display title for SEO and the article header.".to_string(),
added_by: String::new(),
added_at: String::new(),
},
);
let mut models = std::collections::BTreeMap::new();
models.insert(
"Article".to_string(),
crate::intent::ModelIntent {
rationale: "Long-form posts surfaced on the public site.".to_string(),
fields: field_intents,
},
);
crate::intent::IntentFile {
schema_url: None,
models,
}
}
#[test]
fn render_model_page_picks_up_intent_rationale_when_macro_attr_absent() {
let article = fixture_post_no_rationale();
let intent = intent_with_article_rationale();
let body = render_model_page(&article, Some(&intent));
assert!(
body.contains("Long-form posts surfaced on the public site."),
"intent.json model rationale must surface in Markdown when macro attr absent; got: {body}"
);
assert!(
body.contains("_Display title for SEO and the article header._"),
"intent.json field rationale must surface when macro attr absent; got: {body}"
);
}
#[test]
fn render_model_page_macro_attr_wins_over_intent_json() {
let user = fixture_users();
let mut models = std::collections::BTreeMap::new();
models.insert(
"User".to_string(),
crate::intent::ModelIntent {
rationale: "INTENT-JSON-TEXT-SHOULD-NOT-APPEAR".to_string(),
fields: std::collections::BTreeMap::new(),
},
);
let intent = crate::intent::IntentFile {
schema_url: None,
models,
};
let body = render_model_page(&user, Some(&intent));
assert!(
body.contains("Application user accounts."),
"macro-attr rationale must win over intent.json; got: {body}"
);
assert!(
!body.contains("INTENT-JSON-TEXT-SHOULD-NOT-APPEAR"),
"intent.json must not surface when macro attr is present; got: {body}"
);
}
#[test]
fn render_model_page_no_rationale_section_when_neither_set() {
let article = fixture_post_no_rationale();
let body = render_model_page(&article, None);
assert!(
!body.contains("\n> "),
"no `## Rationale` blockquote when neither macro attr nor intent.json sets it; got: {body}"
);
}
#[test]
fn generate_docs_with_provider_renders_provider_models() {
struct EmptyProvider;
impl crate::migrate::DescriptorProvider for EmptyProvider {
fn models(&self) -> Vec<&'static crate::descriptor::ModelDescriptor> {
Vec::new()
}
fn enums(&self) -> Vec<&'static crate::descriptor::EnumDescriptor> {
Vec::new()
}
fn apps(&self) -> &'static [crate::apps::AppDescriptor] {
crate::apps::AppRegistry::all()
}
fn deferrability_specs(&self) -> Vec<&'static crate::descriptor::DeferrabilitySpec> {
Vec::new()
}
}
let dir = std::env::temp_dir().join(format!(
"djogi-docs-provider-{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
let report = generate_docs_with_provider(&EmptyProvider, &dir, None)
.expect("empty provider renders README only");
assert_eq!(report.models_rendered, 0);
let _ = std::fs::remove_dir_all(&dir);
}
}