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
590fn install_python_package(output_path: &str) -> Result<std::path::PathBuf> {
591    let site_packages = detect_site_packages()?;
592    let src = Path::new(output_path);
593    let dst = site_packages.join("nautilus");
594
595    if dst.exists() {
596        fs::remove_dir_all(&dst).with_context(|| {
597            format!(
598                "Failed to remove existing installation at: {}",
599                dst.display()
600            )
601        })?;
602    }
603
604    copy_dir_recursive(src, &dst)?;
605
606    Ok(dst)
607}
608
609/// Walk up from `schema_path` until we find a `node_modules` directory.
610fn detect_node_modules(schema_path: &Path) -> Result<PathBuf> {
611    let mut current = if schema_path.is_file() {
612        schema_path
613            .parent()
614            .ok_or_else(|| anyhow::anyhow!("Schema path has no parent directory"))?
615    } else {
616        schema_path
617    };
618
619    loop {
620        let candidate = current.join("node_modules");
621        if candidate.is_dir() {
622            return Ok(candidate);
623        }
624        current = current.parent().ok_or_else(|| {
625            anyhow::anyhow!(
626                "No node_modules directory found in '{}' or any parent directory.\n\
627                Make sure you run 'nautilus generate' from within a Node.js project \
628                (i.e. a directory with node_modules).",
629                schema_path.display()
630            )
631        })?;
632    }
633}
634
635fn install_js_package(output_path: &str, schema_path: &Path) -> Result<std::path::PathBuf> {
636    let node_modules = detect_node_modules(schema_path)?;
637    let src = Path::new(output_path);
638    let dst = node_modules.join("nautilus");
639
640    if dst.exists() {
641        fs::remove_dir_all(&dst).with_context(|| {
642            format!(
643                "Failed to remove existing installation at: {}",
644                dst.display()
645            )
646        })?;
647    }
648
649    copy_dir_recursive(src, &dst)?;
650
651    Ok(dst)
652}