Skip to main content

alembic_django/
lib.rs

1//! django app generation from alembic ir.
2
3use alembic_core::{FieldFormat, FieldType, Inventory, Schema, TypeName, TypeSchema};
4use anyhow::Result;
5use serde::{Deserialize, Serialize};
6use std::fs;
7use std::path::Path;
8
9const GENERATED_MODELS: &str = "generated_models.py";
10const GENERATED_ADMIN: &str = "generated_admin.py";
11const GENERATED_SERIALIZERS: &str = "generated_serializers.py";
12const GENERATED_VIEWS: &str = "generated_views.py";
13const GENERATED_URLS: &str = "generated_urls.py";
14const USER_MODELS: &str = "models.py";
15const USER_ADMIN: &str = "admin.py";
16const USER_SERIALIZERS: &str = "serializers.py";
17const USER_VIEWS: &str = "views.py";
18const USER_URLS: &str = "urls.py";
19const USER_EXTENSIONS: &str = "extensions.py";
20
21const MODELS_TEMPLATE: &str = include_str!("../templates/models.py.tpl");
22const ADMIN_TEMPLATE: &str = include_str!("../templates/admin.py.tpl");
23const SERIALIZERS_TEMPLATE: &str = include_str!("../templates/serializers.py.tpl");
24const VIEWS_TEMPLATE: &str = include_str!("../templates/views.py.tpl");
25const URLS_TEMPLATE: &str = include_str!("../templates/urls.py.tpl");
26const ADMIN_SEARCH_FIELDS: &[&str] = &["key", "uid"];
27
28#[derive(Debug)]
29struct ModelSpec {
30    class_name: String,
31    fields: Vec<FieldSpec>,
32    key_fields: Vec<String>,
33    has_validators: bool,
34}
35
36#[derive(Debug, Clone)]
37struct FieldSpec {
38    name: String,
39    field_type: DjangoFieldType,
40    required: bool,
41    nullable: bool,
42    choices: Option<Vec<String>>,
43    validators: Vec<String>,
44}
45
46#[derive(Debug, Clone)]
47enum DjangoFieldType {
48    Char,
49    Text,
50    Integer,
51    Float,
52    Boolean,
53    Uuid,
54    Date,
55    DateTime,
56    Time,
57    Json,
58    Slug,
59    IpAddress,
60    ForeignKey { target: String },
61    ManyToMany { target: String },
62}
63
64#[derive(Debug, Clone, Serialize, Deserialize)]
65pub struct DjangoEmitOptions {
66    pub emit_admin: bool,
67}
68
69impl Default for DjangoEmitOptions {
70    fn default() -> Self {
71        Self { emit_admin: true }
72    }
73}
74
75pub fn emit_django_app(
76    app_dir: &Path,
77    inventory: &Inventory,
78    options: DjangoEmitOptions,
79) -> Result<()> {
80    fs::create_dir_all(app_dir)?;
81    let app_name = app_dir
82        .file_name()
83        .and_then(|name| name.to_str())
84        .unwrap_or("alembic_app");
85
86    let types = schema_types(&inventory.schema);
87    let models: Vec<ModelSpec> = types
88        .into_iter()
89        .map(|(name, schema)| model_spec_from_schema(&name, &schema))
90        .collect();
91
92    let rendered = render_files(&models, app_name, options.emit_admin);
93    fs::write(app_dir.join(GENERATED_MODELS), rendered.models)?;
94    if let Some(admin) = rendered.admin {
95        fs::write(app_dir.join(GENERATED_ADMIN), admin)?;
96    }
97    fs::write(app_dir.join(GENERATED_SERIALIZERS), rendered.serializers)?;
98    fs::write(app_dir.join(GENERATED_VIEWS), rendered.views)?;
99    fs::write(app_dir.join(GENERATED_URLS), rendered.urls)?;
100
101    write_user_file(
102        app_dir.join(USER_MODELS),
103        user_models_stub(),
104        &[default_models_stub()],
105    )?;
106    if options.emit_admin {
107        write_user_file(
108            app_dir.join(USER_ADMIN),
109            user_admin_stub(),
110            &[default_admin_stub()],
111        )?;
112    }
113    write_user_file(app_dir.join(USER_SERIALIZERS), user_serializers_stub(), &[])?;
114    write_user_file(
115        app_dir.join(USER_VIEWS),
116        user_views_stub(),
117        &[default_views_stub()],
118    )?;
119    write_user_file(app_dir.join(USER_URLS), user_urls_stub(), &[])?;
120    write_if_missing(app_dir.join(USER_EXTENSIONS), user_extensions_stub())?;
121
122    Ok(())
123}
124
125fn schema_types(schema: &Schema) -> Vec<(TypeName, TypeSchema)> {
126    let mut types: Vec<(String, TypeSchema)> = schema
127        .types
128        .iter()
129        .map(|(name, schema)| (name.clone(), schema.clone()))
130        .collect();
131    types.sort_by(|a, b| a.0.cmp(&b.0));
132    types
133        .into_iter()
134        .map(|(name, schema)| (TypeName::new(name), schema))
135        .collect()
136}
137
138fn model_spec_from_schema(type_name: &TypeName, schema: &TypeSchema) -> ModelSpec {
139    let class_name = class_name_for_type(type_name.as_str());
140    let mut fields = Vec::new();
141    let mut key_fields = Vec::new();
142    let mut has_validators = false;
143
144    for (field, field_schema) in schema.key.iter() {
145        let spec = field_spec_from_schema(field, field_schema, true);
146        if !spec.validators.is_empty() {
147            has_validators = true;
148        }
149        key_fields.push(field.to_string());
150        fields.push(spec);
151    }
152
153    for (field, field_schema) in schema.fields.iter() {
154        if schema.key.contains_key(field) {
155            continue;
156        }
157        let spec = field_spec_from_schema(field, field_schema, false);
158        if !spec.validators.is_empty() {
159            has_validators = true;
160        }
161        fields.push(spec);
162    }
163
164    ModelSpec {
165        class_name,
166        fields,
167        key_fields,
168        has_validators,
169    }
170}
171
172struct DjangoFiles {
173    models: String,
174    admin: Option<String>,
175    serializers: String,
176    views: String,
177    urls: String,
178}
179
180fn render_files(models: &[ModelSpec], app_name: &str, emit_admin: bool) -> DjangoFiles {
181    let model_names: Vec<String> = models.iter().map(|m| m.class_name.clone()).collect();
182    let model_import = import_line("from .generated_models import ", &model_names);
183    let serializer_names: Vec<String> = model_names
184        .iter()
185        .map(|name| format!("{name}Serializer"))
186        .collect();
187    let serializer_import = import_line("from .generated_serializers import ", &serializer_names);
188    let view_names: Vec<String> = model_names
189        .iter()
190        .map(|name| format!("{name}ViewSet"))
191        .collect();
192    let view_import = import_line("from .generated_views import ", &view_names);
193
194    let models_block = render_models_block(models);
195    let validators_import = if models.iter().any(|m| m.has_validators) {
196        "from django.core.validators import RegexValidator"
197    } else {
198        ""
199    };
200    let admins_block = if emit_admin {
201        render_admins_block(models)
202    } else {
203        String::new()
204    };
205    let serializers_block = render_serializers_block(models);
206    let views_block = render_views_block(models);
207    let routes_block = render_routes_block(models);
208
209    let models = render_template(
210        MODELS_TEMPLATE,
211        &[
212            ("validators_import", validators_import.to_string()),
213            ("models", models_block),
214        ],
215    );
216    let admin = if emit_admin {
217        Some(render_template(
218            ADMIN_TEMPLATE,
219            &[
220                ("model_import", model_import.clone()),
221                ("admins", admins_block),
222            ],
223        ))
224    } else {
225        None
226    };
227    let serializers = render_template(
228        SERIALIZERS_TEMPLATE,
229        &[
230            ("model_import", model_import.clone()),
231            ("serializers", serializers_block),
232        ],
233    );
234    let views = render_template(
235        VIEWS_TEMPLATE,
236        &[
237            ("model_import", model_import),
238            ("serializer_import", serializer_import),
239            ("views", views_block),
240        ],
241    );
242    let urls = render_template(
243        URLS_TEMPLATE,
244        &[
245            ("view_import", view_import),
246            ("routes", routes_block),
247            ("app_name", app_name.to_string()),
248        ],
249    );
250
251    DjangoFiles {
252        models,
253        admin,
254        serializers,
255        views,
256        urls,
257    }
258}
259
260fn render_field(field: &FieldSpec) -> String {
261    let mut args = Vec::new();
262    if let Some(choices) = &field.choices {
263        let choice_items = choices
264            .iter()
265            .map(|value| format!("(\"{value}\", \"{value}\")"))
266            .collect::<Vec<_>>()
267            .join(", ");
268        args.push(format!("choices=[{choice_items}]"));
269    }
270    if !field.validators.is_empty() {
271        let validators = field.validators.join(", ");
272        args.push(format!("validators=[{validators}]"));
273    }
274    if !field.required {
275        args.push("blank=True".to_string());
276    }
277    if field.nullable {
278        args.push("null=True".to_string());
279    }
280    if matches!(field.field_type, DjangoFieldType::IpAddress)
281        && args.iter().any(|arg| arg == "blank=True")
282        && !args.iter().any(|arg| arg == "null=True")
283    {
284        args.push("null=True".to_string());
285    }
286
287    let args_str = args.join(", ");
288
289    match &field.field_type {
290        DjangoFieldType::Char => {
291            if args_str.is_empty() {
292                format!("{} = models.CharField(max_length=255)", field.name)
293            } else {
294                format!(
295                    "{} = models.CharField(max_length=255, {})",
296                    field.name, args_str
297                )
298            }
299        }
300        DjangoFieldType::Text => format!("{} = models.TextField({})", field.name, args_str),
301        DjangoFieldType::Integer => format!("{} = models.IntegerField({})", field.name, args_str),
302        DjangoFieldType::Float => format!("{} = models.FloatField({})", field.name, args_str),
303        DjangoFieldType::Boolean => format!("{} = models.BooleanField({})", field.name, args_str),
304        DjangoFieldType::Uuid => format!("{} = models.UUIDField({})", field.name, args_str),
305        DjangoFieldType::Date => format!("{} = models.DateField({})", field.name, args_str),
306        DjangoFieldType::DateTime => format!("{} = models.DateTimeField({})", field.name, args_str),
307        DjangoFieldType::Time => format!("{} = models.TimeField({})", field.name, args_str),
308        DjangoFieldType::Json => format!("{} = models.JSONField({})", field.name, args_str),
309        DjangoFieldType::Slug => format!("{} = models.SlugField({})", field.name, args_str),
310        DjangoFieldType::IpAddress => {
311            format!(
312                "{} = models.GenericIPAddressField({})",
313                field.name, args_str
314            )
315        }
316        DjangoFieldType::ForeignKey { target } => {
317            let mut fk_args = vec![
318                format!("\"{}\"", target),
319                "on_delete=models.PROTECT".to_string(),
320            ];
321            fk_args.extend(args);
322            format!("{} = models.ForeignKey({})", field.name, fk_args.join(", "))
323        }
324        DjangoFieldType::ManyToMany { target } => {
325            let mut m2m_args = vec![format!("\"{}\"", target)];
326            m2m_args.extend(args.into_iter().filter(|arg| arg != "null=True"));
327            format!(
328                "{} = models.ManyToManyField({})",
329                field.name,
330                m2m_args.join(", ")
331            )
332        }
333    }
334}
335
336fn render_models_block(models: &[ModelSpec]) -> String {
337    models
338        .iter()
339        .map(render_model_block)
340        .collect::<Vec<String>>()
341        .join("\n")
342}
343
344fn render_model_block(model: &ModelSpec) -> String {
345    let mut fields = Vec::with_capacity(model.fields.len() + 3);
346    fields.push("uid = models.UUIDField(primary_key=True, editable=False)".to_string());
347    fields.push("key = models.TextField()".to_string());
348    fields.push("attrs = models.JSONField(default=dict, blank=True)".to_string());
349    for field in &model.fields {
350        fields.push(render_field(field));
351    }
352
353    let mut lines = Vec::new();
354    lines.push(format!("class {}(models.Model):", model.class_name));
355    lines.push(format!("    {}", fields.join("\n    ")));
356
357    if !model.key_fields.is_empty() {
358        let unique_fields = model
359            .key_fields
360            .iter()
361            .map(|field| format!("\"{field}\""))
362            .collect::<Vec<_>>()
363            .join(", ");
364        lines.push("".to_string());
365        lines.push("    class Meta:".to_string());
366        lines.push(format!(
367            "        constraints = [models.UniqueConstraint(fields=[{unique_fields}], name=\"{}_key\")]",
368            model.class_name.to_lowercase()
369        ));
370    }
371
372    lines.join("\n") + "\n"
373}
374
375fn render_admins_block(models: &[ModelSpec]) -> String {
376    models
377        .iter()
378        .map(render_admin_block)
379        .collect::<Vec<String>>()
380        .join("\n")
381}
382
383fn render_admin_block(model: &ModelSpec) -> String {
384    let list_display = admin_list_display(model);
385    let list_filter = admin_list_filter(model);
386    let mut lines = vec![
387        format!(
388            "@admin.register({})\nclass {}Admin(admin.ModelAdmin):",
389            model.class_name, model.class_name
390        ),
391        format!("    list_display = [{}]", join_quoted(&list_display)),
392        format!("    search_fields = [{}]", join_quoted(ADMIN_SEARCH_FIELDS)),
393    ];
394    if !list_filter.is_empty() {
395        lines.push(format!("    list_filter = [{}]", join_quoted(&list_filter)));
396    }
397    lines.join("\n")
398}
399
400fn render_serializers_block(models: &[ModelSpec]) -> String {
401    models
402        .iter()
403        .map(render_serializer_block)
404        .collect::<Vec<String>>()
405        .join("\n")
406}
407
408fn render_serializer_block(model: &ModelSpec) -> String {
409    let fields = serializer_fields(model);
410    format!(
411        "class {}Serializer(serializers.ModelSerializer):\n    class Meta:\n        model = {}\n        fields = [{}]\n",
412        model.class_name,
413        model.class_name,
414        join_quoted(&fields)
415    )
416}
417
418fn render_views_block(models: &[ModelSpec]) -> String {
419    models
420        .iter()
421        .map(render_view_block)
422        .collect::<Vec<String>>()
423        .join("\n")
424}
425
426fn render_view_block(model: &ModelSpec) -> String {
427    let list_display = admin_list_display(model);
428    let search_fields = admin_search_fields_for_model(model);
429    format!(
430        "class {}ViewSet(viewsets.ModelViewSet):\n    queryset = {}.objects.all()\n    serializer_class = {}Serializer\n    filterset_fields = [{}]\n    search_fields = [{}]\n    ordering_fields = [{}]\n",
431        model.class_name,
432        model.class_name,
433        model.class_name,
434        join_quoted(&list_display),
435        join_quoted(&search_fields),
436        join_quoted(&list_display)
437    )
438}
439
440fn render_routes_block(models: &[ModelSpec]) -> String {
441    models
442        .iter()
443        .map(|model| {
444            let endpoint = pluralize(model.class_name.to_lowercase().as_str());
445            format!(
446                "router.register(\"{endpoint}\", {}ViewSet)",
447                model.class_name
448            )
449        })
450        .collect::<Vec<String>>()
451        .join("\n")
452}
453
454fn field_name(field: &FieldSpec) -> &str {
455    field.name.as_str()
456}
457
458fn join_quoted(fields: &[&str]) -> String {
459    fields
460        .iter()
461        .map(|field| format!("\"{field}\""))
462        .collect::<Vec<String>>()
463        .join(", ")
464}
465
466fn import_line<T: AsRef<str>>(prefix: &str, names: &[T]) -> String {
467    if names.is_empty() {
468        String::new()
469    } else {
470        format!(
471            "{prefix}{}",
472            names
473                .iter()
474                .map(|name| name.as_ref())
475                .collect::<Vec<&str>>()
476                .join(", ")
477        )
478    }
479}
480
481fn admin_list_display(model: &ModelSpec) -> Vec<&str> {
482    let mut fields = vec!["key", "uid"];
483    for field in &model.fields {
484        fields.push(field_name(field));
485    }
486    fields
487}
488
489fn admin_search_fields_for_model(model: &ModelSpec) -> Vec<&str> {
490    let mut fields = vec!["key"];
491    for field in &model.fields {
492        match field.field_type {
493            DjangoFieldType::Char
494            | DjangoFieldType::Text
495            | DjangoFieldType::Slug
496            | DjangoFieldType::Uuid
497            | DjangoFieldType::IpAddress => fields.push(field_name(field)),
498            _ => {}
499        }
500    }
501    fields
502}
503
504fn admin_list_filter(model: &ModelSpec) -> Vec<&str> {
505    let mut fields = Vec::new();
506    for field in &model.fields {
507        match field.field_type {
508            DjangoFieldType::Boolean => fields.push(field_name(field)),
509            _ => {
510                if field.name == "status" {
511                    fields.push(field_name(field));
512                }
513            }
514        }
515    }
516    fields
517}
518
519fn serializer_fields(model: &ModelSpec) -> Vec<&str> {
520    let mut fields = vec!["uid", "key", "attrs"];
521    for field in &model.fields {
522        fields.push(field_name(field));
523    }
524    fields
525}
526
527fn class_name_for_type(type_name: &str) -> String {
528    type_name
529        .split('.')
530        .map(|segment| {
531            segment
532                .split('_')
533                .filter(|s| !s.is_empty())
534                .map(|part| {
535                    let mut chars = part.chars();
536                    match chars.next() {
537                        Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
538                        None => String::new(),
539                    }
540                })
541                .collect::<String>()
542        })
543        .collect::<String>()
544}
545
546fn pluralize(name: &str) -> String {
547    if name.ends_with('s') {
548        format!("{name}es")
549    } else if let Some(stripped) = name.strip_suffix('y') {
550        format!("{}ies", stripped)
551    } else {
552        format!("{name}s")
553    }
554}
555
556fn field_spec_from_schema(
557    name: &str,
558    schema: &alembic_core::FieldSchema,
559    is_key: bool,
560) -> FieldSpec {
561    let mut validators = Vec::new();
562    let mut choices = None;
563
564    if let Some(format) = &schema.format {
565        validators.push(format_validator(format));
566    }
567    if let Some(pattern) = &schema.pattern {
568        validators.push(format!("RegexValidator(r\"{pattern}\")"));
569    }
570
571    let field_type = match &schema.r#type {
572        FieldType::String => DjangoFieldType::Char,
573        FieldType::Text => DjangoFieldType::Text,
574        FieldType::Int => DjangoFieldType::Integer,
575        FieldType::Float => DjangoFieldType::Float,
576        FieldType::Bool => DjangoFieldType::Boolean,
577        FieldType::Uuid => DjangoFieldType::Uuid,
578        FieldType::Date => DjangoFieldType::Date,
579        FieldType::Datetime => DjangoFieldType::DateTime,
580        FieldType::Time => DjangoFieldType::Time,
581        FieldType::Json => DjangoFieldType::Json,
582        FieldType::IpAddress => DjangoFieldType::IpAddress,
583        FieldType::Cidr | FieldType::Prefix | FieldType::Mac => {
584            validators.push(format_validator(&format_for_field_type(&schema.r#type)));
585            DjangoFieldType::Char
586        }
587        FieldType::Slug => DjangoFieldType::Slug,
588        FieldType::Enum { values } => {
589            choices = Some(values.clone());
590            DjangoFieldType::Char
591        }
592        FieldType::List { .. } | FieldType::Map { .. } => DjangoFieldType::Json,
593        FieldType::Ref { target } => DjangoFieldType::ForeignKey {
594            target: class_name_for_type(target),
595        },
596        FieldType::ListRef { target } => DjangoFieldType::ManyToMany {
597            target: class_name_for_type(target),
598        },
599    };
600
601    let required = schema.required || is_key;
602    let nullable = schema.nullable && !is_key;
603
604    FieldSpec {
605        name: name.to_string(),
606        field_type,
607        required,
608        nullable,
609        choices,
610        validators,
611    }
612}
613
614fn format_for_field_type(field_type: &FieldType) -> FieldFormat {
615    match field_type {
616        FieldType::IpAddress => FieldFormat::IpAddress,
617        FieldType::Cidr => FieldFormat::Cidr,
618        FieldType::Prefix => FieldFormat::Prefix,
619        FieldType::Mac => FieldFormat::Mac,
620        FieldType::Uuid => FieldFormat::Uuid,
621        FieldType::Slug => FieldFormat::Slug,
622        _ => FieldFormat::Slug,
623    }
624}
625
626fn format_validator(format: &FieldFormat) -> String {
627    match format {
628        FieldFormat::Slug => "RegexValidator(r\"^[a-z0-9]+(?:[a-z0-9_-]*[a-z0-9])?$\")".to_string(),
629        FieldFormat::IpAddress => {
630            "RegexValidator(r\"^([0-9]{1,3}\\.){3}[0-9]{1,3}$|^[0-9a-fA-F:]+$\")".to_string()
631        }
632        FieldFormat::Cidr | FieldFormat::Prefix => {
633            "RegexValidator(r\"^[0-9a-fA-F:\\./]+$\")".to_string()
634        }
635        FieldFormat::Mac => {
636            "RegexValidator(r\"^([0-9A-Fa-f]{2}[:-]){5}([0-9A-Fa-f]{2})$\")".to_string()
637        }
638        FieldFormat::Uuid => {
639            "RegexValidator(r\"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$\")".to_string()
640        }
641    }
642}
643
644fn write_if_missing(path: impl AsRef<Path>, contents: &str) -> Result<()> {
645    let path = path.as_ref();
646    if path.exists() {
647        return Ok(());
648    }
649    fs::write(path, contents)?;
650    Ok(())
651}
652
653fn render_template(template: &str, vars: &[(&str, String)]) -> String {
654    let mut output = template.to_string();
655    for (key, value) in vars {
656        let token = format!("{{{{{key}}}}}");
657        output = output.replace(&token, value);
658    }
659    output
660}
661
662fn write_user_file(path: impl AsRef<Path>, contents: &str, defaults: &[&str]) -> Result<()> {
663    let path = path.as_ref();
664    if path.exists() {
665        let existing = fs::read_to_string(path)?;
666        let normalized = existing.trim().replace("\r\n", "\n");
667        let is_default = defaults
668            .iter()
669            .any(|candidate| candidate.trim().replace("\r\n", "\n") == normalized);
670        if !is_default {
671            return Ok(());
672        }
673    }
674    fs::write(path, contents)?;
675    Ok(())
676}
677
678fn user_models_stub() -> &'static str {
679    "from .generated_models import *  # noqa: F401,F403\nfrom .extensions import *  # noqa: F401,F403\n"
680}
681
682fn user_admin_stub() -> &'static str {
683    "from .generated_admin import *  # noqa: F401,F403\nfrom .extensions import *  # noqa: F401,F403\n"
684}
685
686fn user_serializers_stub() -> &'static str {
687    "from .generated_serializers import *  # noqa: F401,F403\nfrom .extensions import *  # noqa: F401,F403\n"
688}
689
690fn user_views_stub() -> &'static str {
691    "from .generated_views import *  # noqa: F401,F403\nfrom .extensions import *  # noqa: F401,F403\n"
692}
693
694fn user_urls_stub() -> &'static str {
695    "from .generated_urls import *  # noqa: F401,F403\n"
696}
697
698fn user_extensions_stub() -> &'static str {
699    "# User extension hooks live here.\n"
700}
701
702fn default_models_stub() -> &'static str {
703    "from django.db import models\n\n# Create your models here.\n"
704}
705
706fn default_admin_stub() -> &'static str {
707    "from django.contrib import admin\n\n# Register your models here.\n"
708}
709
710fn default_views_stub() -> &'static str {
711    "from django.shortcuts import render\n\n# Create your views here.\n"
712}
713
714#[cfg(test)]
715mod tests {
716    use super::*;
717    use alembic_core::{
718        FieldSchema, FieldType, Inventory, JsonMap, Object, Schema, TypeName, TypeSchema,
719    };
720    use serde_json::{json, Value};
721    use std::collections::BTreeMap;
722    use tempfile::tempdir;
723    use uuid::Uuid;
724
725    fn attrs_map(pairs: Vec<(&str, Value)>) -> JsonMap {
726        JsonMap::from(
727            pairs
728                .into_iter()
729                .map(|(key, value)| (key.to_string(), value))
730                .collect::<BTreeMap<_, _>>(),
731        )
732    }
733
734    fn key_str(raw: &str) -> alembic_core::Key {
735        let mut map = BTreeMap::new();
736        for segment in raw.split('/') {
737            let (field, value) = segment
738                .split_once('=')
739                .unwrap_or_else(|| panic!("invalid key segment: {segment}"));
740            map.insert(field.to_string(), Value::String(value.to_string()));
741        }
742        alembic_core::Key::from(map)
743    }
744
745    fn obj(uid: u128, type_name: &str, key: &str, attrs: JsonMap) -> Object {
746        Object::new(
747            Uuid::from_u128(uid),
748            TypeName::new(type_name),
749            key_str(key),
750            attrs,
751        )
752        .unwrap()
753    }
754
755    fn test_schema() -> Schema {
756        let mut types = BTreeMap::new();
757        types.insert(
758            "dcim.site".to_string(),
759            TypeSchema {
760                key: BTreeMap::from([(
761                    "slug".to_string(),
762                    FieldSchema {
763                        r#type: FieldType::Slug,
764                        required: true,
765                        nullable: false,
766                        description: None,
767                        format: None,
768                        pattern: None,
769                    },
770                )]),
771                fields: BTreeMap::from([
772                    (
773                        "name".to_string(),
774                        FieldSchema {
775                            r#type: FieldType::String,
776                            required: true,
777                            nullable: false,
778                            description: None,
779                            format: None,
780                            pattern: None,
781                        },
782                    ),
783                    (
784                        "slug".to_string(),
785                        FieldSchema {
786                            r#type: FieldType::Slug,
787                            required: true,
788                            nullable: false,
789                            description: None,
790                            format: None,
791                            pattern: None,
792                        },
793                    ),
794                ]),
795            },
796        );
797        types.insert(
798            "dcim.device".to_string(),
799            TypeSchema {
800                key: BTreeMap::from([(
801                    "name".to_string(),
802                    FieldSchema {
803                        r#type: FieldType::Slug,
804                        required: true,
805                        nullable: false,
806                        description: None,
807                        format: None,
808                        pattern: None,
809                    },
810                )]),
811                fields: BTreeMap::from([
812                    (
813                        "name".to_string(),
814                        FieldSchema {
815                            r#type: FieldType::String,
816                            required: true,
817                            nullable: false,
818                            description: None,
819                            format: None,
820                            pattern: None,
821                        },
822                    ),
823                    (
824                        "site".to_string(),
825                        FieldSchema {
826                            r#type: FieldType::Ref {
827                                target: "dcim.site".to_string(),
828                            },
829                            required: true,
830                            nullable: false,
831                            description: None,
832                            format: None,
833                            pattern: None,
834                        },
835                    ),
836                    (
837                        "role".to_string(),
838                        FieldSchema {
839                            r#type: FieldType::String,
840                            required: true,
841                            nullable: false,
842                            description: None,
843                            format: None,
844                            pattern: None,
845                        },
846                    ),
847                    (
848                        "device_type".to_string(),
849                        FieldSchema {
850                            r#type: FieldType::String,
851                            required: true,
852                            nullable: false,
853                            description: None,
854                            format: None,
855                            pattern: None,
856                        },
857                    ),
858                ]),
859            },
860        );
861        types.insert(
862            "dcim.interface".to_string(),
863            TypeSchema {
864                key: BTreeMap::from([(
865                    "name".to_string(),
866                    FieldSchema {
867                        r#type: FieldType::Slug,
868                        required: true,
869                        nullable: false,
870                        description: None,
871                        format: None,
872                        pattern: None,
873                    },
874                )]),
875                fields: BTreeMap::from([
876                    (
877                        "name".to_string(),
878                        FieldSchema {
879                            r#type: FieldType::String,
880                            required: true,
881                            nullable: false,
882                            description: None,
883                            format: None,
884                            pattern: None,
885                        },
886                    ),
887                    (
888                        "device".to_string(),
889                        FieldSchema {
890                            r#type: FieldType::Ref {
891                                target: "dcim.device".to_string(),
892                            },
893                            required: true,
894                            nullable: false,
895                            description: None,
896                            format: None,
897                            pattern: None,
898                        },
899                    ),
900                ]),
901            },
902        );
903        Schema { types }
904    }
905
906    fn sample_inventory() -> Inventory {
907        let objects = vec![
908            obj(
909                1,
910                "dcim.device",
911                "name=leaf01",
912                attrs_map(vec![
913                    ("name", json!("leaf01")),
914                    ("site", json!(Uuid::from_u128(2).to_string())),
915                    ("role", json!("leaf")),
916                    ("device_type", json!("leaf-switch")),
917                ]),
918            ),
919            obj(
920                2,
921                "dcim.site",
922                "slug=fra1",
923                attrs_map(vec![("name", json!("FRA1")), ("slug", json!("fra1"))]),
924            ),
925            obj(
926                3,
927                "dcim.interface",
928                "name=eth0",
929                attrs_map(vec![
930                    ("name", json!("eth0")),
931                    ("device", json!(Uuid::from_u128(1).to_string())),
932                ]),
933            ),
934        ];
935        Inventory {
936            schema: test_schema(),
937            objects,
938        }
939    }
940
941    #[test]
942    fn emit_django_app_writes_files_and_stubs() {
943        let dir = tempdir().unwrap();
944        emit_django_app(
945            dir.path(),
946            &sample_inventory(),
947            DjangoEmitOptions::default(),
948        )
949        .unwrap();
950
951        assert!(dir.path().join(GENERATED_MODELS).exists());
952        assert!(dir.path().join(GENERATED_ADMIN).exists());
953        assert!(dir.path().join(GENERATED_SERIALIZERS).exists());
954        assert!(dir.path().join(GENERATED_VIEWS).exists());
955        assert!(dir.path().join(GENERATED_URLS).exists());
956        assert!(dir.path().join(USER_MODELS).exists());
957        assert!(dir.path().join(USER_ADMIN).exists());
958        assert!(dir.path().join(USER_SERIALIZERS).exists());
959        assert!(dir.path().join(USER_VIEWS).exists());
960        assert!(dir.path().join(USER_URLS).exists());
961        assert!(dir.path().join(USER_EXTENSIONS).exists());
962
963        let models = fs::read_to_string(dir.path().join(GENERATED_MODELS)).unwrap();
964        assert!(models.contains("class DcimSite"));
965        assert!(models.contains("site = models.ForeignKey(\"DcimSite\""));
966        assert!(models.contains("device = models.ForeignKey(\"DcimDevice\""));
967        assert!(models.contains("uid = models.UUIDField"));
968        assert!(models.contains("attrs = models.JSONField"));
969    }
970
971    #[test]
972    fn emit_django_app_does_not_overwrite_user_files() {
973        let dir = tempdir().unwrap();
974        let models_path = dir.path().join(USER_MODELS);
975        let admin_path = dir.path().join(USER_ADMIN);
976        fs::write(&models_path, "# user models\n").unwrap();
977        fs::write(&admin_path, "# user admin\n").unwrap();
978
979        emit_django_app(
980            dir.path(),
981            &sample_inventory(),
982            DjangoEmitOptions::default(),
983        )
984        .unwrap();
985
986        assert_eq!(fs::read_to_string(models_path).unwrap(), "# user models\n");
987        assert_eq!(fs::read_to_string(admin_path).unwrap(), "# user admin\n");
988    }
989
990    #[test]
991    fn emit_django_app_overwrites_default_skeleton() {
992        let dir = tempdir().unwrap();
993        let models_path = dir.path().join(USER_MODELS);
994        let admin_path = dir.path().join(USER_ADMIN);
995        let views_path = dir.path().join(USER_VIEWS);
996        fs::write(&models_path, default_models_stub()).unwrap();
997        fs::write(&admin_path, default_admin_stub()).unwrap();
998        fs::write(&views_path, default_views_stub()).unwrap();
999
1000        emit_django_app(
1001            dir.path(),
1002            &sample_inventory(),
1003            DjangoEmitOptions::default(),
1004        )
1005        .unwrap();
1006
1007        let models = fs::read_to_string(models_path).unwrap();
1008        let admin = fs::read_to_string(admin_path).unwrap();
1009        let views = fs::read_to_string(views_path).unwrap();
1010        assert!(models.contains("generated_models"));
1011        assert!(admin.contains("generated_admin"));
1012        assert!(views.contains("generated_views"));
1013    }
1014
1015    #[test]
1016    fn generated_admin_includes_defaults() {
1017        let dir = tempdir().unwrap();
1018        emit_django_app(
1019            dir.path(),
1020            &sample_inventory(),
1021            DjangoEmitOptions::default(),
1022        )
1023        .unwrap();
1024        let admin = fs::read_to_string(dir.path().join(GENERATED_ADMIN)).unwrap();
1025
1026        assert!(admin.contains("class DcimDeviceAdmin"));
1027        assert!(admin.contains(
1028            "list_display = [\"key\", \"uid\", \"name\", \"device_type\", \"role\", \"site\"]"
1029        ));
1030        assert!(admin.contains("search_fields = [\"key\", \"uid\"]"));
1031        assert!(admin.contains("class DcimInterfaceAdmin"));
1032    }
1033
1034    #[test]
1035    fn generated_api_files_include_models() {
1036        let dir = tempdir().unwrap();
1037        emit_django_app(
1038            dir.path(),
1039            &sample_inventory(),
1040            DjangoEmitOptions::default(),
1041        )
1042        .unwrap();
1043        let serializers = fs::read_to_string(dir.path().join(GENERATED_SERIALIZERS)).unwrap();
1044        let views = fs::read_to_string(dir.path().join(GENERATED_VIEWS)).unwrap();
1045        let urls = fs::read_to_string(dir.path().join(GENERATED_URLS)).unwrap();
1046
1047        assert!(serializers.contains("class DcimDeviceSerializer"));
1048        assert!(views.contains("class DcimDeviceViewSet"));
1049        assert!(urls.contains("router.register(\"dcimdevices\""));
1050        assert!(urls.contains("schema_view"));
1051    }
1052
1053    #[test]
1054    fn generated_models_are_deterministic_by_kind() {
1055        let dir = tempdir().unwrap();
1056        emit_django_app(
1057            dir.path(),
1058            &sample_inventory(),
1059            DjangoEmitOptions::default(),
1060        )
1061        .unwrap();
1062
1063        let models = fs::read_to_string(dir.path().join(GENERATED_MODELS)).unwrap();
1064        let device_pos = models.find("class DcimDevice").unwrap();
1065        let interface_pos = models.find("class DcimInterface").unwrap();
1066        let site_pos = models.find("class DcimSite").unwrap();
1067        assert!(device_pos < interface_pos);
1068        assert!(interface_pos < site_pos);
1069    }
1070}