1use crate::app_error::AppError;
2use crate::config::{self, Config, Defaults, NotificationSettings, ResolvedTask};
3use crate::history::{DEFAULT_PATH, Filter, Store};
4use crate::model::{RunRecord, RunSource, RunStatus};
5use crate::notify;
6use crate::output::{self, HistoryRow, TaskRow};
7use crate::runner::{self, Request};
8use crate::version;
9use clap::builder::styling::{AnsiColor, Effects, Styles};
10use clap::{Args, CommandFactory, Parser, Subcommand, ValueEnum};
11use clap_complete::{Generator, generate};
12use rand::Rng;
13use serde::Serialize;
14use std::collections::HashMap;
15use std::fs;
16use std::io::{self, Write};
17use std::path::{Path, PathBuf};
18use std::thread;
19use std::time::Duration;
20use std::time::Instant;
21use time::OffsetDateTime;
22
23const DEFAULT_CONFIG_PATH: &str = "./otto.yml";
24
25const DEFAULT_CONFIG_TEMPLATE: &str = r#"version: 1
26
27defaults:
28 timeout: "2m" # max runtime per attempt
29 retries: 0 # retries after first failure
30 retry_backoff: "1s"
31 notify_on: failure # never | failure | always
32
33notifications:
34 desktop: true # desktop notifications (macOS/Linux)
35 # webhook_url: "https://example.com/otto-hook"
36 # webhook_timeout: "5s"
37
38tasks:
39 test:
40 description: run unit tests
41 exec: ["cargo", "test"]
42
43 clippy:
44 description: run clippy
45 exec: ["cargo", "clippy", "--all-targets", "--all-features", "--", "-D", "warnings"]
46
47 ci:
48 description: run ci task set
49 tasks: ["test", "clippy"]
50 parallel: false
51
52 # shell example:
53 # clean:
54 # run: "rm -rf ./target"
55"#;
56
57#[derive(Debug, Parser)]
58#[command(
59 name = "otto",
60 version = version::VALUE,
61 about = "Task runner with run history and notifications",
62 styles = clap_styles()
63)]
64struct Cli {
65 #[arg(long = "no-color", global = true)]
66 no_color: bool,
67 #[command(subcommand)]
68 command: Commands,
69}
70
71#[derive(Debug, Subcommand)]
72enum Commands {
73 Init(InitArgs),
74 Run(RunArgs),
75 History(HistoryArgs),
76 Tasks(TasksArgs),
77 Validate(ValidateArgs),
78 Version,
79 Completion(CompletionArgs),
80}
81
82#[derive(Debug, Args)]
83struct InitArgs {
84 #[arg(long)]
85 config: Option<PathBuf>,
86 #[arg(long)]
87 force: bool,
88}
89
90#[derive(Debug, Args)]
91struct RunArgs {
92 task: Option<String>,
93 #[arg(last = true, allow_hyphen_values = true)]
94 inline: Vec<String>,
95
96 #[arg(long)]
97 config: Option<PathBuf>,
98
99 #[arg(long)]
100 name: Option<String>,
101
102 #[arg(long)]
103 timeout: Option<String>,
104
105 #[arg(long)]
106 retries: Option<i32>,
107
108 #[arg(long = "notify-on")]
109 notify_on: Option<String>,
110
111 #[arg(long = "env-file")]
112 env_file: Option<PathBuf>,
113
114 #[arg(long = "no-dotenv")]
115 no_dotenv: bool,
116
117 #[arg(long)]
118 json: bool,
119}
120
121#[derive(Debug, Args)]
122struct HistoryArgs {
123 #[arg(long, default_value_t = 20)]
124 limit: usize,
125 #[arg(long)]
126 status: Option<String>,
127 #[arg(long)]
128 source: Option<String>,
129 #[arg(long)]
130 json: bool,
131}
132
133#[derive(Debug, Args)]
134struct TasksArgs {
135 #[arg(long)]
136 config: Option<PathBuf>,
137 #[arg(long)]
138 json: bool,
139}
140
141#[derive(Debug, Args)]
142struct ValidateArgs {
143 #[arg(long)]
144 config: Option<PathBuf>,
145 #[arg(long)]
146 json: bool,
147}
148
149#[derive(Debug, Args)]
150struct CompletionArgs {
151 #[arg(value_enum)]
152 shell: Shell,
153}
154
155#[derive(Debug, Clone, Copy, ValueEnum)]
156enum Shell {
157 Bash,
158 Zsh,
159 Fish,
160 Powershell,
161}
162
163fn clap_styles() -> Styles {
164 Styles::plain()
165 .header(AnsiColor::White.on_default() | Effects::BOLD)
166 .error(AnsiColor::Red.on_default() | Effects::BOLD)
167 .usage(AnsiColor::Cyan.on_default())
168 .literal(AnsiColor::Cyan.on_default())
169 .placeholder(AnsiColor::Cyan.on_default())
170 .valid(AnsiColor::Cyan.on_default())
171 .invalid(AnsiColor::Cyan.on_default())
172 .context(AnsiColor::White.on_default())
173 .context_value(AnsiColor::Cyan.on_default())
174}
175
176pub fn run_cli() -> Result<(), AppError> {
177 let cli = Cli::parse();
178 output::configure(cli.no_color);
179
180 match cli.command {
181 Commands::Init(args) => run_init(args),
182 Commands::Run(args) => run_run(args),
183 Commands::History(args) => run_history(args),
184 Commands::Tasks(args) => run_tasks(args),
185 Commands::Validate(args) => run_validate(args),
186 Commands::Version => {
187 println!("{}", version::VALUE);
188 Ok(())
189 }
190 Commands::Completion(args) => run_completion(args),
191 }
192}
193
194fn run_init(args: InitArgs) -> Result<(), AppError> {
195 let config_path = args
196 .config
197 .unwrap_or_else(|| PathBuf::from(DEFAULT_CONFIG_PATH));
198
199 if config_path.exists() && !args.force {
200 return Err(AppError::usage(format!(
201 "{} already exists (use --force to overwrite)",
202 config_path.display()
203 )));
204 }
205
206 fs::write(&config_path, DEFAULT_CONFIG_TEMPLATE)
207 .map_err(|e| AppError::internal(format!("write {}: {e}", config_path.display())))?;
208
209 println!(
210 "created {}",
211 output::command(&config_path.display().to_string())
212 );
213 Ok(())
214}
215
216fn run_run(args: RunArgs) -> Result<(), AppError> {
217 let config_path = args
218 .config
219 .clone()
220 .unwrap_or_else(|| PathBuf::from(DEFAULT_CONFIG_PATH));
221
222 let dotenv_vars = load_dotenv(
223 args.env_file
224 .as_deref()
225 .unwrap_or_else(|| Path::new(".env")),
226 args.no_dotenv,
227 args.env_file.is_some(),
228 )?;
229
230 if !args.inline.is_empty() {
231 if args.task.is_some() {
232 return Err(AppError::usage(
233 "inline mode requires only command args after --",
234 ));
235 }
236
237 let (mut resolved, notifications) = resolve_inline_run(
238 &args.inline,
239 &config_path,
240 args.config.is_some(),
241 args.name.as_deref(),
242 args.timeout.as_deref(),
243 args.retries,
244 args.notify_on.as_deref(),
245 )?;
246
247 apply_runtime_env(&mut resolved, &dotenv_vars);
248 return execute_run(resolved, notifications, args.json, true);
249 }
250
251 if args.name.is_some()
252 || args.timeout.is_some()
253 || args.retries.is_some()
254 || args.notify_on.is_some()
255 {
256 return Err(AppError::usage(
257 "--name, --timeout, --retries, and --notify-on are inline-only flags; use with 'otto run -- <command>'",
258 ));
259 }
260
261 let task_name = args
262 .task
263 .ok_or_else(|| AppError::usage("named task mode requires exactly one task name"))?;
264
265 let cfg = load_config_classified(&config_path)?;
266 let notifications = cfg
267 .resolve_notification_settings()
268 .map_err(AppError::usage)?;
269
270 let mut stack = Vec::new();
271 run_named_task(
272 &cfg,
273 &task_name,
274 ¬ifications,
275 args.json,
276 &dotenv_vars,
277 true,
278 &mut stack,
279 )
280}
281
282fn run_named_task(
283 cfg: &Config,
284 task_name: &str,
285 notifications: &NotificationSettings,
286 as_json: bool,
287 dotenv_vars: &HashMap<String, String>,
288 emit_notifications: bool,
289 stack: &mut Vec<String>,
290) -> Result<(), AppError> {
291 if let Some(index) = stack.iter().position(|name| name == task_name) {
292 let mut cycle = stack[index..].to_vec();
293 cycle.push(task_name.to_string());
294 return Err(AppError::usage(format!(
295 "task dependency cycle: {}",
296 cycle.join(" -> ")
297 )));
298 }
299
300 stack.push(task_name.to_string());
301 let resolved = cfg.resolve_task(task_name).map_err(AppError::usage)?;
302 let result = if resolved.sub_tasks.is_empty() {
303 let mut runnable = resolved;
304 apply_runtime_env(&mut runnable, dotenv_vars);
305 execute_run(runnable, notifications.clone(), as_json, emit_notifications)
306 } else {
307 execute_task_group(
308 cfg,
309 resolved,
310 notifications,
311 as_json,
312 dotenv_vars,
313 emit_notifications,
314 stack,
315 )
316 };
317 stack.pop();
318 result
319}
320
321fn execute_task_group(
322 cfg: &Config,
323 resolved: ResolvedTask,
324 notifications: &NotificationSettings,
325 as_json: bool,
326 dotenv_vars: &HashMap<String, String>,
327 emit_notifications: bool,
328 stack: &mut Vec<String>,
329) -> Result<(), AppError> {
330 if as_json {
331 return Err(AppError::usage(
332 "--json is not supported for composed tasks yet",
333 ));
334 }
335
336 let started_at = OffsetDateTime::now_utc();
337 let wall = Instant::now();
338 let mut failures: Vec<String> = Vec::new();
339
340 if resolved.parallel {
341 let mut handles = Vec::with_capacity(resolved.sub_tasks.len());
342 for child in &resolved.sub_tasks {
343 let cfg_child = cfg.clone();
344 let notifications_child = notifications.clone();
345 let dotenv_child = dotenv_vars.clone();
346 let mut child_stack = stack.clone();
347 let child_name = child.clone();
348 handles.push(thread::spawn(move || {
349 run_named_task(
350 &cfg_child,
351 &child_name,
352 ¬ifications_child,
353 false,
354 &dotenv_child,
355 false,
356 &mut child_stack,
357 )
358 .map_err(|err| format!("{child_name}: {err}"))
359 }));
360 }
361
362 for handle in handles {
363 match handle.join() {
364 Ok(Ok(())) => {}
365 Ok(Err(err)) => failures.push(err),
366 Err(_) => failures.push("task thread panicked".to_string()),
367 }
368 }
369 } else {
370 for child in &resolved.sub_tasks {
371 if let Err(err) =
372 run_named_task(cfg, child, notifications, false, dotenv_vars, false, stack)
373 {
374 failures.push(format!("{child}: {err}"));
375 break;
376 }
377 }
378 }
379
380 let status = if failures.is_empty() {
381 RunStatus::Success
382 } else {
383 RunStatus::Failed
384 };
385 let exit_code = if failures.is_empty() { 0 } else { 1 };
386 let stderr_tail = if failures.is_empty() {
387 None
388 } else {
389 Some(failures.join("; "))
390 };
391
392 let record = RunRecord {
393 id: new_record_id(),
394 name: resolved.name.clone(),
395 source: RunSource::Task,
396 command_preview: resolved.command_preview.clone(),
397 started_at,
398 duration_ms: wall.elapsed().as_millis() as i64,
399 exit_code,
400 status,
401 stderr_tail: stderr_tail.clone(),
402 };
403
404 let store = Store::new(DEFAULT_PATH);
405 store
406 .append(&record)
407 .map_err(|err| AppError::internal(err.to_string()))?;
408
409 if emit_notifications && should_notify(&resolved.notify_on, status) {
410 let manager = notify::Manager {
411 desktop_enabled: notifications.desktop_enabled,
412 webhook_url: notifications.webhook_url.clone(),
413 webhook_timeout: notifications.webhook_timeout,
414 };
415
416 let event = notify::Event {
417 name: record.name.clone(),
418 source: source_to_str(record.source).to_string(),
419 status: status_to_str(record.status).to_string(),
420 exit_code: record.exit_code,
421 duration: Duration::from_millis(record.duration_ms as u64),
422 started_at: record.started_at,
423 command_preview: record.command_preview.clone(),
424 stderr_tail: record.stderr_tail.clone(),
425 };
426
427 if let Err(err) = manager.notify(&event) {
428 eprintln!(
429 "{} failed to send notification: {err}",
430 output::warning("warn")
431 );
432 }
433 }
434
435 if failures.is_empty() {
436 let mode = if resolved.parallel {
437 "in parallel"
438 } else {
439 "sequentially"
440 };
441 println!(
442 "{} run \"{}\" finished in {} ({} sub-tasks {})",
443 output::success("ok"),
444 resolved.name,
445 output::number(&output::format_duration_ms(record.duration_ms)),
446 resolved.sub_tasks.len(),
447 mode
448 );
449 Ok(())
450 } else {
451 Err(AppError::runtime(failures.join("; ")))
452 }
453}
454
455fn resolve_inline_run(
456 inline: &[String],
457 config_path: &Path,
458 explicit_config: bool,
459 inline_name: Option<&str>,
460 inline_timeout: Option<&str>,
461 inline_retries: Option<i32>,
462 inline_notify_on: Option<&str>,
463) -> Result<(ResolvedTask, NotificationSettings), AppError> {
464 let maybe_cfg = maybe_load_config_for_inline(config_path, explicit_config)?;
465
466 let mut defaults = Defaults::default();
467 let mut notifications = NotificationSettings {
468 desktop_enabled: true,
469 webhook_url: String::new(),
470 webhook_timeout: Duration::from_secs(5),
471 };
472
473 if let Some(cfg) = maybe_cfg {
474 defaults = cfg.defaults.clone();
475 notifications = cfg
476 .resolve_notification_settings()
477 .map_err(AppError::usage)?;
478 }
479
480 let resolved = config::resolve_inline(
481 inline,
482 inline_name.unwrap_or_default(),
483 inline_timeout.unwrap_or_default(),
484 inline_retries,
485 inline_notify_on.unwrap_or_default(),
486 &defaults,
487 )
488 .map_err(AppError::usage)?;
489
490 Ok((resolved, notifications))
491}
492
493fn maybe_load_config_for_inline(path: &Path, explicit: bool) -> Result<Option<Config>, AppError> {
494 if !path.exists() {
495 if explicit {
496 return Err(AppError::usage(format!(
497 "config file {} not found",
498 output::command(&path.display().to_string())
499 )));
500 }
501 return Ok(None);
502 }
503
504 let cfg = load_config_classified(path)?;
505 Ok(Some(cfg))
506}
507
508fn execute_run(
509 resolved: ResolvedTask,
510 notifications: NotificationSettings,
511 as_json: bool,
512 emit_notifications: bool,
513) -> Result<(), AppError> {
514 let request = Request {
515 name: resolved.name.clone(),
516 command_preview: resolved.command_preview.clone(),
517 use_shell: resolved.use_shell,
518 exec: resolved.exec.clone(),
519 shell: resolved.shell.clone(),
520 dir: resolved.dir.clone(),
521 env: resolved.env.clone(),
522 timeout: resolved.timeout,
523 retries: resolved.retries,
524 retry_backoff: resolved.retry_backoff,
525 stream_output: !as_json,
526 };
527
528 let execution = runner::execute(&request);
529 let (result, run_err) = match execution {
530 Ok(ok) => (ok, None),
531 Err(err) => (err.result, Some(err.message)),
532 };
533
534 let record = RunRecord {
535 id: new_record_id(),
536 name: resolved.name,
537 source: resolved.source,
538 command_preview: resolved.command_preview,
539 started_at: result.started_at,
540 duration_ms: result.duration.as_millis() as i64,
541 exit_code: result.exit_code,
542 status: result.status,
543 stderr_tail: result.stderr_tail,
544 };
545
546 let store = Store::new(DEFAULT_PATH);
547 store
548 .append(&record)
549 .map_err(|err| AppError::internal(err.to_string()))?;
550
551 if emit_notifications && should_notify(&resolved.notify_on, record.status) {
552 let manager = notify::Manager {
553 desktop_enabled: notifications.desktop_enabled,
554 webhook_url: notifications.webhook_url,
555 webhook_timeout: notifications.webhook_timeout,
556 };
557
558 let event = notify::Event {
559 name: record.name.clone(),
560 source: source_to_str(record.source).to_string(),
561 status: status_to_str(record.status).to_string(),
562 exit_code: record.exit_code,
563 duration: result.duration,
564 started_at: record.started_at,
565 command_preview: record.command_preview.clone(),
566 stderr_tail: record.stderr_tail.clone(),
567 };
568
569 if let Err(err) = manager.notify(&event) {
570 eprintln!(
571 "{} failed to send notification: {err}",
572 output::warning("warn")
573 );
574 }
575 }
576
577 if let Some(run_err) = run_err {
578 if as_json {
579 print_run_json(&record, Some(run_err.clone()))
580 .map_err(|e| AppError::internal(format!("encode json: {e}")))?;
581 }
582 return Err(AppError::runtime(run_err));
583 }
584
585 if as_json {
586 print_run_json(&record, None)
587 .map_err(|e| AppError::internal(format!("encode json: {e}")))?;
588 return Ok(());
589 }
590
591 println!(
592 "{} run \"{}\" finished in {}",
593 output::success("ok"),
594 record.name,
595 output::number(&output::format_duration_ms(record.duration_ms)),
596 );
597
598 Ok(())
599}
600
601#[derive(Serialize)]
602struct RunJsonPayload<'a> {
603 id: &'a str,
604 name: &'a str,
605 source: &'a str,
606 command_preview: &'a str,
607 #[serde(with = "time::serde::rfc3339")]
608 started_at: OffsetDateTime,
609 duration_ms: i64,
610 exit_code: i32,
611 status: &'a str,
612 #[serde(skip_serializing_if = "Option::is_none")]
613 stderr_tail: Option<&'a str>,
614 #[serde(skip_serializing_if = "Option::is_none")]
615 error: Option<&'a str>,
616}
617
618fn print_run_json(record: &RunRecord, error: Option<String>) -> Result<(), io::Error> {
619 let payload = RunJsonPayload {
620 id: &record.id,
621 name: &record.name,
622 source: source_to_str(record.source),
623 command_preview: &record.command_preview,
624 started_at: record.started_at,
625 duration_ms: record.duration_ms,
626 exit_code: record.exit_code,
627 status: status_to_str(record.status),
628 stderr_tail: record.stderr_tail.as_deref(),
629 error: error.as_deref(),
630 };
631
632 let mut stdout = io::stdout().lock();
633 serde_json::to_writer_pretty(&mut stdout, &payload)?;
634 writeln!(stdout)
635}
636
637fn load_dotenv(
638 path: &Path,
639 disabled: bool,
640 explicit: bool,
641) -> Result<HashMap<String, String>, AppError> {
642 if disabled {
643 return Ok(HashMap::new());
644 }
645
646 match crate::envfile::load(path) {
647 Ok(vars) => Ok(vars),
648 Err(err) if err.kind() == io::ErrorKind::NotFound => {
649 if explicit {
650 Err(AppError::usage(format!(
651 "dotenv file {} not found",
652 output::command(&path.display().to_string())
653 )))
654 } else {
655 Ok(HashMap::new())
656 }
657 }
658 Err(err) => Err(AppError::usage(format!(
659 "load dotenv file {}: {}",
660 output::command(&path.display().to_string()),
661 err
662 ))),
663 }
664}
665
666fn apply_runtime_env(resolved: &mut ResolvedTask, dotenv_vars: &HashMap<String, String>) {
667 let mut lookup: HashMap<String, String> = std::env::vars().collect();
668 let mut runtime_env: HashMap<String, String> = HashMap::new();
669
670 for (key, value) in dotenv_vars {
671 if lookup.contains_key(key) {
672 continue;
673 }
674 runtime_env.insert(key.clone(), value.clone());
675 lookup.insert(key.clone(), value.clone());
676 }
677
678 if !resolved.env.is_empty() {
679 let mut keys: Vec<String> = resolved.env.keys().cloned().collect();
680 keys.sort();
681
682 for key in keys {
683 if let Some(value) = resolved.env.get(&key) {
684 let expanded = expand_variables(value, &lookup);
685 runtime_env.insert(key.clone(), expanded.clone());
686 lookup.insert(key, expanded);
687 }
688 }
689 }
690
691 if !resolved.dir.is_empty() {
692 resolved.dir = expand_variables(&resolved.dir, &lookup);
693 }
694
695 if resolved.use_shell {
696 resolved.shell = expand_variables(&resolved.shell, &lookup);
697 resolved.command_preview = resolved.shell.clone();
698 } else if !resolved.exec.is_empty() {
699 let expanded: Vec<String> = resolved
700 .exec
701 .iter()
702 .map(|token| expand_variables(token, &lookup))
703 .collect();
704 resolved.command_preview = expanded.join(" ");
705 resolved.exec = expanded;
706 }
707
708 resolved.env = runtime_env;
709}
710
711fn expand_variables(value: &str, lookup: &HashMap<String, String>) -> String {
712 let mut out = String::with_capacity(value.len());
713 let bytes = value.as_bytes();
714 let mut i = 0;
715
716 while i < bytes.len() {
717 if bytes[i] != b'$' {
718 out.push(bytes[i] as char);
719 i += 1;
720 continue;
721 }
722
723 if i + 1 >= bytes.len() {
724 out.push('$');
725 break;
726 }
727
728 if bytes[i + 1] == b'{' {
729 if let Some(end_rel) = value[i + 2..].find('}') {
730 let end = i + 2 + end_rel;
731 let key = &value[i + 2..end];
732 if let Some(found) = lookup.get(key) {
733 out.push_str(found);
734 } else {
735 out.push_str(&format!("${{{key}}}"));
736 }
737 i = end + 1;
738 continue;
739 }
740
741 out.push('$');
742 i += 1;
743 continue;
744 }
745
746 let mut j = i + 1;
747 while j < bytes.len() {
748 let ch = bytes[j] as char;
749 if j == i + 1 {
750 if !(ch.is_ascii_alphabetic() || ch == '_') {
751 break;
752 }
753 } else if !(ch.is_ascii_alphanumeric() || ch == '_') {
754 break;
755 }
756 j += 1;
757 }
758
759 if j == i + 1 {
760 out.push('$');
761 i += 1;
762 continue;
763 }
764
765 let key = &value[i + 1..j];
766 if let Some(found) = lookup.get(key) {
767 out.push_str(found);
768 } else {
769 out.push_str(&format!("${{{key}}}"));
770 }
771 i = j;
772 }
773
774 out
775}
776
777fn load_config_classified(path: &Path) -> Result<Config, AppError> {
778 config::load(path).map_err(|err| {
779 if err.starts_with("read config:") && !err.contains("No such file") {
780 AppError::internal(err)
781 } else {
782 AppError::usage(err)
783 }
784 })
785}
786
787fn should_notify(policy: &str, status: RunStatus) -> bool {
788 match policy {
789 "never" => false,
790 "always" => true,
791 _ => status == RunStatus::Failed,
792 }
793}
794
795fn new_record_id() -> String {
796 let mut random = [0_u8; 8];
797 rand::rng().fill(&mut random);
798 let millis = OffsetDateTime::now_utc().unix_timestamp_nanos() / 1_000_000;
799 format!("{millis}-{}", hex_encode(&random))
800}
801
802fn hex_encode(bytes: &[u8]) -> String {
803 let mut out = String::with_capacity(bytes.len() * 2);
804 for b in bytes {
805 out.push_str(&format!("{b:02x}"));
806 }
807 out
808}
809
810fn source_to_str(source: RunSource) -> &'static str {
811 match source {
812 RunSource::Task => "task",
813 RunSource::Inline => "inline",
814 }
815}
816
817fn status_to_str(status: RunStatus) -> &'static str {
818 match status {
819 RunStatus::Success => "success",
820 RunStatus::Failed => "failed",
821 }
822}
823
824fn run_history(args: HistoryArgs) -> Result<(), AppError> {
825 if let Some(status) = &args.status
826 && status != "success"
827 && status != "failed"
828 {
829 return Err(AppError::usage("--status must be success or failed"));
830 }
831
832 if let Some(source) = &args.source
833 && source != "task"
834 && source != "inline"
835 {
836 return Err(AppError::usage("--source must be task or inline"));
837 }
838
839 let store = Store::new(DEFAULT_PATH);
840 let rows = store.list(&Filter {
841 limit: Some(args.limit),
842 status: args.status.clone(),
843 source: args.source.clone(),
844 });
845
846 let rows = rows.map_err(AppError::internal)?;
847
848 if args.json {
849 let mut stdout = io::stdout().lock();
850 serde_json::to_writer_pretty(&mut stdout, &rows)
851 .map_err(|e| AppError::internal(format!("encode history json: {e}")))?;
852 writeln!(stdout).map_err(|e| AppError::internal(format!("write output: {e}")))?;
853 return Ok(());
854 }
855
856 let display_rows: Vec<HistoryRow> = rows
857 .into_iter()
858 .map(|row| HistoryRow {
859 name: row.name,
860 source: row.source,
861 status: row.status,
862 exit_code: row.exit_code,
863 started_at: row.started_at,
864 duration_ms: row.duration_ms,
865 })
866 .collect();
867
868 output::print_history(io::stdout().lock(), &display_rows)
869 .map_err(|e| AppError::internal(format!("print history: {e}")))
870}
871
872fn run_tasks(args: TasksArgs) -> Result<(), AppError> {
873 let config_path = args
874 .config
875 .unwrap_or_else(|| PathBuf::from(DEFAULT_CONFIG_PATH));
876 let cfg = load_config_classified(&config_path)?;
877 let tasks = cfg
878 .tasks
879 .as_ref()
880 .ok_or_else(|| AppError::usage("tasks: is required"))?;
881
882 let mut names: Vec<&String> = tasks.keys().collect();
883 names.sort();
884
885 #[derive(Serialize)]
886 struct TaskJson {
887 name: String,
888 #[serde(skip_serializing_if = "String::is_empty")]
889 description: String,
890 command: String,
891 }
892
893 let mut items = Vec::with_capacity(names.len());
894 for name in names {
895 let task = tasks.get(name).expect("task exists");
896 let command = if !task.exec.is_empty() {
897 task.exec.join(" ")
898 } else if !task.tasks.is_empty() {
899 let mode = if task.parallel {
900 "parallel"
901 } else {
902 "sequential"
903 };
904 format!("tasks ({mode}): {}", task.tasks.join(", "))
905 } else {
906 task.run.clone()
907 };
908
909 items.push(TaskJson {
910 name: name.clone(),
911 description: task.description.clone(),
912 command,
913 });
914 }
915
916 if args.json {
917 let mut stdout = io::stdout().lock();
918 serde_json::to_writer_pretty(&mut stdout, &items)
919 .map_err(|e| AppError::internal(format!("encode tasks json: {e}")))?;
920 writeln!(stdout).map_err(|e| AppError::internal(format!("write output: {e}")))?;
921 return Ok(());
922 }
923
924 let rows: Vec<TaskRow> = items
925 .into_iter()
926 .map(|item| TaskRow {
927 name: item.name,
928 description: item.description,
929 command: compact_command(&item.command, 100),
930 })
931 .collect();
932
933 output::print_tasks(io::stdout().lock(), &rows)
934 .map_err(|e| AppError::internal(format!("print tasks: {e}")))
935}
936
937fn run_validate(args: ValidateArgs) -> Result<(), AppError> {
938 #[derive(Serialize)]
939 struct Issue<'a> {
940 field: &'a str,
941 message: &'a str,
942 }
943
944 #[derive(Serialize)]
945 struct ValidateOutput<'a> {
946 valid: bool,
947 config: &'a str,
948 #[serde(skip_serializing_if = "Option::is_none")]
949 issues: Option<Vec<Issue<'a>>>,
950 #[serde(skip_serializing_if = "Option::is_none")]
951 error: Option<&'a str>,
952 }
953
954 let config_path = args
955 .config
956 .unwrap_or_else(|| PathBuf::from(DEFAULT_CONFIG_PATH));
957 let config_path_text = config_path.display().to_string();
958
959 let cfg = match config::parse(&config_path) {
960 Ok(cfg) => cfg,
961 Err(err) => {
962 if args.json {
963 let output = ValidateOutput {
964 valid: false,
965 config: &config_path_text,
966 issues: None,
967 error: Some(&err),
968 };
969 let mut stdout = io::stdout().lock();
970 serde_json::to_writer_pretty(&mut stdout, &output)
971 .map_err(|e| AppError::internal(format!("encode validate json: {e}")))?;
972 writeln!(stdout).map_err(|e| AppError::internal(format!("write output: {e}")))?;
973 }
974 return Err(AppError::usage(err));
975 }
976 };
977
978 match config::validate(&cfg) {
979 Ok(()) => {
980 if args.json {
981 let output = ValidateOutput {
982 valid: true,
983 config: &config_path_text,
984 issues: None,
985 error: None,
986 };
987 let mut stdout = io::stdout().lock();
988 serde_json::to_writer_pretty(&mut stdout, &output)
989 .map_err(|e| AppError::internal(format!("encode validate json: {e}")))?;
990 writeln!(stdout).map_err(|e| AppError::internal(format!("write output: {e}")))?;
991 } else {
992 println!("valid {}", output::command(&config_path_text));
993 }
994 Ok(())
995 }
996 Err(err) => {
997 if args.json {
998 let issues: Vec<Issue<'_>> = err
999 .issues
1000 .iter()
1001 .map(|issue| Issue {
1002 field: &issue.field,
1003 message: &issue.message,
1004 })
1005 .collect();
1006 let output = ValidateOutput {
1007 valid: false,
1008 config: &config_path_text,
1009 issues: Some(issues),
1010 error: Some(&err.to_string()),
1011 };
1012 let mut stdout = io::stdout().lock();
1013 serde_json::to_writer_pretty(&mut stdout, &output)
1014 .map_err(|e| AppError::internal(format!("encode validate json: {e}")))?;
1015 writeln!(stdout).map_err(|e| AppError::internal(format!("write output: {e}")))?;
1016 }
1017 Err(AppError::usage(err.to_string()))
1018 }
1019 }
1020}
1021
1022fn compact_command(command: &str, max_chars: usize) -> String {
1023 let compact = command.split_whitespace().collect::<Vec<_>>().join(" ");
1024
1025 if max_chars == 0 || compact.chars().count() <= max_chars {
1026 return compact;
1027 }
1028
1029 let limit = max_chars.max(4) - 3;
1030 format!("{}...", compact.chars().take(limit).collect::<String>())
1031}
1032
1033fn run_completion(args: CompletionArgs) -> Result<(), AppError> {
1034 let mut cmd = Cli::command();
1035 let mut stdout = io::stdout().lock();
1036
1037 match args.shell {
1038 Shell::Bash => generate_completion(clap_complete::shells::Bash, &mut cmd, &mut stdout),
1039 Shell::Zsh => generate_completion(clap_complete::shells::Zsh, &mut cmd, &mut stdout),
1040 Shell::Fish => generate_completion(clap_complete::shells::Fish, &mut cmd, &mut stdout),
1041 Shell::Powershell => {
1042 generate_completion(clap_complete::shells::PowerShell, &mut cmd, &mut stdout)
1043 }
1044 }
1045 .map_err(|e| AppError::internal(format!("generate completion: {e}")))
1046}
1047
1048fn generate_completion<G: Generator>(
1049 generator: G,
1050 cmd: &mut clap::Command,
1051 writer: &mut impl Write,
1052) -> Result<(), io::Error> {
1053 generate(generator, cmd, "otto", writer);
1054 writer.flush()
1055}