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