1
use std::collections::HashSet;
2
use std::path::Path;
3
use std::str::FromStr;
4
use std::sync::{
5
  Arc,
6
  Mutex,
7
};
8

            
9
use crate::secrets::Secrets;
10
use anyhow::Ok;
11
use clap::{
12
  crate_authors,
13
  CommandFactory,
14
  Parser,
15
  Subcommand,
16
};
17
use clap_complete::Shell;
18
use console::style;
19
use mk_lib::schema::{
20
  ExecutionStack,
21
  Task,
22
  TaskContext,
23
  TaskRoot,
24
};
25
use mk_lib::version::get_version_digits;
26
use once_cell::sync::Lazy;
27
use prettytable::format::consts;
28
use prettytable::{
29
  row,
30
  Table,
31
};
32

            
33
static VERSION: Lazy<String> = Lazy::new(get_version_digits);
34

            
35
/// The CLI arguments
36
#[derive(Debug, Parser)]
37
#[command(
38
  version = VERSION.as_str(),
39
  about,
40
  long_about = "mk is a powerful and flexible task runner designed to help you automate and manage your tasks efficiently. It supports running commands both locally and inside containers, making it versatile for various environments and use cases. Running tasks in containers is a first-class citizen, ensuring seamless integration with containerized workflows.",
41
  arg_required_else_help = true,
42
  author = crate_authors!("\n"),
43
  propagate_version = true,
44
)]
45
struct Args {
46
  #[arg(
47
    short,
48
    long,
49
    help = "Config file to source",
50
    env = "MK_CONFIG",
51
    default_value = "tasks.yaml"
52
  )]
53
  config: String,
54

            
55
  // Waiting for the dynamic completion to be implemented
56
  // Tracking can be found here:
57
  // - https://github.com/clap-rs/clap/issues/3166
58
  // - https://github.com/clap-rs/clap/issues/1232
59
  //
60
  // Usually, this would call `mk list --plain` or `mk list --json` to capture
61
  // the available tasks and use them as completions.
62
  #[arg(help = "The task name to run", value_hint = clap::ValueHint::Other)]
63
  task_name: Option<String>,
64

            
65
  #[command(subcommand)]
66
  command: Option<Command>,
