Skip to main content

nautilus_codegen/
lib.rs

1//! Nautilus Codegen — library entry point.
2//!
3//! Exposes `generate_command`, `validate_command`, and helpers so they can be
4//! called from `nautilus-cli` (the unified binary) as well as from the
5//! standalone `nautilus-codegen` binary.
6
7#![forbid(unsafe_code)]
8
9pub mod backend;
10pub mod composite_type_gen;
11pub mod enum_gen;
12pub mod generator;
13pub mod js;
14pub mod python;
15pub mod type_helpers;
16pub mod writer;
17
18use anyhow::{Context, Result};
19use std::fs;
20use std::path::{Path, PathBuf};
21
22use crate::composite_type_gen::generate_all_composite_types;
23use crate::enum_gen::generate_all_enums;
24use crate::generator::generate_all_models;
25use crate::js::{
26    generate_all_js_models, generate_js_client, generate_js_composite_types, generate_js_enums,
27    generate_js_models_index, js_runtime_files,
28};
29use crate::python::{
30    generate_all_python_models, generate_python_composite_types, generate_python_enums,
31    python_runtime_files,
32};
33use crate::writer::{write_js_code, write_python_code, write_rust_code};
34use nautilus_schema::ir::{ResolvedFieldType, SchemaIr};
35use nautilus_schema::{parse_schema_source, validate_schema_source};
36
37/// Auto-detect the first `.nautilus` file in the current directory, or return
38/// `schema` as-is if explicitly provided.
39pub fn resolve_schema_path(schema: Option<PathBuf>) -> Result<PathBuf> {
40    if let Some(path) = schema {
41        return Ok(path);
42    }
43
44    let nautilus_files = nautilus_schema::discover_schema_paths_in_current_dir()
45        .context("Failed to inspect current directory for .nautilus schema files")?;
46
47    if nautilus_files.is_empty() {
48        return Err(anyhow::anyhow!(
49            "No .nautilus schema file found in current directory.\n\n\
50            Hint: Create a schema file (e.g. 'schema.nautilus') or specify the path:\n\
51            nautilus generate --schema path/to/schema.nautilus"
52        ));
53    }
54
55    let schema_file = &nautilus_files[0];
56
57    if nautilus_files.len() > 1 {
58        eprintln!(
59            "warning: multiple .nautilus files found, using: {}",
60            schema_file.display()
61        );
62    }
63
64    Ok(schema_file.clone())
65}
66
67/// Options controlling code generation behaviour.
68#[derive(Debug, Clone, Default)]
69pub struct GenerateOptions {
70    /// Install the generated package after generation.
71    /// Python: copy to site-packages. Rust: add to workspace `Cargo.toml`.
72    pub install: bool,
73    /// Print verbose progress and IR debug output.
74    pub verbose: bool,
75    /// (Rust only) Also emit a `Cargo.toml` for the generated crate.
76    /// Default mode produces bare source files that integrate into an existing
77    /// Cargo workspace. Pass `true` when you want a self-contained crate.
78    pub standalone: bool,
79}
80
81/// Verify that all type references in the IR resolve to known definitions.
82///
83/// The schema validator already checks these, but this acts as a defense-in-depth
84/// guard so codegen never silently produces broken output from a malformed IR.
85fn validate_ir_references(ir: &SchemaIr) -> Result<()> {
86    for (model_name, model) in &ir.models {
87        for field in &model.fields {
88            match &field.field_type {
89                ResolvedFieldType::Enum { enum_name } => {
90                    if !ir.enums.contains_key(enum_name) {
91                        return Err(anyhow::anyhow!(
92                            "Model '{}' field '{}' references unknown enum '{}'",
93                            model_name,
94                            field.logical_name,
95                            enum_name
96                        ));
97                    }
98                }
99                ResolvedFieldType::Relation(rel) => {
100                    if !ir.models.contains_key(&rel.target_model) {
101                        return Err(anyhow::anyhow!(
102                            "Model '{}' field '{}' references unknown model '{}'",
103                            model_name,
104                            field.logical_name,
105                            rel.target_model
106                        ));
107                    }
108                }
109                ResolvedFieldType::CompositeType { type_name } => {
110                    if !ir.composite_types.contains_key(type_name) {
111                        return Err(anyhow::anyhow!(
112                            "Model '{}' field '{}' references unknown composite type '{}'",
113                            model_name,
114                            field.logical_name,
115                            type_name
116                        ));
117                    }
118                }
119                ResolvedFieldType::Scalar(_) => {}
120            }
121        }
122    }
123    Ok(())
124}
125
126/// Parse, validate, and if successful generate client code for the given schema.
127///
128/// `options.standalone` (Rust provider only): also write a `Cargo.toml` for the output crate.
129/// When `false` (default) the code is written without a Cargo.toml so it can be
130/// included directly in an existing Cargo workspace.
131pub fn generate_command(schema_path: &PathBuf, options: GenerateOptions) -> Result<()> {
132    let start = std::time::Instant::now();
133    let install = options.install;
134    let verbose = options.verbose;
135    let standalone = options.standalone;
136
137    let source = fs::read_to_string(schema_path)
138        .with_context(|| format!("Failed to read schema file: {}", schema_path.display()))?;
139
140    let validated = validate_schema_source(&source).map_err(|e| {
141        anyhow::anyhow!(
142            "Validation failed:\n{}",
143            e.format_with_file(&schema_path.display().to_string(), &source)
144        )
145    })?;
146    let nautilus_schema::ValidatedSchema { ast, ir } = validated;
147
148    if verbose {
149        println!("parsed {} declarations", ast.declarations.len());
150    }
151
152    validate_ir_references(&ir)?;
153
154    if verbose {
155        println!("{:#?}", ir);
156    }
157
158    if let Some(ds) = &ir.datasource {
159        if let Some(var_name) = ds
160            .url
161            .strip_prefix("env(")
162            .and_then(|s| s.strip_suffix(')'))
163        {
164            println!(
165                "{} {} {}",
166                console::style("Loaded").dim(),
167                console::style(var_name).bold(),
168                console::style("from .env").dim()
169            );
170        }
171    }
172
173    println!(
174        "{} {}",
175        console::style("Nautilus schema loaded from").dim(),
176        console::style(schema_path.display()).italic().dim()
177    );
178
179    let output_path_opt: Option<String> = ir.generator.as_ref().and_then(|g| g.output.clone());
180
181    let provider = ir
182        .generator
183        .as_ref()
184        .map(|g| g.provider.as_str())
185        .unwrap_or("nautilus-client-rs");
186
187    let is_async = ir
188        .generator
189        .as_ref()
190        .map(|g| g.interface == nautilus_schema::ir::InterfaceKind::Async)
191        .unwrap_or(false);
192
193    let recursive_type_depth = ir
194        .generator
195        .as_ref()
196        .map(|g| g.recursive_type_depth)
197        .unwrap_or(5);
198
199    let final_output: String;
200    let client_name: &str;
201
202    match provider {
203        "nautilus-client-rs" => {
204            let models = generate_all_models(&ir, is_async);
205            client_name = "Rust";
206
207            let enums_code = if !ir.enums.is_empty() {
208                Some(generate_all_enums(&ir.enums))
209            } else {
210                None
211            };
212
213            let composite_types_code = generate_all_composite_types(&ir);
214
215            // Rust integration always needs a persistent output path because
216            // `integrate_rust_package` adds a Cargo path-dependency pointing to
217            // the generated crate on disk.
218            let output_path = output_path_opt
219                .as_deref()
220                .unwrap_or("./generated")
221                .to_string();
222
223            write_rust_code(
224                &output_path,
225                &models,
226                enums_code,
227                composite_types_code,
228                &source,
229                standalone,
230            )?;
231
232            if install {
233                integrate_rust_package(&output_path, schema_path)?;
234            }
235
236            final_output = output_path;
237        }
238        "nautilus-client-py" => {
239            let models = generate_all_python_models(&ir, is_async, recursive_type_depth);
240            client_name = "Python";
241
242            let enums_code = if !ir.enums.is_empty() {
243                Some(generate_python_enums(&ir.enums))
244            } else {
245                None
246            };
247
248            let composite_types_code = generate_python_composite_types(&ir.composite_types);
249
250            let abs_path = schema_path
251                .canonicalize()
252                .unwrap_or_else(|_| schema_path.clone());
253            let schema_path_str = abs_path
254                .to_string_lossy()
255                .trim_start_matches(r"\\?\")
256                .replace('\\', "/");
257
258            let client_code =
259                python::generate_python_client(&ir.models, &schema_path_str, is_async);
260            let runtime = python_runtime_files();
261
262            match output_path_opt.as_deref() {
263                Some(output_path) => {
264                    write_python_code(
265                        output_path,
266                        &models,
267                        enums_code,
268                        composite_types_code,
269                        Some(client_code),
270                        &runtime,
271                    )?;
272                    if install {
273                        let installed = install_python_package(output_path)?;
274                        final_output = installed.display().to_string();
275                    } else {
276                        final_output = output_path.to_string();
277                    }
278                }
279                None => {
280                    if install {
281                        let tmp_dir = std::env::temp_dir().join("nautilus_codegen_tmp");
282                        let tmp_path = tmp_dir.to_string_lossy().to_string();
283
284                        write_python_code(
285                            &tmp_path,
286                            &models,
287                            enums_code,
288                            composite_types_code,
289                            Some(client_code),
290                            &runtime,
291                        )?;
292                        let installed = install_python_package(&tmp_path)?;
293                        let _ = fs::remove_dir_all(&tmp_dir);
294                        final_output = installed.display().to_string();
295                    } else {
296                        eprintln!("warning: no output path specified and --no-install given; nothing written");
297                        return Ok(());
298                    }
299                }
300            }
301        }
302        "nautilus-client-js" => {
303            let (js_models, dts_models) = generate_all_js_models(&ir);
304            client_name = "JavaScript";
305
306            let (js_enums, dts_enums) = if !ir.enums.is_empty() {
307                let (js, dts) = generate_js_enums(&ir.enums);
308                (Some(js), Some(dts))
309            } else {
310                (None, None)
311            };
312
313            let dts_composite_types = generate_js_composite_types(&ir.composite_types);
314
315            let abs_path = schema_path
316                .canonicalize()
317                .unwrap_or_else(|_| schema_path.clone());
318            let schema_path_str = abs_path
319                .to_string_lossy()
320                .trim_start_matches(r"\\?\")
321                .replace('\\', "/");
322
323            let (js_client, dts_client) = generate_js_client(&ir.models, &schema_path_str);
324            let (js_models_index, dts_models_index) = generate_js_models_index(&js_models);
325            let runtime = js_runtime_files();
326
327            match output_path_opt.as_deref() {
328                Some(output_path) => {
329                    write_js_code(
330                        output_path,
331                        &js_models,
332                        &dts_models,
333                        js_enums,
334                        dts_enums,
335                        dts_composite_types,
336                        Some(js_client),
337                        Some(dts_client),
338                        Some(js_models_index),
339                        Some(dts_models_index),
340                        &runtime,
341                    )?;
342                    if install {
343                        let installed = install_js_package(output_path, schema_path)?;
344                        final_output = installed.display().to_string();
345                    } else {
346                        final_output = output_path.to_string();
347                    }
348                }
349                None => {
350                    if install {
351                        let tmp_dir = std::env::temp_dir().join("nautilus_codegen_js_tmp");
352                        let tmp_path = tmp_dir.to_string_lossy().to_string();
353
354                        write_js_code(
355                            &tmp_path,
356                            &js_models,
357                            &dts_models,
358                            js_enums,
359                            dts_enums,
360                            dts_composite_types,
361                            Some(js_client),
362                            Some(dts_client),
363                            Some(js_models_index),
364                            Some(dts_models_index),
365                            &runtime,
366                        )?;
367                        let installed = install_js_package(&tmp_path, schema_path)?;
368                        let _ = fs::remove_dir_all(&tmp_dir);
369                        final_output = installed.display().to_string();
370                    } else {
371                        eprintln!("warning: no output path specified and --no-install given; nothing written");
372                        return Ok(());
373                    }
374                }
375            }
376        }
377        other => {
378            return Err(anyhow::anyhow!(
379                "Unsupported generator provider: '{}'. Supported: 'nautilus-client-rs', 'nautilus-client-py', 'nautilus-client-js'",
380                other
381            ));
382        }
383    }
384
385    println!(
386        "\nGenerated {} {} {} {}\n",
387        console::style(format!(
388            "Nautilus Client for {} (v{})",
389            client_name,
390            env!("CARGO_PKG_VERSION")
391        ))
392        .bold(),
393        console::style("to").dim(),
394        console::style(final_output).italic().dim(),
395        console::style(format!("({}ms)", start.elapsed().as_millis())).italic()
396    );
397
398    Ok(())
399}
400
401/// Parse and validate the schema, printing a summary. Does not generate code.
402pub fn validate_command(schema_path: &PathBuf) -> Result<()> {
403    let source = fs::read_to_string(schema_path)
404        .with_context(|| format!("Failed to read schema file: {}", schema_path.display()))?;
405
406    let ir = validate_schema_source(&source)
407        .map(|validated| validated.ir)
408        .map_err(|e| {
409            anyhow::anyhow!(
410                "Validation failed:\n{}",
411                e.format_with_file(&schema_path.display().to_string(), &source)
412            )
413        })?;
414
415    println!("models: {}, enums: {}", ir.models.len(), ir.enums.len());
416    for (name, model) in &ir.models {
417        println!("  {} ({} fields)", name, model.fields.len());
418    }
419
420    Ok(())
421}
422
423pub fn parse_schema(source: &str) -> Result<nautilus_schema::ast::Schema> {
424    parse_schema_source(source).map_err(|e| anyhow::anyhow!("{}", e))
425}
426
427/// Add the generated crate to the workspace `Cargo.toml` `[members]` array
428/// (analogous to `install_python_package` for the Python provider).
429///
430/// Walks up from `schema_path` until it finds a `Cargo.toml` that contains
431/// `[workspace]`. The member entry is expressed as a path relative to that
432/// workspace root so the result stays portable.
433fn integrate_rust_package(output_path: &str, schema_path: &Path) -> Result<()> {
434    use std::io::Write;
435
436    let workspace_toml_path = find_workspace_cargo_toml(schema_path).ok_or_else(|| {
437        anyhow::anyhow!(
438            "No workspace Cargo.toml found in '{}' or any parent directory.\n\
439            Make sure you run 'nautilus generate' from within a Cargo workspace.",
440            schema_path.display()
441        )
442    })?;
443
444    let mut content =
445        fs::read_to_string(&workspace_toml_path).context("Failed to read workspace Cargo.toml")?;
446
447    let workspace_dir = workspace_toml_path.parent().unwrap();
448
449    // Resolve the output path to an absolute path (it may be relative to cwd).
450    let output_absolute = if Path::new(output_path).is_absolute() {
451        PathBuf::from(output_path)
452    } else {
453        std::env::current_dir()
454            .context("Failed to get current directory")?
455            .join(output_path)
456    };
457    // Strip the Windows \\?\ UNC prefix when present.
458    let cleaned_output = {
459        let s = output_absolute.to_string_lossy();
460        if let Some(stripped) = s.strip_prefix(r"\\?\") {
461            PathBuf::from(stripped)
462        } else {
463            output_absolute.clone()
464        }
465    };
466
467    let member_path: String = if let Ok(rel) = cleaned_output.strip_prefix(workspace_dir) {
468        rel.to_string_lossy().replace('\\', "/")
469    } else {
470        // Fall back to the absolute path (unusual, but don't panic).
471        cleaned_output.to_string_lossy().replace('\\', "/")
472    };
473
474    if content.contains(&member_path) {
475    } else {
476        // Find the closing bracket of the `members = [...]` array and insert
477        // our entry before it. We handle both single-line and multi-line forms.
478        //
479        // Strategy: find "members" key, then find the matching `]` and inject.
480        if let Some(members_pos) = content.find("members") {
481            // Find the `[` that opens the array.
482            if let Some(bracket_open) = content[members_pos..].find('[') {
483                let open_abs = members_pos + bracket_open;
484                // Find the matching `]`.
485                if let Some(bracket_close) = content[open_abs..].find(']') {
486                    let close_abs = open_abs + bracket_close;
487                    // Insert before the closing bracket, with a trailing comma.
488                    let insert = format!(",\n    \"{}\"", member_path);
489                    // If the array is empty we don't want a leading comma.
490                    let inner = content[open_abs + 1..close_abs].trim();
491                    let insert = if inner.is_empty() {
492                        format!("\n    \"{}\"", member_path)
493                    } else {
494                        insert
495                    };
496                    content.insert_str(close_abs, &insert);
497                }
498            }
499        } else {
500            // No `members` key at all — append a new one.
501            content.push_str(&format!("\nmembers = [\n    \"{}\"]\n", member_path));
502        }
503
504        let mut file = fs::File::create(&workspace_toml_path)
505            .context("Failed to open workspace Cargo.toml for writing")?;
506        file.write_all(content.as_bytes())
507            .context("Failed to write workspace Cargo.toml")?;
508    }
509
510    Ok(())
511}
512
513/// Walk up from `start` until we find a `Cargo.toml` that contains `[workspace]`.
514pub(crate) fn find_workspace_cargo_toml(start: &Path) -> Option<PathBuf> {
515    let mut current = if start.is_file() {
516        start.parent()?
517    } else {
518        start
519    };
520    loop {
521        let candidate = current.join("Cargo.toml");
522        if candidate.exists() {
523            if let Ok(content) = fs::read_to_string(&candidate) {
524                if content.contains("[workspace]") {
525                    return Some(candidate);
526                }
527            }
528        }
529        current = current.parent()?;
530    }
531}
532
533fn detect_site_packages() -> Result<PathBuf> {
534    use std::process::Command;
535
536    let script = "import sysconfig; print(sysconfig.get_path('purelib'))";
537    for exe in &["python", "python3"] {
538        if let Ok(out) = Command::new(exe).arg("-c").arg(script).output() {
539            if out.status.success() {
540                let path_str = String::from_utf8_lossy(&out.stdout).trim().to_string();
541                if !path_str.is_empty() {
542                    return Ok(PathBuf::from(path_str));
543                }
544            }
545        }
546    }
547
548    Err(anyhow::anyhow!(
549        "Could not detect Python site-packages directory.\n\
550        Make sure Python is installed and available as 'python' or 'python3'."
551    ))
552}
553
554fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<()> {
555    fs::create_dir_all(dst)
556        .with_context(|| format!("Failed to create directory: {}", dst.display()))?;
557
558    for entry in
559        fs::read_dir(src).with_context(|| format!("Failed to read directory: {}", src.display()))?
560    {
561        let entry = entry.with_context(|| "Failed to read directory entry")?;
562        let file_type = entry
563            .file_type()
564            .with_context(|| "Failed to get file type")?;
565        let src_path = entry.path();
566        let dst_path = dst.join(entry.file_name());
567
568        if file_type.is_dir() {
569            copy_dir_recursive(&src_path, &dst_path)?;
570        } else {
571            fs::copy(&src_path, &dst_path).with_context(|| {
572                format!(
573                    "Failed to copy {} -> {}",
574                    src_path.display(),
575                    dst_path.display()
576                )
577            })?;
578        }
579    }
580    Ok(())
581}
582
583const PYTHON_GENERATED_PACKAGE_ENTRIES: &[&str] = &[
584    "__init__.py",
585    "client.py",
586    "transaction.py",
587    "py.typed",
588    "models",
589    "enums",
590    "errors",
591    "_internal",
592    "types",
593];
594
595fn clear_generated_python_package(dst: &Path) -> Result<()> {
596    for entry in PYTHON_GENERATED_PACKAGE_ENTRIES {
597        let path = dst.join(entry);
598        if path.is_dir() {
599            fs::remove_dir_all(&path).with_context(|| {
600                format!(
601                    "Failed to remove generated directory from Python install: {}",
602                    path.display()
603                )
604            })?;
605        } else if path.exists() {
606            fs::remove_file(&path).with_context(|| {
607                format!(
608                    "Failed to remove generated file from Python install: {}",
609                    path.display()
610                )
611            })?;
612        }
613    }
614    Ok(())
615}
616
617fn install_python_package_into(src: &Path, dst: &Path) -> Result<()> {
618    if dst.exists() {
619        if !dst.is_dir() {
620            return Err(anyhow::anyhow!(
621                "Python install target exists but is not a directory: {}",
622                dst.display()
623            ));
624        }
625
626        // Keep the CLI wrapper files that pip installs (`__main__.py`,
627        // `nautilus`, `nautilus.exe`) and refresh only the generated client tree.
628        clear_generated_python_package(dst)?;
629    }
630
631    copy_dir_recursive(src, dst)
632}
633
634fn install_python_package(output_path: &str) -> Result<std::path::PathBuf> {
635    let site_packages = detect_site_packages()?;
636    let src = Path::new(output_path);
637    let dst = site_packages.join("nautilus");
638
639    install_python_package_into(src, &dst)?;
640    Ok(dst)
641}
642
643/// Walk up from `schema_path` until we find a `node_modules` directory.
644fn detect_node_modules(schema_path: &Path) -> Result<PathBuf> {
645    let mut current = if schema_path.is_file() {
646        schema_path
647            .parent()
648            .ok_or_else(|| anyhow::anyhow!("Schema path has no parent directory"))?
649    } else {
650        schema_path
651    };
652
653    loop {
654        let candidate = current.join("node_modules");
655        if candidate.is_dir() {
656            return Ok(candidate);
657        }
658        current = current.parent().ok_or_else(|| {
659            anyhow::anyhow!(
660                "No node_modules directory found in '{}' or any parent directory.\n\
661                Make sure you run 'nautilus generate' from within a Node.js project \
662                (i.e. a directory with node_modules).",
663                schema_path.display()
664            )
665        })?;
666    }
667}
668
669fn install_js_package(output_path: &str, schema_path: &Path) -> Result<std::path::PathBuf> {
670    let node_modules = detect_node_modules(schema_path)?;
671    let src = Path::new(output_path);
672    let dst = node_modules.join("nautilus");
673
674    if dst.exists() {
675        fs::remove_dir_all(&dst).with_context(|| {
676            format!(
677                "Failed to remove existing installation at: {}",
678                dst.display()
679            )
680        })?;
681    }
682
683    copy_dir_recursive(src, &dst)?;
684
685    Ok(dst)
686}
687
688#[cfg(test)]
689mod tests {
690    use super::install_python_package_into;
691
692    #[test]
693    fn python_install_preserves_cli_wrapper_files() {
694        let src_root = tempfile::TempDir::new().expect("temp src dir");
695        let dst_root = tempfile::TempDir::new().expect("temp dst dir");
696        let src = src_root.path().join("generated");
697        let dst = dst_root.path().join("nautilus");
698
699        std::fs::create_dir_all(src.join("models")).expect("create generated models dir");
700        std::fs::write(src.join("__init__.py"), "from .client import Nautilus\n")
701            .expect("write generated __init__.py");
702        std::fs::write(src.join("client.py"), "class Nautilus: ...\n")
703            .expect("write generated client.py");
704        std::fs::write(src.join("py.typed"), "").expect("write generated py.typed");
705        std::fs::write(src.join("models").join("user.py"), "class User: ...\n")
706            .expect("write generated model");
707
708        std::fs::create_dir_all(dst.join("models")).expect("create installed models dir");
709        std::fs::write(dst.join("__main__.py"), "def main(): ...\n")
710            .expect("write cli __main__.py");
711        std::fs::write(dst.join("nautilus"), "binary").expect("write cli binary");
712        std::fs::write(dst.join("nautilus.exe"), "binary").expect("write cli windows binary");
713        std::fs::write(dst.join("__init__.py"), "old generated package\n")
714            .expect("write stale generated __init__.py");
715        std::fs::write(dst.join("client.py"), "old client\n").expect("write stale client.py");
716        std::fs::write(dst.join("models").join("legacy.py"), "old model\n")
717            .expect("write stale model");
718
719        install_python_package_into(&src, &dst).expect("overlay install should succeed");
720
721        assert_eq!(
722            std::fs::read_to_string(dst.join("__main__.py")).expect("read cli __main__.py"),
723            "def main(): ...\n"
724        );
725        assert_eq!(
726            std::fs::read_to_string(dst.join("nautilus")).expect("read cli binary"),
727            "binary"
728        );
729        assert_eq!(
730            std::fs::read_to_string(dst.join("nautilus.exe")).expect("read cli windows binary"),
731            "binary"
732        );
733        assert_eq!(
734            std::fs::read_to_string(dst.join("__init__.py")).expect("read generated __init__.py"),
735            "from .client import Nautilus\n"
736        );
737        assert_eq!(
738            std::fs::read_to_string(dst.join("client.py")).expect("read generated client.py"),
739            "class Nautilus: ...\n"
740        );
741        assert!(
742            !dst.join("models").join("legacy.py").exists(),
743            "stale generated model should be removed"
744        );
745        assert!(
746            dst.join("models").join("user.py").exists(),
747            "new generated model should be installed"
748        );
749    }
750
751    #[test]
752    fn python_install_removes_generated_entries_absent_from_new_output() {
753        let src_root = tempfile::TempDir::new().expect("temp src dir");
754        let dst_root = tempfile::TempDir::new().expect("temp dst dir");
755        let src = src_root.path().join("generated");
756        let dst = dst_root.path().join("nautilus");
757
758        std::fs::create_dir_all(src.join("_internal")).expect("create generated runtime dir");
759        std::fs::write(src.join("__init__.py"), "fresh init\n").expect("write generated init");
760        std::fs::write(src.join("_internal").join("__init__.py"), "").expect("write runtime init");
761
762        std::fs::create_dir_all(dst.join("types")).expect("create stale types dir");
763        std::fs::write(dst.join("types").join("__init__.py"), "stale types\n")
764            .expect("write stale types init");
765
766        install_python_package_into(&src, &dst).expect("overlay install should succeed");
767
768        assert!(
769            !dst.join("types").exists(),
770            "stale generated types dir should be removed when no longer generated"
771        );
772        assert!(
773            dst.join("_internal").join("__init__.py").exists(),
774            "fresh generated runtime files should be installed"
775        );
776    }
777}