Skip to main content

cargo_teaql/
lib.rs

1pub mod cli;
2pub mod config;
3pub mod eval;
4pub mod generator;
5pub mod service;
6
7use std::{
8    ffi::OsString,
9    fs,
10    path::{Path, PathBuf},
11};
12
13use anyhow::{Context, Result, bail};
14use clap::Parser;
15use cli::{CheckArgs, Cli, Commands, EvalArgs, GenServiceArgs, GenerateArgs, InstallLinksArgs, ServiceArgs};
16use config::{ConfigOverrides, EnvConfig, TeaqlConfig, config_file_path};
17
18pub fn run_from_env() -> Result<()> {
19    let args: Vec<OsString> = std::env::args_os().collect();
20    run_with_args(args)
21}
22
23pub fn run_with_args<I, T>(args: I) -> Result<()>
24where
25    I: IntoIterator<Item = T>,
26    T: Into<OsString>,
27{
28    let args: Vec<OsString> = args.into_iter().map(Into::into).collect();
29    let argv = rewrite_args_for_alias(args);
30    run_cli(Cli::parse_from(argv))
31}
32
33pub fn run_cli(cli: Cli) -> Result<()> {
34    match cli.command {
35        Commands::Config => {
36            let config_path = config_file_path()?;
37            let existing = TeaqlConfig::load()?;
38            let updated = config::run_wizard(existing)?;
39            updated.save(&config_path)?;
40            println!("saved config to {}", config_path.display());
41        }
42        Commands::ShowConfig => {
43            let config_path = config_file_path()?;
44            let config = TeaqlConfig::load()?;
45            println!("config_path: {}", config_path.display());
46            println!("{}", serde_yaml::to_string(&config)?);
47        }
48        Commands::InstallLinks(args) => install_links(args)?,
49        Commands::GenLib(args) => run_generate(args, Some("rust-lib"), cli.cwd)?,
50        Commands::GenDoc(args) => run_generate(args, Some("doc"), cli.cwd)?,
51        Commands::GenModel(args) => run_generate(args, Some("frontend"), cli.cwd)?,
52        Commands::GenWorkspace(args) => run_generate(args, Some("rust-app-console"), cli.cwd)?,
53        Commands::GenService(args) => run_generate(args.generate_args, Some(&args.service), cli.cwd)?,
54        Commands::Version(args) => run_version(args, cli.cwd)?,
55        Commands::ListServices(args) => run_list_services(args, cli.cwd)?,
56        Commands::Ping(args) => run_ping(args, cli.cwd)?,
57        Commands::Eval(args) => {
58            let code = run_eval(args, cli.cwd)?;
59            std::process::exit(code);
60        }
61        Commands::Check(args) => {
62            let code = run_check(args, cli.cwd)?;
63            std::process::exit(code);
64        }
65    }
66
67    Ok(())
68}
69
70fn run_eval(args: EvalArgs, cwd: PathBuf) -> Result<i32> {
71    let config = TeaqlConfig::load()?;
72    let env = EnvConfig::from_env();
73    let overrides = ConfigOverrides {
74        endpoint_prefix: args.endpoint_prefix.clone(),
75        service_url: args.service_url.clone(),
76        api_key: None,
77        build_dir: None,
78        timeout_seconds: args.timeout_seconds,
79    };
80    let resolved = config.resolve(overrides, &env, &cwd);
81    eval::evaluate(&args.input, &args, &resolved)
82}
83
84fn run_generate(args: GenerateArgs, scope: Option<&str>, cwd: PathBuf) -> Result<()> {
85    let config = TeaqlConfig::load()?;
86    let env = EnvConfig::from_env();
87    let overrides = ConfigOverrides {
88        endpoint_prefix: args.endpoint_prefix,
89        service_url: args.service_url,
90        api_key: args.api_key,
91        build_dir: args.output,
92        timeout_seconds: args.timeout_seconds,
93    };
94    let resolved = config.resolve(overrides, &env, &cwd);
95    generator::generate(&args.input, scope, &resolved)
96}
97
98fn run_version(args: ServiceArgs, cwd: PathBuf) -> Result<()> {
99    let config = TeaqlConfig::load()?;
100    let env = EnvConfig::from_env();
101    let overrides = ConfigOverrides {
102        endpoint_prefix: args.endpoint_prefix,
103        service_url: args.service_url,
104        api_key: None,
105        build_dir: None,
106        timeout_seconds: args.timeout_seconds,
107    };
108    let resolved = config.resolve(overrides, &env, &cwd);
109    service::print_version(&resolved)
110}
111
112fn run_list_services(args: ServiceArgs, cwd: PathBuf) -> Result<()> {
113    let config = TeaqlConfig::load()?;
114    let env = EnvConfig::from_env();
115    let overrides = ConfigOverrides {
116        endpoint_prefix: args.endpoint_prefix,
117        service_url: args.service_url,
118        api_key: None,
119        build_dir: None,
120        timeout_seconds: args.timeout_seconds,
121    };
122    let resolved = config.resolve(overrides, &env, &cwd);
123    service::list_services(&resolved)
124}
125
126fn run_ping(args: ServiceArgs, cwd: PathBuf) -> Result<()> {
127    let config = TeaqlConfig::load()?;
128    let env = EnvConfig::from_env();
129    let overrides = ConfigOverrides {
130        endpoint_prefix: args.endpoint_prefix,
131        service_url: args.service_url,
132        api_key: args.api_key,
133        build_dir: Some(std::env::temp_dir().join("teaql-ping")),
134        timeout_seconds: args.timeout_seconds,
135    };
136    let resolved = config.resolve(overrides, &env, &cwd);
137    service::ping(&resolved)
138}
139
140fn rewrite_args_for_alias(mut args: Vec<OsString>) -> Vec<OsString> {
141    let alias_name = args
142        .first()
143        .and_then(|arg| Path::new(arg).file_name())
144        .and_then(|name| name.to_str())
145        .map(String::from);
146
147    if let Some(ref program_name) = alias_name {
148        if let Some(subcommand) = alias_subcommand(program_name) {
149            if args
150                .get(1)
151                .and_then(|arg| arg.to_str())
152                .is_some_and(|arg| arg == cargo_invoked_subcommand(program_name))
153            {
154                args.remove(1);
155            }
156            args[0] = OsString::from("teaql");
157            args.insert(1, OsString::from(subcommand));
158            // Cargo passes the subcommand name (without the "cargo-" prefix)
159            // as the second argument, e.g.:
160            //   "cargo teaql-version" → argv[1] = "teaql-version"
161            // After rewriting, this becomes redundant; strip it.
162            let cargo_arg = program_name.strip_prefix("cargo-").unwrap_or(program_name);
163            if args.len() > 2 && args[2] == cargo_arg {
164                args.remove(2);
165            }
166        }
167    }
168    args
169}
170
171fn alias_subcommand(program_name: &str) -> Option<&'static str> {
172    match program_name {
173        "cargo-teaql-gen-lib" => Some("gen-lib"),
174        "cargo-teaql-gen-doc" => Some("gen-doc"),
175        "cargo-teaql-gen-model" => Some("gen-model"),
176        "cargo-teaql-gen-workspace" => Some("gen-workspace"),
177        "cargo-teaql-version" => Some("version"),
178        "cargo-teaql-ping" => Some("ping"),
179        "cargo-teaql-show-config" => Some("show-config"),
180        "cargo-teaql-config" => Some("config"),
181        "cargo-teaql-eval" => Some("eval"),
182        "cargo-teaql-check" => Some("check"),
183        _ => None,
184    }
185}
186
187fn cargo_invoked_subcommand(program_name: &str) -> &str {
188    program_name.strip_prefix("cargo-").unwrap_or(program_name)
189}
190
191fn install_links(args: InstallLinksArgs) -> Result<()> {
192    #[cfg(not(unix))]
193    {
194        let _ = args;
195        bail!("install-links currently supports Unix-style symlinks only");
196    }
197
198    #[cfg(unix)]
199    {
200        use std::os::unix::fs::symlink;
201
202        let current_exe = std::env::current_exe().context("failed to locate current executable")?;
203        let target = fs::canonicalize(&current_exe)
204            .with_context(|| format!("failed to resolve {}", current_exe.display()))?;
205        let install_dir = match args.dir {
206            Some(dir) => dir,
207            None => current_exe
208                .parent()
209                .context("current executable has no parent directory")?
210                .to_path_buf(),
211        };
212
213        fs::create_dir_all(&install_dir)
214            .with_context(|| format!("failed to create {}", install_dir.display()))?;
215
216        for alias in link_names() {
217            let link_path = install_dir.join(alias);
218            if link_path.exists() || symlink_metadata_exists(&link_path) {
219                if points_to_target(&link_path, &target)? {
220                    println!("exists {}", link_path.display());
221                    continue;
222                }
223
224                if !args.force {
225                    bail!(
226                        "refusing to overwrite existing path without --force: {}",
227                        link_path.display()
228                    );
229                }
230
231                fs::remove_file(&link_path)
232                    .with_context(|| format!("failed to remove {}", link_path.display()))?;
233            }
234
235            symlink(&target, &link_path).with_context(|| {
236                format!(
237                    "failed to create symlink {} -> {}",
238                    link_path.display(),
239                    target.display()
240                )
241            })?;
242            println!("linked {} -> {}", link_path.display(), target.display());
243        }
244    }
245
246    Ok(())
247}
248
249fn link_names() -> &'static [&'static str] {
250    &[
251        "teaql",
252        "cargo-teaql-gen-lib",
253        "cargo-teaql-gen-doc",
254        "cargo-teaql-gen-model",
255        "cargo-teaql-gen-workspace",
256        "cargo-teaql-version",
257        "cargo-teaql-show-config",
258        "cargo-teaql-ping",
259        "cargo-teaql-config",
260        "cargo-teaql-eval",
261        "cargo-teaql-check",
262    ]
263}
264
265fn symlink_metadata_exists(path: &Path) -> bool {
266    fs::symlink_metadata(path).is_ok()
267}
268
269fn points_to_target(link_path: &Path, target: &Path) -> Result<bool> {
270    let metadata = match fs::symlink_metadata(link_path) {
271        Ok(metadata) => metadata,
272        Err(_) => return Ok(false),
273    };
274    if !metadata.file_type().is_symlink() {
275        return Ok(false);
276    }
277
278    let linked = fs::canonicalize(link_path)
279        .with_context(|| format!("failed to resolve {}", link_path.display()))?;
280    Ok(linked == target)
281}
282
283fn run_check(args: CheckArgs, cwd: PathBuf) -> Result<i32> {
284    use std::io::{BufRead, BufReader};
285
286    let mut command = std::process::Command::new("cargo");
287    command.arg("check").arg("--message-format=json");
288    for cargo_arg in args.cargo_args {
289        command.arg(cargo_arg);
290    }
291    command.current_dir(&cwd);
292    command.stdout(std::process::Stdio::piped());
293
294    let mut child = command.spawn().context("failed to spawn cargo check")?;
295    let stdout = child.stdout.take().context("failed to take stdout")?;
296    let reader = BufReader::new(stdout);
297
298    for line_res in reader.lines() {
299        let line = match line_res {
300            Ok(l) => l,
301            Err(_) => break,
302        };
303
304        if let Ok(cargo_json) = serde_json::from_str::<CargoJson>(&line) {
305            if cargo_json.reason == "compiler-message" {
306                if let Some(diagnostic) = cargo_json.message {
307                    let mut mapped = false;
308                    for span in &diagnostic.spans {
309                        if span.is_primary {
310                            if let Some((xml_path, xml_line)) = try_map_span(&cwd, span) {
311                                print_mapped_error(
312                                    &diagnostic.level,
313                                    &diagnostic.message,
314                                    &xml_path,
315                                    xml_line,
316                                    span,
317                                    &cwd,
318                                );
319                                mapped = true;
320                                break;
321                            }
322                        }
323                    }
324                    if !mapped {
325                        if let Some(rendered) = diagnostic.rendered {
326                            eprint!("{}", rendered);
327                        }
328                    }
329                }
330            }
331        }
332    }
333
334    let status = child.wait()?;
335    Ok(status.code().unwrap_or(1))
336}
337
338#[derive(serde::Deserialize, Debug)]
339struct CargoJson {
340    reason: String,
341    message: Option<Diagnostic>,
342}
343
344#[derive(serde::Deserialize, Debug)]
345struct Diagnostic {
346    message: String,
347    level: String,
348    spans: Vec<DiagnosticSpan>,
349    rendered: Option<String>,
350}
351
352#[derive(serde::Deserialize, Debug)]
353struct DiagnosticSpan {
354    file_name: String,
355    line_start: usize,
356    column_start: usize,
357    is_primary: bool,
358}
359
360fn try_map_span(cwd: &Path, span: &DiagnosticSpan) -> Option<(PathBuf, usize)> {
361    let file_path = cwd.join(&span.file_name);
362    if !file_path.exists() {
363        return None;
364    }
365    let content = std::fs::read_to_string(&file_path).ok()?;
366    let lines: Vec<&str> = content.lines().collect();
367
368    let mut current_idx = span.line_start.checked_sub(1)?;
369    while current_idx < lines.len() {
370        let line = lines[current_idx].trim();
371        if line.starts_with("// @source ") {
372            let parts = line.strip_prefix("// @source ")?;
373            let mut parts_split = parts.split(':');
374            let path_str = parts_split.next()?;
375            let line_str = parts_split.next()?;
376            let line_num = line_str.parse::<usize>().ok()?;
377            return Some((PathBuf::from(path_str), line_num));
378        }
379        if current_idx == 0 {
380            break;
381        }
382        current_idx -= 1;
383    }
384    None
385}
386
387fn print_mapped_error(
388    level: &str,
389    message: &str,
390    xml_path: &Path,
391    xml_line: usize,
392    span: &DiagnosticSpan,
393    cwd: &Path,
394) {
395    eprintln!("{}: {}", level, message);
396    eprintln!("  --> {}:{}", xml_path.display(), xml_line);
397
398    let full_xml_path = cwd.join(xml_path);
399    if full_xml_path.exists() {
400        if let Ok(content) = std::fs::read_to_string(&full_xml_path) {
401            let lines: Vec<&str> = content.lines().collect();
402            if xml_line > 0 && xml_line <= lines.len() {
403                let line_content = lines[xml_line - 1];
404                eprintln!("   |");
405                eprintln!("{:3} | {}", xml_line, line_content);
406                eprintln!("   | (error generated from here)");
407            }
408        }
409    }
410    eprintln!("   =");
411    eprintln!(
412        "   = note: generated Rust code in {}:{}:{} failed to compile",
413        span.file_name, span.line_start, span.column_start
414    );
415    eprintln!();
416}
417
418#[cfg(test)]
419mod tests {
420    use super::*;
421
422    #[test]
423    fn rewrites_alias_binary_name_to_subcommand() {
424        let args = vec![
425            OsString::from("/tmp/bin/cargo-teaql-gen-lib"),
426            OsString::from("model.yml"),
427            OsString::from("--cwd"),
428            OsString::from("/workspace"),
429        ];
430
431        let rewritten = rewrite_args_for_alias(args);
432
433        assert_eq!(rewritten[0], OsString::from("teaql"));
434        assert_eq!(rewritten[1], OsString::from("gen-lib"));
435        assert_eq!(rewritten[2], OsString::from("model.yml"));
436        assert_eq!(rewritten[3], OsString::from("--cwd"));
437        assert_eq!(rewritten[4], OsString::from("/workspace"));
438    }
439
440    #[test]
441    fn strips_cargo_injected_subcommand_name() {
442        // Cargo strips the "cargo-" prefix when passing the subcommand name:
443        // "cargo teaql-version" → argv = ["/path/to/cargo-teaql-version", "teaql-version"]
444        let args = vec![
445            OsString::from("/tmp/bin/cargo-teaql-version"),
446            OsString::from("teaql-version"),
447        ];
448
449        let rewritten = rewrite_args_for_alias(args);
450
451        assert_eq!(rewritten[0], OsString::from("teaql"));
452        assert_eq!(rewritten[1], OsString::from("version"));
453        assert_eq!(rewritten.len(), 2, "cargo-injected arg should be stripped");
454    }
455
456    #[test]
457    fn strips_cargo_injected_arg_for_gen_lib_with_input() {
458        // "cargo teaql-gen-lib model.xml"
459        // cargo passes: argv = ["/path/to/cargo-teaql-gen-lib", "teaql-gen-lib", "model.xml"]
460        let args = vec![
461            OsString::from("/tmp/bin/cargo-teaql-gen-lib"),
462            OsString::from("teaql-gen-lib"),
463            OsString::from("model.xml"),
464        ];
465
466        let rewritten = rewrite_args_for_alias(args);
467
468        assert_eq!(rewritten[0], OsString::from("teaql"));
469        assert_eq!(rewritten[1], OsString::from("gen-lib"));
470        assert_eq!(rewritten[2], OsString::from("model.xml"));
471        assert_eq!(rewritten.len(), 3);
472    }
473
474    #[test]
475    fn leaves_primary_binary_name_unchanged() {
476        let args = vec![OsString::from("cargo-teaql"), OsString::from("show-config")];
477
478        let rewritten = rewrite_args_for_alias(args.clone());
479
480        assert_eq!(rewritten, args);
481    }
482
483    #[test]
484    fn removes_cargo_forwarded_subcommand_argument_for_aliases() {
485        let args = vec![
486            OsString::from("/tmp/bin/cargo-teaql-show-config"),
487            OsString::from("teaql-show-config"),
488            OsString::from("--cwd"),
489            OsString::from("/workspace"),
490        ];
491
492        let rewritten = rewrite_args_for_alias(args);
493
494        assert_eq!(rewritten[0], OsString::from("teaql"));
495        assert_eq!(rewritten[1], OsString::from("show-config"));
496        assert_eq!(rewritten[2], OsString::from("--cwd"));
497        assert_eq!(rewritten[3], OsString::from("/workspace"));
498        assert_eq!(rewritten.len(), 4);
499    }
500
501    #[test]
502    fn link_names_cover_all_aliases() {
503        assert!(link_names().contains(&"teaql"));
504        assert!(link_names().contains(&"cargo-teaql-gen-lib"));
505        assert!(link_names().contains(&"cargo-teaql-gen-doc"));
506        assert!(link_names().contains(&"cargo-teaql-gen-model"));
507        assert!(link_names().contains(&"cargo-teaql-gen-workspace"));
508        assert!(link_names().contains(&"cargo-teaql-version"));
509        assert!(link_names().contains(&"cargo-teaql-show-config"));
510        assert!(link_names().contains(&"cargo-teaql-ping"));
511        assert!(link_names().contains(&"cargo-teaql-config"));
512    }
513
514    #[test]
515    fn rewrites_workspace_alias_binary_name_to_subcommand() {
516        let args = vec![
517            OsString::from("/tmp/bin/cargo-teaql-gen-workspace"),
518            OsString::from("model.yml"),
519        ];
520
521        let rewritten = rewrite_args_for_alias(args);
522
523        assert_eq!(rewritten[0], OsString::from("teaql"));
524        assert_eq!(rewritten[1], OsString::from("gen-workspace"));
525        assert_eq!(rewritten[2], OsString::from("model.yml"));
526    }
527}