Skip to main content

alembic_adapter_django/
lib.rs

1//! django app generation from alembic ir.
2
3use crate::emit::{CommandRunner, DjangoConfig};
4use alembic_adapter_sdk::{AppliedOp, ApplyReport, Op};
5use alembic_core::{key_string, FieldFormat, FieldType, Inventory, Object, Schema, TypeSchema};
6use alembic_engine::{pluralize, Emitter, StateStore};
7use anyhow::{anyhow, bail, Context, Result};
8use async_trait::async_trait;
9use serde::{Deserialize, Serialize};
10use serde_json::{json, Map, Value};
11use std::collections::BTreeMap;
12use std::fs;
13use std::path::Path;
14
15pub mod emit;
16
17pub struct DjangoAdapter {
18    config: DjangoConfig,
19}
20
21impl DjangoAdapter {
22    pub fn new(config: DjangoConfig) -> Self {
23        Self { config }
24    }
25}
26
27#[async_trait]
28impl Emitter for DjangoAdapter {
29    async fn write(&self, schema: &Schema, ops: &[Op], _state: &StateStore) -> Result<ApplyReport> {
30        let mut inventory = Inventory {
31            scope: None,
32            schema: schema.clone(),
33            objects: Vec::new(),
34        };
35        let mut apply_report = ApplyReport::default();
36
37        for op in ops {
38            match op {
39                Op::Create {
40                    uid,
41                    type_name,
42                    desired,
43                } => {
44                    inventory.objects.push(Object {
45                        uid: *uid,
46                        type_name: type_name.clone(),
47                        key: desired.key.clone(),
48                        attrs: desired.attrs.clone(),
49                        source: desired.source.clone(),
50                    });
51                    apply_report.applied.push(AppliedOp {
52                        uid: *uid,
53                        type_name: type_name.clone(),
54                        backend_id: None,
55                    })
56                }
57                Op::Update { .. } => {
58                    return Err(anyhow!("unsupported operation, cannot update object"))
59                }
60                Op::Delete { .. } => {
61                    return Err(anyhow!("unsupported operation, cannot delete object"))
62                }
63            }
64        }
65
66        let runner = CommandRunner::new();
67        emit::run_emit(&runner, &inventory, &self.config)?;
68        Ok(apply_report)
69    }
70}
71
72const GENERATED_MODELS: &str = "generated_models.py";
73const GENERATED_ADMIN: &str = "generated_admin.py";
74const GENERATED_SERIALIZERS: &str = "generated_serializers.py";
75const GENERATED_VIEWS: &str = "generated_views.py";
76const GENERATED_URLS: &str = "generated_urls.py";
77const USER_MODELS: &str = "models.py";
78const USER_ADMIN: &str = "admin.py";
79const USER_SERIALIZERS: &str = "serializers.py";
80const USER_VIEWS: &str = "views.py";
81const USER_URLS: &str = "urls.py";
82const USER_EXTENSIONS: &str = "extensions.py";
83const FIXTURES_DIR: &str = "fixtures";
84/// the objects land in one fixture, loaded by label (`manage.py loaddata alembic`).
85pub const FIXTURE_LABEL: &str = "alembic";
86const FIXTURE_FILE: &str = "alembic.json";
87
88const MODELS_TEMPLATE: &str = include_str!("../templates/models.py.tpl");
89const ADMIN_TEMPLATE: &str = include_str!("../templates/admin.py.tpl");
90const SERIALIZERS_TEMPLATE: &str = include_str!("../templates/serializers.py.tpl");
91const VIEWS_TEMPLATE: &str = include_str!("../templates/views.py.tpl");
92const URLS_TEMPLATE: &str = include_str!("../templates/urls.py.tpl");
93
94/// two blank lines between top-level definitions, as pep8 wants them.
95const BLOCK_SEPARATOR: &str = "\n\n\n";
96
97/// a list is a plain `JSONField`, so a declared element type has no native slot:
98/// this carries it as a member check instead. the leading underscore keeps the
99/// name clear of the model classes, which never start with one.
100const MEMBER_VALIDATOR_CLASS: &str = r#"_MEMBER_KINDS = {
101    "str": lambda member: isinstance(member, str),
102    # a python bool is an int, and alembic's int is not a bool.
103    "int": lambda member: isinstance(member, int) and not isinstance(member, bool),
104    "float": lambda member: isinstance(member, (int, float))
105    and not isinstance(member, bool),
106    "bool": lambda member: isinstance(member, bool),
107    "list": lambda member: isinstance(member, (list, tuple)),
108    "map": lambda member: isinstance(member, dict),
109}
110
111
112@deconstructible
113class _ListMembers:
114    """checks every member of a list against its declared element type."""
115
116    def __init__(self, choices=None, regex=None, kind=None):
117        self.choices = choices
118        self.regex = regex
119        self.kind = kind
120
121    def __call__(self, value):
122        if not isinstance(value, (list, tuple)):
123            raise ValidationError(
124                "expected a list, got %(got)s", params={"got": type(value).__name__}
125            )
126        for member in value:
127            if self.choices is not None and member not in self.choices:
128                raise ValidationError(
129                    "%(member)s is not one of %(choices)s",
130                    params={"member": member, "choices": ", ".join(self.choices)},
131                )
132            if self.kind is not None and not _MEMBER_KINDS[self.kind](member):
133                raise ValidationError(
134                    "%(member)s is not a %(kind)s",
135                    params={"member": member, "kind": self.kind},
136                )
137            # every pattern here is one of core's anchored format regexes, and
138            # python's `$` also matches before a trailing newline: `fullmatch`
139            # is what core's own `is_match` makes of an anchored pattern.
140            if self.regex is not None and not (
141                isinstance(member, str) and re.fullmatch(self.regex, member)
142            ):
143                raise ValidationError(
144                    "%(member)s does not match %(regex)s",
145                    params={"member": member, "regex": self.regex},
146                )"#;
147
148#[derive(Debug)]
149struct ModelSpec {
150    type_name: String,
151    class_name: String,
152    fields: Vec<FieldSpec>,
153    key_fields: Vec<String>,
154}
155
156impl ModelSpec {
157    fn relation_fields(&self) -> impl Iterator<Item = &FieldSpec> {
158        self.fields
159            .iter()
160            .filter(|field| matches!(field.field_type, DjangoFieldType::ForeignKey { .. }))
161    }
162
163    fn many_to_many_fields(&self) -> impl Iterator<Item = &FieldSpec> {
164        self.fields
165            .iter()
166            .filter(|field| matches!(field.field_type, DjangoFieldType::ManyToMany { .. }))
167    }
168}
169
170#[derive(Debug, Clone)]
171struct FieldSpec {
172    name: String,
173    field_type: DjangoFieldType,
174    required: bool,
175    nullable: bool,
176    choices: Option<Vec<String>>,
177    validators: Vec<String>,
178    /// a list's declared element check. the validator ships on every list, so
179    /// `None` is an element carrying no check rather than no validator.
180    member_check: Option<MemberCheck>,
181    help_text: Option<String>,
182}
183
184#[derive(Debug, Clone)]
185enum DjangoFieldType {
186    Char,
187    Text,
188    Integer,
189    Float,
190    Boolean,
191    Uuid,
192    Date,
193    DateTime,
194    Time,
195    /// `default=list` for a list-shaped field, `default=dict` otherwise.
196    Json {
197        list: bool,
198    },
199    Slug,
200    IpAddress,
201    ForeignKey {
202        target: String,
203    },
204    ManyToMany {
205        target: String,
206    },
207}
208
209impl DjangoFieldType {
210    /// text columns hold "" for absent, so they stay NOT NULL; everything else
211    /// needs `null=True` or an optional value has nowhere to go.
212    fn is_textual(&self) -> bool {
213        matches!(
214            self,
215            DjangoFieldType::Char | DjangoFieldType::Text | DjangoFieldType::Slug
216        )
217    }
218
219    fn is_json(&self) -> bool {
220        matches!(self, DjangoFieldType::Json { .. })
221    }
222
223    fn is_list(&self) -> bool {
224        matches!(self, DjangoFieldType::Json { list: true })
225    }
226
227    fn is_many_to_many(&self) -> bool {
228        matches!(self, DjangoFieldType::ManyToMany { .. })
229    }
230}
231
232#[derive(Debug, Clone, Serialize, Deserialize)]
233pub struct DjangoEmitOptions {
234    pub emit_admin: bool,
235    /// whether `django_filters` is importable in the target interpreter. without
236    /// it there is no filter backend, so per-field filtering is not advertised.
237    pub filter_backend: bool,
238    /// whether `drf_spectacular` is importable in the target interpreter. it
239    /// serves the openapi schema and the docs page; without it neither is routed.
240    pub schema_view: bool,
241}
242
243impl Default for DjangoEmitOptions {
244    fn default() -> Self {
245        Self {
246            emit_admin: true,
247            filter_backend: false,
248            schema_view: false,
249        }
250    }
251}
252
253// python hard keywords; soft keywords (match, case, type, _) are legal
254// identifiers and stay allowed.
255const PYTHON_KEYWORDS: &[&str] = &[
256    "False", "None", "True", "and", "as", "assert", "async", "await", "break", "class", "continue",
257    "def", "del", "elif", "else", "except", "finally", "for", "from", "global", "if", "import",
258    "in", "is", "lambda", "nonlocal", "not", "or", "pass", "raise", "return", "try", "while",
259    "with", "yield",
260];
261
262// field names the emitter assigns on every generated model; a schema field of
263// the same name would silently override them (the `uid` case drops the uuid
264// primary key without any error from `manage.py check`).
265const RESERVED_FIELD_NAMES: &[&str] = &["uid", "key", "attrs"];
266
267// names django or python already give a meaning to on a model instance; a field
268// of the same name shadows the manager, the pk alias, or a model method.
269const DJANGO_RESERVED_FIELD_NAMES: &[&str] =
270    &["pk", "id", "objects", "save", "delete", "clean", "_state"];
271
272fn is_python_identifier(name: &str) -> bool {
273    let mut chars = name.chars();
274    let Some(first) = chars.next() else {
275        return false;
276    };
277    if !(first.is_ascii_alphabetic() || first == '_') {
278        return false;
279    }
280    if !chars.all(|ch| ch.is_ascii_alphanumeric() || ch == '_') {
281        return false;
282    }
283    !PYTHON_KEYWORDS.contains(&name)
284}
285
286// field and type names are interpolated into python source verbatim, so a name
287// that is not a usable python identifier must fail the emit instead of
288// producing a broken or silently wrong app. all violations are collected into
289// one error.
290fn validate_schema_names(schema: &Schema) -> Result<()> {
291    let mut problems = Vec::new();
292    for (type_name, type_schema) in &schema.types {
293        let class_name = class_name_for_type(type_name);
294        if !is_python_identifier(&class_name) {
295            problems.push(format!(
296                "type '{type_name}' renders to class name '{class_name}', which is not a valid python identifier"
297            ));
298        }
299        let field_names = type_schema.key.keys().chain(
300            type_schema
301                .fields
302                .keys()
303                .filter(|field| !type_schema.key.contains_key(*field)),
304        );
305        for field in field_names {
306            if RESERVED_FIELD_NAMES.contains(&field.as_str()) {
307                problems.push(format!(
308                    "field '{field}' of type '{type_name}' is reserved: it would shadow the generated model attribute of the same name"
309                ));
310            } else if DJANGO_RESERVED_FIELD_NAMES.contains(&field.as_str()) {
311                problems.push(format!(
312                    "field '{field}' of type '{type_name}' is reserved: django already gives that name a meaning on a model"
313                ));
314            } else if !is_python_identifier(field) {
315                problems.push(format!(
316                    "field '{field}' of type '{type_name}' is not a valid python identifier"
317                ));
318            } else if field.ends_with('_') {
319                // fields.E001/E002: django rejects both outright.
320                problems.push(format!(
321                    "field '{field}' of type '{type_name}' ends with an underscore, which django rejects"
322                ));
323            } else if field.contains("__") {
324                problems.push(format!(
325                    "field '{field}' of type '{type_name}' contains a double underscore, which django rejects"
326                ));
327            }
328        }
329    }
330    if problems.is_empty() {
331        Ok(())
332    } else {
333        Err(anyhow!(
334            "cannot emit django app, invalid names in schema:\n  {}",
335            problems.join("\n  ")
336        ))
337    }
338}
339
340pub fn emit_django_app(
341    app_dir: &Path,
342    inventory: &Inventory,
343    options: DjangoEmitOptions,
344) -> Result<()> {
345    validate_schema_names(&inventory.schema)?;
346    fs::create_dir_all(app_dir)?;
347    let app_name = app_dir
348        .file_name()
349        .and_then(|name| name.to_str())
350        .unwrap_or("alembic_app");
351
352    let models = build_models(&inventory.schema)?;
353
354    let rendered = render_files(&models, &options);
355    fs::write(app_dir.join(GENERATED_MODELS), rendered.models)?;
356    if let Some(admin) = rendered.admin {
357        fs::write(app_dir.join(GENERATED_ADMIN), admin)?;
358    }
359    fs::write(app_dir.join(GENERATED_SERIALIZERS), rendered.serializers)?;
360    fs::write(app_dir.join(GENERATED_VIEWS), rendered.views)?;
361    fs::write(app_dir.join(GENERATED_URLS), rendered.urls)?;
362
363    let fixtures_dir = app_dir.join(FIXTURES_DIR);
364    fs::create_dir_all(&fixtures_dir)?;
365    let entries = fixture_entries(app_name, &models, &inventory.objects)?;
366    fs::write(
367        fixtures_dir.join(FIXTURE_FILE),
368        format!("{}\n", serde_json::to_string_pretty(&entries)?),
369    )?;
370
371    write_user_file(
372        app_dir.join(USER_MODELS),
373        user_models_stub(),
374        &[default_models_stub()],
375    )?;
376    if options.emit_admin {
377        write_user_file(
378            app_dir.join(USER_ADMIN),
379            user_admin_stub(),
380            &[default_admin_stub()],
381        )?;
382    }
383    write_user_file(app_dir.join(USER_SERIALIZERS), user_serializers_stub(), &[])?;
384    write_user_file(
385        app_dir.join(USER_VIEWS),
386        user_views_stub(),
387        &[default_views_stub()],
388    )?;
389    write_user_file(app_dir.join(USER_URLS), user_urls_stub(), &[])?;
390    write_if_missing(app_dir.join(USER_EXTENSIONS), user_extensions_stub())?;
391
392    Ok(())
393}
394
395/// turn the schema into model specs, rejecting anything that cannot become a
396/// valid django app: colliding model names, dangling relation targets, field
397/// names python or the ir envelope already own.
398fn build_models(schema: &Schema) -> Result<Vec<ModelSpec>> {
399    let mut by_class: BTreeMap<String, String> = BTreeMap::new();
400    for type_name in schema.types.keys() {
401        let class_name = class_name_for_type(type_name);
402        if class_name.is_empty() {
403            bail!("type '{type_name}' does not map to a usable django model name");
404        }
405        if let Some(previous) = by_class.insert(class_name.clone(), type_name.clone()) {
406            bail!(
407                "types '{previous}' and '{type_name}' both map to the django model \
408                 '{class_name}'; rename one of them"
409            );
410        }
411    }
412
413    for (type_name, type_schema) in schema.types.iter() {
414        for (field, field_schema) in type_schema.key.iter().chain(type_schema.fields.iter()) {
415            let target = match &field_schema.r#type {
416                FieldType::Ref { target } | FieldType::ListRef { target } => target,
417                _ => continue,
418            };
419            if !schema.types.contains_key(target) {
420                bail!(
421                    "{type_name}.{field} references unknown type '{target}'; \
422                     the django backend can only relate to types in the same model"
423                );
424            }
425        }
426    }
427
428    let mut models = Vec::with_capacity(schema.types.len());
429    let mut endpoints: BTreeMap<String, String> = BTreeMap::new();
430    for (type_name, type_schema) in schema.types.iter() {
431        let model = model_spec_from_schema(type_name, type_schema);
432        if let Some(previous) = endpoints.insert(endpoint_for(&model), model.type_name.clone()) {
433            bail!(
434                "types '{previous}' and '{}' both map to the api route '{}'; rename one of them",
435                model.type_name,
436                endpoint_for(&model)
437            );
438        }
439        models.push(model);
440    }
441    Ok(models)
442}
443
444fn model_spec_from_schema(type_name: &str, schema: &TypeSchema) -> ModelSpec {
445    let class_name = class_name_for_type(type_name);
446    let mut fields = Vec::new();
447    let mut key_fields = Vec::new();
448
449    for (field, field_schema) in schema.key.iter() {
450        key_fields.push(field.to_string());
451        fields.push(field_spec_from_schema(field, field_schema, true));
452    }
453
454    for (field, field_schema) in schema.fields.iter() {
455        if schema.key.contains_key(field) {
456            continue;
457        }
458        fields.push(field_spec_from_schema(field, field_schema, false));
459    }
460
461    ModelSpec {
462        type_name: type_name.to_string(),
463        class_name,
464        fields,
465        key_fields,
466    }
467}
468
469struct DjangoFiles {
470    models: String,
471    admin: Option<String>,
472    serializers: String,
473    views: String,
474    urls: String,
475}
476
477fn render_files(models: &[ModelSpec], options: &DjangoEmitOptions) -> DjangoFiles {
478    let model_names: Vec<String> = models.iter().map(|m| m.class_name.clone()).collect();
479    let model_import = import_line("from .generated_models import ", &model_names);
480    let serializer_names: Vec<String> = model_names
481        .iter()
482        .map(|name| format!("{name}Serializer"))
483        .collect();
484    let serializer_import = import_line("from .generated_serializers import ", &serializer_names);
485    let view_names: Vec<String> = model_names
486        .iter()
487        .map(|name| format!("{name}ViewSet"))
488        .collect();
489    let view_import = import_line("from .generated_views import ", &view_names);
490
491    let has_field = |predicate: fn(&FieldSpec) -> bool| {
492        models
493            .iter()
494            .any(|model| model.fields.iter().any(&predicate))
495    };
496    let member_validators = has_field(|field| field.field_type.is_list());
497
498    let mut model_imports = Vec::new();
499    // only a regex member check reaches `re`, so an app whose lists are all
500    // enums does not get an import its own linter would flag.
501    if has_field(|field| matches!(field.member_check, Some(MemberCheck::Regex(_)))) {
502        model_imports.push("import re".to_string());
503    }
504    model_imports.push("import uuid".to_string());
505    model_imports.push(String::new());
506    model_imports.push("from django.db import models".to_string());
507    if has_field(|field| !field.validators.is_empty()) {
508        model_imports.push("from django.core.validators import RegexValidator".to_string());
509    }
510    if member_validators {
511        model_imports.push("from django.core.exceptions import ValidationError".to_string());
512        model_imports.push("from django.utils.deconstruct import deconstructible".to_string());
513    }
514
515    let mut model_blocks = render_blocks(models, render_model_block);
516    if member_validators {
517        model_blocks = format!("{MEMBER_VALIDATOR_CLASS}{BLOCK_SEPARATOR}{model_blocks}");
518    }
519    let models_file = render_template(
520        MODELS_TEMPLATE,
521        &[
522            ("imports", model_imports.join("\n")),
523            ("models", model_blocks),
524        ],
525    );
526    let admin = if options.emit_admin {
527        Some(render_template(
528            ADMIN_TEMPLATE,
529            &[
530                ("model_import", model_import.clone()),
531                ("admins", render_blocks(models, render_admin_block)),
532            ],
533        ))
534    } else {
535        None
536    };
537    let serializers = render_template(
538        SERIALIZERS_TEMPLATE,
539        &[
540            ("model_import", model_import.clone()),
541            (
542                "serializers",
543                render_blocks(models, render_serializer_block),
544            ),
545        ],
546    );
547    let views = render_template(
548        VIEWS_TEMPLATE,
549        &[
550            ("model_import", model_import),
551            ("serializer_import", serializer_import),
552            (
553                "views",
554                render_blocks(models, |model| render_view_block(model, options)),
555            ),
556        ],
557    );
558    // drf's own schema view is the legacy coreapi surface: it needs three
559    // optional packages and django-filter no longer implements the hook it
560    // calls, so it is served by drf-spectacular or not at all.
561    let (schema_import, schema_routes) = if options.schema_view {
562        (
563            "from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView\n"
564                .to_string(),
565            concat!(
566                "    path(\"schema/\", SpectacularAPIView.as_view(), name=\"schema\"),\n",
567                "    path(\"docs/\", SpectacularSwaggerView.as_view(url_name=\"schema\"), name=\"docs\"),\n",
568            )
569            .to_string(),
570        )
571    } else {
572        (String::new(), String::new())
573    };
574    let urls = render_template(
575        URLS_TEMPLATE,
576        &[
577            ("schema_import", schema_import),
578            ("view_import", view_import),
579            ("routes", render_routes_block(models)),
580            ("schema_routes", schema_routes),
581        ],
582    );
583
584    DjangoFiles {
585        models: models_file,
586        admin,
587        serializers,
588        views,
589        urls,
590    }
591}
592
593fn render_blocks(models: &[ModelSpec], render: impl Fn(&ModelSpec) -> String) -> String {
594    models
595        .iter()
596        .map(render)
597        .collect::<Vec<String>>()
598        .join(BLOCK_SEPARATOR)
599}
600
601fn render_field(model: &ModelSpec, field: &FieldSpec) -> String {
602    let mut args = Vec::new();
603    if let Some(choices) = &field.choices {
604        let choice_items = choices
605            .iter()
606            .map(|value| format!("({}, {})", py_str(value), py_str(value)))
607            .collect::<Vec<_>>()
608            .join(", ");
609        args.push(format!("choices=[{choice_items}]"));
610    }
611    let validators: Vec<String> = field
612        .validators
613        .iter()
614        .cloned()
615        .chain(
616            field
617                .field_type
618                .is_list()
619                .then(|| render_member_check(field.member_check.as_ref())),
620        )
621        .collect();
622    if !validators.is_empty() {
623        args.push(format!("validators=[{}]", validators.join(", ")));
624    }
625    if let Some(help_text) = &field.help_text {
626        args.push(format!("help_text={}", py_str(help_text)));
627    }
628
629    let optional = !field.required;
630    if optional {
631        args.push("blank=True".to_string());
632    }
633    // `blank=True` is a form-level flag only: without `null=True` the column is
634    // still NOT NULL and an absent value cannot be saved at all.
635    let nullable = field.nullable || (optional && !field.field_type.is_textual());
636    if nullable && !field.field_type.is_json() && !field.field_type.is_many_to_many() {
637        args.push("null=True".to_string());
638    }
639
640    let mut leading = Vec::new();
641    let mut trailing = Vec::new();
642    let django_type = match &field.field_type {
643        DjangoFieldType::Char => {
644            leading.push("max_length=255".to_string());
645            "CharField"
646        }
647        DjangoFieldType::Text => "TextField",
648        DjangoFieldType::Integer => "IntegerField",
649        DjangoFieldType::Float => "FloatField",
650        DjangoFieldType::Boolean => "BooleanField",
651        DjangoFieldType::Uuid => "UUIDField",
652        DjangoFieldType::Date => "DateField",
653        DjangoFieldType::DateTime => "DateTimeField",
654        DjangoFieldType::Time => "TimeField",
655        DjangoFieldType::Json { list } => {
656            if optional {
657                trailing.push(format!("default={}", if *list { "list" } else { "dict" }));
658            }
659            "JSONField"
660        }
661        DjangoFieldType::Slug => "SlugField",
662        DjangoFieldType::IpAddress => "GenericIPAddressField",
663        DjangoFieldType::ForeignKey { target } => {
664            leading.push(py_str(target));
665            leading.push("on_delete=models.PROTECT".to_string());
666            leading.push(related_name_arg(model, field));
667            "ForeignKey"
668        }
669        DjangoFieldType::ManyToMany { target } => {
670            leading.push(py_str(target));
671            leading.push(related_name_arg(model, field));
672            "ManyToManyField"
673        }
674    };
675
676    leading.extend(args);
677    leading.extend(trailing);
678    format!(
679        "{} = models.{}({})",
680        field.name,
681        django_type,
682        leading.join(", ")
683    )
684}
685
686/// a reverse accessor per relation: unique by construction (model names are
687/// unique, field names are unique within a model), so two relations to the same
688/// target cannot clash (fields.E304) while navigation stays available.
689fn related_name_arg(model: &ModelSpec, field: &FieldSpec) -> String {
690    format!(
691        "related_name={}",
692        py_str(&format!(
693            "{}_{}",
694            model.class_name.to_lowercase(),
695            field.name
696        ))
697    )
698}
699
700fn render_model_block(model: &ModelSpec) -> String {
701    let mut fields = Vec::with_capacity(model.fields.len() + 3);
702    fields.push(
703        "uid = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)".to_string(),
704    );
705    fields.push("key = models.TextField()".to_string());
706    fields.push("attrs = models.JSONField(default=dict, blank=True)".to_string());
707    for field in &model.fields {
708        fields.push(render_field(model, field));
709    }
710
711    let mut lines = vec![
712        format!("class {}(models.Model):", model.class_name),
713        format!("    {}", fields.join("\n    ")),
714        String::new(),
715        "    class Meta:".to_string(),
716        // pagination over an unordered queryset is not stable; the key is unique
717        // per type, so it is the natural order.
718        "        ordering = [\"key\"]".to_string(),
719        format!("        verbose_name = {}", py_str(&model.type_name)),
720        format!(
721            "        verbose_name_plural = {}",
722            py_str(&pluralize(&model.type_name))
723        ),
724    ];
725
726    if !model.key_fields.is_empty() {
727        let unique_fields = model
728            .key_fields
729            .iter()
730            .map(|field| py_str(field))
731            .collect::<Vec<_>>()
732            .join(", ");
733        lines.push(format!(
734            "        constraints = [models.UniqueConstraint(fields=[{unique_fields}], name=\"{}_key\")]",
735            model.class_name.to_lowercase()
736        ));
737    }
738
739    lines.push(String::new());
740    lines.push("    def __str__(self):".to_string());
741    // the key column holds the ir key as json, which reads as
742    // `{"name":"leaf01"}` wherever django prints an object. the key *values*
743    // are what a person recognises, and a ref key field renders through the
744    // target's own __str__.
745    if model.key_fields.is_empty() {
746        lines.push("        return self.key".to_string());
747    } else {
748        let parts = model
749            .key_fields
750            .iter()
751            .map(|field| format!("{{self.{field}}}"))
752            .collect::<Vec<_>>()
753            .join(" / ");
754        lines.push(format!("        return f\"{parts}\""));
755    }
756
757    lines.join("\n")
758}
759
760fn render_admin_block(model: &ModelSpec) -> String {
761    let list_display = admin_list_display(model);
762    let list_filter = admin_list_filter(model);
763    let related: Vec<&str> = model
764        .relation_fields()
765        .map(|field| field.name.as_str())
766        .collect();
767    let mut lines = vec![
768        format!(
769            "@admin.register({})\nclass {}Admin(admin.ModelAdmin):",
770            model.class_name, model.class_name
771        ),
772        format!("    list_display = [{}]", join_quoted(&list_display)),
773        format!(
774            "    search_fields = [{}]",
775            join_quoted(&text_search_fields(model))
776        ),
777    ];
778    if !list_filter.is_empty() {
779        lines.push(format!("    list_filter = [{}]", join_quoted(&list_filter)));
780    }
781    if !related.is_empty() {
782        // a foreign key in list_display is one query per row without this.
783        lines.push(format!(
784            "    list_select_related = [{}]",
785            join_quoted(&related)
786        ));
787    }
788    lines.join("\n")
789}
790
791fn render_serializer_block(model: &ModelSpec) -> String {
792    let fields = serializer_fields(model);
793    format!(
794        "class {}Serializer(serializers.ModelSerializer):\n    class Meta:\n        model = {}\n        fields = [{}]",
795        model.class_name,
796        model.class_name,
797        join_quoted(&fields)
798    )
799}
800
801fn render_view_block(model: &ModelSpec, options: &DjangoEmitOptions) -> String {
802    let prefetch: Vec<&str> = model
803        .many_to_many_fields()
804        .map(|field| field.name.as_str())
805        .collect();
806    let queryset = if prefetch.is_empty() {
807        format!("{}.objects.all()", model.class_name)
808    } else {
809        format!(
810            "{}.objects.all().prefetch_related({})",
811            model.class_name,
812            join_quoted(&prefetch)
813        )
814    };
815
816    let mut lines = vec![
817        format!("class {}ViewSet(viewsets.ModelViewSet):", model.class_name),
818        format!("    queryset = {queryset}"),
819        format!("    serializer_class = {}Serializer", model.class_name),
820    ];
821    if options.filter_backend {
822        lines.push(format!(
823            "    filterset_fields = [{}]",
824            join_quoted(&filterset_fields(model))
825        ));
826    }
827    lines.push(format!(
828        "    search_fields = [{}]",
829        join_quoted(&text_search_fields(model))
830    ));
831    lines.push(format!(
832        "    ordering_fields = [{}]",
833        join_quoted(&ordering_fields(model))
834    ));
835    lines.push("    ordering = [\"key\"]".to_string());
836    lines.join("\n")
837}
838
839fn endpoint_for(model: &ModelSpec) -> String {
840    pluralize(model.class_name.to_lowercase().as_str())
841}
842
843fn render_routes_block(models: &[ModelSpec]) -> String {
844    models
845        .iter()
846        .map(|model| {
847            format!(
848                "router.register(\"{}\", {}ViewSet)",
849                endpoint_for(model),
850                model.class_name
851            )
852        })
853        .collect::<Vec<String>>()
854        .join("\n")
855}
856
857/// a python string literal for an arbitrary value: everything here ends up in
858/// generated source, so a stray quote or backslash must not break the file.
859fn py_str(value: &str) -> String {
860    let mut out = String::with_capacity(value.len() + 2);
861    out.push('"');
862    for ch in value.chars() {
863        match ch {
864            '\\' => out.push_str("\\\\"),
865            '"' => out.push_str("\\\""),
866            '\n' => out.push_str("\\n"),
867            '\r' => out.push_str("\\r"),
868            '\t' => out.push_str("\\t"),
869            ch if (ch as u32) < 0x20 => out.push_str(&format!("\\x{:02x}", ch as u32)),
870            ch => out.push(ch),
871        }
872    }
873    out.push('"');
874    out
875}
876
877fn join_quoted(fields: &[&str]) -> String {
878    fields
879        .iter()
880        .map(|field| py_str(field))
881        .collect::<Vec<String>>()
882        .join(", ")
883}
884
885fn import_line<T: AsRef<str>>(prefix: &str, names: &[T]) -> String {
886    if names.is_empty() {
887        String::new()
888    } else {
889        format!(
890            "{prefix}{}",
891            names
892                .iter()
893                .map(|name| name.as_ref())
894                .collect::<Vec<&str>>()
895                .join(", ")
896        )
897    }
898}
899
900fn admin_list_display(model: &ModelSpec) -> Vec<&str> {
901    // the object's own __str__ leads: the `key` column is the ir key as json,
902    // which is not what anyone scans a changelist for. it stays searchable.
903    let mut fields = vec!["__str__"];
904    for field in &model.fields {
905        // ManyToManyField is invalid in a Django admin list_display (admin.E109),
906        // and json/text blobs make the changelist unreadable.
907        if field.field_type.is_many_to_many()
908            || field.field_type.is_json()
909            || matches!(field.field_type, DjangoFieldType::Text)
910        {
911            continue;
912        }
913        fields.push(field.name.as_str());
914    }
915    fields.push("uid");
916    fields
917}
918
919/// icontains is what both the admin and drf's SearchFilter emit, and postgres
920/// has no such operator for uuid, inet, or json columns.
921fn text_search_fields(model: &ModelSpec) -> Vec<&str> {
922    let mut fields = vec!["key"];
923    for field in &model.fields {
924        if field.field_type.is_textual() {
925            fields.push(field.name.as_str());
926        }
927    }
928    fields
929}
930
931fn admin_list_filter(model: &ModelSpec) -> Vec<&str> {
932    model
933        .fields
934        .iter()
935        .filter(|field| {
936            matches!(field.field_type, DjangoFieldType::Boolean) || field.choices.is_some()
937        })
938        .map(|field| field.name.as_str())
939        .collect()
940}
941
942/// django-filter has no filter for json columns and cannot resolve a text blob
943/// to a sensible lookup, so those stay out of the filterset.
944fn filterset_fields(model: &ModelSpec) -> Vec<&str> {
945    let mut fields = vec!["key", "uid"];
946    for field in &model.fields {
947        if field.field_type.is_json() || matches!(field.field_type, DjangoFieldType::Text) {
948            continue;
949        }
950        fields.push(field.name.as_str());
951    }
952    fields
953}
954
955fn ordering_fields(model: &ModelSpec) -> Vec<&str> {
956    let mut fields = vec!["key", "uid"];
957    for field in &model.fields {
958        if field.field_type.is_json() || field.field_type.is_many_to_many() {
959            continue;
960        }
961        fields.push(field.name.as_str());
962    }
963    fields
964}
965
966fn serializer_fields(model: &ModelSpec) -> Vec<&str> {
967    let mut fields = vec!["uid", "key", "attrs"];
968    for field in &model.fields {
969        fields.push(field.name.as_str());
970    }
971    fields
972}
973
974/// the value the fixture carries for `spec`.
975///
976/// a datetime goes out with an uppercase separator. `loaddata` reads it with
977/// django's `parse_datetime`, which falls back to a regex taking only `T` when
978/// `datetime.fromisoformat` declines, and below python 3.11 `fromisoformat`
979/// declines a trailing `Z`. rfc 3339 lets the inventory write either case, so
980/// the two meet here rather than in `validate`.
981fn fixture_value(spec: &FieldSpec, value: &Value) -> Value {
982    match (&spec.field_type, value) {
983        (DjangoFieldType::DateTime, Value::String(raw)) => {
984            // rfc 3339 fixes the separator at index 10, after `full-date`.
985            let mut out = raw.clone();
986            if out.as_bytes().get(10) == Some(&b't') {
987                out.replace_range(10..11, "T");
988            }
989            Value::String(out)
990        }
991        _ => value.clone(),
992    }
993}
994
995/// the ir objects as a django fixture: the uid is the primary key, so relations
996/// carry over as-is and `loaddata` is idempotent across runs.
997fn fixture_entries(app_name: &str, models: &[ModelSpec], objects: &[Object]) -> Result<Vec<Value>> {
998    let by_type: BTreeMap<&str, &ModelSpec> = models
999        .iter()
1000        .map(|model| (model.type_name.as_str(), model))
1001        .collect();
1002
1003    let mut entries: Vec<(String, String, Value)> = Vec::with_capacity(objects.len());
1004    for object in objects {
1005        let type_name = object.type_name.as_str();
1006        let model = by_type.get(type_name).ok_or_else(|| {
1007            anyhow!(
1008                "object {} has type '{type_name}' which is not in the schema",
1009                object.uid
1010            )
1011        })?;
1012
1013        let mut fields = Map::new();
1014        for spec in &model.fields {
1015            let value = object
1016                .attrs
1017                .get(&spec.name)
1018                .or_else(|| object.key.get(&spec.name));
1019            match value {
1020                Some(Value::Null) | None => {}
1021                Some(value) => {
1022                    fields.insert(spec.name.clone(), fixture_value(spec, value));
1023                }
1024            }
1025        }
1026        // whatever the schema does not model stays in the envelope blob, so
1027        // nothing in the inventory is dropped on the way into the app.
1028        let leftovers: Map<String, Value> = object
1029            .attrs
1030            .iter()
1031            .filter(|(name, _)| !model.fields.iter().any(|spec| &spec.name == *name))
1032            .map(|(name, value)| (name.clone(), value.clone()))
1033            .collect();
1034        fields.insert("key".to_string(), Value::String(key_string(&object.key)));
1035        fields.insert("attrs".to_string(), Value::Object(leftovers));
1036
1037        let model_label = format!("{app_name}.{}", model.class_name.to_lowercase());
1038        let pk = object.uid.to_string();
1039        entries.push((
1040            model_label.clone(),
1041            pk.clone(),
1042            json!({"model": model_label, "pk": pk, "fields": Value::Object(fields)}),
1043        ));
1044    }
1045
1046    entries.sort_by(|a, b| (&a.0, &a.1).cmp(&(&b.0, &b.1)));
1047    Ok(entries.into_iter().map(|(_, _, entry)| entry).collect())
1048}
1049
1050fn class_name_for_type(type_name: &str) -> String {
1051    type_name
1052        .split('.')
1053        .map(|segment| {
1054            segment
1055                .split('_')
1056                .filter(|s| !s.is_empty())
1057                .map(|part| {
1058                    let mut chars = part.chars();
1059                    match chars.next() {
1060                        Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
1061                        None => String::new(),
1062                    }
1063                })
1064                .collect::<String>()
1065        })
1066        .collect::<String>()
1067}
1068
1069fn field_spec_from_schema(
1070    name: &str,
1071    schema: &alembic_core::FieldSchema,
1072    is_key: bool,
1073) -> FieldSpec {
1074    let mut validators = Vec::new();
1075    let mut choices = None;
1076    let mut member_check = None;
1077
1078    if let Some(format) = &schema.format {
1079        validators.push(format_validator(format));
1080    }
1081    if let Some(pattern) = &schema.pattern {
1082        validators.push(format!("RegexValidator({})", py_str(pattern)));
1083    }
1084    // an author-written constraint beats a type-derived one, as in the engine's
1085    // `validation_regex_for_schema`. a declared format and pattern both apply
1086    // though: `validators` is a list, where a custom field carries one regex.
1087    let declared = schema.format.is_some() || schema.pattern.is_some();
1088
1089    let field_type = match &schema.r#type {
1090        FieldType::String => DjangoFieldType::Char,
1091        FieldType::Text => DjangoFieldType::Text,
1092        FieldType::Int => DjangoFieldType::Integer,
1093        FieldType::Float => DjangoFieldType::Float,
1094        FieldType::Bool => DjangoFieldType::Boolean,
1095        FieldType::Uuid => DjangoFieldType::Uuid,
1096        FieldType::Date => DjangoFieldType::Date,
1097        FieldType::Datetime => DjangoFieldType::DateTime,
1098        FieldType::Time => DjangoFieldType::Time,
1099        FieldType::Json => DjangoFieldType::Json { list: false },
1100        FieldType::IpAddress => DjangoFieldType::IpAddress,
1101        FieldType::Cidr | FieldType::Prefix | FieldType::Mac => {
1102            if !declared {
1103                validators.extend(
1104                    alembic_core::format_for_field_type(&schema.r#type)
1105                        .as_ref()
1106                        .map(format_validator),
1107                );
1108            }
1109            DjangoFieldType::Char
1110        }
1111        FieldType::Slug => DjangoFieldType::Slug,
1112        FieldType::Enum { values } => {
1113            choices = Some(values.clone());
1114            DjangoFieldType::Char
1115        }
1116        FieldType::List { item } => {
1117            member_check = member_check_for(item);
1118            DjangoFieldType::Json { list: true }
1119        }
1120        FieldType::Map { .. } => DjangoFieldType::Json { list: false },
1121        FieldType::Ref { target } => DjangoFieldType::ForeignKey {
1122            target: class_name_for_type(target),
1123        },
1124        FieldType::ListRef { target } => DjangoFieldType::ManyToMany {
1125            target: class_name_for_type(target),
1126        },
1127    };
1128
1129    let required = schema.required || is_key;
1130    let nullable = schema.nullable && !is_key;
1131
1132    FieldSpec {
1133        name: name.to_string(),
1134        field_type,
1135        required,
1136        nullable,
1137        choices,
1138        validators,
1139        member_check,
1140        help_text: schema.description.clone(),
1141    }
1142}
1143
1144/// the element constraints a list column can carry, each one exactly what core
1145/// checks an entry against: an enum's members, a format-typed item's regex, or
1146/// the json type `type_check` tests for.
1147#[derive(Debug, Clone)]
1148enum MemberCheck {
1149    Choices(Vec<String>),
1150    Regex(&'static str),
1151    Kind(&'static str),
1152}
1153
1154fn member_check_for(item: &FieldType) -> Option<MemberCheck> {
1155    match item {
1156        FieldType::Enum { values } => Some(MemberCheck::Choices(values.clone())),
1157        FieldType::Uuid
1158        | FieldType::Cidr
1159        | FieldType::Prefix
1160        | FieldType::Mac
1161        | FieldType::Slug => alembic_core::format_for_field_type(item)
1162            .map(|format| MemberCheck::Regex(alembic_core::format_regex(&format))),
1163        // core checks `ip_address` as a plain string, so it belongs here rather
1164        // than with the formats: a regex would be stricter than the check.
1165        FieldType::String | FieldType::Text | FieldType::IpAddress => {
1166            Some(MemberCheck::Kind("str"))
1167        }
1168        FieldType::Int => Some(MemberCheck::Kind("int")),
1169        FieldType::Float => Some(MemberCheck::Kind("float")),
1170        FieldType::Bool => Some(MemberCheck::Kind("bool")),
1171        // core recurses into the entries of these (`element_schema`), so the
1172        // exact check is recursive and this one is only the outer shape. that
1173        // shape is exact: core takes a list and a map and nothing else.
1174        FieldType::List { .. } | FieldType::ListRef { .. } => Some(MemberCheck::Kind("list")),
1175        FieldType::Map { .. } => Some(MemberCheck::Kind("map")),
1176        // core parses a ref's uid exactly as it parses a `uuid` field, then
1177        // resolves it against the inventory, which the generated app cannot see.
1178        FieldType::Ref { .. } => Some(MemberCheck::Regex(alembic_core::format_regex(
1179            &FieldFormat::Uuid,
1180        ))),
1181        // core parses these as rfc 3339 and checks the calendar with it, which
1182        // no django-side check mirrors: its own parser takes shapes core
1183        // refuses (`2026-8-1`, a space separator), so a check built on it would
1184        // be an approximation either way.
1185        FieldType::Date | FieldType::Datetime | FieldType::Time => None,
1186        // core takes any json for a `json` element, so no check is the exact one.
1187        FieldType::Json => None,
1188    }
1189}
1190
1191fn render_member_check(check: Option<&MemberCheck>) -> String {
1192    match check {
1193        // core takes an array for a declared list and nothing else, whatever
1194        // its element type, so the outer shape ships even where no element
1195        // check does.
1196        None => "_ListMembers()".to_string(),
1197        Some(MemberCheck::Choices(values)) => format!(
1198            "_ListMembers(choices=[{}])",
1199            values
1200                .iter()
1201                .map(|value| py_str(value))
1202                .collect::<Vec<_>>()
1203                .join(", ")
1204        ),
1205        Some(MemberCheck::Regex(pattern)) => format!("_ListMembers(regex={})", py_str(pattern)),
1206        Some(MemberCheck::Kind(kind)) => format!("_ListMembers(kind={})", py_str(kind)),
1207    }
1208}
1209
1210fn format_validator(format: &FieldFormat) -> String {
1211    format!(
1212        "RegexValidator({})",
1213        py_str(alembic_core::format_regex(format))
1214    )
1215}
1216
1217fn write_if_missing(path: impl AsRef<Path>, contents: &str) -> Result<()> {
1218    let path = path.as_ref();
1219    if path
1220        .try_exists()
1221        .with_context(|| format!("check {}", path.display()))?
1222    {
1223        return Ok(());
1224    }
1225    fs::write(path, contents).with_context(|| format!("write {}", path.display()))?;
1226    Ok(())
1227}
1228
1229fn render_template(template: &str, vars: &[(&str, String)]) -> String {
1230    let mut output = template.to_string();
1231    for (key, value) in vars {
1232        let token = format!("{{{{{key}}}}}");
1233        output = output.replace(&token, value);
1234    }
1235    output
1236}
1237
1238fn write_user_file(path: impl AsRef<Path>, contents: &str, defaults: &[&str]) -> Result<()> {
1239    let path = path.as_ref();
1240    if path
1241        .try_exists()
1242        .with_context(|| format!("check {}", path.display()))?
1243    {
1244        let existing =
1245            fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?;
1246        let normalized = existing.trim().replace("\r\n", "\n");
1247        let is_default = defaults
1248            .iter()
1249            .any(|candidate| candidate.trim().replace("\r\n", "\n") == normalized);
1250        if !is_default {
1251            return Ok(());
1252        }
1253    }
1254    fs::write(path, contents).with_context(|| format!("write {}", path.display()))?;
1255    Ok(())
1256}
1257
1258fn user_models_stub() -> &'static str {
1259    "from .generated_models import *  # noqa: F401,F403\nfrom .extensions import *  # noqa: F401,F403\n"
1260}
1261
1262fn user_admin_stub() -> &'static str {
1263    "from .generated_admin import *  # noqa: F401,F403\nfrom .extensions import *  # noqa: F401,F403\n"
1264}
1265
1266fn user_serializers_stub() -> &'static str {
1267    "from .generated_serializers import *  # noqa: F401,F403\nfrom .extensions import *  # noqa: F401,F403\n"
1268}
1269
1270fn user_views_stub() -> &'static str {
1271    "from .generated_views import *  # noqa: F401,F403\nfrom .extensions import *  # noqa: F401,F403\n"
1272}
1273
1274fn user_urls_stub() -> &'static str {
1275    "from .generated_urls import *  # noqa: F401,F403\n"
1276}
1277
1278fn user_extensions_stub() -> &'static str {
1279    "# User extension hooks live here.\n"
1280}
1281
1282fn default_models_stub() -> &'static str {
1283    "from django.db import models\n\n# Create your models here.\n"
1284}
1285
1286fn default_admin_stub() -> &'static str {
1287    "from django.contrib import admin\n\n# Register your models here.\n"
1288}
1289
1290fn default_views_stub() -> &'static str {
1291    "from django.shortcuts import render\n\n# Create your views here.\n"
1292}
1293
1294#[cfg(test)]
1295mod tests {
1296    use super::*;
1297    use alembic_core::{FieldSchema, JsonMap, TypeName};
1298    use serde_json::json;
1299    use tempfile::tempdir;
1300    use uuid::Uuid;
1301
1302    fn attrs_map(pairs: Vec<(&str, Value)>) -> JsonMap {
1303        JsonMap::from(
1304            pairs
1305                .into_iter()
1306                .map(|(key, value)| (key.to_string(), value))
1307                .collect::<BTreeMap<_, _>>(),
1308        )
1309    }
1310
1311    fn key_str(raw: &str) -> alembic_core::Key {
1312        let mut map = BTreeMap::new();
1313        for segment in raw.split('/') {
1314            let (field, value) = segment
1315                .split_once('=')
1316                .unwrap_or_else(|| panic!("invalid key segment: {segment}"));
1317            map.insert(field.to_string(), Value::String(value.to_string()));
1318        }
1319        alembic_core::Key::from(map)
1320    }
1321
1322    fn obj(uid: u128, type_name: &str, key: &str, attrs: JsonMap) -> Object {
1323        Object::new(
1324            Uuid::from_u128(uid),
1325            TypeName::new(type_name),
1326            key_str(key),
1327            attrs,
1328        )
1329        .unwrap()
1330    }
1331
1332    fn field(r#type: FieldType) -> FieldSchema {
1333        FieldSchema {
1334            r#type,
1335            required: true,
1336            nullable: false,
1337            description: None,
1338            format: None,
1339            pattern: None,
1340        }
1341    }
1342
1343    fn optional(r#type: FieldType) -> FieldSchema {
1344        FieldSchema {
1345            required: false,
1346            ..field(r#type)
1347        }
1348    }
1349
1350    fn type_schema(key: Vec<(&str, FieldSchema)>, fields: Vec<(&str, FieldSchema)>) -> TypeSchema {
1351        TypeSchema {
1352            key: key
1353                .into_iter()
1354                .map(|(name, schema)| (name.to_string(), schema))
1355                .collect(),
1356            fields: fields
1357                .into_iter()
1358                .map(|(name, schema)| (name.to_string(), schema))
1359                .collect(),
1360        }
1361    }
1362
1363    fn schema_of(types: Vec<(&str, TypeSchema)>) -> Schema {
1364        Schema {
1365            types: types
1366                .into_iter()
1367                .map(|(name, schema)| (name.to_string(), schema))
1368                .collect(),
1369        }
1370    }
1371
1372    fn test_schema() -> Schema {
1373        schema_of(vec![
1374            (
1375                "dcim.site",
1376                type_schema(
1377                    vec![("slug", field(FieldType::Slug))],
1378                    vec![
1379                        ("name", field(FieldType::String)),
1380                        ("slug", field(FieldType::Slug)),
1381                    ],
1382                ),
1383            ),
1384            (
1385                "dcim.device",
1386                type_schema(
1387                    vec![("name", field(FieldType::Slug))],
1388                    vec![
1389                        ("name", field(FieldType::String)),
1390                        (
1391                            "site",
1392                            field(FieldType::Ref {
1393                                target: "dcim.site".to_string(),
1394                            }),
1395                        ),
1396                        ("role", field(FieldType::String)),
1397                        ("device_type", field(FieldType::String)),
1398                    ],
1399                ),
1400            ),
1401            (
1402                "dcim.interface",
1403                type_schema(
1404                    vec![("name", field(FieldType::Slug))],
1405                    vec![
1406                        ("name", field(FieldType::String)),
1407                        (
1408                            "device",
1409                            field(FieldType::Ref {
1410                                target: "dcim.device".to_string(),
1411                            }),
1412                        ),
1413                    ],
1414                ),
1415            ),
1416        ])
1417    }
1418
1419    fn sample_inventory() -> Inventory {
1420        let objects = vec![
1421            obj(
1422                1,
1423                "dcim.device",
1424                "name=leaf01",
1425                attrs_map(vec![
1426                    ("name", json!("leaf01")),
1427                    ("site", json!(Uuid::from_u128(2).to_string())),
1428                    ("role", json!("leaf")),
1429                    ("device_type", json!("leaf-switch")),
1430                    ("unmodelled", json!("kept in attrs")),
1431                ]),
1432            ),
1433            obj(
1434                2,
1435                "dcim.site",
1436                "slug=fra1",
1437                attrs_map(vec![("name", json!("FRA1")), ("slug", json!("fra1"))]),
1438            ),
1439            obj(
1440                3,
1441                "dcim.interface",
1442                "name=eth0",
1443                attrs_map(vec![
1444                    ("name", json!("eth0")),
1445                    ("device", json!(Uuid::from_u128(1).to_string())),
1446                ]),
1447            ),
1448        ];
1449        Inventory {
1450            scope: None,
1451            schema: test_schema(),
1452            objects,
1453        }
1454    }
1455
1456    fn emit_to_temp(inventory: &Inventory) -> tempfile::TempDir {
1457        let dir = tempdir().unwrap();
1458        emit_django_app(dir.path(), inventory, DjangoEmitOptions::default()).unwrap();
1459        dir
1460    }
1461
1462    fn generated(dir: &tempfile::TempDir, name: &str) -> String {
1463        fs::read_to_string(dir.path().join(name)).unwrap()
1464    }
1465
1466    #[test]
1467    fn emit_django_app_writes_files_and_stubs() {
1468        let dir = emit_to_temp(&sample_inventory());
1469
1470        assert!(dir.path().join(GENERATED_MODELS).exists());
1471        assert!(dir.path().join(GENERATED_ADMIN).exists());
1472        assert!(dir.path().join(GENERATED_SERIALIZERS).exists());
1473        assert!(dir.path().join(GENERATED_VIEWS).exists());
1474        assert!(dir.path().join(GENERATED_URLS).exists());
1475        assert!(dir.path().join(USER_MODELS).exists());
1476        assert!(dir.path().join(USER_ADMIN).exists());
1477        assert!(dir.path().join(USER_SERIALIZERS).exists());
1478        assert!(dir.path().join(USER_VIEWS).exists());
1479        assert!(dir.path().join(USER_URLS).exists());
1480        assert!(dir.path().join(USER_EXTENSIONS).exists());
1481        assert!(dir.path().join(FIXTURES_DIR).join(FIXTURE_FILE).exists());
1482
1483        let models = generated(&dir, GENERATED_MODELS);
1484        assert!(models.contains("class DcimSite"));
1485        assert!(models.contains(
1486            "site = models.ForeignKey(\"DcimSite\", on_delete=models.PROTECT, related_name=\"dcimdevice_site\")"
1487        ));
1488        assert!(models.contains(
1489            "device = models.ForeignKey(\"DcimDevice\", on_delete=models.PROTECT, related_name=\"dciminterface_device\")"
1490        ));
1491        assert!(models.contains("attrs = models.JSONField"));
1492    }
1493
1494    #[test]
1495    fn uid_defaults_so_the_api_can_create_objects() {
1496        // the pk is not editable, so drf marks it read-only: without a default,
1497        // every POST to the generated api dies on a NOT NULL uid.
1498        let models = generated(&emit_to_temp(&sample_inventory()), GENERATED_MODELS);
1499        assert!(models.contains("import uuid"));
1500        assert!(models.contains(
1501            "uid = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)"
1502        ));
1503    }
1504
1505    #[test]
1506    fn optional_non_text_fields_are_nullable() {
1507        // `blank=True` is a form-level flag: without `null=True` the column stays
1508        // NOT NULL and an absent value cannot be saved at all.
1509        let inventory = Inventory {
1510            scope: None,
1511            schema: schema_of(vec![(
1512                "dcim.site",
1513                type_schema(
1514                    vec![("slug", field(FieldType::Slug))],
1515                    vec![
1516                        ("active", optional(FieldType::Bool)),
1517                        ("created", optional(FieldType::Datetime)),
1518                        ("mgmt_ip", optional(FieldType::IpAddress)),
1519                        ("name", optional(FieldType::String)),
1520                        ("meta", optional(FieldType::Json)),
1521                        (
1522                            "tags",
1523                            optional(FieldType::List {
1524                                item: Box::new(FieldType::String),
1525                            }),
1526                        ),
1527                    ],
1528                ),
1529            )]),
1530            objects: vec![],
1531        };
1532        let models = generated(&emit_to_temp(&inventory), GENERATED_MODELS);
1533
1534        assert!(models.contains("active = models.BooleanField(blank=True, null=True)"));
1535        assert!(models.contains("created = models.DateTimeField(blank=True, null=True)"));
1536        assert!(models.contains("mgmt_ip = models.GenericIPAddressField(blank=True, null=True)"));
1537        // text columns hold "" for absent, so they stay NOT NULL.
1538        assert!(models.contains("name = models.CharField(max_length=255, blank=True)"));
1539        // json columns get a default instead of null.
1540        assert!(models.contains("meta = models.JSONField(blank=True, default=dict)"));
1541        assert!(models.contains(
1542            "tags = models.JSONField(validators=[_ListMembers(kind=\"str\")], blank=True, default=list)"
1543        ));
1544    }
1545
1546    #[test]
1547    fn many_to_many_is_never_nullable() {
1548        // django rejects null=True on a ManyToManyField (fields.W340).
1549        let inventory = Inventory {
1550            scope: None,
1551            schema: schema_of(vec![
1552                (
1553                    "dcim.tag",
1554                    type_schema(vec![("name", field(FieldType::Slug))], vec![]),
1555                ),
1556                (
1557                    "dcim.device",
1558                    type_schema(
1559                        vec![("name", field(FieldType::Slug))],
1560                        vec![(
1561                            "tags",
1562                            optional(FieldType::ListRef {
1563                                target: "dcim.tag".to_string(),
1564                            }),
1565                        )],
1566                    ),
1567                ),
1568            ]),
1569            objects: vec![],
1570        };
1571        let models = generated(&emit_to_temp(&inventory), GENERATED_MODELS);
1572        assert!(models.contains(
1573            "tags = models.ManyToManyField(\"DcimTag\", related_name=\"dcimdevice_tags\", blank=True)"
1574        ));
1575    }
1576
1577    fn list_field_models(item: FieldType) -> String {
1578        let inventory = Inventory {
1579            scope: None,
1580            schema: schema_of(vec![(
1581                "dcim.interface",
1582                type_schema(
1583                    vec![("name", field(FieldType::Slug))],
1584                    vec![(
1585                        "members",
1586                        field(FieldType::List {
1587                            item: Box::new(item),
1588                        }),
1589                    )],
1590                ),
1591            )]),
1592            objects: vec![],
1593        };
1594        generated(&emit_to_temp(&inventory), GENERATED_MODELS)
1595    }
1596
1597    #[test]
1598    fn list_of_enum_carries_its_declared_members() {
1599        let models = list_field_models(FieldType::Enum {
1600            values: vec!["access".to_string(), "trunk".to_string()],
1601        });
1602        assert!(
1603            models.contains(
1604                "members = models.JSONField(validators=[_ListMembers(choices=[\"access\", \"trunk\"])])"
1605            ),
1606            "{models}"
1607        );
1608        assert!(models.contains("class _ListMembers:"), "{models}");
1609        assert!(
1610            models.contains("from django.utils.deconstruct import deconstructible"),
1611            "{models}"
1612        );
1613        // the member check is not a RegexValidator, so nothing imports one, and
1614        // no check in this app runs a regex, so nothing imports `re` either.
1615        assert!(!models.contains("import RegexValidator"), "{models}");
1616        assert!(!models.contains("import re"), "{models}");
1617    }
1618
1619    #[test]
1620    fn list_of_format_typed_items_carries_the_format_regex() {
1621        let models = list_field_models(FieldType::Mac);
1622        assert!(
1623            models.contains(&format!(
1624                "members = models.JSONField(validators=[_ListMembers(regex={})])",
1625                py_str(alembic_core::format_regex(&FieldFormat::Mac))
1626            )),
1627            "{models}"
1628        );
1629        assert!(models.contains("import re"), "{models}");
1630    }
1631
1632    #[test]
1633    fn list_of_a_primitive_carries_the_type_core_checks_for() {
1634        for (item, kind) in [
1635            (FieldType::String, "str"),
1636            (FieldType::Text, "str"),
1637            (FieldType::IpAddress, "str"),
1638            (FieldType::Int, "int"),
1639            (FieldType::Float, "float"),
1640            (FieldType::Bool, "bool"),
1641        ] {
1642            let models = list_field_models(item);
1643            assert!(
1644                models.contains(&format!(
1645                    "members = models.JSONField(validators=[_ListMembers(kind=\"{kind}\")])"
1646                )),
1647                "{models}"
1648            );
1649        }
1650    }
1651
1652    #[test]
1653    fn list_of_a_nested_collection_carries_its_outer_shape() {
1654        // core recurses into the entries, so only the outer shape is checkable
1655        // here -- and on that shape core is exact.
1656        for (item, kind) in [
1657            (
1658                FieldType::List {
1659                    item: Box::new(FieldType::Int),
1660                },
1661                "list",
1662            ),
1663            (
1664                FieldType::ListRef {
1665                    target: "dcim.interface".to_string(),
1666                },
1667                "list",
1668            ),
1669            (
1670                FieldType::Map {
1671                    value: Box::new(FieldType::Int),
1672                },
1673                "map",
1674            ),
1675        ] {
1676            let models = list_field_models(item);
1677            assert!(
1678                models.contains(&format!(
1679                    "members = models.JSONField(validators=[_ListMembers(kind=\"{kind}\")])"
1680                )),
1681                "{models}"
1682            );
1683        }
1684    }
1685
1686    #[test]
1687    fn list_of_refs_carries_the_uuid_regex() {
1688        // core parses a ref's uid exactly as it parses a `uuid`; only resolving
1689        // it needs the inventory.
1690        let models = list_field_models(FieldType::Ref {
1691            target: "dcim.interface".to_string(),
1692        });
1693        assert!(
1694            models.contains(&format!(
1695                "members = models.JSONField(validators=[_ListMembers(regex={})])",
1696                py_str(alembic_core::format_regex(&FieldFormat::Uuid))
1697            )),
1698            "{models}"
1699        );
1700    }
1701
1702    #[test]
1703    fn list_of_an_uncheckable_element_still_carries_the_outer_shape() {
1704        // core reads a date as rfc 3339 and checks the calendar with it, and
1705        // takes any json for a `json` element, so neither carries an element
1706        // check -- but core still takes an array for the field and nothing
1707        // else, which is what the bare validator holds it to.
1708        for item in [
1709            FieldType::Date,
1710            FieldType::Datetime,
1711            FieldType::Time,
1712            FieldType::Json,
1713        ] {
1714            let models = list_field_models(item);
1715            assert!(
1716                models.contains("members = models.JSONField(validators=[_ListMembers()])"),
1717                "{models}"
1718            );
1719            assert!(!models.contains("import re"), "{models}");
1720        }
1721    }
1722
1723    #[test]
1724    fn map_values_carry_no_member_check() {
1725        // maps stay plain json, as they do on the nautobot backend.
1726        let inventory = Inventory {
1727            scope: None,
1728            schema: schema_of(vec![(
1729                "dcim.interface",
1730                type_schema(
1731                    vec![("name", field(FieldType::Slug))],
1732                    vec![(
1733                        "labels",
1734                        field(FieldType::Map {
1735                            value: Box::new(FieldType::Enum {
1736                                values: vec!["access".to_string()],
1737                            }),
1738                        }),
1739                    )],
1740                ),
1741            )]),
1742            objects: vec![],
1743        };
1744        let models = generated(&emit_to_temp(&inventory), GENERATED_MODELS);
1745        assert!(models.contains("labels = models.JSONField()"), "{models}");
1746        assert!(!models.contains("_ListMembers"), "{models}");
1747    }
1748
1749    /// what core's own checker makes of `[member]` in a declared `list` field.
1750    fn core_accepts_member(item: &FieldType, member: &Value) -> bool {
1751        core_accepts(
1752            field(FieldType::List {
1753                item: Box::new(item.clone()),
1754            }),
1755            json!([member]),
1756        )
1757    }
1758
1759    fn core_accepts(schema: FieldSchema, value: Value) -> bool {
1760        let inventory = Inventory {
1761            scope: None,
1762            schema: schema_of(vec![(
1763                "dcim.interface",
1764                type_schema(
1765                    vec![("name", field(FieldType::Slug))],
1766                    vec![("members", schema)],
1767                ),
1768            )]),
1769            objects: vec![obj(
1770                1,
1771                "dcim.interface",
1772                "name=eth0",
1773                attrs_map(vec![("members", value)]),
1774            )],
1775        };
1776        alembic_core::validate_inventory(&inventory)
1777            .errors
1778            .is_empty()
1779    }
1780
1781    /// what the emitted member check makes of the same value, read here as a
1782    /// table over every element type. this is a rust reading of the check, not
1783    /// the python that ships: `django_generated_api_enforces_a_declared_list_element`
1784    /// puts the same corpus through the generated `_ListMembers` itself, which is
1785    /// the only place the two regex engines meet.
1786    fn check_accepts_member(check: &Option<MemberCheck>, member: &Value) -> bool {
1787        match check {
1788            None => true,
1789            Some(MemberCheck::Choices(values)) => member
1790                .as_str()
1791                .is_some_and(|raw| values.iter().any(|value| value == raw)),
1792            Some(MemberCheck::Regex(pattern)) => {
1793                let mut schema = field(FieldType::String);
1794                schema.pattern = Some((*pattern).to_string());
1795                core_accepts(schema, member.clone())
1796            }
1797            Some(MemberCheck::Kind(kind)) => match *kind {
1798                "str" => member.is_string(),
1799                "int" => member.is_i64() || member.is_u64(),
1800                "float" => member.is_number(),
1801                "bool" => member.is_boolean(),
1802                "list" => member.is_array(),
1803                "map" => member.is_object(),
1804                other => panic!("no member kind {other}"),
1805            },
1806        }
1807    }
1808
1809    #[test]
1810    fn member_check_never_rejects_what_core_accepts() {
1811        let items = vec![
1812            FieldType::Enum {
1813                values: vec!["access".to_string(), "trunk".to_string()],
1814            },
1815            FieldType::Mac,
1816            FieldType::Cidr,
1817            FieldType::Prefix,
1818            FieldType::Uuid,
1819            FieldType::Slug,
1820            FieldType::IpAddress,
1821            FieldType::String,
1822            FieldType::Text,
1823            FieldType::Int,
1824            FieldType::Float,
1825            FieldType::Bool,
1826            FieldType::Date,
1827            FieldType::Datetime,
1828            FieldType::Time,
1829            FieldType::Json,
1830            FieldType::List {
1831                item: Box::new(FieldType::Int),
1832            },
1833            FieldType::Map {
1834                value: Box::new(FieldType::Int),
1835            },
1836            FieldType::Ref {
1837                target: "dcim.interface".to_string(),
1838            },
1839            FieldType::ListRef {
1840                target: "dcim.interface".to_string(),
1841            },
1842        ];
1843        let members = vec![
1844            json!("access"),
1845            json!("trunk"),
1846            json!("ACCESS"),
1847            json!("bogus"),
1848            json!(""),
1849            json!("aa:bb:cc:dd:ee:ff"),
1850            json!("AA-BB-CC-DD-EE-FF"),
1851            json!("aabbccddeeff"),
1852            json!("10.0.0.0/24"),
1853            json!("2001:db8::/32"),
1854            json!("::ffff:192.168.0.1"),
1855            json!("192.168.0.1"),
1856            json!("f47ac10b-58cc-4372-a567-0e02b2c3d479"),
1857            json!("f47ac10b58cc4372a5670e02b2c3d479"),
1858            json!("{f47ac10b-58cc-4372-a567-0e02b2c3d479}"),
1859            json!("urn:uuid:f47ac10b-58cc-4372-a567-0e02b2c3d479"),
1860            json!("fra1"),
1861            json!("fra-1_a"),
1862            json!("Fra1"),
1863            json!("with space"),
1864            json!("with\nnewline"),
1865            json!("ünïcödé"),
1866            json!("2024-01-01"),
1867            json!("2024-01-01T00:00:00Z"),
1868            json!("00:00:00"),
1869            // the uid `core_accepts` puts in the inventory, so a ref member has
1870            // one value core resolves and the crossing is not vacuous there.
1871            json!(Uuid::from_u128(1).to_string()),
1872            json!(7),
1873            json!(1.5),
1874            json!(true),
1875            json!(null),
1876            json!([]),
1877            json!(["nested"]),
1878            json!({}),
1879            json!({"nested": "object"}),
1880        ];
1881
1882        for item in &items {
1883            let check = member_check_for(item);
1884            let mut accepted = 0;
1885            for member in &members {
1886                if core_accepts_member(item, member) {
1887                    accepted += 1;
1888                    assert!(
1889                        check_accepts_member(&check, member),
1890                        "core accepts {member} as a {item:?} member, the generated check rejects it"
1891                    );
1892                }
1893            }
1894            // an element type no member is valid for would satisfy the
1895            // assertion above without ever reaching it.
1896            assert!(accepted > 0, "no member in the corpus is a valid {item:?}");
1897        }
1898    }
1899
1900    #[test]
1901    fn member_check_rejects_an_undeclared_member() {
1902        // the other half of the invariant: a check that accepted everything
1903        // would satisfy `member_check_never_rejects_what_core_accepts` too.
1904        let enum_check = member_check_for(&FieldType::Enum {
1905            values: vec!["access".to_string(), "trunk".to_string()],
1906        });
1907        assert!(!check_accepts_member(&enum_check, &json!("bogus")));
1908        assert!(check_accepts_member(&enum_check, &json!("access")));
1909
1910        let mac_check = member_check_for(&FieldType::Mac);
1911        assert!(!check_accepts_member(&mac_check, &json!("not-a-mac")));
1912        assert!(check_accepts_member(
1913            &mac_check,
1914            &json!("aa:bb:cc:dd:ee:ff")
1915        ));
1916
1917        let string_check = member_check_for(&FieldType::String);
1918        assert!(!check_accepts_member(&string_check, &json!(7)));
1919        assert!(check_accepts_member(&string_check, &json!("anything")));
1920
1921        // a python bool is an int, so an int list taking `true` is the failure
1922        // this pins.
1923        let int_check = member_check_for(&FieldType::Int);
1924        assert!(!check_accepts_member(&int_check, &json!(true)));
1925        assert!(check_accepts_member(&int_check, &json!(7)));
1926
1927        // a nested collection is checked on its outer shape alone, so the
1928        // wrong shape is still refused even though the entries are not read.
1929        let list_check = member_check_for(&FieldType::List {
1930            item: Box::new(FieldType::Int),
1931        });
1932        assert!(!check_accepts_member(&list_check, &json!("notalist")));
1933        assert!(check_accepts_member(&list_check, &json!(["x"])));
1934
1935        let map_check = member_check_for(&FieldType::Map {
1936            value: Box::new(FieldType::Int),
1937        });
1938        assert!(!check_accepts_member(&map_check, &json!([])));
1939        assert!(check_accepts_member(&map_check, &json!({})));
1940
1941        let ref_check = member_check_for(&FieldType::Ref {
1942            target: "dcim.interface".to_string(),
1943        });
1944        assert!(!check_accepts_member(&ref_check, &json!("not-a-uuid")));
1945        assert!(check_accepts_member(
1946            &ref_check,
1947            &json!(Uuid::from_u128(1).to_string())
1948        ));
1949    }
1950
1951    #[test]
1952    fn string_values_are_escaped_into_python() {
1953        // a stray quote in an enum value or pattern must not break the
1954        // generated python.
1955        let mut status = field(FieldType::Enum {
1956            values: vec!["active".to_string(), "retired \"old\"".to_string()],
1957        });
1958        status.pattern = Some("^\\d+\"$".to_string());
1959        status.description = Some("a \"quoted\" description".to_string());
1960        let inventory = Inventory {
1961            scope: None,
1962            schema: schema_of(vec![(
1963                "dcim.site",
1964                type_schema(
1965                    vec![("slug", field(FieldType::Slug))],
1966                    vec![("status", status)],
1967                ),
1968            )]),
1969            objects: vec![],
1970        };
1971        let models = generated(&emit_to_temp(&inventory), GENERATED_MODELS);
1972
1973        assert!(
1974            models.contains(r#"("retired \"old\"", "retired \"old\"")"#),
1975            "{models}"
1976        );
1977        assert!(models.contains(r#"RegexValidator("^\\d+\"$")"#), "{models}");
1978        assert!(
1979            models.contains(r#"help_text="a \"quoted\" description""#),
1980            "{models}"
1981        );
1982    }
1983
1984    fn models_for_field(schema: FieldSchema) -> String {
1985        let inventory = Inventory {
1986            scope: None,
1987            schema: schema_of(vec![(
1988                "ipam.address",
1989                type_schema(
1990                    vec![("slug", field(FieldType::Slug))],
1991                    vec![("value", schema)],
1992                ),
1993            )]),
1994            objects: vec![],
1995        };
1996        generated(&emit_to_temp(&inventory), GENERATED_MODELS)
1997    }
1998
1999    const CIDR_REGEX: &str = r#"RegexValidator("^[0-9a-fA-F:\\./]+$")"#;
2000
2001    #[test]
2002    fn a_format_typed_field_gets_the_derived_validator() {
2003        let models = models_for_field(field(FieldType::Cidr));
2004
2005        assert!(
2006            models.contains(&format!("validators=[{CIDR_REGEX}]")),
2007            "{models}"
2008        );
2009    }
2010
2011    #[test]
2012    fn a_declared_format_replaces_the_derived_one() {
2013        let mut value = field(FieldType::Cidr);
2014        value.format = Some(FieldFormat::Cidr);
2015        let models = models_for_field(value);
2016
2017        assert_eq!(models.matches(CIDR_REGEX).count(), 1, "{models}");
2018    }
2019
2020    #[test]
2021    fn a_declared_pattern_replaces_the_derived_one() {
2022        let mut value = field(FieldType::Mac);
2023        value.pattern = Some("^00:".to_string());
2024        let models = models_for_field(value);
2025
2026        assert!(
2027            models.contains(r#"validators=[RegexValidator("^00:")]"#),
2028            "{models}"
2029        );
2030    }
2031
2032    #[test]
2033    fn a_declared_format_and_pattern_both_apply() {
2034        let mut value = field(FieldType::Mac);
2035        value.format = Some(FieldFormat::Mac);
2036        value.pattern = Some("^00:".to_string());
2037        let models = models_for_field(value);
2038
2039        assert!(
2040            models.contains(
2041                r#"validators=[RegexValidator("^([0-9A-Fa-f]{2}[:-]){5}([0-9A-Fa-f]{2})$"), RegexValidator("^00:")]"#
2042            ),
2043            "{models}"
2044        );
2045    }
2046
2047    #[test]
2048    fn field_descriptions_become_help_text() {
2049        let mut name = field(FieldType::String);
2050        name.description = Some("human readable name".to_string());
2051        let inventory = Inventory {
2052            scope: None,
2053            schema: schema_of(vec![(
2054                "dcim.site",
2055                type_schema(vec![("slug", field(FieldType::Slug))], vec![("name", name)]),
2056            )]),
2057            objects: vec![],
2058        };
2059        let models = generated(&emit_to_temp(&inventory), GENERATED_MODELS);
2060        assert!(
2061            models.contains("help_text=\"human readable name\""),
2062            "{models}"
2063        );
2064    }
2065
2066    #[test]
2067    fn unusable_field_names_are_rejected() {
2068        for name in ["class", "key", "attrs", "uid", "pk", "trailing_", "do__ble"] {
2069            let inventory = Inventory {
2070                scope: None,
2071                schema: schema_of(vec![(
2072                    "dcim.site",
2073                    type_schema(
2074                        vec![("slug", field(FieldType::Slug))],
2075                        vec![(name, field(FieldType::String))],
2076                    ),
2077                )]),
2078                objects: vec![],
2079            };
2080            let dir = tempdir().unwrap();
2081            let result = emit_django_app(dir.path(), &inventory, DjangoEmitOptions::default());
2082            assert!(
2083                result.is_err(),
2084                "expected '{name}' to be rejected instead of emitting broken python"
2085            );
2086        }
2087    }
2088
2089    #[test]
2090    fn colliding_model_names_are_rejected() {
2091        // both types render as `class DcimSite`, which would silently drop one.
2092        let inventory = Inventory {
2093            scope: None,
2094            schema: schema_of(vec![
2095                (
2096                    "dcim.site",
2097                    type_schema(vec![("slug", field(FieldType::Slug))], vec![]),
2098                ),
2099                (
2100                    "dcim_site",
2101                    type_schema(vec![("slug", field(FieldType::Slug))], vec![]),
2102                ),
2103            ]),
2104            objects: vec![],
2105        };
2106        let dir = tempdir().unwrap();
2107        let err = emit_django_app(dir.path(), &inventory, DjangoEmitOptions::default())
2108            .expect_err("colliding model names must fail");
2109        assert!(err.to_string().contains("DcimSite"), "{err}");
2110    }
2111
2112    #[test]
2113    fn dangling_relation_targets_are_rejected() {
2114        let inventory = Inventory {
2115            scope: None,
2116            schema: schema_of(vec![(
2117                "dcim.device",
2118                type_schema(
2119                    vec![("name", field(FieldType::Slug))],
2120                    vec![(
2121                        "site",
2122                        field(FieldType::Ref {
2123                            target: "dcim.site".to_string(),
2124                        }),
2125                    )],
2126                ),
2127            )]),
2128            objects: vec![],
2129        };
2130        let dir = tempdir().unwrap();
2131        let err = emit_django_app(dir.path(), &inventory, DjangoEmitOptions::default())
2132            .expect_err("a relation to a type outside the model must fail");
2133        assert!(err.to_string().contains("dcim.site"), "{err}");
2134    }
2135
2136    #[test]
2137    fn a_datetime_is_emitted_with_an_uppercase_separator() {
2138        // rfc 3339 permits a lowercase `t` and `validate` takes it, but django
2139        // reads the fixture with `parse_datetime`, whose regex fallback wants an
2140        // uppercase one. the fallback is reached whenever `fromisoformat`
2141        // declines, which it does for a trailing `Z` below python 3.11.
2142        let inventory = Inventory {
2143            scope: None,
2144            schema: schema_of(vec![(
2145                "ops.window",
2146                type_schema(
2147                    vec![("slug", field(FieldType::Slug))],
2148                    vec![("starts_at", optional(FieldType::Datetime))],
2149                ),
2150            )]),
2151            objects: vec![obj(
2152                1,
2153                "ops.window",
2154                "slug=maintenance",
2155                attrs_map(vec![
2156                    ("slug", json!("maintenance")),
2157                    ("starts_at", json!("2026-08-01t22:00:00Z")),
2158                ]),
2159            )],
2160        };
2161
2162        let dir = emit_to_temp(&inventory);
2163        let fixture: Value = serde_json::from_str(
2164            &fs::read_to_string(dir.path().join(FIXTURES_DIR).join(FIXTURE_FILE)).unwrap(),
2165        )
2166        .unwrap();
2167        let entry = &fixture.as_array().expect("a fixture list")[0];
2168        assert_eq!(entry["fields"]["starts_at"], json!("2026-08-01T22:00:00Z"));
2169    }
2170
2171    #[test]
2172    fn objects_are_emitted_as_a_fixture() {
2173        let dir = emit_to_temp(&sample_inventory());
2174        let fixture: Value = serde_json::from_str(
2175            &fs::read_to_string(dir.path().join(FIXTURES_DIR).join(FIXTURE_FILE)).unwrap(),
2176        )
2177        .unwrap();
2178        let entries = fixture.as_array().expect("a fixture list");
2179        assert_eq!(entries.len(), 3);
2180
2181        let device = entries
2182            .iter()
2183            .find(|entry| entry["pk"] == json!(Uuid::from_u128(1).to_string()))
2184            .expect("the device is in the fixture");
2185        let app_name = dir.path().file_name().unwrap().to_str().unwrap();
2186        assert_eq!(device["model"], json!(format!("{app_name}.dcimdevice")));
2187        assert_eq!(device["fields"]["name"], json!("leaf01"));
2188        // a relation carries over as the target uid, which is that model's pk.
2189        assert_eq!(
2190            device["fields"]["site"],
2191            json!(Uuid::from_u128(2).to_string())
2192        );
2193        assert_eq!(device["fields"]["key"], json!("{\"name\":\"leaf01\"}"));
2194        // what the schema does not model stays in the envelope blob.
2195        assert_eq!(
2196            device["fields"]["attrs"],
2197            json!({"unmodelled": "kept in attrs"})
2198        );
2199    }
2200
2201    #[test]
2202    fn admin_list_display_excludes_many_to_many_fields() {
2203        // a ManyToManyField in admin list_display trips admin.E109 under `manage.py check`,
2204        // so a list_ref field must not leak into it. it must still exist as a model field.
2205        let inventory = Inventory {
2206            scope: None,
2207            schema: schema_of(vec![
2208                (
2209                    "dcim.tag",
2210                    type_schema(vec![("name", field(FieldType::Slug))], vec![]),
2211                ),
2212                (
2213                    "dcim.device",
2214                    type_schema(
2215                        vec![("name", field(FieldType::Slug))],
2216                        vec![
2217                            ("name", field(FieldType::String)),
2218                            (
2219                                "tags",
2220                                optional(FieldType::ListRef {
2221                                    target: "dcim.tag".to_string(),
2222                                }),
2223                            ),
2224                        ],
2225                    ),
2226                ),
2227            ]),
2228            objects: vec![],
2229        };
2230        let dir = emit_to_temp(&inventory);
2231
2232        // the field is still generated as a real M2M relation on the model...
2233        assert!(generated(&dir, GENERATED_MODELS).contains("tags = models.ManyToManyField("));
2234
2235        // ...but it must not appear in the admin list_display (its only path into admin.py).
2236        let admin = generated(&dir, GENERATED_ADMIN);
2237        assert!(admin.contains("list_display"));
2238        assert!(!admin.contains("\"tags\""));
2239    }
2240
2241    #[test]
2242    fn emit_django_app_does_not_overwrite_user_files() {
2243        let dir = tempdir().unwrap();
2244        let models_path = dir.path().join(USER_MODELS);
2245        let admin_path = dir.path().join(USER_ADMIN);
2246        fs::write(&models_path, "# user models\n").unwrap();
2247        fs::write(&admin_path, "# user admin\n").unwrap();
2248
2249        emit_django_app(
2250            dir.path(),
2251            &sample_inventory(),
2252            DjangoEmitOptions::default(),
2253        )
2254        .unwrap();
2255
2256        assert_eq!(fs::read_to_string(models_path).unwrap(), "# user models\n");
2257        assert_eq!(fs::read_to_string(admin_path).unwrap(), "# user admin\n");
2258    }
2259
2260    #[test]
2261    fn emit_django_app_overwrites_default_skeleton() {
2262        let dir = tempdir().unwrap();
2263        let models_path = dir.path().join(USER_MODELS);
2264        let admin_path = dir.path().join(USER_ADMIN);
2265        let views_path = dir.path().join(USER_VIEWS);
2266        fs::write(&models_path, default_models_stub()).unwrap();
2267        fs::write(&admin_path, default_admin_stub()).unwrap();
2268        fs::write(&views_path, default_views_stub()).unwrap();
2269
2270        emit_django_app(
2271            dir.path(),
2272            &sample_inventory(),
2273            DjangoEmitOptions::default(),
2274        )
2275        .unwrap();
2276
2277        let models = fs::read_to_string(models_path).unwrap();
2278        let admin = fs::read_to_string(admin_path).unwrap();
2279        let views = fs::read_to_string(views_path).unwrap();
2280        assert!(models.contains("generated_models"));
2281        assert!(admin.contains("generated_admin"));
2282        assert!(views.contains("generated_views"));
2283    }
2284
2285    #[test]
2286    fn generated_admin_includes_defaults() {
2287        let admin = generated(&emit_to_temp(&sample_inventory()), GENERATED_ADMIN);
2288
2289        assert!(admin.contains("class DcimDeviceAdmin"));
2290        assert!(admin.contains(
2291            "list_display = [\"__str__\", \"name\", \"device_type\", \"role\", \"site\", \"uid\"]"
2292        ));
2293        // icontains has no postgres operator for uuid, so only text columns are searched.
2294        assert!(admin.contains("search_fields = [\"key\", \"name\", \"device_type\", \"role\"]"));
2295        assert!(admin.contains("list_select_related = [\"site\"]"));
2296        assert!(admin.contains("class DcimInterfaceAdmin"));
2297    }
2298
2299    #[test]
2300    fn str_renders_the_key_values_not_the_json_key() {
2301        // `key` holds the ir key as json, so a __str__ returning it reads as
2302        // `{"name":"leaf01"}` in the admin and the browsable api.
2303        let models = generated(&emit_to_temp(&sample_inventory()), GENERATED_MODELS);
2304        assert!(
2305            models.contains("        return f\"{self.name}\""),
2306            "{models}"
2307        );
2308        // a composite key renders every component, refs through their own __str__.
2309        let inventory = Inventory {
2310            scope: None,
2311            schema: schema_of(vec![
2312                (
2313                    "dcim.device",
2314                    type_schema(vec![("name", field(FieldType::Slug))], vec![]),
2315                ),
2316                (
2317                    "dcim.interface",
2318                    type_schema(
2319                        vec![
2320                            (
2321                                "device",
2322                                field(FieldType::Ref {
2323                                    target: "dcim.device".to_string(),
2324                                }),
2325                            ),
2326                            ("name", field(FieldType::Slug)),
2327                        ],
2328                        vec![],
2329                    ),
2330                ),
2331            ]),
2332            objects: vec![],
2333        };
2334        let models = generated(&emit_to_temp(&inventory), GENERATED_MODELS);
2335        assert!(
2336            models.contains("        return f\"{self.device} / {self.name}\""),
2337            "{models}"
2338        );
2339    }
2340
2341    #[test]
2342    fn generated_api_files_include_models() {
2343        let dir = emit_to_temp(&sample_inventory());
2344        let serializers = generated(&dir, GENERATED_SERIALIZERS);
2345        let views = generated(&dir, GENERATED_VIEWS);
2346        let urls = generated(&dir, GENERATED_URLS);
2347
2348        assert!(serializers.contains("class DcimDeviceSerializer"));
2349        assert!(views.contains("class DcimDeviceViewSet"));
2350        assert!(views.contains("ordering = [\"key\"]"));
2351        assert!(urls.contains("router.register(\"dcimdevices\""));
2352        // without drf-spectacular there is nothing to serve a schema with.
2353        assert!(!urls.contains("schema"), "{urls}");
2354    }
2355
2356    #[test]
2357    fn filterset_fields_are_only_emitted_with_a_filter_backend() {
2358        // without django-filter installed there is no backend to honour them, and
2359        // a filter that silently returns everything is worse than none.
2360        let dir = tempdir().unwrap();
2361        emit_django_app(
2362            dir.path(),
2363            &sample_inventory(),
2364            DjangoEmitOptions {
2365                emit_admin: true,
2366                filter_backend: false,
2367                ..DjangoEmitOptions::default()
2368            },
2369        )
2370        .unwrap();
2371        assert!(!generated(&dir, GENERATED_VIEWS).contains("filterset_fields"));
2372
2373        let dir = tempdir().unwrap();
2374        emit_django_app(
2375            dir.path(),
2376            &sample_inventory(),
2377            DjangoEmitOptions {
2378                emit_admin: true,
2379                filter_backend: true,
2380                ..DjangoEmitOptions::default()
2381            },
2382        )
2383        .unwrap();
2384        let views = generated(&dir, GENERATED_VIEWS);
2385        assert!(
2386            views.contains("filterset_fields = [\"key\", \"uid\", \"name\""),
2387            "{views}"
2388        );
2389    }
2390
2391    #[test]
2392    fn schema_route_is_wired_when_drf_can_serve_it() {
2393        let dir = tempdir().unwrap();
2394        emit_django_app(
2395            dir.path(),
2396            &sample_inventory(),
2397            DjangoEmitOptions {
2398                schema_view: true,
2399                ..DjangoEmitOptions::default()
2400            },
2401        )
2402        .unwrap();
2403        let urls = generated(&dir, GENERATED_URLS);
2404        assert!(urls.contains("from drf_spectacular.views import"), "{urls}");
2405        assert!(urls.contains("SpectacularAPIView.as_view()"), "{urls}");
2406        assert!(urls.contains("SpectacularSwaggerView.as_view("), "{urls}");
2407    }
2408
2409    #[test]
2410    fn generated_models_are_deterministic_by_kind() {
2411        let models = generated(&emit_to_temp(&sample_inventory()), GENERATED_MODELS);
2412        let device_pos = models.find("class DcimDevice").unwrap();
2413        let interface_pos = models.find("class DcimInterface").unwrap();
2414        let site_pos = models.find("class DcimSite").unwrap();
2415        assert!(device_pos < interface_pos);
2416        assert!(interface_pos < site_pos);
2417    }
2418
2419    fn plain_field(r#type: FieldType) -> FieldSchema {
2420        FieldSchema {
2421            r#type,
2422            required: false,
2423            nullable: false,
2424            description: None,
2425            format: None,
2426            pattern: None,
2427        }
2428    }
2429
2430    fn inventory_with_field(field_name: &str) -> Inventory {
2431        let mut types = BTreeMap::new();
2432        types.insert(
2433            "dcim.site".to_string(),
2434            TypeSchema {
2435                key: BTreeMap::from([("name".to_string(), plain_field(FieldType::Slug))]),
2436                fields: BTreeMap::from([(field_name.to_string(), plain_field(FieldType::String))]),
2437            },
2438        );
2439        Inventory {
2440            scope: None,
2441            schema: Schema { types },
2442            objects: vec![],
2443        }
2444    }
2445
2446    fn emit_error(inventory: &Inventory) -> String {
2447        let dir = tempdir().unwrap();
2448        let result = emit_django_app(dir.path(), inventory, DjangoEmitOptions::default());
2449        result
2450            .expect_err("expected emit to reject the schema")
2451            .to_string()
2452    }
2453
2454    #[test]
2455    fn rejects_field_named_uid() {
2456        // the silent one: a schema field `uid` would override the generated
2457        // `uid = models.UUIDField(primary_key=True)`, dropping the uuid primary key
2458        // without any error from `manage.py check`.
2459        let err = emit_error(&inventory_with_field("uid"));
2460        assert!(
2461            err.contains("uid"),
2462            "error should name the field, got: {err}"
2463        );
2464        assert!(
2465            err.contains("reserved"),
2466            "error should say reserved, got: {err}"
2467        );
2468    }
2469
2470    #[test]
2471    fn rejects_field_named_key() {
2472        let err = emit_error(&inventory_with_field("key"));
2473        assert!(
2474            err.contains("'key'"),
2475            "error should name the field, got: {err}"
2476        );
2477        assert!(
2478            err.contains("reserved"),
2479            "error should say reserved, got: {err}"
2480        );
2481    }
2482
2483    #[test]
2484    fn rejects_field_named_attrs() {
2485        let err = emit_error(&inventory_with_field("attrs"));
2486        assert!(
2487            err.contains("'attrs'"),
2488            "error should name the field, got: {err}"
2489        );
2490        assert!(
2491            err.contains("reserved"),
2492            "error should say reserved, got: {err}"
2493        );
2494    }
2495
2496    #[test]
2497    fn rejects_python_keyword_field_name() {
2498        let err = emit_error(&inventory_with_field("from"));
2499        assert!(
2500            err.contains("'from'"),
2501            "error should name the field, got: {err}"
2502        );
2503    }
2504
2505    #[test]
2506    fn rejects_leading_digit_field_name() {
2507        let err = emit_error(&inventory_with_field("10g_port"));
2508        assert!(
2509            err.contains("'10g_port'"),
2510            "error should name the field, got: {err}"
2511        );
2512    }
2513
2514    #[test]
2515    fn rejects_invalid_key_field_name() {
2516        // key fields are rendered as model fields too, so the identifier
2517        // check must cover them.
2518        let mut types = BTreeMap::new();
2519        types.insert(
2520            "dcim.site".to_string(),
2521            TypeSchema {
2522                key: BTreeMap::from([("class".to_string(), plain_field(FieldType::Slug))]),
2523                fields: BTreeMap::new(),
2524            },
2525        );
2526        let inventory = Inventory {
2527            scope: None,
2528            schema: Schema { types },
2529            objects: vec![],
2530        };
2531        let err = emit_error(&inventory);
2532        assert!(
2533            err.contains("'class'"),
2534            "error should name the key field, got: {err}"
2535        );
2536    }
2537
2538    #[test]
2539    fn rejects_type_name_that_renders_to_invalid_class_name() {
2540        // class_name_for_type("10g.port") yields "10gPort", which cannot be a
2541        // python class name.
2542        let mut types = BTreeMap::new();
2543        types.insert(
2544            "10g.port".to_string(),
2545            TypeSchema {
2546                key: BTreeMap::from([("name".to_string(), plain_field(FieldType::Slug))]),
2547                fields: BTreeMap::new(),
2548            },
2549        );
2550        let inventory = Inventory {
2551            scope: None,
2552            schema: Schema { types },
2553            objects: vec![],
2554        };
2555        let err = emit_error(&inventory);
2556        assert!(
2557            err.contains("10g.port"),
2558            "error should name the type, got: {err}"
2559        );
2560        assert!(
2561            err.contains("10gPort"),
2562            "error should show the class name, got: {err}"
2563        );
2564    }
2565
2566    #[test]
2567    fn reports_all_offending_names_at_once() {
2568        let mut types = BTreeMap::new();
2569        types.insert(
2570            "dcim.site".to_string(),
2571            TypeSchema {
2572                key: BTreeMap::from([("name".to_string(), plain_field(FieldType::Slug))]),
2573                fields: BTreeMap::from([
2574                    ("uid".to_string(), plain_field(FieldType::String)),
2575                    ("from".to_string(), plain_field(FieldType::String)),
2576                    ("2fa".to_string(), plain_field(FieldType::String)),
2577                ]),
2578            },
2579        );
2580        let inventory = Inventory {
2581            scope: None,
2582            schema: Schema { types },
2583            objects: vec![],
2584        };
2585        let err = emit_error(&inventory);
2586        for name in ["'uid'", "'from'", "'2fa'"] {
2587            assert!(err.contains(name), "error should list {name}, got: {err}");
2588        }
2589    }
2590
2591    #[test]
2592    fn accepts_soft_keyword_field_names() {
2593        // `match` and `type` are soft keywords, legal as python identifiers.
2594        let dir = tempdir().unwrap();
2595        let mut types = BTreeMap::new();
2596        types.insert(
2597            "dcim.site".to_string(),
2598            TypeSchema {
2599                key: BTreeMap::from([("name".to_string(), plain_field(FieldType::Slug))]),
2600                fields: BTreeMap::from([
2601                    ("match".to_string(), plain_field(FieldType::String)),
2602                    ("type".to_string(), plain_field(FieldType::String)),
2603                ]),
2604            },
2605        );
2606        let inventory = Inventory {
2607            scope: None,
2608            schema: Schema { types },
2609            objects: vec![],
2610        };
2611        emit_django_app(dir.path(), &inventory, DjangoEmitOptions::default()).unwrap();
2612    }
2613
2614    #[test]
2615    fn render_routes_block_pluralizes_endpoints() {
2616        // `prefix` -> `prefixes` (not `prefixs`); vowel + y `gateway` ->
2617        // `gateways` (not `gatewaies`).
2618        let models = vec![
2619            ModelSpec {
2620                type_name: "ipam.prefix".to_string(),
2621                class_name: "Prefix".to_string(),
2622                fields: Vec::new(),
2623                key_fields: Vec::new(),
2624            },
2625            ModelSpec {
2626                type_name: "ipam.gateway".to_string(),
2627                class_name: "Gateway".to_string(),
2628                fields: Vec::new(),
2629                key_fields: Vec::new(),
2630            },
2631        ];
2632        let routes = render_routes_block(&models);
2633        assert!(routes.contains("router.register(\"prefixes\", PrefixViewSet)"));
2634        assert!(routes.contains("router.register(\"gateways\", GatewayViewSet)"));
2635    }
2636
2637    /// a parent that is a regular file makes the stat fail with `NotADirectory`
2638    /// rather than answering absent. the write that follows fails too, so what
2639    /// this pins is that the failure names the path and the stat, not an
2640    /// unlabelled `Not a directory` from somewhere in the emit.
2641    #[test]
2642    fn write_if_missing_reports_a_stat_it_could_not_make() {
2643        let dir = tempdir().unwrap();
2644        let blocker = dir.path().join("blocker");
2645        fs::write(&blocker, "regular file").unwrap();
2646
2647        let err = write_if_missing(blocker.join("extensions.py"), "new").unwrap_err();
2648
2649        assert!(
2650            format!("{err:#}").contains("extensions.py"),
2651            "the error must name the path: {err:#}"
2652        );
2653    }
2654
2655    #[test]
2656    fn write_user_file_reports_a_stat_it_could_not_make() {
2657        let dir = tempdir().unwrap();
2658        let blocker = dir.path().join("blocker");
2659        fs::write(&blocker, "regular file").unwrap();
2660
2661        let err = write_user_file(blocker.join("models.py"), "new", &[]).unwrap_err();
2662
2663        assert!(
2664            format!("{err:#}").contains("models.py"),
2665            "the error must name the path: {err:#}"
2666        );
2667    }
2668
2669    /// the ordinary failure is a stat that succeeded: a dangling symlink the
2670    /// write follows nowhere, or a directory where a file was expected.
2671    #[cfg(unix)]
2672    #[test]
2673    fn write_helpers_name_the_path_when_the_stat_succeeded() {
2674        let dir = tempdir().unwrap();
2675
2676        let dangling = dir.path().join("extensions.py");
2677        std::os::unix::fs::symlink("nowhere/target.py", &dangling).unwrap();
2678        let err = write_if_missing(&dangling, "new").unwrap_err();
2679        assert!(
2680            format!("{err:#}").contains("extensions.py"),
2681            "the write must name the path: {err:#}"
2682        );
2683
2684        let dangling = dir.path().join("models.py");
2685        std::os::unix::fs::symlink("nowhere/target.py", &dangling).unwrap();
2686        let err = write_user_file(&dangling, "new", &[]).unwrap_err();
2687        assert!(
2688            format!("{err:#}").contains("models.py"),
2689            "the write must name the path: {err:#}"
2690        );
2691
2692        let as_dir = dir.path().join("settings.py");
2693        fs::create_dir(&as_dir).unwrap();
2694        let err = write_user_file(&as_dir, "new", &[]).unwrap_err();
2695        assert!(
2696            format!("{err:#}").contains("settings.py"),
2697            "the read must name the path: {err:#}"
2698        );
2699    }
2700
2701    /// the absent case still writes, and an existing non-default file is still
2702    /// preserved: `ENOENT` stays `Ok(false)`.
2703    #[test]
2704    fn write_helpers_still_treat_enoent_as_absent() {
2705        let dir = tempdir().unwrap();
2706
2707        let fresh = dir.path().join("fresh.py");
2708        write_if_missing(&fresh, "generated").unwrap();
2709        assert_eq!(fs::read_to_string(&fresh).unwrap(), "generated");
2710        write_if_missing(&fresh, "second").unwrap();
2711        assert_eq!(fs::read_to_string(&fresh).unwrap(), "generated");
2712
2713        let user = dir.path().join("user.py");
2714        write_user_file(&user, "stub", &[]).unwrap();
2715        fs::write(&user, "hand written").unwrap();
2716        write_user_file(&user, "stub", &["stub"]).unwrap();
2717        assert_eq!(fs::read_to_string(&user).unwrap(), "hand written");
2718    }
2719}