Skip to main content

alembic_engine/
django.rs

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