1
use std::env;
2
use std::ffi::OsString;
3
use std::io::IsTerminal;
4
use std::path::Path;
5
use std::sync::Arc;
6
use std::time::Duration;
7

            
8
use crate::secrets::Secrets;
9
use crate::task_selector::{
10
  FuzzyTaskSelector,
11
  TaskSelectorCandidate,
12
};
13
use anyhow::Context as _;
14
use clap::{
15
  crate_authors,
16
  CommandFactory,
17
  Parser,
18
  Subcommand,
19
};
20
use clap_complete::Shell;
21
use console::style;
22
use mk_lib::file::DisplayPath as _;
23
use mk_lib::label_filter::{
24
  matches_all,
25
  LabelFilter,
26
};
27
use mk_lib::schema::{
28
  run_task_by_name,
29
  Task,
30
  TaskContext,
31
  TaskPlan,
32
  TaskRoot,
33
};
34
use mk_lib::version::get_version_digits;
35
use once_cell::sync::Lazy;
36
use prettytable::format::consts;
37
use prettytable::{
38
  row,
39
  Table,
40
};
41
use reqwest::blocking::Client;
42
use reqwest::header::{
43
  HeaderMap,
44
  ACCEPT,
45
  USER_AGENT,
46
};
47
use reqwest::redirect::Policy;
48
use reqwest::StatusCode;
49
use serde::{
50
  Deserialize,
51
  Serialize,
52
};
53

            
54
static VERSION: Lazy<String> = Lazy::new(get_version_digits);
55
static INIT_SCHEMA_URL: &str = "https://raw.githubusercontent.com/ffimnsr/mk-rs/main/docs/schema.json";
56

            
57
/// The CLI arguments
58
#[derive(Debug, Parser)]
59
#[command(
60
  version = VERSION.as_str(),
61
  about,
62
  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.",
63
  arg_required_else_help = true,
64
  author = crate_authors!("\n"),
65
  propagate_version = true,
66
)]
67
struct Args {
68
  #[arg(
69
    short,
70
    long,
71
    help = "Config file to source",
72
    env = "MK_CONFIG",
73
    default_value = "tasks.yaml"
74
  )]
75
  config: String,
76

            
77
  // Waiting for the dynamic completion to be implemented
78
  // Tracking can be found here:
79
  // - https://github.com/clap-rs/clap/issues/3166
80
  // - https://github.com/clap-rs/clap/issues/1232
81
  //
82
  // Usually, this would call `mk list --plain` or `mk list --json` to capture
83
  // the available tasks and use them as completions.
84
  #[arg(help = "The task name to run", value_hint = clap::ValueHint::Other)]
85
  task_name: Option<String>,
86

            
87
  #[command(subcommand)]
88
  command: Option<Command>,
89
}
90

            
91
/// The available subcommands
92
#[derive(Debug, Subcommand)]
93
enum Command {
94
  #[command(about = "Initialize a sample task config file in the current directory")]
95
  Init {
96
    #[arg(short, long, help = "Overwrite existing config file if present")]
97
    force: bool,
98
    #[arg(help = "Optional output path for the created config file")]
99
    output: Option<String>,
100
  },
101
  #[command(visible_aliases = ["r"], about = "Run specific tasks")]
102
  Run {
103
    #[arg(help = "The task name to run", value_hint = clap::ValueHint::Other)]
104
    task_name: Option<String>,
105

            
106
    #[arg(
107
      long,
108
      help = "Print the resolved task plan without executing commands",
109
      conflicts_with = "json_events"
110
    )]
111
    dry_run: bool,
112

            
113
    #[arg(long, help = "Bypass task cache and force execution")]
114
    force: bool,
115

            
116
    #[arg(long, help = "Emit newline-delimited JSON execution events")]
117
    json_events: bool,
118

            
119
    #[arg(
120
      short = 'F',
121
      long = "fzf",
122
      help = "Choose task with fuzzy finder before execution",
123
      conflicts_with_all = ["task_name", "dry_run", "json_events"]
124
    )]
125
    fuzzy: bool,
126

            
127
    #[arg(
128
      long = "label",
129
      help = "Run tasks matching label (KEY or KEY=VALUE). Repeatable; all must match.",
130
      value_name = "FILTER"
131
    )]
132
    labels: Vec<String>,
133
  },
134
  #[command(visible_aliases = ["ls"], about = "List all available tasks")]
135
  List {
136
    #[arg(short, long, help = "Show list that does not include headers")]
137
    plain: bool,
138

            
139
    #[arg(short, long, help = "Show list in JSON format", conflicts_with = "plain")]
140
    json: bool,
141

            
142
    #[arg(long, help = "Disable colored list output", conflicts_with_all = ["plain", "json"])]
143
    no_color: bool,
144

            
145
    #[arg(
146
      long = "label",
147
      help = "Filter tasks by label (KEY or KEY=VALUE). Repeatable; all must match.",
148
      value_name = "FILTER"
149
    )]
150
    labels: Vec<String>,
151
  },
152
  #[command(visible_aliases = ["comp", "completions"], about = "Generate shell completions")]
153
  Completion {
154
    #[arg(required = true, value_enum, help = "The shell to generate completions for")]
155
    shell: Shell,
156
  },
157
  #[command(about = "Validate task configuration without executing tasks")]
158
  Validate {
159
    #[arg(long, help = "Show validation results in JSON format")]
160
    json: bool,
161
  },
162
  #[command(about = "Show the resolved execution plan for a task")]
163
  Plan {
164
    #[arg(help = "The task name to inspect", value_hint = clap::ValueHint::Other)]
165
    task_name: Option<String>,
166

            
167
    #[arg(long, help = "Show the plan in JSON format")]
168
    json: bool,
169

            
170
    #[arg(
171
      long = "label",
172
      help = "Plan tasks matching label (KEY or KEY=VALUE). Repeatable; all must match.",
173
      value_name = "FILTER"
174
    )]
175
    labels: Vec<String>,
176
  },
177
  #[command(visible_aliases = ["s"], arg_required_else_help = true, about = "Access stored secrets")]
178
  Secrets(Box<Secrets>),
179
  // Update does not require a config file.
180
  #[command(about = "Check for mk (make) updates")]
181
  Update,
182
  #[command(about = "Remove mk task cache metadata")]
183
  CleanCache,
184
  #[command(about = "Print the JSON Schema for the task configuration file")]
185
  Schema,
