1#![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
37pub 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#[derive(Debug, Clone, Default)]
69pub struct GenerateOptions {
70 pub install: bool,
73 pub verbose: bool,
75 pub standalone: bool,
79}
80
81fn 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
126pub 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 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
401pub 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
427fn 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 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 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 cleaned_output.to_string_lossy().replace('\\', "/")
472 };
473
474 if content.contains(&member_path) {
475 } else {
476 if let Some(members_pos) = content.find("members") {
481 if let Some(bracket_open) = content[members_pos..].find('[') {
483 let open_abs = members_pos + bracket_open;
484 if let Some(bracket_close) = content[open_abs..].find(']') {
486 let close_abs = open_abs + bracket_close;
487 let insert = format!(",\n \"{}\"", member_path);
489 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 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
513pub(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 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
643fn 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}