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