Skip to main content

otto_cli/cli/
mod.rs

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        &notifications,
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                    &notifications_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}