67
}
68

            
69
/// The available subcommands
70
#[derive(Debug, Subcommand)]
71
enum Command {
72
  #[command(visible_aliases = ["r"], arg_required_else_help = true, about = "Run specific tasks")]
73
  Run {
74
    #[arg(required = true, help = "The task name to run", value_hint = clap::ValueHint::Other)]
75
    task_name: String,
76
  },
77
  #[command(visible_aliases = ["ls"], about = "List all available tasks")]
78
  List {
79
    #[arg(short, long, help = "Show list that does not include headers")]
80
    plain: bool,
81

            
82
    #[arg(short, long, help = "Show list in JSON format", conflicts_with = "plain")]
83
    json: bool,
84
  },
85
  #[command(visible_aliases = ["comp"], about = "Generate shell completions")]
86
  Completion {
87
    #[arg(required = true, help = "The shell to generate completions for")]
88
    shell: String,
89
  },
90
  #[command(visible_aliases = ["s"], arg_required_else_help = true, about = "Access stored secrets")]
91
  Secrets(Secrets),
92
}
93

            
94
/// The CLI entry
95
pub(super) struct CliEntry {
96
  args: Args,
97
  task_root: Arc<TaskRoot>,
98
  execution_stack: ExecutionStack,
99
}
100

            
101
impl CliEntry {
102
  /// Create a new CLI entry
103
294
  pub fn new() -> anyhow::Result<Self> {
104
294
    let args = Args::parse();
105
294
    log::trace!("Config: {}", args.config);
106

            
107
294
    assert!(!args.config.is_empty());
108

            
109
294
    let config = Path::new(&args.config);
110
294
    if !config.exists() {
111
147
      anyhow::bail!("Config file does not exist");
112
147
    }
113

            
114
147
    let task_root = Arc::new(TaskRoot::from_file(&args.config)?);
115
147
    let execution_stack = Arc::new(Mutex::new(HashSet::new()));
116
147
    Ok(Self {
117
147
      args,
118
147
      task_root,
119
147
      execution_stack,
120
147
    })
121
294
  }
122

            
123
  /// Run the CLI entry
124
147
  pub fn run(&self) -> anyhow::Result<()> {
125
84
    match &self.args.command {
126
63
      Some(Command::Run { task_name }) => {
127
63
        self.run_task(task_name)?;
128
      },
129
21
      Some(Command::List { plain, json }) => {
130
21
        self.print_available_tasks(*plain, *json)?;
131
      },
132
      Some(Command::Completion { shell }) => {
133
        self.write_completions(shell)?;
134
      },
135
      Some(Command::Secrets(secrets)) => {
136
        secrets.execute()?;
137
      },
138
      None => {
139
63
        if let Some(task_name) = &self.args.task_name {
140
63
          self.run_task(task_name)?;
141
        } else {
142
          anyhow::bail!("No subcommand or task name provided. Use `--help` flag for more information.");
143
        }
144
      },
145
    }
146

            
147
84
    Ok(())
148
147
  }
149

            
150
  /// Run the specified tasks
151
126
  fn run_task(&self, task_name: &str) -> anyhow::Result<()> {
152
126
    assert!(!task_name.is_empty());
153

            
154
126
    let task = self
155
126
      .task_root
156
126
      .tasks
157
126
      .get(task_name)
158
126
      .ok_or_else(|| anyhow::anyhow!("Task not found"))?;
159

            
160
63
    log::trace!("Task: {:?}", task);
161

            
162
    // Scope the lock to the task execution
163
    {
164
63
      let mut stack = self
165
63
        .execution_stack
166
63
        .lock()
167
63
        .map_err(|e| anyhow::anyhow!("Failed to lock execution stack - {}", e))?;
168

            
169
63
      stack.insert(task_name.to_string());
170
63
    }
171
63

            
172
63
    let mut context = TaskContext::new(self.task_root.clone(), self.execution_stack.clone());
173
63
    task.run(&mut context)?;
174

            
175
    // Don't carry over the execution stack to the next task
176
    {
177
63
      let mut stack = self
178
63
        .execution_stack
179
63
        .lock()
180
63
        .map_err(|e| anyhow::anyhow!("Failed to lock execution stack - {}", e))?;
181

            
182
63
      stack.clear();
183
63
    }
184
63

            
185
63
    Ok(())
186
126
  }
187

            
188
  /// Print all available tasks
189
21
  fn print_available_tasks(&self, plain: bool, json: bool) -> anyhow::Result<()> {
190
21
    if json {
191
      let tasks: Vec<_> = self
192
        .task_root
193
        .tasks
194
        .iter()
195
        .map(|(name, task)| {
196
          if let Task::Task(task) = task {
197
            serde_json::json!({
198
              "name": name,
199
              "description": task.description,
200
            })
201
          } else {
202
            serde_json::json!({
203
              "name": name,
204
              "description": "No description provided",
205
            })
206
          }
207
        })
208
        .collect();
209
      println!("{}", serde_json::to_string_pretty(&tasks)?);
210
    } else {
211
21
      let mut table = Table::new();
212
21
      if !plain {
213
21
        table.set_titles(row![Fbb->"Task", Fbb->"Description"]);
214
21

            
215
21
        let msg = style("Available tasks:").bold().cyan();
216
21
        println!();
217
21
        println!("{msg}");
218
21
        println!();
219
21
      }
220
21
      table.set_format(*consts::FORMAT_CLEAN);
221

            
222
21
      for (task_name, task) in &self.task_root.tasks {
223
21
        if let Task::Task(task) = task {
224
21
          table.add_row(row![b->&task_name, Fg->&task.description]);
225
21
        } else {
226
          table.add_row(row![b->&task_name, Fg->"No description provided"]);
227
        }
228
      }
229

            
230
21
      table.printstd();
231
    }
232

            
233
21
    Ok(())
234
21
  }
235

            
236
  fn write_completions(&self, shell: &str) -> anyhow::Result<()> {
237
    let shell = Shell::from_str(shell).map_err(|e| anyhow::anyhow!("Invalid shell - {}", e))?;
238

            
239
    let mut app = Args::command();
240
    clap_complete::generate(shell, &mut app, "mk", &mut std::io::stdout().lock());
241

            
242
    Ok(())
243
  }
244
}