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 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 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 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
408pub 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
434fn 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 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 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 cleaned_output.to_string_lossy().replace('\\', "/")
479 };
480
481 if content.contains(&member_path) {
482 } else {
483 if let Some(members_pos) = content.find("members") {
488 if let Some(bracket_open) = content[members_pos..].find('[') {
490 let open_abs = members_pos + bracket_open;
491 if let Some(bracket_close) = content[open_abs..].find(']') {
493 let close_abs = open_abs + bracket_close;
494 let insert = format!(",\n \"{}\"", member_path);
496 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 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
520pub(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
609fn 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}