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