186
}
187

            
188
/// The CLI entry
189
pub(super) struct CliEntry {
190
  args: Args,
191
  task_root: Arc<TaskRoot>,
192
}
193

            
194
impl CliEntry {
195
53
  pub fn maybe_print_task_completion_from_env() -> anyhow::Result<bool> {
196
53
    if env::var_os("MK_COMPLETE_TASKS").is_none() {
197
53
      return Ok(false);
198
    }
199

            
200
    let prefix = env::var("MK_COMPLETE_PREFIX").ok();
201
    let config = Self::completion_config_path()?;
202
    let task_root = if config.exists() {
203
      TaskRoot::from_file(&config)?
204
    } else {
205
      TaskRoot::default()
206
    };
207

            
208
    let mut names = task_root.tasks.keys().map(String::as_str).collect::<Vec<_>>();
209
    names.sort_unstable();
210

            
211
    for name in names {
212
      if prefix
213
        .as_deref()
214
        .map(|prefix| name.starts_with(prefix))
215
        .unwrap_or(true)
216
      {
217
        println!("{name}");
218
      }
219
    }
220

            
221
    Ok(true)
222
53
  }
223

            
224
  /// Create a new CLI entry
225
53
  pub fn new() -> anyhow::Result<Self> {
226
53
    let args = Self::parse_args();
227
53
    assert!(!args.config.is_empty());
228

            
229
45
    let (config, allow_without_config) = Self::resolve_config(&args)?;
230
45
    log::trace!("Config: {}", config.display_lossy());
231

            
232
45
    if !config.exists() && !allow_without_config {
233
1
      let mut message = format!("Config file does not exist: {}", config.display_lossy());
234
1
      if args.config == "tasks.yaml" {
235
        message.push_str(". Note: mk also checks for tasks.yml when tasks.yaml is missing.");
236
1
      }
237
1
      anyhow::bail!(message);
238
44
    }
239

            
240
44
    let task_root = if config.exists() {
241
44
      Arc::new(TaskRoot::from_file(&config)?)
242
    } else {
243
      debug_assert!(allow_without_config);
244
      let mut task_root = TaskRoot::default();
245
      task_root.source_path = Some(Self::absolute_config_path(&config)?);
246
      Arc::new(task_root)
247
    };
248
43
    Ok(Self { args, task_root })
249
45
  }
250

            
251
53
  fn parse_args() -> Args {
252
53
    Args::parse_from(Self::expand_hydra_aliases(env::args_os()))
253
53
  }
254

            
255
68
  fn expand_hydra_aliases(args: impl IntoIterator<Item = OsString>) -> Vec<OsString> {
256
68
    let mut args = args.into_iter();
257
68
    let mut expanded = Vec::new();
258

            
259
68
    if let Some(program) = args.next() {
260
68
      expanded.push(program);
261
68
    }
262

            
263
68
    let mut expanded_command = false;
264
68
    let mut config_value_pending = false;
265

            
266
216
    for arg in args {
267
216
      if config_value_pending {
268
41
        expanded.push(arg);
269
41
        config_value_pending = false;
270
41
        continue;
271
175
      }
272

            
273
175
      if !expanded_command {
274
108
        if arg == "-c" || arg == "--config" {
275
41
          config_value_pending = true;
276
41
          expanded.push(arg);
277
41
          continue;
278
67
        }
279

            
280
67
        if arg.to_string_lossy().starts_with("--config=") {
281
          expanded.push(arg);
282
          continue;
283
67
        }
284

            
285
        // Hydra aliases: single token → multi-word subcommand path.
286
        // Ordered longest-match first to avoid prefix ambiguity (e.g. `svls`
287
        // before `sv`).  None of these must shadow a top-level clap
288
        // visible_alias (`r`, `ls`, `comp`, `completions`, `s`).
289
67
        let hydra: Option<&[&str]> = match arg.to_string_lossy().as_ref() {
290
          // secrets vault <subcmd>
291
67
          "svls" | "svl" => Some(&["secrets", "vault", "list-secrets"]),
292
64
          "svi" => Some(&["secrets", "vault", "init-vault"]),
293
63
          "svst" => Some(&["secrets", "vault", "store-secret"]),
294
62
          "svsh" => Some(&["secrets", "vault", "show-secret"]),
295
61
          "svp" => Some(&["secrets", "vault", "purge-secret"]),
296
60
          "sve" => Some(&["secrets", "vault", "export-secret"]),
297
          // secrets vault (prefix — remainder passes through unchanged)
298
59
          "sv" => Some(&["secrets", "vault"]),
299
          // secrets <subcmd>
300
58
          "sk" => Some(&["secrets", "key"]),
301
57
          "slk" => Some(&["secrets", "list-keys"]),
302
56
          "sd" => Some(&["secrets", "doctor"]),
303
55
          "si" => Some(&["secrets", "init-vault"]),
304
54
          "se" => Some(&["secrets", "export-secret"]),
305
53
          _ => None,
306
        };
307
67
        if let Some(tokens) = hydra {
308
36
          expanded.extend(tokens.iter().map(|t| OsString::from(*t)));
309
14
          expanded_command = true;
310
14
          continue;
311
53
        }
312

            
313
53
        if !arg.to_string_lossy().starts_with('-') {
314
50
          expanded_command = true;
315
50
        }
316
67
      }
317

            
318
120
      expanded.push(arg);
319
    }
320

            
321
68
    expanded
322
68
  }
323

            
324
46
  fn resolve_config(args: &Args) -> anyhow::Result<(std::path::PathBuf, bool)> {
325
46
    let mut config = Path::new(&args.config).to_path_buf();
326
46
    if !config.exists() && args.config == "tasks.yaml" {
327
      for candidate in Self::default_config_candidates() {
328
        let fallback = Path::new(candidate);
329
        if fallback.exists() {
330
          config = fallback.to_path_buf();
331
          break;
332
        }
333
      }
334
46
    }
335

            
336
46
    let allow_without_config = matches!(
337
42
      args.command,
338
      Some(Command::Init { .. })
339
        | Some(Command::Completion { .. })
340
        | Some(Command::Update)
341
        | Some(Command::CleanCache)
342
        | Some(Command::Schema)
343
42
    ) || (matches!(args.command, Some(Command::Secrets(_)))
344
      && !Self::config_requested_explicitly(args));
345

            
346
46
    Ok((config, allow_without_config))
347
46
  }
348

            
349
  fn config_requested_explicitly(args: &Args) -> bool {
350
    if args.config != "tasks.yaml" || env::var_os("MK_CONFIG").is_some() {
351
      return true;
352
    }
353

            
354
    env::args_os()
355
      .any(|arg| arg == "-c" || arg == "--config" || arg.to_string_lossy().starts_with("--config="))
356
  }
357

            
358
1
  fn default_config_candidates() -> &'static [&'static str] {
359
1
    &[
360
1
      "tasks.yml",
361
1
      ".mk/tasks.yaml",
362
1
      ".mk/tasks.yml",
363
1
      "mk.toml",
364
1
      "tasks.toml",
365
1
      "tasks.json",
366
1
      "tasks.lua",
367
1
      ".mk/tasks.toml",
368
1
      ".mk/tasks.json",
369
1
      ".mk/tasks.lua",
370
1
    ]
371
1
  }
372

            
373
  fn absolute_config_path(config: &Path) -> anyhow::Result<std::path::PathBuf> {
374
    if config.is_absolute() {
375
      Ok(config.to_path_buf())
376
    } else {
377
      Ok(env::current_dir()?.join(config))
378
    }
379
  }
