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