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::{validate_schema, Lexer, Parser as SchemaParser};
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 current_dir = std::env::current_dir().context("Failed to get current directory")?;
45
46 let mut nautilus_files: Vec<PathBuf> = fs::read_dir(¤t_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#[derive(Debug, Clone, Default)]
76pub struct GenerateOptions {
77 pub install: bool,
80 pub verbose: bool,
82 pub standalone: bool,
86}
87
88fn 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
133pub 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 ast = parse_schema(&source)?;
148
149 if verbose {
150 println!("parsed {} declarations", ast.declarations.len());
151 }
152
153 let ir = validate_schema(ast).map_err(|e| {
154 anyhow::anyhow!(
155 "Validation failed:\n{}",
156 e.format_with_file(&schema_path.display().to_string(), &source)
157 )
158 })?;
159
160 validate_ir_references(&ir)?;
161
162 if verbose {
163 println!("{:#?}", ir);
164 }
165
166 if let Some(ds) = &ir.datasource {
167 if let Some(var_name) = ds
168 .url
169 .strip_prefix("env(")
170 .and_then(|s| s.strip_suffix(')'))
171 {
172 println!(
173 "{} {} {}",
174 console::style("Loaded").dim(),
175 console::style(var_name).bold(),
176 console::style("from .env").dim()
177 );
178 }
179 }
180
181 println!(
182 "{} {}",
183 console::style("Nautilus schema loaded from").dim(),
184 console::style(schema_path.display()).italic().dim()
185 );
186
187 let output_path_opt: Option<String> = ir.generator.as_ref().and_then(|g| g.output.clone());
188
189 let provider = ir
190 .generator
191 .as_ref()
192 .map(|g| g.provider.as_str())
193 .unwrap_or("nautilus-client-rs");
194
195 let is_async = ir
196 .generator
197 .as_ref()
198 .map(|g| g.interface == nautilus_schema::ir::InterfaceKind::Async)
199 .unwrap_or(false); let recursive_type_depth = ir
202 .generator
203 .as_ref()
204 .map(|g| g.recursive_type_depth)
205 .unwrap_or(5);
206
207 let final_output: String;
208 let client_name: &str;
209
210 match provider {
211 "nautilus-client-rs" => {
212 let models = generate_all_models(&ir, is_async);
213 client_name = "Rust";
214
215 let enums_code = if !ir.enums.is_empty() {
216 Some(generate_all_enums(&ir.enums))
217 } else {
218 None
219 };
220
221 let composite_types_code = generate_all_composite_types(&ir);
222
223 let output_path = output_path_opt
227 .as_deref()
228 .unwrap_or("./generated")
229 .to_string();
230
231 write_rust_code(
232 &output_path,
233 &models,
234 enums_code,
235 composite_types_code,
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(
273 output_path,
274 &models,
275 enums_code,
276 composite_types_code,
277 Some(client_code),
278 &runtime,
279 )?;
280 if install {
281 let installed = install_python_package(output_path)?;
282 final_output = installed.display().to_string();
283 } else {
284 final_output = output_path.to_string();
285 }
286 }
287 None => {
288 if install {
291 let tmp_dir = std::env::temp_dir().join("nautilus_codegen_tmp");
292 let tmp_path = tmp_dir.to_string_lossy().to_string();
293
294 write_python_code(
295 &tmp_path,
296 &models,
297 enums_code,
298 composite_types_code,
299 Some(client_code),
300 &runtime,
301 )?;
302 let installed = install_python_package(&tmp_path)?;
303 let _ = fs::remove_dir_all(&tmp_dir);
304 final_output = installed.display().to_string();
305 } else {
306 eprintln!("warning: no output path specified and --no-install given; nothing written");
307 return Ok(());
308 }
309 }
310 }
311 }
312 "nautilus-client-js" => {
313 let (js_models, dts_models) = generate_all_js_models(&ir);
314 client_name = "JavaScript";
315
316 let (js_enums, dts_enums) = if !ir.enums.is_empty() {
317 let (js, dts) = generate_js_enums(&ir.enums);
318 (Some(js), Some(dts))
319 } else {
320 (None, None)
321 };
322
323 let dts_composite_types = generate_js_composite_types(&ir.composite_types);
324
325 let abs_path = schema_path
326 .canonicalize()
327 .unwrap_or_else(|_| schema_path.clone());
328 let schema_path_str = abs_path
329 .to_string_lossy()
330 .trim_start_matches(r"\\?\")
331 .replace('\\', "/");
332
333 let (js_client, dts_client) = generate_js_client(&ir.models, &schema_path_str);
334 let (js_models_index, dts_models_index) = generate_js_models_index(&js_models);
335 let runtime = js_runtime_files();
336
337 match output_path_opt.as_deref() {
338 Some(output_path) => {
339 write_js_code(
340 output_path,
341 &js_models,
342 &dts_models,
343 js_enums,
344 dts_enums,
345 dts_composite_types,
346 Some(js_client),
347 Some(dts_client),
348 Some(js_models_index),
349 Some(dts_models_index),
350 &runtime,
351 )?;
352 if install {
353 let installed = install_js_package(output_path, schema_path)?;
354 final_output = installed.display().to_string();
355 } else {
356 final_output = output_path.to_string();
357 }
358 }
359 None => {
360 if install {
361 let tmp_dir = std::env::temp_dir().join("nautilus_codegen_js_tmp");
362 let tmp_path = tmp_dir.to_string_lossy().to_string();
363
364 write_js_code(
365 &tmp_path,
366 &js_models,
367 &dts_models,
368 js_enums,
369 dts_enums,
370 dts_composite_types,
371 Some(js_client),
372 Some(dts_client),
373 Some(js_models_index),
374 Some(dts_models_index),
375 &runtime,
376 )?;
377 let installed = install_js_package(&tmp_path, schema_path)?;
378 let _ = fs::remove_dir_all(&tmp_dir);
379 final_output = installed.display().to_string();
380 } else {
381 eprintln!("warning: no output path specified and --no-install given; nothing written");
382 return Ok(());
383 }
384 }
385 }
386 }
387 other => {
388 return Err(anyhow::anyhow!(
389 "Unsupported generator provider: '{}'. Supported: 'nautilus-client-rs', 'nautilus-client-py', 'nautilus-client-js'",
390 other
391 ));
392 }
393 }
394
395 println!(
396 "\nGenerated {} {} {} {}\n",
397 console::style(format!(
398 "Nautilus Client for {} (v{})",
399 client_name,
400 env!("CARGO_PKG_VERSION")
401 ))
402 .bold(),
403 console::style("to").dim(),
404 console::style(final_output).italic().dim(),
405 console::style(format!("({}ms)", start.elapsed().as_millis())).italic()
406 );
407
408 Ok(())
409}
410
411pub fn validate_command(schema_path: &PathBuf) -> Result<()> {
413 let source = fs::read_to_string(schema_path)
414 .with_context(|| format!("Failed to read schema file: {}", schema_path.display()))?;
415
416 let ast = parse_schema(&source)?;
417
418 let ir = validate_schema(ast).map_err(|e| {
419 anyhow::anyhow!(
420 "Validation failed:\n{}",
421 e.format_with_file(&schema_path.display().to_string(), &source)
422 )
423 })?;
424
425 println!("models: {}, enums: {}", ir.models.len(), ir.enums.len());
426 for (name, model) in &ir.models {
427 println!(" {} ({} fields)", name, model.fields.len());
428 }
429
430 Ok(())
431}
432
433pub fn parse_schema(source: &str) -> Result<nautilus_schema::ast::Schema> {
434 let mut lexer = Lexer::new(source);
435 let mut tokens = Vec::new();
436 loop {
437 let token = lexer
438 .next_token()
439 .map_err(|e| anyhow::anyhow!("Lexer error: {}", e))?;
440 let is_eof = matches!(token.kind, nautilus_schema::TokenKind::Eof);
441 tokens.push(token);
442 if is_eof {
443 break;
444 }
445 }
446 SchemaParser::new(&tokens, source)
447 .parse_schema()
448 .map_err(|e| anyhow::anyhow!("Parser error: {}", e))
449}
450
451fn integrate_rust_package(output_path: &str, schema_path: &Path) -> Result<()> {
458 use std::io::Write;
459
460 let workspace_toml_path = find_workspace_cargo_toml(schema_path).ok_or_else(|| {
461 anyhow::anyhow!(
462 "No workspace Cargo.toml found in '{}' or any parent directory.\n\
463 Make sure you run 'nautilus generate' from within a Cargo workspace.",
464 schema_path.display()
465 )
466 })?;
467
468 let mut content =
469 fs::read_to_string(&workspace_toml_path).context("Failed to read workspace Cargo.toml")?;
470
471 let workspace_dir = workspace_toml_path.parent().unwrap();
472
473 let output_absolute = if Path::new(output_path).is_absolute() {
475 PathBuf::from(output_path)
476 } else {
477 std::env::current_dir()
478 .context("Failed to get current directory")?
479 .join(output_path)
480 };
481 let cleaned_output = {
483 let s = output_absolute.to_string_lossy();
484 if let Some(stripped) = s.strip_prefix(r"\\?\") {
485 PathBuf::from(stripped)
486 } else {
487 output_absolute.clone()
488 }
489 };
490
491 let member_path: String = if let Ok(rel) = cleaned_output.strip_prefix(workspace_dir) {
492 rel.to_string_lossy().replace('\\', "/")
493 } else {
494 cleaned_output.to_string_lossy().replace('\\', "/")
496 };
497
498 if content.contains(&member_path) {
499 } else {
501 if let Some(members_pos) = content.find("members") {
506 if let Some(bracket_open) = content[members_pos..].find('[') {
508 let open_abs = members_pos + bracket_open;
509 if let Some(bracket_close) = content[open_abs..].find(']') {
511 let close_abs = open_abs + bracket_close;
512 let insert = format!(",\n \"{}\"", member_path);
514 let inner = content[open_abs + 1..close_abs].trim();
516 let insert = if inner.is_empty() {
517 format!("\n \"{}\"", member_path)
518 } else {
519 insert
520 };
521 content.insert_str(close_abs, &insert);
522 }
523 }
524 } else {
525 content.push_str(&format!("\nmembers = [\n \"{}\"]\n", member_path));
527 }
528
529 let mut file = fs::File::create(&workspace_toml_path)
530 .context("Failed to open workspace Cargo.toml for writing")?;
531 file.write_all(content.as_bytes())
532 .context("Failed to write workspace Cargo.toml")?;
533 }
534
535 Ok(())
536}
537
538pub(crate) fn find_workspace_cargo_toml(start: &Path) -> Option<PathBuf> {
540 let mut current = if start.is_file() {
541 start.parent()?
542 } else {
543 start
544 };
545 loop {
546 let candidate = current.join("Cargo.toml");
547 if candidate.exists() {
548 if let Ok(content) = fs::read_to_string(&candidate) {
549 if content.contains("[workspace]") {
550 return Some(candidate);
551 }
552 }
553 }
554 current = current.parent()?;
555 }
556}
557
558fn detect_site_packages() -> Result<PathBuf> {
559 use std::process::Command;
560
561 let script = "import sysconfig; print(sysconfig.get_path('purelib'))";
562 for exe in &["python", "python3"] {
563 if let Ok(out) = Command::new(exe).arg("-c").arg(script).output() {
564 if out.status.success() {
565 let path_str = String::from_utf8_lossy(&out.stdout).trim().to_string();
566 if !path_str.is_empty() {
567 return Ok(PathBuf::from(path_str));
568 }
569 }
570 }
571 }
572
573 Err(anyhow::anyhow!(
574 "Could not detect Python site-packages directory.\n\
575 Make sure Python is installed and available as 'python' or 'python3'."
576 ))
577}
578
579fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<()> {
580 fs::create_dir_all(dst)
581 .with_context(|| format!("Failed to create directory: {}", dst.display()))?;
582
583 for entry in
584 fs::read_dir(src).with_context(|| format!("Failed to read directory: {}", src.display()))?
585 {
586 let entry = entry.with_context(|| "Failed to read directory entry")?;
587 let file_type = entry
588 .file_type()
589 .with_context(|| "Failed to get file type")?;
590 let src_path = entry.path();
591 let dst_path = dst.join(entry.file_name());
592
593 if file_type.is_dir() {
594 copy_dir_recursive(&src_path, &dst_path)?;
595 } else {
596 fs::copy(&src_path, &dst_path).with_context(|| {
597 format!(
598 "Failed to copy {} → {}",
599 src_path.display(),
600 dst_path.display()
601 )
602 })?;
603 }
604 }
605 Ok(())
606}
607
608fn install_python_package(output_path: &str) -> Result<std::path::PathBuf> {
609 let site_packages = detect_site_packages()?;
610 let src = Path::new(output_path);
611 let dst = site_packages.join("nautilus");
612
613 if dst.exists() {
614 fs::remove_dir_all(&dst).with_context(|| {
615 format!(
616 "Failed to remove existing installation at: {}",
617 dst.display()
618 )
619 })?;
620 }
621
622 copy_dir_recursive(src, &dst)?;
623
624 Ok(dst)
625}
626
627fn detect_node_modules(schema_path: &Path) -> Result<PathBuf> {
629 let mut current = if schema_path.is_file() {
630 schema_path
631 .parent()
632 .ok_or_else(|| anyhow::anyhow!("Schema path has no parent directory"))?
633 } else {
634 schema_path
635 };
636
637 loop {
638 let candidate = current.join("node_modules");
639 if candidate.is_dir() {
640 return Ok(candidate);
641 }
642 current = current.parent().ok_or_else(|| {
643 anyhow::anyhow!(
644 "No node_modules directory found in '{}' or any parent directory.\n\
645 Make sure you run 'nautilus generate' from within a Node.js project \
646 (i.e. a directory with node_modules).",
647 schema_path.display()
648 )
649 })?;
650 }
651}
652
653fn install_js_package(output_path: &str, schema_path: &Path) -> Result<std::path::PathBuf> {
654 let node_modules = detect_node_modules(schema_path)?;
655 let src = Path::new(output_path);
656 let dst = node_modules.join("nautilus");
657
658 if dst.exists() {
659 fs::remove_dir_all(&dst).with_context(|| {
660 format!(
661 "Failed to remove existing installation at: {}",
662 dst.display()
663 )
664 })?;
665 }
666
667 copy_dir_recursive(src, &dst)?;
668
669 Ok(dst)
670}