380

            
381
  fn completion_config_path() -> anyhow::Result<std::path::PathBuf> {
382
    let mut config = env::var_os("MK_CONFIG")
383
      .map(std::path::PathBuf::from)
384
      .unwrap_or_else(|| std::path::PathBuf::from("tasks.yaml"));
385

            
386
    let mut args = env::args_os();
387
    let _program = args.next();
388
    let mut expect_config_value = false;
389

            
390
    for arg in args {
391
      if expect_config_value {
392
        config = std::path::PathBuf::from(arg);
393
        expect_config_value = false;
394
        continue;
395
      }
396

            
397
      if arg == "-c" || arg == "--config" {
398
        expect_config_value = true;
399
        continue;
400
      }
401

            
402
      let arg = arg.to_string_lossy();
403
      if let Some(value) = arg.strip_prefix("--config=") {
404
        config = std::path::PathBuf::from(value);
405
      }
406
    }
407

            
408
    if !config.exists() && config == Path::new("tasks.yaml") {
409
      for candidate in Self::default_config_candidates() {
410
        let candidate = std::path::PathBuf::from(candidate);
411
        if candidate.exists() {
412
          config = candidate;
413
          break;
414
        }
415
      }
416
    }
417

            
418
    Self::absolute_config_path(&config)
419
  }
420

            
421
  /// Run the CLI entry
422
43
  pub fn run(&self) -> anyhow::Result<()> {
423
40
    match &self.args.command {
424
      Some(Command::Init { force, output }) => {
425
        let config_path = if let Some(ref out) = output {
426
          Path::new(out)
427
        } else {
428
          Path::new(&self.args.config)
429
        };
430

            
431
        if config_path.exists() && !force {
432
          anyhow::bail!("Config file already exists. Use `--force` to overwrite");
433
        }
434

            
435
        Self::ensure_init_path_supported(config_path)?;
436
        let contents = Self::build_init_contents(config_path)?;
437

            
438
        std::fs::write(config_path, contents)?;
439
        println!("Config file created at {}", config_path.display_lossy());
440
      },
441
      Some(Command::Run {
442
12
        task_name,
443
12
        dry_run,
444
12
        force,
445
12
        json_events,
446
12
        fuzzy,
447
12
        labels,
448
      }) => {
449
12
        let filters: Vec<LabelFilter> = labels.iter().map(|s| LabelFilter::parse(s)).collect();
450
12
        let names = if *fuzzy {
451
          vec![self.select_run_task(&filters)?]
452
        } else {
453
12
          self.resolve_run_tasks(task_name.as_deref(), &filters)?
454
        };
455
7
        if *dry_run {
456
1
          for name in &names {
457
1
            self.print_plan(name, false)?;
458
          }
459
        } else {
460
7
          for name in &names {
461
7
            self.run_task(name, *force, *json_events)?;
462
          }
463
        }
464
      },
465
      Some(Command::List {
466
10
        plain,
467
10
        json,
468
10
        no_color,
469
10
        labels,
470
      }) => {
471
10
        let filters: Vec<LabelFilter> = labels.iter().map(|s| LabelFilter::parse(s)).collect();
472
10
        self.print_available_tasks(*plain, *json, *no_color, &filters)?;
473
      },
474
1
      Some(Command::Completion { shell }) => {
475
1
        self.write_completions(*shell)?;
476
      },
477
8
      Some(Command::Validate { json }) => {
478
8
        self.validate_config(*json)?;
479
      },
480
      Some(Command::Plan {
481
7
        task_name,
482
7
        json,
483
7
        labels,
484
      }) => {
485
7
        let filters: Vec<LabelFilter> = labels.iter().map(|s| LabelFilter::parse(s)).collect();
486
7
        let names = self.resolve_run_tasks(task_name.as_deref(), &filters)?;
487
7
        for name in &names {
488
7
          self.print_plan(name, *json)?;
489
        }
490
      },
491
      Some(Command::Secrets(secrets)) => {
492
        secrets.execute(&self.task_root)?;
493
      },
494
      Some(Command::Update) => {
495
1
        self.update_mk()?;
496
      },
497
      Some(Command::CleanCache) => {
498
        mk_lib::cache::CacheStore::remove_in_dir(&self.task_root.cache_base_dir())?;
499
        println!("Cache cleared");
500
      },
501
      Some(Command::Schema) => {
502
1
        let schema = mk_lib::generate_schema()?;
503
1
        let schema: serde_json::Value = serde_json::from_str(&schema)?;
504
1
        Self::print_json_value(schema)?;
505
      },
506
      None => {
507
3
        if let Some(task_name) = &self.args.task_name {
508
3
          self.run_task(task_name, false, false)?;
509
        } else {
510
          anyhow::bail!("No subcommand or task name provided. Use `--help` flag for more information.");
511
        }
512
      },
513
    }
514

            
515
26
    Ok(())
516
43
  }
517

            
518
10
  fn print_json<T: Serialize>(value: &T) -> anyhow::Result<()> {
519
10
    Self::print_json_value(serde_json::to_value(value)?)
520
10
  }
521

            
522
11
  fn print_json_value(value: serde_json::Value) -> anyhow::Result<()> {
523
11
    let value = Self::canonicalize_json(value);
524
11
    println!("{}", serde_json::to_string_pretty(&value)?);
525
11
    Ok(())
526
11
  }
527

            
528
817
  fn canonicalize_json(value: serde_json::Value) -> serde_json::Value {
529
817
    match value {
530
107
      serde_json::Value::Array(items) => {
531
107
        serde_json::Value::Array(items.into_iter().map(Self::canonicalize_json).collect::<Vec<_>>())
532
      },
533
235
      serde_json::Value::Object(map) => {
534
235
        let mut entries = map.into_iter().collect::<Vec<_>>();
535
832
        entries.sort_by(|left, right| left.0.cmp(&right.0));
536
235
        let mut canonical = serde_json::Map::new();
537
624
        for (key, value) in entries {
538
624
          canonical.insert(key, Self::canonicalize_json(value));
539
624
        }
540
235
        serde_json::Value::Object(canonical)
541
      },
542
475
      other => other,
543
    }
544
817
  }
545

            
546
1
  fn update_mk(&self) -> anyhow::Result<()> {
547
1
    println!("Checking for updates...");
548
1
    let current_version = VERSION.as_str();
549
1
    println!("Current version: {}", current_version);
550

            
551
    // Extract semver without git hash
552
1
    let current_semver = current_version.split_whitespace().next().unwrap_or("0.0.0");
553

            
554
    // GitHub API endpoint for latest release
555
1
    let client = Self::build_update_client(Self::update_connect_timeout(), Self::update_request_timeout())?;
556
1
    let latest_version = Self::fetch_latest_version(&client, &Self::update_latest_release_url())?;
557

            
558
    // Compare using semver for accurate ordering
559
    match (
560
      semver::Version::parse(&latest_version),
561
      semver::Version::parse(current_semver),
562
    ) {
563
      (Result::Ok(latest_v), Result::Ok(current_v)) => {
564
        if latest_v <= current_v {
565
          println!("You are using the latest version.");
566
        } else {
567
          println!(
568
            "New version {} is available (you have {})",
569
            latest_version, current_semver
570
          );
571
          println!("Visit https://github.com/ffimnsr/mk-rs/releases/latest to update");
572
        }
573
      },
574
      // Fallback to simple equality check if parsing fails
575
      _ => {
576
        if latest_version == current_semver {
577
          println!("You are using the latest version.");
578
        } else {
579
          println!(
580
            "New version {} is available (you have {})",
581
            latest_version, current_semver
582
          );
583
          println!("Visit https://github.com/ffimnsr/mk-rs/releases/latest to update");
584
        }
585
      },
586
    }
587

            
588
    Ok(())
589
1
  }
590

            
591
  /// Resolve task names from an optional explicit name or label filters.
