armdb 0.5.4

sharded bitcask key-value storage optimized for NVMe
Documentation
use std::fmt::Write;

use armour_core::{Fields, PathSeg, RelationKind, SchemaGraph, Typ};

/// Text formatter for a schema graph. Model → formatter architecture:
/// DOT can be added as a second implementation without changing the model.
pub trait SchemaFormatter {
    fn format(&self, graph: &SchemaGraph) -> String;
}

pub struct MermaidEr;

fn typ_label(t: &Typ) -> String {
    match t {
        Typ::Scalar(s) => format!("{s:?}"),
        Typ::Array(n, inner) => format!("Array{}_{}", n, typ_label(inner)),
        Typ::Vec(inner) => format!("Vec_{}", typ_label(inner)),
        Typ::Optional(inner) => format!("Opt_{}", typ_label(inner)),
        Typ::SimpleEnum(e) => e.name.to_string(),
        Typ::Struct(s) if !s.name.is_empty() => s.name.to_string(),
        Typ::Struct(_) => "tuple".to_string(),
        Typ::Enum(e) => e.name.to_string(),
        Typ::Custom(name, _) => (*name).to_string(),
    }
}

fn seg_label(seg: &PathSeg) -> String {
    match seg {
        PathSeg::Field(n) | PathSeg::Variant(n) => (*n).to_string(),
        PathSeg::Index(i) => i.to_string(),
        PathSeg::Elem => "[]".to_string(),
    }
}

/// Mermaid identifier: anything outside `[A-Za-z0-9_]` breaks erDiagram
/// parsing, so non-identifier characters are replaced with `_`.
fn ident(name: &str) -> String {
    name.chars()
        .map(|c| {
            if c.is_ascii_alphanumeric() || c == '_' {
                c
            } else {
                '_'
            }
        })
        .collect()
}

impl SchemaFormatter for MermaidEr {
    fn format(&self, graph: &SchemaGraph) -> String {
        let mut out = String::from("erDiagram\n");
        for c in &graph.collections {
            let _ = writeln!(out, "    {} {{", ident(&c.name));
            if let Typ::Struct(s) = &c.ty
                && let Fields::Named(fields) = &s.fields
            {
                for (fname, fty) in fields.iter() {
                    let _ = writeln!(out, "        {} {}", ident(&typ_label(fty)), ident(fname));
                }
            }
            out.push_str("    }\n");
        }
        for r in &graph.relations {
            let (arrow, label) = match &r.kind {
                RelationKind::Fk {
                    brand, field_path, ..
                } => {
                    let field = field_path
                        .iter()
                        .map(seg_label)
                        .collect::<Vec<_>>()
                        .join(".");
                    ("}o--||", format!("fk {field} ({brand})"))
                }
                RelationKind::Index => ("||--o{", "index".to_string()),
                RelationKind::Denorm => ("||--||", "denorm".to_string()),
                RelationKind::Counter { field } => ("||--o{", format!("counter {field}")),
            };
            let _ = writeln!(
                out,
                "    {} {arrow} {} : \"{label}\"",
                ident(&r.from),
                ident(&r.to)
            );
        }
        out
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use armour_core::{CollectionKind, CollectionNode, ScalarTyp, SchemaGraph};

    #[test]
    fn schema_mermaid_sanitizes_identifiers() {
        let g = SchemaGraph {
            collections: vec![CollectionNode {
                key_ty: None,
                name: "my coll!".into(),
                kind: CollectionKind::Standalone,
                ty: Typ::Scalar(ScalarTyp::U64),
                self_brand: None,
                storage: None,
            }],
            relations: vec![],
        };
        let out = MermaidEr.format(&g);
        assert!(out.contains("my_coll_ {"), "{out}");
    }
}