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 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 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 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
157fn 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 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(¤t_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 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 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}