592
  /// Returns a sorted list of matching task names.
593
19
  fn resolve_run_tasks(
594
19
    &self,
595
19
    task_name: Option<&str>,
596
19
    filters: &[LabelFilter],
597
19
  ) -> anyhow::Result<Vec<String>> {
598
19
    if let Some(name) = task_name {
599
9
      if !filters.is_empty() {
600
        anyhow::bail!("Cannot combine a task name with --label filters");
601
9
      }
602
9
      return Ok(vec![name.to_owned()]);
603
10
    }
604

            
605
10
    if filters.is_empty() {
606
5
      anyhow::bail!("Provide a task name or at least one --label filter");
607
5
    }
608

            
609
5
    let matched: Vec<String> = self
610
5
      .filtered_tasks(filters)
611
5
      .into_iter()
612
6
      .map(|(name, _)| name.to_owned())
613
5
      .collect();
614

            
615
5
    if matched.is_empty() {
616
1
      anyhow::bail!("No tasks matched the given label filters");
617
4
    }
618

            
619
4
    Ok(matched)
620
19
  }
621

            
622
  fn select_run_task(&self, filters: &[LabelFilter]) -> anyhow::Result<String> {
623
    let candidates = self.task_selector_candidates(filters);
624

            
625
    if candidates.is_empty() {
626
      if filters.is_empty() {
627
        anyhow::bail!("No tasks available to select");
628
      }
629
      anyhow::bail!("No tasks matched the given label filters");
630
    }
631

            
632
    if candidates.len() == 1 {
633
      return Ok(candidates[0].name.clone());
634
    }
635

            
636
    FuzzyTaskSelector::select(&candidates)
637
  }
638

            
639
  /// Run the specified tasks
640
10
  fn run_task(&self, task_name: &str, force: bool, json_events: bool) -> anyhow::Result<()> {
641
10
    assert!(!task_name.is_empty());
642
10
    let context = TaskContext::new_with_options(self.task_root.clone(), force, json_events);
643
10
    run_task_by_name(&context, task_name)
644
10
  }
645

            
646
  /// Build contents of new task config, including auto-detected integrations.
647
  fn build_init_contents(config_path: &Path) -> anyhow::Result<String> {
648
    let format = InitTemplateFormat::from_path(config_path)?;
649
    let cwd = std::env::current_dir().unwrap_or_default();
650
    let has_cargo = cwd.join("Cargo.toml").exists();
651
    let has_npm = cwd.join("package.json").exists();
652
    Ok(format.render(has_cargo, has_npm))
653
  }
654

            
655
6
  fn ensure_init_path_supported(config_path: &Path) -> anyhow::Result<()> {
656
6
    let _ = InitTemplateFormat::from_path(config_path)?;
657
5
    Ok(())
658
6
  }
659

            
660
2
  fn update_connect_timeout() -> Duration {
661
2
    Duration::from_secs(3)
662
2
  }
663

            
664
2
  fn update_request_timeout() -> Duration {
665
2
    Duration::from_secs(10)
666
2
  }
667

            
668
2
  fn update_latest_release_url() -> String {
669
2
    env::var("MK_UPDATE_URL")
670
2
      .unwrap_or_else(|_| String::from("https://api.github.com/repos/ffimnsr/mk-rs/releases/latest"))
671
2
  }
672

            
673
3
  fn build_update_client(connect_timeout: Duration, request_timeout: Duration) -> anyhow::Result<Client> {
674
3
    Client::builder()
675
3
      .connect_timeout(connect_timeout)
676
3
      .timeout(request_timeout)
677
3
      .redirect(Policy::limited(10))
678
3
      .build()
679
3
      .context("Failed to build update client")
680
3
  }
681

            
682
3
  fn fetch_latest_version(client: &Client, url: &str) -> anyhow::Result<String> {
683
3
    let resp = client
684
3
      .get(url)
685
3
      .header(USER_AGENT, format!("mk-rs/{}", VERSION.as_str()))
686
3
      .header(ACCEPT, "application/vnd.github+json")
687
3
      .send()
688
3
      .context("Failed to check for updates. Network unavailable or request timed out")?;
689

            
690
1
    let status = resp.status();
691
1
    let headers = resp.headers().clone();
692
1
    let body = resp.text().context("Failed to read update response body")?;
693

            
694
1
    if !status.is_success() {
695
1
      anyhow::bail!(Self::format_update_error(url, status, &headers, &body));
696
    }
697

            
698
    let release: GitHubRelease = serde_json::from_str(&body).context("Invalid update response payload")?;
699
    let latest_version = release.tag_name.trim_start_matches('v').to_owned();
700

            
701
    Ok(latest_version)
702
3
  }
703

            
704
1
  fn format_update_error(url: &str, status: StatusCode, headers: &HeaderMap, body: &str) -> String {
705
1
    let mut details = Vec::new();
706

            
707
1
    if let Ok(api_error) = serde_json::from_str::<GitHubApiError>(body) {
708
1
      if let Some(message) = api_error.message.filter(|message| !message.is_empty()) {
709
1
        details.push(message);
710
1
      }
711
1
      if let Some(documentation_url) = api_error.documentation_url.filter(|url| !url.is_empty()) {
712
1
        details.push(format!("docs: {}", documentation_url));
713
1
      }
714
    } else {
715
      let snippet = body.trim();
716
      if !snippet.is_empty() {
717
        let snippet = if snippet.len() > 240 {
718
          format!("{}...", &snippet[..240])
719
        } else {
720
          snippet.to_string()
721
        };
722
        details.push(format!("body: {}", snippet));
723
      }
724
    }
725

            
726
1
    if let Some(reset) = headers
727
1
      .get("x-ratelimit-reset")
728
1
      .and_then(|value| value.to_str().ok())
729
1
    {
730
1
      details.push(format!("rate limit reset: {}", reset));
731
1
    }
732

            
733
1
    let reason = status.canonical_reason().unwrap_or("Unknown Status");
734
1
    if details.is_empty() {
735
      format!(
736
        "Failed to check for updates via {}: HTTP {} {}",
737
        url,
738
        status.as_u16(),
739
        reason
740
      )
741
    } else {
742
1
      format!(
743
        "Failed to check for updates via {}: HTTP {} {} ({})",
744
        url,
745
1
        status.as_u16(),
746
        reason,
747
1
        details.join("; ")
748
      )
749
    }
750
1
  }
751

            
752
16
  fn sorted_tasks(&self) -> Vec<(&str, &Task)> {
753
16
    let mut tasks = self
754
16
      .task_root
755
16
      .tasks
756
16
      .iter()
757
35
      .map(|(name, task)| (name.as_str(), task))
758
16
      .collect::<Vec<_>>();
759
26
    tasks.sort_unstable_by(|left, right| left.0.cmp(right.0));
760
16
    tasks
761
16
  }
762

            
763
16
  fn filtered_tasks<'a>(&'a self, filters: &[LabelFilter]) -> Vec<(&'a str, &'a Task)> {
764
16
    if filters.is_empty() {
765
7
      return self.sorted_tasks();
766
9
    }
767
9
    self
768
9
      .sorted_tasks()
769
9
      .into_iter()
770
22
      .filter(|(_, task)| match task {
771
22
        Task::Task(t) => matches_all(filters, &t.labels),
772
        // string shorthand tasks have no labels — never match a label filter
773
        Task::String(_) => false,
774
22
      })
775
9
      .collect()
776
16
  }
