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