777

            
778
1
  fn task_selector_candidates(&self, filters: &[LabelFilter]) -> Vec<TaskSelectorCandidate> {
779
1
    self
780
1
      .filtered_tasks(filters)
781
1
      .into_iter()
782
1
      .map(|(name, task)| TaskSelectorCandidate {
783
1
        name: name.to_owned(),
784
        description: match task {
785
          Task::Task(task) if !task.description.is_empty() => task.description.clone(),
786
1
          _ => String::from("No description provided"),
787
        },
788
1
      })
789
1
      .collect()
790
1
  }
791

            
792
  /// Print all available tasks
793
10
  fn print_available_tasks(
794
10
    &self,
795
10
    plain: bool,
796
10
    json: bool,
797
10
    no_color: bool,
798
10
    filters: &[LabelFilter],
799
10
  ) -> anyhow::Result<()> {
800
10
    if json {
801
4
      let tasks: Vec<_> = self
802
4
        .filtered_tasks(filters)
803
4
        .into_iter()
804
8
        .map(|(name, task)| {
805
8
          if let Task::Task(task) = task {
806
8
            let mut labels: Vec<_> = task.labels.iter().collect();
807
8
            labels.sort_by_key(|(k, _)| k.as_str());
808
8
            let labels_obj: serde_json::Map<String, serde_json::Value> = labels
809
8
              .into_iter()
810
8
              .map(|(k, v)| (k.clone(), serde_json::Value::String(v.clone())))
811
8
              .collect();
812
8
            serde_json::json!({
813
8
              "description": task.description,
814
8
              "labels": labels_obj,
815
8
              "name": name,
816
            })
817
          } else {
818
            serde_json::json!({
819
              "description": "No description provided",
820
              "labels": {},
821
              "name": name,
822
            })
823
          }
824
8
        })
825
4
        .collect();
826
4
      Self::print_json(&tasks)?;
827
    } else {
828
6
      let mut table = Table::new();
829
6
      if !plain {
830
2
        table.set_titles(row![Fbb->"Task", Fbb->"Description"]);
831

            
832
2
        let use_color = !no_color && std::io::stdout().is_terminal();
833
2
        println!();
834
2
        if use_color {
835
          println!("{}", style("Available tasks:").bold().cyan());
836
2
        } else {
837
2
          println!("Available tasks:");
838
2
        }
839
2
        println!();
840
4
      }
841
6
      table.set_format(*consts::FORMAT_CLEAN);
842

            
843
9
      for (task_name, task) in self.filtered_tasks(filters) {
844
9
        if let Task::Task(task) = task {
845
9
          table.add_row(row![b->task_name, Fg->&task.description]);
846
9
        } else {
847
          table.add_row(row![b->task_name, Fg->"No description provided"]);
848
        }
849
      }
850

            
851
6
      table.printstd();
852
    }
853

            
854
10
    Ok(())
855
10
  }
856

            
857
1
  fn write_completions(&self, shell: Shell) -> anyhow::Result<()> {
858
1
    let mut app = Args::command();
859
1
    let mut output = Vec::new();
860
1
    clap_complete::generate(shell, &mut app, "mk", &mut output);
861
1
    let script = String::from_utf8(output).context("Generated completion script was not valid UTF-8")?;
862
1
    print!("{}", Self::augment_completion_script(shell, &script));
863

            
864
1
    Ok(())
865
1
  }
866

            
867
1
  fn augment_completion_script(shell: Shell, script: &str) -> String {
868
1
    match shell {
869
1
      Shell::Bash => Self::augment_bash_completion(script),
870
      Shell::Fish => Self::augment_fish_completion(script),
871
      Shell::Zsh => Self::augment_zsh_completion(script),
872
      _ => script.to_owned(),
873
    }
874
1
  }
875

            
876
1
  fn augment_bash_completion(script: &str) -> String {
877
1
    let static_script = script.replacen("_mk() {", "_mk_clap_static() {", 1);
878
1
    format!(
879
      r#"{static_script}
880

            
881
__mk_complete_tasks() {{
882
    local cur="$1"
883
    local cmd="$2"
884
    local -a config_args=()
885
    local i=1
886

            
887
    while (( i < COMP_CWORD )); do
888
        local word="${{COMP_WORDS[i]}}"
889
        case "$word" in
890
            -c|--config)
891
                if (( i + 1 < ${{#COMP_WORDS[@]}} )); then
892
                    config_args+=("$word" "${{COMP_WORDS[i + 1]}}")
893
                    ((i+=2))
894
                    continue
895
                fi
896
                ;;
897
            --config=*)
898
                config_args+=("$word")
899
                ;;
900
        esac
901
        ((i+=1))
902
    done
903

            
904
    MK_COMPLETE_TASKS=1 MK_COMPLETE_PREFIX="$cur" "$cmd" "${{config_args[@]}}" 2>/dev/null
905
}}
906

            
907
__mk_should_complete_task() {{
908
    local mode="top"
909
    local expect_value=0
910
    local i=1
911

            
912
    while (( i < COMP_CWORD )); do
913
        local word="${{COMP_WORDS[i]}}"
914

            
915
        if (( expect_value )); then
916
            expect_value=0
917
            ((i+=1))
918
            continue
919
        fi
920

            
921
        case "$mode" in
922
            top)
923
                case "$word" in
924
                    -c|--config)
925
                        expect_value=1
926
                        ;;
927
                    --config=*)
928
                        ;;
929
                    -h|--help|-V|--version)
930
                        ;;
931
                    run|r|plan)
932
                        mode="$word"
933
                        ;;
934
                    init|list|ls|completion|comp|completions|validate|secrets|s|update|clean-cache|schema|help)
935
                        return 1
936
                        ;;
937
                    -*)
938
                        ;;
939
                    *)
940
                        return 1
941
                        ;;
942
                esac
943
                ;;
944
            run|r)
945
                case "$word" in
946
                    --label)
947
                        expect_value=1
948
                        ;;
949
                    --label=*|--dry-run|--force|--json-events|-h|--help|-V|--version)
950
                        ;;
951
                    -*)
952
                        ;;
953
                    *)
954
                        return 1
955
                        ;;
956
                esac
957
                ;;
958
            plan)
959
                case "$word" in
960
                    --label)
961
                        expect_value=1
962
                        ;;
963
                    --label=*|--json|-h|--help|-V|--version)
964
                        ;;
965
                    -*)
966
                        ;;
967
                    *)
968
                        return 1
969
                        ;;
970
                esac
971
                ;;
972
        esac
973

            
974
        ((i+=1))
975
    done
976

            
977
    return 0
978
}}
979

            
980
_mk() {{
981
    _mk_clap_static "$@"
982

            
983
    if __mk_should_complete_task; then
984
        local cur="$2"
985
        local cmd="${{COMP_WORDS[0]}}"
986
        local candidate
987
        while IFS= read -r candidate; do
988
            [[ -n "$candidate" ]] && COMPREPLY+=("$candidate")
989
        done < <(__mk_complete_tasks "$cur" "$cmd")
990
    fi
991
}}
992
"#
993
    )
994
1
  }
995

            
996
  fn augment_fish_completion(script: &str) -> String {
997
    format!(
998
      r#"{script}
999

            
function __fish_mk_complete_tasks
    set -l cmd (commandline -opc)
    set -e cmd[1]
    set -l current (commandline -ct)
    set -l mode top
    set -l expect_value 0
    set -l config_args
    for word in $cmd
        if test $expect_value -eq 1
            set config_args $config_args $word
            set expect_value 0
            continue
        end
        switch $mode
            case top
                switch $word
                    case -c --config
                        set config_args $config_args $word
                        set expect_value 1
                    case '--config=*'
                        set config_args $config_args $word
                    case -h --help -V --version
                    case run r plan
                        set mode $word
                    case init list ls completion comp completions validate secrets s update clean-cache schema help
                        return
                    case '-*'
                    case '*'
                        return
                end
            case run r
                switch $word
                    case --label
                        set expect_value 1
                    case '--label=*' --dry-run --force --json-events -h --help -V --version
                    case '-*'
                    case '*'
                        return
                end
            case plan
                switch $word
                    case --label
                        set expect_value 1
                    case '--label=*' --json -h --help -V --version
                    case '-*'
                    case '*'
                        return
                end
        end
    end
    env MK_COMPLETE_TASKS=1 MK_COMPLETE_PREFIX="$current" command mk $config_args 2>/dev/null
end
complete -c mk -f -a "(__fish_mk_complete_tasks)"
"#
    )
  }
  fn augment_zsh_completion(script: &str) -> String {
    let static_script = script.replacen("_mk() {", "_mk_clap_static() {", 1);
    format!(
      r#"{static_script}
function _mk_complete_tasks() {{
    local cur="$1"
    local -a config_args
    local i=2
    while (( i < CURRENT )); do
        local word="${{words[i]}}"
        case "$word" in
            -c|--config)
                if (( i + 1 <= $#words )); then
                    config_args+=("$word" "${{words[i + 1]}}")
                    ((i+=2))
                    continue
                fi
                ;;
            --config=*)
                config_args+=("$word")
                ;;
        esac
        ((i+=1))
    done
    MK_COMPLETE_TASKS=1 MK_COMPLETE_PREFIX="$cur" "${{words[1]}}" "${{config_args[@]}}" 2>/dev/null
}}
function _mk_should_complete_task() {{
    local mode="top"
    local expect_value=0
    local i=2
    while (( i < CURRENT )); do
        local word="${{words[i]}}"
        if (( expect_value )); then
            expect_value=0
            ((i+=1))
            continue
        fi
        case "$mode" in
            top)
                case "$word" in
                    -c|--config)
                        expect_value=1
                        ;;
                    --config=*)
                        ;;
                    -h|--help|-V|--version)
                        ;;
                    run|r|plan)
                        mode="$word"
                        ;;
                    init|list|ls|completion|comp|completions|validate|secrets|s|update|clean-cache|schema|help)
                        return 1
                        ;;
                    -*)
                        ;;
                    *)
                        return 1
                        ;;
                esac
                ;;
            run|r)
                case "$word" in
                    --label)
                        expect_value=1
                        ;;
                    --label=*|--dry-run|--force|--json-events|-h|--help|-V|--version)
                        ;;
                    -*)
                        ;;
                    *)
                        return 1
                        ;;
                esac
                ;;
            plan)
                case "$word" in
                    --label)
                        expect_value=1
                        ;;
                    --label=*|--json|-h|--help|-V|--version)
                        ;;
                    -*)
                        ;;
                    *)
                        return 1
                        ;;
                esac
                ;;
        esac
        ((i+=1))
    done
    return 0
}}
function _mk() {{
    _mk_clap_static "$@"
    if _mk_should_complete_task; then
        local -a task_candidates
        task_candidates=(${{(@f)$(_mk_complete_tasks "$PREFIX")}})
        (( $#task_candidates )) && compadd -a task_candidates
    fi
}}
"#
    )
  }
8
  fn validate_config(&self, json: bool) -> anyhow::Result<()> {
8
    let report = self.task_root.validate();
8
    if json {
2
      Self::print_json(&report)?;
6
    } else if report.has_errors() {
5
      println!("Validation failed");
5
      println!();
5
      for issue in &report.issues {
5
        let task = issue
5
          .task
5
          .as_ref()
5
          .map(|task| format!(" task={}", task))
5
          .unwrap_or_default();
5
        let field = issue
5
          .field
5
          .as_ref()
5
          .map(|field| format!(" field={}", field))
5
          .unwrap_or_default();
5
        println!(
          "{}{}{}",
5
          match issue.severity {
5
            mk_lib::schema::ValidationSeverity::Error => "ERROR",
            mk_lib::schema::ValidationSeverity::Warning => "WARNING",
          },
          task,
          field
        );
5
        println!("  {}", issue.message);
      }
    } else {
1
      println!("Validation passed");
1
      if !report.issues.is_empty() {
        println!();
1
      }
1
      for issue in &report.issues {
        let task = issue
          .task
          .as_ref()
          .map(|task| format!(" task={}", task))
          .unwrap_or_default();
        let field = issue
          .field
          .as_ref()
          .map(|field| format!(" field={}", field))
          .unwrap_or_default();
        println!(
          "{}{}{}",
          match issue.severity {
            mk_lib::schema::ValidationSeverity::Error => "ERROR",
            mk_lib::schema::ValidationSeverity::Warning => "WARNING",
          },
          task,
          field
        );
        println!("  {}", issue.message);
      }
    }
8
    if report.has_errors() {
7
      anyhow::bail!("Validation failed");
1
    }
1
    Ok(())
8
  }
8
  fn print_plan(&self, task_name: &str, json: bool) -> anyhow::Result<()> {
8
    let plan = self.task_root.plan_task(task_name)?;
8
    if json {
4
      Self::print_json(&plan)?;
4
    } else {
4
      self.print_plan_text(&plan);
4
    }
8
    Ok(())
8
  }
4
  fn print_plan_text(&self, plan: &TaskPlan) {
4
    println!("Plan for task: {}", plan.root_task);
4
    println!();
5
    for (index, step) in plan.steps.iter().enumerate() {
5
      println!("{}. {}", index + 1, step.name);
5
      println!("   base_dir: {}", step.base_dir);
5
      if let Some(description) = &step.description {
2
        if !description.is_empty() {
2
          println!("   description: {}", description);
2
        }
3
      }
5
      println!(
        "   mode: {}",
5
        match step.execution_mode {
5
          mk_lib::schema::PlannedExecutionMode::Sequential => "sequential",
          mk_lib::schema::PlannedExecutionMode::Parallel => "parallel",
        }
      );
5
      if !step.dependencies.is_empty() {
1
        println!("   depends_on: {}", step.dependencies.join(", "));
4
      }
5
      for command in &step.commands {
5
        println!("   {}", command.summary());
5
      }
5
      if let Some(reason) = &step.skipped_reason {
        println!("   skip: {}", reason);
5
      }
5
      println!();
    }
4
  }
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum InitTemplateFormat {
  Yaml,
  Toml,
  Json,
  Lua,
}
impl InitTemplateFormat {
6
  fn from_path(config_path: &Path) -> anyhow::Result<Self> {
6
    match config_path.extension().and_then(|ext| ext.to_str()) {
6
      Some("yaml") | Some("yml") | None => Ok(Self::Yaml),
4
      Some("toml") => Ok(Self::Toml),
3
      Some("json") => Ok(Self::Json),
2
      Some("lua") => Ok(Self::Lua),
1
      Some(ext) => anyhow::bail!(
        "Unsupported `mk init` output extension .{}. Supported formats: .yaml, .yml, .toml, .json, .lua",
        ext
      ),
    }
6
  }
  fn render(self, has_cargo: bool, has_npm: bool) -> String {
    match self {
      Self::Yaml => self.render_yaml(has_cargo, has_npm),
      Self::Toml => self.render_toml(has_cargo, has_npm),
      Self::Json => self.render_json(has_cargo, has_npm),
      Self::Lua => self.render_lua(has_cargo, has_npm),
    }
  }
  fn render_yaml(self, has_cargo: bool, has_npm: bool) -> String {
    let mut out = String::new();
    out.push_str("# yaml-language-server: $schema=");
    out.push_str(INIT_SCHEMA_URL);
    out.push('\n');
    out.push('\n');
    self.push_shared_flags(&mut out, has_cargo, has_npm, "", ": true\n", '\n');
    out.push_str("tasks:\n");
    out.push_str("  greet:\n");
    out.push_str("    commands:\n");
    out.push_str("      - echo \"Hello, World!\"\n");
    out.push_str("    description: Sample greet command\n");
    out
  }
  fn render_toml(self, has_cargo: bool, has_npm: bool) -> String {
    let mut out = String::new();
    self.push_shared_flags(&mut out, has_cargo, has_npm, "", " = true\n", '\n');
    out.push_str("[tasks.greet]\n");
    out.push_str("commands = [\"echo \\\"Hello, World!\\\"\"]\n");
    out.push_str("description = \"Sample greet command\"\n");
    out
  }
  fn render_json(self, has_cargo: bool, has_npm: bool) -> String {
    let mut lines = Vec::new();
    lines.push("{".to_string());
    if has_cargo {
      lines.push("  \"use_cargo\": true,".to_string());
    }
    if has_npm {
      lines.push("  \"use_npm\": true,".to_string());
    }
    lines.push("  \"tasks\": {".to_string());
    lines.push("    \"greet\": {".to_string());
    lines.push("      \"commands\": [\"echo \\\"Hello, World!\\\"\"],".to_string());
    lines.push("      \"description\": \"Sample greet command\"".to_string());
    lines.push("    }".to_string());
    lines.push("  }".to_string());
    lines.push("}".to_string());
    lines.join("\n")
  }
  fn render_lua(self, has_cargo: bool, has_npm: bool) -> String {
    let mut out = String::new();
    out.push_str("return {\n");
    self.push_shared_flags(&mut out, has_cargo, has_npm, "  ", " = true,\n", '\0');
    out.push_str("  tasks = {\n");
    out.push_str("    greet = {\n");
    out.push_str("      commands = { \"echo \\\"Hello, World!\\\"\" },\n");
    out.push_str("      description = \"Sample greet command\"\n");
    out.push_str("    }\n");
    out.push_str("  }\n");
    out.push_str("}\n");
    out
  }
  fn push_shared_flags(
    self,
    out: &mut String,
    has_cargo: bool,
    has_npm: bool,
    prefix: &str,
    suffix: &str,
    separator: char,
  ) {
    if has_cargo {
      out.push_str(prefix);
      out.push_str("use_cargo");
      out.push_str(suffix);
    }
    if has_npm {
      out.push_str(prefix);
      out.push_str("use_npm");
      out.push_str(suffix);
    }
    if (has_cargo || has_npm) && separator != '\0' {
      out.push(separator);
    }
  }
}
#[derive(Debug, Deserialize)]
struct GitHubRelease {
  tag_name: String,
}
#[derive(Debug, Deserialize)]
struct GitHubApiError {
  message: Option<String>,
  documentation_url: Option<String>,
}
#[cfg(test)]
mod tests {
  use super::{
    Args,
    CliEntry,
    Command,
  };
  use mk_lib::schema::{
    Task,
    TaskRoot,
  };
  use std::io::Read as _;
  use std::net::TcpListener;
  use std::sync::{
    mpsc,
    Arc,
  };
  use std::thread;
  #[test]
1
  fn init_rejects_unsupported_output_paths() {
1
    let err = CliEntry::ensure_init_path_supported(std::path::Path::new("mk.txt")).unwrap_err();
1
    assert!(err
1
      .to_string()
1
      .contains("Unsupported `mk init` output extension .txt"));
1
  }
  #[test]
1
  fn init_accepts_supported_output_paths() {
1
    assert!(CliEntry::ensure_init_path_supported(std::path::Path::new("tasks.yaml")).is_ok());
1
    assert!(CliEntry::ensure_init_path_supported(std::path::Path::new("tasks.yml")).is_ok());
1
    assert!(CliEntry::ensure_init_path_supported(std::path::Path::new("mk.toml")).is_ok());
1
    assert!(CliEntry::ensure_init_path_supported(std::path::Path::new("tasks.json")).is_ok());
1
    assert!(CliEntry::ensure_init_path_supported(std::path::Path::new("tasks.lua")).is_ok());
1
  }
  #[test]
1
  fn clean_cache_allows_missing_config() {
1
    let args = Args {
1
      config: String::from("tasks.yaml"),
1
      task_name: None,
1
      command: Some(Command::CleanCache),
1
    };
1
    let (_, allow_without_config) = CliEntry::resolve_config(&args).unwrap();
1
    assert!(allow_without_config);
1
  }
  #[test]
1
  fn update_timeouts_stay_bounded() {
1
    assert_eq!(
1
      CliEntry::update_connect_timeout(),
1
      std::time::Duration::from_secs(3)
    );
1
    assert_eq!(
1
      CliEntry::update_request_timeout(),
1
      std::time::Duration::from_secs(10)
    );
1
  }
  #[test]
1
  fn update_fetch_times_out_with_clear_error() {
1
    let listener = TcpListener::bind("127.0.0.1:0").unwrap();
1
    let address = listener.local_addr().unwrap();
1
    let (ready_tx, ready_rx) = mpsc::channel();
1
    let server = thread::spawn(move || {
1
      ready_tx.send(()).unwrap();
1
      let (mut stream, _) = listener.accept().unwrap();
1
      let mut buf = [0_u8; 1024];
1
      let _ = stream.read(&mut buf);
1
      thread::sleep(std::time::Duration::from_millis(250));
1
    });
1
    ready_rx.recv().unwrap();
1
    let client = CliEntry::build_update_client(
1
      std::time::Duration::from_millis(50),
1
      std::time::Duration::from_millis(100),
    )
1
    .unwrap();
1
    let err = CliEntry::fetch_latest_version(&client, &format!("http://{}/latest", address)).unwrap_err();
1
    assert!(err
1
      .to_string()
1
      .contains("Failed to check for updates. Network unavailable or request timed out"));
1
    server.join().unwrap();
1
  }
  #[test]
1
  fn update_fetch_surfaces_api_error_details() {
1
    let listener = TcpListener::bind("127.0.0.1:0").unwrap();
1
    let address = listener.local_addr().unwrap();
1
    let (ready_tx, ready_rx) = mpsc::channel();
1
    let server = thread::spawn(move || {
1
      ready_tx.send(()).unwrap();
1
      let (mut stream, _) = listener.accept().unwrap();
1
      let mut buf = [0_u8; 1024];
1
      let _ = stream.read(&mut buf);
1
      let body =
1
        "{\"message\":\"API rate limit exceeded\",\"documentation_url\":\"https://docs.github.com\"}";
1
      let response = format!(
        concat!(
          "HTTP/1.1 403 Forbidden\r\n",
          "Content-Type: application/json\r\n",
          "X-RateLimit-Reset: 1712345678\r\n",
          "Content-Length: {}\r\n",
          "\r\n",
          "{}"
        ),
1
        body.len(),
        body
      );
      use std::io::Write as _;
1
      stream.write_all(response.as_bytes()).unwrap();
1
    });
1
    ready_rx.recv().unwrap();
1
    let client = CliEntry::build_update_client(
1
      std::time::Duration::from_millis(50),
1
      std::time::Duration::from_millis(100),
    )
1
    .unwrap();
1
    let err = CliEntry::fetch_latest_version(&client, &format!("http://{}/latest", address)).unwrap_err();
1
    let message = err.to_string();
1
    assert!(message.contains("HTTP 403 Forbidden"));
1
    assert!(message.contains("API rate limit exceeded"));
1
    assert!(message.contains("https://docs.github.com"));
1
    assert!(message.contains("rate limit reset: 1712345678"));
1
    server.join().unwrap();
1
  }
  #[test]
1
  fn update_url_can_be_overridden_for_tests() {
1
    unsafe {
1
      std::env::set_var("MK_UPDATE_URL", "http://127.0.0.1:9/latest");
1
    }
1
    assert_eq!(CliEntry::update_latest_release_url(), "http://127.0.0.1:9/latest");
1
    unsafe {
1
      std::env::remove_var("MK_UPDATE_URL");
1
    }
1
  }
  #[test]
1
  fn default_config_candidates_include_supported_formats() {
1
    let candidates = CliEntry::default_config_candidates();
1
    assert!(candidates.contains(&"tasks.toml"));
1
    assert!(candidates.contains(&"tasks.json"));
1
    assert!(candidates.contains(&"tasks.lua"));
1
  }
46
  fn os(s: &str) -> std::ffi::OsString {
46
    std::ffi::OsString::from(s)
46
  }
15
  fn expand(args: &[&str]) -> Vec<String> {
46
    CliEntry::expand_hydra_aliases(args.iter().map(|s| os(s)))
15
      .into_iter()
68
      .map(|s| s.to_string_lossy().into_owned())
15
      .collect()
15
  }
  #[test]
1
  fn hydra_svls_expands_to_secrets_vault_list_secrets() {
1
    assert_eq!(
1
      expand(&["mk", "svls"]),
      ["mk", "secrets", "vault", "list-secrets"]
    );
1
  }
  #[test]
1
  fn hydra_svl_expands_to_secrets_vault_list_secrets() {
1
    assert_eq!(expand(&["mk", "svl"]), ["mk", "secrets", "vault", "list-secrets"]);
1
  }
  #[test]
1
  fn hydra_sv_expands_to_secrets_vault_prefix() {
1
    assert_eq!(
1
      expand(&["mk", "sv", "show", "mykey"]),
      ["mk", "secrets", "vault", "show", "mykey"]
    );
1
  }
  #[test]
1
  fn hydra_svi_expands_to_secrets_vault_init_vault() {
1
    assert_eq!(expand(&["mk", "svi"]), ["mk", "secrets", "vault", "init-vault"]);
1
  }
  #[test]
1
  fn hydra_svst_expands_to_secrets_vault_store_secret() {
1
    assert_eq!(
1
      expand(&["mk", "svst", "app/token", "value"]),
      ["mk", "secrets", "vault", "store-secret", "app/token", "value"]
    );
1
  }
  #[test]
1
  fn hydra_svsh_expands_to_secrets_vault_show_secret() {
1
    assert_eq!(
1
      expand(&["mk", "svsh", "app/token"]),
      ["mk", "secrets", "vault", "show-secret", "app/token"]
    );
1
  }
  #[test]
1
  fn hydra_svp_expands_to_secrets_vault_purge_secret() {
1
    assert_eq!(
1
      expand(&["mk", "svp", "app/token"]),
      ["mk", "secrets", "vault", "purge-secret", "app/token"]
    );
1
  }
  #[test]
1
  fn hydra_sve_expands_to_secrets_vault_export_secret() {
1
    assert_eq!(
1
      expand(&["mk", "sve", "app/token", "--output", "out.txt"]),
      [
        "mk",
        "secrets",
        "vault",
        "export-secret",
        "app/token",
        "--output",
        "out.txt"
      ]
    );
1
  }
  #[test]
1
  fn hydra_sk_expands_to_secrets_key() {
1
    assert_eq!(
1
      expand(&["mk", "sk", "generate-key"]),
      ["mk", "secrets", "key", "generate-key"]
    );
1
  }
  #[test]
1
  fn hydra_slk_expands_to_secrets_list_keys() {
1
    assert_eq!(expand(&["mk", "slk"]), ["mk", "secrets", "list-keys"]);
1
  }
  #[test]
1
  fn hydra_sd_expands_to_secrets_doctor() {
1
    assert_eq!(expand(&["mk", "sd"]), ["mk", "secrets", "doctor"]);
1
  }
  #[test]
1
  fn hydra_si_expands_to_secrets_init_vault() {
1
    assert_eq!(expand(&["mk", "si"]), ["mk", "secrets", "init-vault"]);
1
  }
  #[test]
1
  fn hydra_se_expands_to_secrets_export_secret() {
1
    assert_eq!(
1
      expand(&["mk", "se", "app/token", "--output", "out.txt"]),
      [
        "mk",
        "secrets",
        "export-secret",
        "app/token",
        "--output",
        "out.txt"
      ]
    );
1
  }
  #[test]
1
  fn hydra_config_flag_preserved_before_hydra_token() {
1
    assert_eq!(
1
      expand(&["mk", "-c", "my.yaml", "svls"]),
      ["mk", "-c", "my.yaml", "secrets", "vault", "list-secrets"]
    );
1
  }
  #[test]
1
  fn hydra_unknown_token_passes_through_unchanged() {
1
    assert_eq!(expand(&["mk", "run", "my-task"]), ["mk", "run", "my-task"]);
1
  }
  #[test]
1
  fn task_selector_candidates_use_description_fallback() {
1
    let mut task_root = TaskRoot::default();
1
    task_root
1
      .tasks
1
      .insert(String::from("plain"), Task::String(String::from("echo plain")));
1
    let cli = CliEntry {
1
      args: Args {
1
        config: String::from("tasks.yaml"),
1
        task_name: None,
1
        command: None,
1
      },
1
      task_root: Arc::new(task_root),
1
    };
1
    let candidates = cli.task_selector_candidates(&[]);
1
    assert_eq!(candidates.len(), 1);
1
    assert_eq!(candidates[0].name, "plain");
1
    assert_eq!(candidates[0].description, "No description provided");
1
  }
}