1mod completions;
15
16use regex::Regex;
17use std::collections::{HashMap, HashSet};
18use std::fmt::Write as _;
19use std::io::{IsTerminal, Write as _};
20use std::path::Path;
21use std::sync::atomic::{AtomicBool, Ordering};
22
23#[derive(Debug, Clone, PartialEq)]
29pub enum Value {
30 Null,
32 Bool(bool),
34 Int(i64),
36 Float(f64),
38 Str(String),
40 List(Vec<Value>),
42}
43
44impl Value {
45 pub fn as_str(&self) -> Option<&str> {
47 if let Value::Str(s) = self {
48 Some(s)
49 } else {
50 None
51 }
52 }
53 pub fn as_int(&self) -> Option<i64> {
55 if let Value::Int(n) = self {
56 Some(*n)
57 } else {
58 None
59 }
60 }
61 pub fn as_float(&self) -> Option<f64> {
63 match self {
64 Value::Float(f) => Some(*f),
65 Value::Int(n) => Some(*n as f64),
66 _ => None,
67 }
68 }
69 pub fn as_bool(&self) -> Option<bool> {
71 if let Value::Bool(b) = self {
72 Some(*b)
73 } else {
74 None
75 }
76 }
77 pub fn as_list(&self) -> &[Value] {
79 if let Value::List(l) = self {
80 l
81 } else {
82 &[]
83 }
84 }
85 pub fn is_null(&self) -> bool {
87 matches!(self, Value::Null)
88 }
89}
90
91#[derive(Debug, Clone, Default)]
93pub struct Values(Vec<(String, Value)>);
94
95static NULL: Value = Value::Null;
96
97impl Values {
98 pub fn get(&self, name: &str) -> &Value {
100 self.0.iter().find(|(k, _)| k == name).map(|(_, v)| v).unwrap_or(&NULL)
101 }
102 pub fn str(&self, name: &str) -> &str {
104 self.get(name).as_str().unwrap_or("")
105 }
106 pub fn int(&self, name: &str) -> i64 {
108 self.get(name).as_int().unwrap_or(0)
109 }
110 pub fn float(&self, name: &str) -> f64 {
112 self.get(name).as_float().unwrap_or(0.0)
113 }
114 pub fn bool(&self, name: &str) -> bool {
116 self.get(name).as_bool().unwrap_or(false)
117 }
118 pub fn strs(&self, name: &str) -> Vec<&str> {
120 self.get(name).as_list().iter().filter_map(Value::as_str).collect()
121 }
122 pub fn iter(&self) -> impl Iterator<Item = (&str, &Value)> {
124 self.0.iter().map(|(k, v)| (k.as_str(), v))
125 }
126 fn set(&mut self, name: &str, value: Value) {
127 self.0.push((name.to_string(), value));
128 }
129}
130
131enum Json {
136 Null,
137 Bool(bool),
138 Num(String),
139 Str(String),
140 Arr(Vec<Json>),
141 Obj(Vec<(&'static str, Json)>),
142 Map(Vec<(String, Json)>),
143}
144
145fn json_str(s: &str, out: &mut String) {
146 out.push('"');
147 for c in s.chars() {
148 match c {
149 '"' => out.push_str("\\\""),
150 '\\' => out.push_str("\\\\"),
151 '\n' => out.push_str("\\n"),
152 '\r' => out.push_str("\\r"),
153 '\t' => out.push_str("\\t"),
154 c if (c as u32) < 0x20 => {
155 let _ = write!(out, "\\u{:04x}", c as u32);
156 }
157 c => out.push(c),
158 }
159 }
160 out.push('"');
161}
162
163impl Json {
164 fn render(&self, depth: usize, out: &mut String) {
165 let pad = |d: usize, out: &mut String| out.push_str(&" ".repeat(d));
166 match self {
167 Json::Null => out.push_str("null"),
168 Json::Bool(b) => out.push_str(if *b { "true" } else { "false" }),
169 Json::Num(n) => out.push_str(n),
170 Json::Str(s) => json_str(s, out),
171 Json::Arr(items) if items.is_empty() => out.push_str("[]"),
172 Json::Arr(items) => {
173 out.push_str("[\n");
174 for (i, item) in items.iter().enumerate() {
175 pad(depth + 1, out);
176 item.render(depth + 1, out);
177 out.push_str(if i + 1 < items.len() { ",\n" } else { "\n" });
178 }
179 pad(depth, out);
180 out.push(']');
181 }
182 Json::Obj(_) | Json::Map(_) => {
183 let entries: Vec<(&str, &Json)> = match self {
184 Json::Obj(e) => e.iter().map(|(k, v)| (*k, v)).collect(),
185 Json::Map(e) => e.iter().map(|(k, v)| (k.as_str(), v)).collect(),
186 _ => unreachable!(),
187 };
188 if entries.is_empty() {
189 out.push_str("{}");
190 return;
191 }
192 out.push_str("{\n");
193 for (i, (k, v)) in entries.iter().enumerate() {
194 pad(depth + 1, out);
195 json_str(k, out);
196 out.push_str(": ");
197 v.render(depth + 1, out);
198 out.push_str(if i + 1 < entries.len() { ",\n" } else { "\n" });
199 }
200 pad(depth, out);
201 out.push('}');
202 }
203 }
204 }
205
206 fn pretty(&self) -> String {
207 let mut out = String::new();
208 self.render(0, &mut out);
209 out
210 }
211
212 fn from_value(v: &Value) -> Json {
213 match v {
214 Value::Null => Json::Null,
215 Value::Bool(b) => Json::Bool(*b),
216 Value::Int(n) => Json::Num(n.to_string()),
217 Value::Float(f) => Json::Num(if f.is_finite() { format!("{f}") } else { "null".into() }),
218 Value::Str(s) => Json::Str(s.clone()),
219 Value::List(l) => Json::Arr(l.iter().map(Json::from_value).collect()),
220 }
221 }
222}
223
224static SILENT: AtomicBool = AtomicBool::new(false);
229
230pub fn set_silent(value: bool) {
232 SILENT.store(value, Ordering::Relaxed);
233}
234
235#[cfg(unix)]
236fn timestamp() -> String {
237 #[repr(C)]
240 struct Tm {
241 sec: i32,
242 min: i32,
243 hour: i32,
244 mday: i32,
245 mon: i32,
246 year: i32,
247 wday: i32,
248 yday: i32,
249 isdst: i32,
250 gmtoff: i64,
251 zone: *const i8,
252 }
253 extern "C" {
254 fn time(t: *mut i64) -> i64;
255 fn localtime_r(t: *const i64, tm: *mut Tm) -> *mut Tm;
256 }
257 let mut tm =
258 Tm { sec: 0, min: 0, hour: 0, mday: 0, mon: 0, year: 0, wday: 0, yday: 0, isdst: 0, gmtoff: 0, zone: std::ptr::null() };
259 unsafe {
260 let now = time(std::ptr::null_mut());
261 localtime_r(&now, &mut tm);
262 }
263 format!("{:04}-{:02}-{:02} {:02}:{:02}:{:02}", tm.year + 1900, tm.mon + 1, tm.mday, tm.hour, tm.min, tm.sec)
264}
265
266#[cfg(not(unix))]
267fn timestamp() -> String {
268 let secs = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs() as i64).unwrap_or(0);
270 let (days, rem) = (secs.div_euclid(86400), secs.rem_euclid(86400));
271 let z = days + 719468;
272 let era = z.div_euclid(146097);
273 let doe = z - era * 146097;
274 let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
275 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
276 let mp = (5 * doy + 2) / 153;
277 let d = doy - (153 * mp + 2) / 5 + 1;
278 let m = if mp < 10 { mp + 3 } else { mp - 9 };
279 let y = yoe + era * 400 + if m <= 2 { 1 } else { 0 };
280 format!("{y:04}-{m:02}-{d:02} {:02}:{:02}:{:02}", rem / 3600, rem / 60 % 60, rem % 60)
281}
282
283fn emit(level: &str, msg: &str, force: bool) {
284 if !force && (SILENT.load(Ordering::Relaxed) || std::env::var("CLYOPS_SILENT").as_deref() == Ok("true")) {
285 return;
286 }
287 let mut tag = level.to_string();
288 if std::io::stderr().is_terminal() && std::env::var_os("NO_COLOR").is_none() {
289 let color = match level {
290 "info" => "1;37",
291 "warning" => "0;33",
292 "success" => "0;32",
293 _ => "0;31",
294 };
295 tag = format!("\x1b[{color}m{level}\x1b[0m");
296 }
297 let _ = writeln!(std::io::stderr(), "{} [{}] {}", timestamp(), tag, msg);
298}
299
300pub fn info(msg: &str) {
302 emit("info", msg, false)
303}
304pub fn warn(msg: &str) {
306 emit("warning", msg, false)
307}
308pub fn error(msg: &str) {
310 emit("error", msg, false)
311}
312pub fn success(msg: &str) {
314 emit("success", msg, false)
315}
316
317pub fn die(code: i32, msg: &str) -> ! {
319 emit("error", msg, true);
320 std::process::exit(code)
321}
322
323fn re(pattern: &str) -> Regex {
328 Regex::new(pattern).expect("built-in pattern")
329}
330
331fn full(pattern: &str, value: &str) -> bool {
332 re(&format!("^(?:{pattern})$")).is_match(value)
333}
334
335fn bool_word(value: &str) -> Option<bool> {
336 match value.to_ascii_lowercase().as_str() {
337 "true" | "yes" | "1" | "on" => Some(true),
338 "false" | "no" | "0" | "off" => Some(false),
339 _ => None,
340 }
341}
342
343const FIXED_RULES: &[&str] = &[
344 "int",
345 "float",
346 "string",
347 "path",
348 "ip",
349 "hostname",
350 "url",
351 "port",
352 "email",
353 "uuid",
354 "bool",
355 "date:YYYY-MM-DD",
356 "file:exists",
357 "file:readable",
358 "file:writable",
359 "dir:exists",
360 "dir:writable",
361];
362
363fn known_rule(rule: &str) -> bool {
364 rule.is_empty()
365 || FIXED_RULES.contains(&rule)
366 || full(r"int:(\d+-\d*|-\d+)", rule)
367 || full(r"float:(\d*\.?\d+-(\d*\.?\d+)?|-\d*\.?\d+)", rule)
368 || full(r"string:(\d+|\d+-\d*|-\d+)", rule)
369 || (rule.starts_with("choice:") && rule.len() > 7)
370 || (rule.starts_with("regex:") && rule.len() > 6 && Regex::new(&rule[6..]).is_ok())
371}
372
373fn bounds(rule: &str) -> (&str, &str) {
374 let range = &rule[rule.find(':').map_or(0, |i| i + 1)..];
375 range.split_once('-').unwrap_or((range, ""))
376}
377
378fn is_path_rule(rule: &str) -> bool {
379 rule == "path" || rule.starts_with("file:") || rule.starts_with("dir:")
380}
381
382pub fn describe_rule(rule: &str) -> String {
384 let fixed = match rule {
385 "int" => "integer",
386 "float" => "number",
387 "string" => "text",
388 "path" => "path",
389 "ip" => "IP address",
390 "hostname" => "hostname",
391 "url" => "URL",
392 "port" => "port: 1-65535",
393 "email" => "email address",
394 "uuid" => "UUID",
395 "bool" => "true/false, yes/no, 1/0, on/off",
396 "date:YYYY-MM-DD" => "date: YYYY-MM-DD",
397 "file:exists" => "existing file",
398 "file:readable" => "readable file",
399 "file:writable" => "writable file",
400 "dir:exists" => "existing directory",
401 "dir:writable" => "writable directory",
402 _ => "",
403 };
404 if !fixed.is_empty() {
405 return fixed.to_string();
406 }
407 for (prefix, noun, suffix) in [("int:", "integer", ""), ("float:", "number", ""), ("string:", "text", " chars")] {
408 if let Some(range) = rule.strip_prefix(prefix) {
409 if !range.contains('-') {
410 return format!("{noun}: {range}{suffix}");
411 }
412 let (lo, hi) = bounds(rule);
413 return match (lo.is_empty(), hi.is_empty()) {
414 (false, false) => format!("{noun}: {lo}-{hi}{suffix}"),
415 (false, true) => format!("{noun}: >={lo}{suffix}"),
416 _ => format!("{noun}: <={hi}{suffix}"),
417 };
418 }
419 }
420 if let Some(c) = rule.strip_prefix("choice:") {
421 return format!("choices: {}", c.split(',').collect::<Vec<_>>().join(", "));
422 }
423 if let Some(p) = rule.strip_prefix("regex:") {
424 return format!("pattern: {p}");
425 }
426 rule.to_string()
427}
428
429#[cfg(unix)]
430fn access(path: &str, mode: i32) -> bool {
431 extern "C" {
432 fn access(path: *const std::os::raw::c_char, mode: std::os::raw::c_int) -> std::os::raw::c_int;
433 }
434 match std::ffi::CString::new(path) {
435 Ok(c) => unsafe { access(c.as_ptr(), mode) == 0 },
436 Err(_) => false,
437 }
438}
439
440#[cfg(not(unix))]
441fn access(path: &str, mode: i32) -> bool {
442 match std::fs::metadata(path) {
443 Ok(m) => mode != W_OK || !m.permissions().readonly(),
444 Err(_) => false,
445 }
446}
447
448const R_OK: i32 = 4;
449const W_OK: i32 = 2;
450
451pub fn validate(value: &str, rule: &str, name: &str) -> Result<Value, String> {
454 let fail = |msg: String| Err(format!("{name} {msg}"));
455 let check_bounds = |num: f64, (lo, hi): (&str, &str)| -> Result<(), String> {
456 if !lo.is_empty() && num < lo.parse::<f64>().unwrap_or(f64::MIN) {
457 return Err(format!("{name} must be >= {lo}, got {value}"));
458 }
459 if !hi.is_empty() && num > hi.parse::<f64>().unwrap_or(f64::MAX) {
460 return Err(format!("{name} must be <= {hi}, got {value}"));
461 }
462 Ok(())
463 };
464
465 if rule == "int" || rule.starts_with("int:") {
466 if !full("-?[0-9]+", value) {
467 return fail(format!("must be an integer, got '{value}'"));
468 }
469 let num: i64 = value.parse().map_err(|_| format!("{name} must be an integer, got '{value}'"))?;
470 if rule != "int" {
471 check_bounds(num as f64, bounds(rule))?;
472 }
473 return Ok(Value::Int(num));
474 }
475 if rule == "float" || rule.starts_with("float:") {
476 if !full(r"-?[0-9]*\.?[0-9]+", value) {
477 return fail(format!("must be a number, got '{value}'"));
478 }
479 let num: f64 = value.parse().unwrap_or(0.0);
480 if rule != "float" {
481 check_bounds(num, bounds(rule))?;
482 }
483 return Ok(Value::Float(num));
484 }
485 if let Some(spec) = rule.strip_prefix("string:") {
486 let len = value.chars().count();
487 if !spec.contains('-') {
488 if len != spec.parse::<usize>().unwrap_or(0) {
489 return fail(format!("must be exactly {spec} characters, got {len}"));
490 }
491 } else {
492 let (lo, hi) = bounds(rule);
493 if !lo.is_empty() && len < lo.parse().unwrap_or(0) {
494 return fail(format!("must be at least {lo} characters, got {len}"));
495 }
496 if !hi.is_empty() && len > hi.parse().unwrap_or(usize::MAX) {
497 return fail(format!("must be at most {hi} characters, got {len}"));
498 }
499 }
500 return Ok(Value::Str(value.into()));
501 }
502 if let Some(choices) = rule.strip_prefix("choice:") {
503 if !choices.split(',').any(|c| c == value) {
504 return fail(format!("must be one of: {}, got '{value}'", choices.split(',').collect::<Vec<_>>().join(", ")));
505 }
506 return Ok(Value::Str(value.into()));
507 }
508 if let Some(pattern) = rule.strip_prefix("regex:") {
509 if !re(pattern).is_match(value) {
510 return fail(format!("does not match required pattern, got '{value}'"));
511 }
512 return Ok(Value::Str(value.into()));
513 }
514
515 let ok = match rule {
516 "bool" => {
517 return match bool_word(value) {
518 Some(b) => Ok(Value::Bool(b)),
519 None => fail(format!("must be a boolean (true/false, yes/no, 1/0, on/off), got '{value}'")),
520 }
521 }
522 "port" => {
523 return match value.parse::<i64>() {
524 Ok(p) if full("[0-9]+", value) && (1..=65535).contains(&p) => Ok(Value::Int(p)),
525 _ => fail(format!("must be a valid port (1-65535), got '{value}'")),
526 }
527 }
528 "ip" => full(r"([0-9]{1,3}\.){3}[0-9]{1,3}", value) || full("([0-9a-fA-F]{0,4}:){1,7}[0-9a-fA-F]{0,4}", value),
529 "hostname" => full(r"[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*", value),
530 "url" => full(r"https?://[a-zA-Z0-9.-]+(:[0-9]+)?(/(?s:.)*)?", value),
531 "email" => full(r"[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}", value),
532 "uuid" => full("[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}", value),
533 "date:YYYY-MM-DD" => full("[0-9]{4}-[0-9]{2}-[0-9]{2}", value),
534 _ => true,
535 };
536 if !ok {
537 let what = match rule {
538 "ip" => "must be a valid IP address".to_string(),
539 "hostname" => "must be a valid hostname".to_string(),
540 "url" => "must be a valid URL".to_string(),
541 "email" => "must be a valid email address".to_string(),
542 "uuid" => "must be a valid UUID".to_string(),
543 _ => "must be in YYYY-MM-DD format".to_string(),
544 };
545 return fail(format!("{what}, got '{value}'"));
546 }
547
548 let p = Path::new(value);
549 match rule {
550 "file:exists" if !p.is_file() => return fail(format!("file does not exist: {value}")),
551 "file:readable" if !access(value, R_OK) => return fail(format!("file is not readable: {value}")),
552 "file:writable" => {
553 if p.symlink_metadata().is_ok() {
554 if !access(value, W_OK) {
555 return fail(format!("file is not writable: {value}"));
556 }
557 } else {
558 let dir = dirname(value);
559 if !Path::new(&dir).is_dir() || !access(&dir, W_OK) {
560 return fail(format!("directory is not writable: {dir}"));
561 }
562 }
563 }
564 "dir:exists" if !p.is_dir() => return fail(format!("directory does not exist: {value}")),
565 "dir:writable" if !p.is_dir() || !access(value, W_OK) => {
566 return fail(format!("directory does not exist or is not writable: {value}"))
567 }
568 _ => {}
569 }
570 Ok(Value::Str(value.into()))
571}
572
573fn dirname(path: &str) -> String {
574 match path.rfind('/') {
575 Some(0) => "/".into(),
576 Some(i) => path[..i].into(),
577 None => ".".into(),
578 }
579}
580
581fn join_norm(base: &str, value: &str) -> String {
583 let joined = if value.starts_with('/') { value.to_string() } else { format!("{base}/{value}") };
584 let mut parts: Vec<&str> = Vec::new();
585 for part in joined.split('/') {
586 match part {
587 "" | "." => {}
588 ".." => {
589 parts.pop();
590 }
591 p => parts.push(p),
592 }
593 }
594 format!("/{}", parts.join("/"))
595}
596
597pub fn resolve_path(value: &str, base: &str, search_dirs: &[String]) -> String {
599 if value.is_empty() || ["-", "disabled", "optional"].contains(&value) || value.starts_with('/') {
600 return value.to_string();
601 }
602 if re("^[A-Za-z][A-Za-z0-9+.-]+:").is_match(value) {
603 return value.to_string();
604 }
605 let from_base = join_norm(base, value);
606 let bare = !(value == "." || value == ".." || value.starts_with("./") || value.starts_with("../"));
607 if bare && !search_dirs.is_empty() && !Path::new(&from_base).exists() {
608 for dir in search_dirs {
609 let candidate = join_norm(dir, value);
610 if Path::new(&candidate).exists() {
611 return candidate;
612 }
613 }
614 }
615 from_base
616}
617
618pub fn wrap_text(text: &str, width: usize) -> Vec<String> {
620 let mut out = Vec::new();
621 for original in text.split('\n') {
622 let original = original.strip_suffix('\r').unwrap_or(original);
623 let mut line = String::new();
624 let mut any = false;
625 for word in original.split_whitespace() {
626 any = true;
627 if line.is_empty() {
628 line = word.to_string();
629 } else if line.chars().count() + 1 + word.chars().count() <= width {
630 line.push(' ');
631 line.push_str(word);
632 } else {
633 out.push(std::mem::replace(&mut line, word.to_string()));
634 }
635 }
636 out.push(if any { line } else { String::new() });
637 }
638 out
639}
640
641fn command_available(cmd: &str, path_var: &str) -> bool {
642 let is_exec = |p: &Path| -> bool {
643 match p.metadata() {
644 #[cfg(unix)]
645 Ok(m) => m.is_file() && std::os::unix::fs::PermissionsExt::mode(&m.permissions()) & 0o111 != 0,
646 #[cfg(not(unix))]
647 Ok(m) => m.is_file(),
648 Err(_) => false,
649 }
650 };
651 if cmd.contains('/') {
652 return is_exec(Path::new(cmd));
653 }
654 std::env::split_paths(path_var).any(|dir| is_exec(&dir.join(cmd)))
655}
656
657fn completion_kind(rule: &str, dirs: &[String]) -> (&'static str, String) {
658 if rule == "path" || rule.starts_with("file:") {
659 ("file", dirs.join(":"))
660 } else if rule.starts_with("dir:") {
661 ("dir", dirs.join(":"))
662 } else if let Some(c) = rule.strip_prefix("choice:") {
663 ("choice", c.to_string())
664 } else if rule == "bool" {
665 ("choice", "true,false".into())
666 } else if rule == "hostname" || rule == "ip" {
667 ("host", String::new())
668 } else if rule.is_empty() {
669 ("default", String::new())
670 } else {
671 ("none", String::new())
672 }
673}
674
675#[derive(Debug, Clone, Copy, PartialEq)]
680enum Kind {
681 Flag,
682 Value,
683 Array,
684}
685
686#[derive(Debug, Clone)]
687struct Opt {
688 var: String,
689 long: String,
690 short: String,
691 kind: Kind,
692 default: String,
693 required: bool,
694 description: String,
695 group: String,
696 rule: String,
697 search_dirs: Vec<String>,
698 secret: bool,
699}
700
701impl Opt {
702 fn label(&self) -> String {
703 let head =
704 if self.short.is_empty() { format!(" --{}", self.long) } else { format!("-{}, --{}", self.short, self.long) };
705 if self.kind == Kind::Flag {
706 head
707 } else {
708 head + "=<value>"
709 }
710 }
711 fn bool_like(&self) -> bool {
712 self.kind == Kind::Flag || ["bool", "choice:true,false", "choice:false,true"].contains(&self.rule.as_str())
713 }
714}
715
716#[derive(Debug, Clone)]
717struct Arg {
718 name: String,
719 description: String,
720 default: String,
721 rule: String,
722 variadic: bool,
723}
724
725#[derive(Debug, Clone)]
726enum Raw {
727 One(String),
728 Many(Vec<String>),
729}
730
731#[derive(Debug, Clone, PartialEq)]
733pub enum Parsed {
734 Ok,
736 Help,
738 Error {
740 message: String,
742 show_usage: bool,
744 detail: Vec<String>,
746 },
747}
748
749impl Parsed {
750 fn err(message: String) -> Parsed {
751 Parsed::Error { message, show_usage: true, detail: Vec::new() }
752 }
753}
754
755pub struct OptRef<'a> {
757 cli: &'a mut Cli,
758 index: usize,
759}
760
761impl<'a> OptRef<'a> {
762 pub fn group(self, group: &str) -> Self {
764 self.cli.options[self.index].group = group.to_string();
765 self
766 }
767 pub fn rule(self, rule: &str) -> Self {
770 let long = self.cli.options[self.index].long.clone();
771 let (secret, rule) = match rule.strip_prefix("secret") {
772 Some("") => (true, ""),
773 Some(r) if r.starts_with(':') => (true, &r[1..]),
774 _ => (false, rule),
775 };
776 if !known_rule(rule) {
777 panic!("Unknown validation rule '{rule}' for --{long}");
778 }
779 self.cli.options[self.index].rule = rule.to_string();
780 self.cli.options[self.index].secret = secret;
781 self
782 }
783}
784
785pub struct ArgRef<'a> {
787 cli: &'a mut Cli,
788 index: usize,
789}
790
791impl<'a> ArgRef<'a> {
792 pub fn rule(self, rule: &str) -> Self {
794 let name = self.cli.args[self.index].name.clone();
795 if !known_rule(rule) {
796 panic!("Unknown validation rule '{rule}' for {name}");
797 }
798 self.cli.args[self.index].rule = rule.to_string();
799 self
800 }
801}
802
803#[derive(Debug, Clone)]
804pub struct Cli {
806 name: String,
807 root: String,
808 cwd: String,
809 env: HashMap<String, String>,
810 description: String,
811 epilog: String,
812 options: Vec<Opt>,
813 args: Vec<Arg>,
814 commands: Vec<(String, String, String)>,
815 config_option: String,
816 config_prefixes: Vec<String>,
817 raw: HashMap<String, Raw>,
818 arg_raw: HashMap<String, Raw>,
819 sources: HashMap<String, &'static str>,
820 config: HashMap<String, (String, String)>,
821 values: Values,
822 effects: Vec<String>,
823 stdin: Option<(String, String)>,
824 stdout: Option<(String, String)>,
825 constraints: Vec<(&'static str, Vec<String>)>,
826 children: Vec<Cli>,
827 word: String,
828 inherited: Vec<String>,
830 selected: Vec<usize>,
832}
833
834impl Default for Cli {
835 fn default() -> Self {
836 Self::new()
837 }
838}
839
840impl Cli {
841 pub fn new() -> Cli {
843 let name = std::env::args().next().map(|a| a.rsplit('/').next().unwrap_or("").to_string()).unwrap_or_default();
844 let cwd = std::env::current_dir().map(|p| p.to_string_lossy().into_owned()).unwrap_or_else(|_| "/".into());
845 Cli {
846 name,
847 root: cwd.clone(),
848 cwd,
849 env: std::env::vars().collect(),
850 description: String::new(),
851 epilog: String::new(),
852 options: Vec::new(),
853 args: Vec::new(),
854 commands: Vec::new(),
855 config_option: String::new(),
856 config_prefixes: Vec::new(),
857 raw: HashMap::new(),
858 arg_raw: HashMap::new(),
859 sources: HashMap::new(),
860 config: HashMap::new(),
861 values: Values::default(),
862 effects: Vec::new(),
863 stdin: None,
864 stdout: None,
865 constraints: Vec::new(),
866 children: Vec::new(),
867 word: String::new(),
868 inherited: Vec::new(),
869 selected: Vec::new(),
870 }
871 }
872
873 pub fn name(&mut self, name: &str) -> &mut Self {
875 self.name = name.into();
876 self
877 }
878 pub fn root(&mut self, root: &str) -> &mut Self {
880 self.root = join_norm(&self.cwd, root);
881 self
882 }
883 pub fn cwd(&mut self, cwd: &str) -> &mut Self {
885 self.cwd = cwd.into();
886 self
887 }
888 pub fn env<I: IntoIterator<Item = (String, String)>>(&mut self, env: I) -> &mut Self {
890 self.env = env.into_iter().collect();
891 self
892 }
893 pub fn description(&mut self, text: &str) -> &mut Self {
895 self.description = text.into();
896 self
897 }
898 pub fn epilog(&mut self, text: &str) -> &mut Self {
900 self.epilog = text.into();
901 self
902 }
903
904 pub fn effects(&mut self, effects: &[&str]) -> &mut Self {
906 for e in effects {
907 if !["read-only", "idempotent", "destructive", "network"].contains(e) {
908 panic!("Unknown effect '{e}'");
909 }
910 }
911 self.effects = effects.iter().map(|e| e.to_string()).collect();
912 self
913 }
914 pub fn stdin(&mut self, description: &str, content_type: &str) -> &mut Self {
916 self.stdin = Some((description.into(), content_type.into()));
917 self
918 }
919 pub fn stdout(&mut self, description: &str, content_type: &str) -> &mut Self {
921 self.stdout = Some((description.into(), content_type.into()));
922 self
923 }
924 pub fn exclusive(&mut self, longs: &[&str]) -> &mut Self {
926 self.add_constraint("exclusive", longs.iter().copied())
927 }
928 pub fn requires(&mut self, long: &str, longs: &[&str]) -> &mut Self {
930 self.add_constraint("requires", std::iter::once(long).chain(longs.iter().copied()))
931 }
932 pub fn one_of(&mut self, longs: &[&str]) -> &mut Self {
934 self.add_constraint("oneOf", longs.iter().copied())
935 }
936
937 fn add_constraint<'a>(&mut self, kind: &'static str, longs: impl Iterator<Item = &'a str>) -> &mut Self {
938 let longs: Vec<String> = longs.map(String::from).collect();
939 for long in &longs {
940 if !self.options.iter().any(|o| &o.long == long) && !self.inherited.contains(long) {
941 panic!("Unknown option --{long} in constraint");
942 }
943 }
944 self.constraints.push((kind, longs));
945 self
946 }
947
948 pub fn command(&mut self, name: &str, description: &str) -> &mut Cli {
950 if !self.args.is_empty() {
951 panic!("Cannot mix commands and positional arguments");
952 }
953 if self.children.iter().any(|c| c.word == name) {
954 panic!("Duplicate command {name}");
955 }
956 let mut child = Cli::new();
957 child.name = format!("{} {name}", self.name);
958 child.root = self.root.clone();
959 child.cwd = self.cwd.clone();
960 child.description = description.into();
961 child.word = name.into();
962 child.inherited = self.inherited.iter().cloned().chain(self.options.iter().map(|o| o.long.clone())).collect();
963 self.children.push(child);
964 self.children.last_mut().expect("just pushed")
965 }
966
967 pub fn command_path(&self) -> Vec<String> {
969 let mut node = self;
970 let mut out = Vec::new();
971 for &i in &self.selected {
972 node = &node.children[i];
973 out.push(node.word.clone());
974 }
975 out
976 }
977
978 fn chain(&self) -> Vec<&Cli> {
980 let mut node = self;
981 let mut out = vec![self];
982 for &i in &self.selected {
983 node = &node.children[i];
984 out.push(node);
985 }
986 out.reverse();
987 out
988 }
989
990 fn node(&self) -> &Cli {
991 self.chain()[0]
992 }
993
994 fn chain_options(&self) -> Vec<Opt> {
995 self.chain().iter().flat_map(|n| n.options.iter().cloned()).collect()
996 }
997
998 fn chain_find(&self, long: &str) -> Option<Opt> {
999 self.chain_options().into_iter().find(|o| o.long == long)
1000 }
1001
1002 pub fn config(&mut self, option: &str, prefixes: &str) -> &mut Self {
1004 self.config_option = option.into();
1005 self.config_prefixes = prefixes.split(',').map(str::trim).filter(|p| !p.is_empty()).map(String::from).collect();
1006 self
1007 }
1008
1009 pub fn require_command(&mut self, command: &str, description: &str, install_hint: &str) -> &mut Self {
1011 self.commands.push((command.into(), description.into(), install_hint.into()));
1012 self
1013 }
1014
1015 pub fn path_search(&mut self, long: &str, dirs: &str) -> &mut Self {
1017 let root = self.root.clone();
1018 let opt =
1019 self.options.iter_mut().find(|o| o.long == long).unwrap_or_else(|| panic!("path_search: unknown option --{long}"));
1020 opt.search_dirs = dirs.split(':').filter(|d| !d.is_empty()).map(|d| join_norm(&root, d)).collect();
1021 if opt.rule.is_empty() {
1022 opt.rule = "path".into();
1023 }
1024 self
1025 }
1026
1027 pub fn opt(&mut self, var: &str, long: &str, short: &str, default: &str, description: &str) -> OptRef<'_> {
1029 let kind = if default == "flag" { Kind::Flag } else { Kind::Value };
1030 let dflt = if default == "flag" || default == "optional" { "" } else { default };
1031 self.add_opt(var, long, short, kind, dflt, default.is_empty(), description)
1032 }
1033
1034 pub fn opt_array(&mut self, var: &str, long: &str, short: &str, description: &str) -> OptRef<'_> {
1036 self.add_opt(var, long, short, Kind::Array, "", false, description)
1037 }
1038
1039 pub fn arg(&mut self, name: &str, description: &str, default: &str, rule: &str) -> ArgRef<'_> {
1041 self.add_arg(name, description, default, false).rule(rule)
1042 }
1043
1044 pub fn arg_variadic(&mut self, name: &str, description: &str, rule: &str) -> ArgRef<'_> {
1046 self.add_arg(name, description, "", true).rule(rule)
1047 }
1048
1049 #[allow(clippy::too_many_arguments)]
1050 fn add_opt(
1051 &mut self,
1052 var: &str,
1053 long: &str,
1054 short: &str,
1055 kind: Kind,
1056 default: &str,
1057 required: bool,
1058 description: &str,
1059 ) -> OptRef<'_> {
1060 if self.options.iter().any(|o| o.long == long) {
1061 panic!("Duplicate option --{long}");
1062 }
1063 if !short.is_empty() && (short.chars().count() != 1 || self.options.iter().any(|o| o.short == short)) {
1064 panic!("Invalid or duplicate short option -{short}");
1065 }
1066 self.options.push(Opt {
1067 var: var.into(),
1068 long: long.into(),
1069 short: short.into(),
1070 kind,
1071 default: default.into(),
1072 required,
1073 description: description.into(),
1074 group: "Options".into(),
1075 rule: String::new(),
1076 search_dirs: Vec::new(),
1077 secret: false,
1078 });
1079 let index = self.options.len() - 1;
1080 OptRef { cli: self, index }
1081 }
1082
1083 fn add_arg(&mut self, name: &str, description: &str, default: &str, variadic: bool) -> ArgRef<'_> {
1084 if !self.children.is_empty() {
1085 panic!("Cannot mix commands and positional arguments");
1086 }
1087 if self.args.iter().any(|a| a.variadic) {
1088 panic!("Argument {name} registered after a variadic argument");
1089 }
1090 self.args.push(Arg {
1091 name: name.into(),
1092 description: description.into(),
1093 default: default.into(),
1094 rule: String::new(),
1095 variadic,
1096 });
1097 let index = self.args.len() - 1;
1098 ArgRef { cli: self, index }
1099 }
1100
1101 fn ensure_help(&mut self) {
1102 if !self.options.iter().any(|o| o.long == "help") {
1103 let short = if self.options.iter().any(|o| o.short == "h") { "" } else { "h" };
1104 self.add_opt("HELP", "help", short, Kind::Flag, "", false, "Show this help message and exit").group("Global");
1105 }
1106 }
1107
1108 pub fn parse<S: AsRef<str>>(&mut self, argv: &[S]) -> Parsed {
1112 let argv: Vec<String> = argv.iter().map(|s| s.as_ref().to_string()).collect();
1113 self.raw.clear();
1114 self.arg_raw.clear();
1115 self.sources.clear();
1116 self.config.clear();
1117 self.values = Values::default();
1118 self.selected.clear();
1119 self.ensure_help();
1120
1121 let mut result = self.scan(&argv);
1122 if result.is_ok() && !self.config_option.is_empty() {
1123 result = self.load_config();
1124 }
1125 if matches!(self.raw.get("help"), Some(Raw::One(v)) if v == "true") {
1126 return Parsed::Help;
1127 }
1128 if let Err(e) = result.and_then(|_| self.resolve()) {
1129 return Parsed::err(e);
1130 }
1131
1132 let path_var = self.env.get("PATH").cloned().unwrap_or_default();
1133 let required: Vec<(String, String, String)> =
1134 self.chain().iter().rev().flat_map(|n| n.commands.iter().cloned()).collect();
1135 let missing: Vec<&(String, String, String)> = required.iter().filter(|c| !command_available(&c.0, &path_var)).collect();
1136 if !missing.is_empty() {
1137 let mut detail = Vec::new();
1138 for (cmd, desc, hint) in &missing {
1139 detail.push(format!(" {cmd} - {desc}"));
1140 if !hint.is_empty() {
1141 detail.push(format!(" Install: {hint}"));
1142 }
1143 }
1144 let names: Vec<&str> = missing.iter().map(|c| c.0.as_str()).collect();
1145 return Parsed::Error {
1146 message: format!("Missing required command(s): {}", names.join(", ")),
1147 show_usage: false,
1148 detail,
1149 };
1150 }
1151
1152 let missing: Vec<String> = self
1153 .chain_options()
1154 .iter()
1155 .filter(|o| o.required && !matches!(self.raw.get(&o.long), Some(Raw::One(v)) if !v.is_empty()))
1156 .map(|o| format!("--{}", o.long))
1157 .collect();
1158 if !missing.is_empty() {
1159 return Parsed::err(format!("Missing required argument(s): {}", missing.join(" ")));
1160 }
1161 match self.check_constraints() {
1162 Some(e) => Parsed::err(e),
1163 None => Parsed::Ok,
1164 }
1165 }
1166
1167 fn check_constraints(&self) -> Option<String> {
1169 let given = |long: &str| {
1170 let var = self.chain_find(long).map(|o| o.var).unwrap_or_default();
1171 matches!(self.source(long), "cli" | "config" | "env")
1172 && !matches!(self.values.get(&var), Value::Bool(false))
1173 && !matches!(self.values.get(&var), Value::List(l) if l.is_empty())
1174 };
1175 for node in self.chain().iter().rev() {
1176 for (kind, longs) in &node.constraints {
1177 let on: Vec<&String> = longs.iter().filter(|l| given(l)).collect();
1178 match *kind {
1179 "exclusive" if on.len() > 1 => {
1180 return Some(format!("Options --{} and --{} cannot be used together", on[0], on[1]))
1181 }
1182 "requires" if given(&longs[0]) => {
1183 if let Some(absent) = longs[1..].iter().find(|l| !given(l)) {
1184 return Some(format!("Option --{} requires --{absent}", longs[0]));
1185 }
1186 }
1187 "oneOf" if on.is_empty() => return Some(format!("One of --{} is required", longs.join(", --"))),
1188 _ => {}
1189 }
1190 }
1191 }
1192 None
1193 }
1194
1195 fn set_cli(&mut self, opt: &Opt, value: String) {
1196 let long = opt.long.clone();
1197 if opt.kind == Kind::Array {
1198 let mut list = match (self.sources.get(&long), self.raw.remove(&long)) {
1199 (Some(&"cli"), Some(Raw::Many(l))) => l,
1200 _ => Vec::new(),
1201 };
1202 list.push(value);
1203 self.raw.insert(long.clone(), Raw::Many(list));
1204 } else {
1205 self.raw.insert(long.clone(), Raw::One(value));
1206 }
1207 self.sources.insert(long, "cli");
1208 }
1209
1210 fn scan(&mut self, argv: &[String]) -> Result<(), String> {
1211 let mut pos = 0;
1212 let mut rest: Option<Vec<String>> = None;
1213 let mut variadic_name = String::new();
1214 let mut end_of_options = false;
1215 let mut i = 0;
1216 let result = (|| {
1217 while i < argv.len() {
1218 let token = argv[i].clone();
1219 i += 1;
1220 if end_of_options || token == "-" || !token.starts_with('-') {
1221 let node = self.node();
1222 if let Some(r) = rest.as_mut() {
1223 r.push(token);
1224 } else if !node.children.is_empty() {
1225 match node.children.iter().position(|c| c.word == token) {
1226 Some(i) => self.selected.push(i),
1227 None => return Err(format!("Unknown command: {token}")),
1228 }
1229 } else if pos >= node.args.len() {
1230 return Err(format!("Unexpected argument: {token}"));
1231 } else {
1232 let arg = node.args[pos].clone();
1233 pos += 1;
1234 if arg.variadic {
1235 variadic_name = arg.name.clone();
1236 rest = Some(vec![token]);
1237 } else {
1238 self.arg_raw.insert(arg.name.clone(), Raw::One(token));
1239 }
1240 }
1241 } else if token == "--" {
1242 end_of_options = true;
1243 } else if let Some(body) = token.strip_prefix("--") {
1244 let (name, value) = match body.split_once('=') {
1245 Some((n, v)) => (n.to_string(), Some(v.to_string())),
1246 None => (body.to_string(), None),
1247 };
1248 if let Some(opt) = self.chain_find(&name) {
1249 let kind = opt.kind;
1250 match value {
1251 Some(v) if kind == Kind::Flag => match bool_word(&v) {
1252 Some(b) => self.set_cli(&opt, b.to_string()),
1253 None => return Err(format!("Option --{name} expects a boolean value, got '{v}'")),
1254 },
1255 Some(v) => self.set_cli(&opt, v),
1256 None if kind == Kind::Flag => self.set_cli(&opt, "true".into()),
1257 None => match argv.get(i) {
1258 Some(next) if !next.starts_with("--") => {
1259 let next = next.clone();
1260 self.set_cli(&opt, next);
1261 i += 1;
1262 }
1263 _ => return Err(format!("Option --{name} requires an argument")),
1264 },
1265 }
1266 } else if let (Some(target), None) = (name.strip_prefix("no-").and_then(|n| self.chain_find(n)), &value) {
1267 if !target.bool_like() {
1268 return Err(format!("Option --{name} can only be used with flag/boolean options"));
1269 }
1270 self.set_cli(&target, "false".into());
1271 } else {
1272 return Err(format!("Unknown option: --{name}"));
1273 }
1274 } else {
1275 let cluster: Vec<char> = token[1..].chars().collect();
1276 for (j, ch) in cluster.iter().enumerate() {
1277 let opt = self
1278 .chain_options()
1279 .into_iter()
1280 .find(|o| o.short == ch.to_string())
1281 .ok_or(format!("Unknown option: -{ch}"))?;
1282 if opt.kind == Kind::Flag {
1283 self.set_cli(&opt, "true".into());
1284 continue;
1285 }
1286 if j + 1 < cluster.len() {
1287 self.set_cli(&opt, cluster[j + 1..].iter().collect());
1288 break;
1289 }
1290 match argv.get(i) {
1291 Some(next) if !next.starts_with('-') => {
1292 let next = next.clone();
1293 self.set_cli(&opt, next);
1294 i += 1;
1295 }
1296 _ => return Err(format!("Option -{ch} requires an argument")),
1297 }
1298 }
1299 }
1300 }
1301 Ok(())
1302 })();
1303 if let Some(r) = rest {
1304 self.arg_raw.insert(variadic_name, Raw::Many(r));
1305 }
1306 result
1307 }
1308
1309 fn load_config(&mut self) -> Result<(), String> {
1310 let Some(opt) = self.chain_find(&self.config_option.clone()) else { return Ok(()) };
1311 let (path, source) = match self.raw.get(&opt.long) {
1312 Some(Raw::One(v)) => (v.clone(), "cli"),
1313 _ => match self.env.get(&opt.var).filter(|v| !v.is_empty()) {
1314 Some(v) => (v.clone(), "env"),
1315 None => (opt.default.clone(), "default"),
1316 },
1317 };
1318 if path.is_empty() || path == "disabled" {
1319 return Ok(());
1320 }
1321 let resolved = resolve_path(&path, &self.cwd, &opt.search_dirs);
1322 self.raw.insert(opt.long.clone(), Raw::One(resolved.clone()));
1323 self.sources.insert(opt.long.clone(), source);
1324 self.read_config(&resolved, 0, &mut HashSet::new())?;
1325
1326 let entries: Vec<(String, String)> = self.config.iter().map(|(k, (v, _))| (k.clone(), v.clone())).collect();
1327 for (key, value) in entries {
1328 let Some(t) = self.chain_find(&key) else { continue };
1329 if t.long == opt.long || self.sources.get(&key) == Some(&"cli") {
1330 continue;
1331 }
1332 let raw = match t.kind {
1333 Kind::Flag => match bool_word(&value) {
1334 Some(b) => Raw::One(b.to_string()),
1335 None => return Err(format!("Config value for --{key} must be a boolean, got '{value}'")),
1336 },
1337 Kind::Array => Raw::Many(vec![value]),
1338 Kind::Value => Raw::One(value),
1339 };
1340 self.raw.insert(key.clone(), raw);
1341 self.sources.insert(key, "config");
1342 }
1343 Ok(())
1344 }
1345
1346 fn read_config(&mut self, path: &str, depth: usize, stack: &mut HashSet<String>) -> Result<(), String> {
1347 if depth > 10 {
1348 return Err(format!("Config include depth exceeded (10) while processing: {path}"));
1349 }
1350 if !Path::new(path).is_file() {
1351 return Err(format!("Config file not found: {path}"));
1352 }
1353 if !stack.insert(path.to_string()) {
1354 return Err(format!("Circular config include detected: {path}"));
1355 }
1356 let dir = dirname(path);
1357 let text = std::fs::read_to_string(path).map_err(|_| format!("Config file not found: {path}"))?;
1358 let include = re(r"^\s*@include\s+(.+)$");
1359 for line in text.split('\n') {
1360 let line = line.strip_suffix('\r').unwrap_or(line);
1361 let trimmed = line.trim();
1362 if trimmed.is_empty() || trimmed.starts_with('#') {
1363 continue;
1364 }
1365 if let Some(cap) = include.captures(line) {
1366 let mut target = cap[1].trim();
1367 for q in ['"', '\''] {
1368 if target.len() >= 2 && target.starts_with(q) && target.ends_with(q) {
1369 target = &target[1..target.len() - 1];
1370 }
1371 }
1372 self.read_config(&join_norm(&dir, target), depth + 1, stack)?;
1373 continue;
1374 }
1375 let body = if self.config_prefixes.is_empty() {
1376 line
1377 } else {
1378 match self.config_prefixes.iter().find(|p| line.starts_with(p.as_str())) {
1379 Some(p) => &line[p.len()..],
1380 None => continue,
1381 }
1382 };
1383 let Some((key, value)) = body.split_once('=') else { continue };
1384 let key = key.trim();
1385 let key = key.strip_prefix("--").unwrap_or(key);
1386 if !key.is_empty() {
1387 self.config.insert(key.to_string(), (value.trim().to_string(), dir.clone()));
1388 }
1389 }
1390 stack.remove(path);
1391 Ok(())
1392 }
1393
1394 fn resolve(&mut self) -> Result<(), String> {
1395 if !self.node().children.is_empty() {
1396 return Err("Missing command".into());
1397 }
1398 let options = self.chain_options();
1399 let args = self.node().args.clone();
1400 for arg in &args {
1401 if self.arg_raw.contains_key(&arg.name) {
1402 continue;
1403 }
1404 if arg.variadic {
1405 self.arg_raw.insert(arg.name.clone(), Raw::Many(Vec::new()));
1406 } else if arg.default.is_empty() {
1407 return Err(format!("Missing required positional argument: {}", arg.name));
1408 } else {
1409 self.arg_raw.insert(arg.name.clone(), Raw::One(arg.default.clone()));
1410 }
1411 }
1412
1413 for opt in &options {
1414 if self.sources.contains_key(&opt.long) {
1415 continue;
1416 }
1417 let env_value = if opt.kind == Kind::Array { None } else { self.env.get(&opt.var).filter(|v| !v.is_empty()) };
1418 if let Some(v) = env_value {
1419 let v = if opt.kind == Kind::Flag {
1420 bool_word(v).ok_or(format!("Environment variable {} must be a boolean, got '{v}'", opt.var))?.to_string()
1421 } else {
1422 v.clone()
1423 };
1424 self.raw.insert(opt.long.clone(), Raw::One(v));
1425 self.sources.insert(opt.long.clone(), "env");
1426 } else if opt.kind == Kind::Flag {
1427 self.raw.insert(opt.long.clone(), Raw::One("false".into()));
1428 self.sources.insert(opt.long.clone(), "default");
1429 } else if !opt.default.is_empty() {
1430 self.raw.insert(opt.long.clone(), Raw::One(opt.default.clone()));
1431 self.sources.insert(opt.long.clone(), "default");
1432 }
1433 }
1434
1435 for opt in &options {
1437 if !is_path_rule(&opt.rule) || opt.long == self.config_option {
1438 continue;
1439 }
1440 let Some(raw) = self.raw.get_mut(&opt.long) else { continue };
1441 let base = match self.sources.get(&opt.long) {
1442 Some(&"cli") => self.cwd.clone(),
1443 Some(&"config") => self.config[&opt.long].1.clone(),
1444 _ => self.root.clone(),
1445 };
1446 match raw {
1447 Raw::One(v) => *v = resolve_path(v, &base, &opt.search_dirs),
1448 Raw::Many(l) => l.iter_mut().for_each(|v| *v = resolve_path(v, &base, &opt.search_dirs)),
1449 }
1450 }
1451 for arg in &args {
1452 if !is_path_rule(&arg.rule) {
1453 continue;
1454 }
1455 match self.arg_raw.get_mut(&arg.name) {
1456 Some(Raw::One(v)) => *v = resolve_path(v, &self.cwd, &[]),
1457 Some(Raw::Many(l)) => l.iter_mut().for_each(|v| *v = resolve_path(v, &self.cwd, &[])),
1458 None => {}
1459 }
1460 }
1461
1462 let convert = |v: &str, rule: &str, name: &str| -> Result<Value, String> {
1463 if v.is_empty() || rule.is_empty() {
1464 Ok(Value::Str(v.into()))
1465 } else {
1466 validate(v, rule, name)
1467 }
1468 };
1469 let convert_raw = |raw: &Raw, rule: &str, name: &str| -> Result<Value, String> {
1470 match raw {
1471 Raw::One(v) => convert(v, rule, name),
1472 Raw::Many(l) => Ok(Value::List(l.iter().map(|v| convert(v, rule, name)).collect::<Result<_, _>>()?)),
1473 }
1474 };
1475 let mut values = Values::default();
1476 for opt in &options {
1477 let value = match (self.raw.get(&opt.long), opt.kind) {
1478 (None, Kind::Array) => Value::List(Vec::new()),
1479 (None, _) => Value::Null,
1480 (Some(Raw::One(v)), Kind::Flag) => Value::Bool(v == "true"),
1481 (Some(raw), _) => convert_raw(raw, &opt.rule, &format!("--{}", opt.long))?,
1482 };
1483 values.set(&opt.var, value);
1484 }
1485 for arg in &args {
1486 values.set(&arg.name, convert_raw(&self.arg_raw[&arg.name], &arg.rule, &arg.name)?);
1487 }
1488 if !self.children.is_empty() {
1489 values.set("command", Value::List(self.command_path().into_iter().map(Value::Str).collect()));
1490 }
1491 self.values = values;
1492 Ok(())
1493 }
1494
1495 pub fn run(&mut self) -> Values {
1498 let argv: Vec<String> = std::env::args().skip(1).collect();
1499 self.run_with(&argv)
1500 }
1501
1502 pub fn run_with<S: AsRef<str>>(&mut self, argv: &[S]) -> Values {
1504 let head = argv.iter().map(AsRef::as_ref).take_while(|a| *a != "--");
1505 let words: Vec<&str> = argv.iter().map(AsRef::as_ref).skip_while(|a| *a != "--").skip(1).collect();
1506 for a in head {
1507 if a == "--help-json-schema" {
1508 println!("{}", self.json_schema());
1509 std::process::exit(0);
1510 }
1511 if a == "--bash-completion" {
1512 print!("{}", self.completion_data_for(&words));
1513 std::process::exit(0);
1514 }
1515 if a == "--completion" {
1516 let shell = argv.iter().map(AsRef::as_ref).skip_while(|x| *x != "--completion").nth(1).unwrap_or("");
1517 match self.completion_script(shell) {
1518 Some(script) => {
1519 print!("{script}");
1520 std::process::exit(0);
1521 }
1522 None => die(1, &format!("Unknown shell '{shell}' (expected bash, zsh or fish)")),
1523 }
1524 }
1525 }
1526 match self.parse(argv) {
1527 Parsed::Ok => self.values.clone(),
1528 Parsed::Help => {
1529 print!("{}", self.usage());
1530 std::process::exit(0)
1531 }
1532 Parsed::Error { message, show_usage, detail } => {
1533 emit("error", &message, true);
1534 for line in detail {
1535 eprintln!("{line}");
1536 }
1537 if show_usage {
1538 eprint!("{}", self.usage());
1539 }
1540 std::process::exit(1)
1541 }
1542 }
1543 }
1544
1545 pub fn values(&self) -> &Values {
1549 &self.values
1550 }
1551
1552 pub fn source(&self, long: &str) -> &'static str {
1554 self.sources.get(long.strip_prefix("--").unwrap_or(long)).copied().unwrap_or("unset")
1555 }
1556
1557 pub fn is_set(&self, long: &str) -> bool {
1559 self.source(long) == "cli"
1560 }
1561
1562 pub fn is_explicitly_set(&self, long: &str) -> bool {
1564 matches!(self.source(long), "cli" | "config" | "env")
1565 }
1566
1567 pub fn values_json(&self) -> String {
1569 let secrets: Vec<String> = self.chain_options().into_iter().filter(|o| o.secret).map(|o| o.var).collect();
1570 let mask = |k: &str, v: &Value| match v {
1571 _ if !secrets.iter().any(|s| s == k) => Json::from_value(v),
1572 Value::Null => Json::Null,
1573 Value::List(l) => Json::Arr(l.iter().map(|_| Json::Str("***".into())).collect()),
1574 _ => Json::Str("***".into()),
1575 };
1576 Json::Map(self.values.iter().map(|(k, v)| (k.to_string(), mask(k, v))).collect()).pretty()
1577 }
1578
1579 pub fn usage(&mut self) -> String {
1583 self.ensure_help();
1584 let chain = self.chain();
1585 let node = chain[0];
1586 let options = self.chain_options();
1587 let max_width = self.env.get("CLYOPS_MAX_WIDTH").and_then(|w| w.parse::<usize>().ok()).filter(|w| *w > 0).unwrap_or(100);
1588 let longest = options
1589 .iter()
1590 .map(|o| o.label().chars().count())
1591 .chain(node.children.iter().map(|c| c.word.chars().count()))
1592 .max()
1593 .unwrap_or(0);
1594 let indent = (longest + 4).clamp(32, 50);
1595 let text_width = max_width.saturating_sub(indent).max(20);
1596
1597 let row = |label: &str, text: &str| -> Vec<String> {
1598 let mut left = format!(" {label}");
1599 let len = left.chars().count();
1600 if len < indent {
1601 left.push_str(&" ".repeat(indent - len))
1602 } else {
1603 left.push(' ')
1604 }
1605 let lines = wrap_text(text, text_width);
1606 let mut out = vec![format!("{left}{}", lines[0])];
1607 out.extend(lines[1..].iter().map(|l| format!("{}{l}", " ".repeat(indent))));
1608 out
1609 };
1610 let annotate = |text: &str, notes: &[String]| {
1611 if notes.is_empty() {
1612 text.to_string()
1613 } else {
1614 format!("{text} ({})", notes.join(", "))
1615 }
1616 };
1617
1618 let mut sections: Vec<Vec<String>> = Vec::new();
1619 let mut usage = format!("Usage: {}", node.name);
1620 if !node.children.is_empty() {
1621 usage += " <command>";
1622 }
1623 for a in &node.args {
1624 usage += &if a.variadic {
1625 format!(" [<{}...>]", a.name)
1626 } else if a.default.is_empty() {
1627 format!(" <{}>", a.name)
1628 } else {
1629 format!(" [<{}>]", a.name)
1630 };
1631 }
1632 sections.push(vec![usage + " [OPTIONS]"]);
1633
1634 if !node.description.is_empty() {
1635 sections.push(wrap_text(&node.description, max_width));
1636 }
1637
1638 let io: Vec<String> = [("Input:", &node.stdin), ("Output:", &node.stdout)]
1639 .iter()
1640 .filter_map(|(label, decl)| {
1641 let (d, t) = decl.as_ref()?;
1642 let mut line = label.to_string();
1643 if !d.is_empty() {
1644 line += &format!(" {d}");
1645 }
1646 if !t.is_empty() {
1647 line += &format!(" ({t})");
1648 }
1649 Some(line)
1650 })
1651 .collect();
1652 if !io.is_empty() {
1653 sections.push(io);
1654 }
1655
1656 if !node.children.is_empty() {
1657 let mut lines = vec!["Commands:".to_string()];
1658 for c in &node.children {
1659 lines.extend(row(&c.word, &c.description));
1660 }
1661 sections.push(lines);
1662 }
1663
1664 if !node.args.is_empty() {
1665 let mut lines = vec!["Positional Arguments:".to_string()];
1666 for a in &node.args {
1667 let mut notes = Vec::new();
1668 if a.variadic {
1669 notes.push("variadic".to_string())
1670 }
1671 if !a.default.is_empty() {
1672 notes.push(format!("default: {}", a.default))
1673 }
1674 if !a.rule.is_empty() {
1675 notes.push(format!("accepts: {}", describe_rule(&a.rule)))
1676 }
1677 lines.extend(row(&a.name, &annotate(&a.description, ¬es)));
1678 }
1679 sections.push(lines);
1680 }
1681
1682 let required: Vec<&(String, String, String)> = chain.iter().rev().flat_map(|n| n.commands.iter()).collect();
1683 if !required.is_empty() {
1684 let path_var = self.env.get("PATH").cloned().unwrap_or_default();
1685 let mut lines = vec!["Required Commands:".to_string()];
1686 for (cmd, desc, hint) in required {
1687 let status = if command_available(cmd, &path_var) { "installed" } else { "not found" };
1688 let text = if hint.is_empty() { desc.clone() } else { format!("{desc} ({hint})") };
1689 lines.extend(row(&format!("{cmd} [{status}]"), &text));
1690 }
1691 sections.push(lines);
1692 }
1693
1694 let constraints: Vec<&(&str, Vec<String>)> = chain.iter().rev().flat_map(|n| n.constraints.iter()).collect();
1695 let list = |longs: &[&String]| longs.iter().map(|l| format!("--{l}")).collect::<Vec<_>>().join(", ");
1696 let mut groups: Vec<&str> = Vec::new();
1697 for o in &options {
1698 if !groups.contains(&o.group.as_str()) {
1699 groups.push(&o.group);
1700 }
1701 }
1702 for group in groups {
1703 let mut lines = vec![format!("{group}:")];
1704 for o in options.iter().filter(|o| o.group == group) {
1705 let mut notes = Vec::new();
1706 if o.required {
1707 notes.push("required".to_string())
1708 }
1709 if o.kind == Kind::Array {
1710 notes.push("multiple".to_string())
1711 }
1712 if o.secret {
1713 notes.push("secret".to_string())
1714 }
1715 if let Some((v, _)) = self.config.get(&o.long) {
1716 notes.push(format!("config: {}", if o.secret { "***" } else { v }))
1717 }
1718 if !o.default.is_empty() {
1719 notes.push(format!("default: {}", o.default))
1720 }
1721 if !o.rule.is_empty() {
1722 notes.push(format!("accepts: {}", describe_rule(&o.rule)))
1723 }
1724 for (kind, longs) in constraints.iter().map(|c| (c.0, &c.1)) {
1725 if !longs.contains(&o.long) {
1726 continue;
1727 }
1728 match kind {
1729 "exclusive" => notes.push(format!(
1730 "conflicts with: {}",
1731 list(&longs.iter().filter(|l| **l != o.long).collect::<Vec<_>>())
1732 )),
1733 "requires" if longs[0] == o.long => {
1734 notes.push(format!("requires: {}", list(&longs[1..].iter().collect::<Vec<_>>())))
1735 }
1736 "oneOf" => notes.push(format!("one of: {}", list(&longs.iter().collect::<Vec<_>>()))),
1737 _ => {}
1738 }
1739 }
1740 lines.extend(row(&o.label(), &annotate(&o.description, ¬es)));
1741 }
1742 sections.push(lines);
1743 }
1744
1745 if !node.epilog.is_empty() {
1746 sections.push(node.epilog.trim_end_matches('\n').split('\n').map(String::from).collect());
1747 }
1748
1749 let text = sections.iter().map(|s| s.join("\n")).collect::<Vec<_>>().join("\n\n");
1750 text.split('\n').map(str::trim_end).collect::<Vec<_>>().join("\n") + "\n"
1751 }
1752
1753 pub fn json_schema(&mut self) -> String {
1755 self.ensure_help();
1756 let mut fields = vec![("clyops", Json::Num("1".into())), ("script", Json::Str(self.name.clone()))];
1757 fields.extend(self.schema_node());
1758 Json::Obj(fields).pretty()
1759 }
1760
1761 fn schema_node(&self) -> Vec<(&'static str, Json)> {
1762 let type_of = |o: &Opt| {
1763 let r = o.rule.as_str();
1764 if o.kind == Kind::Flag || r == "bool" {
1765 "boolean"
1766 } else if r.starts_with("int") || r == "port" {
1767 "integer"
1768 } else if r.starts_with("float") {
1769 "number"
1770 } else if r.starts_with("choice:") {
1771 "choice"
1772 } else if is_path_rule(r) {
1773 "path"
1774 } else {
1775 "string"
1776 }
1777 };
1778 let s = |v: &str| Json::Str(v.to_string());
1779 let stream = |d: &Option<(String, String)>| match d {
1780 Some((d, t)) => Json::Obj(vec![("description", s(d)), ("contentType", s(t))]),
1781 None => Json::Null,
1782 };
1783 let mut fields = if self.word.is_empty() { Vec::new() } else { vec![("name", s(&self.word))] };
1784 fields.extend(vec![
1785 ("description", s(&self.description)),
1786 ("epilog", s(&self.epilog)),
1787 (
1788 "arguments",
1789 Json::Arr(
1790 self.args
1791 .iter()
1792 .map(|a| {
1793 Json::Obj(vec![
1794 ("name", s(&a.name)),
1795 ("description", s(&a.description)),
1796 ("required", Json::Bool(!a.variadic && a.default.is_empty())),
1797 ("isVariadic", Json::Bool(a.variadic)),
1798 ("default", s(&a.default)),
1799 ("validation", s(&a.rule)),
1800 ])
1801 })
1802 .collect(),
1803 ),
1804 ),
1805 (
1806 "options",
1807 Json::Arr(
1808 self.options
1809 .iter()
1810 .map(|o| {
1811 Json::Obj(vec![
1812 ("name", s(&o.long)),
1813 ("shortName", s(&o.short)),
1814 ("variableName", s(&o.var)),
1815 ("description", s(&o.description)),
1816 ("default", s(if o.kind == Kind::Flag { "false" } else { &o.default })),
1817 ("group", s(&o.group)),
1818 ("type", s(type_of(o))),
1819 ("isFlag", Json::Bool(o.kind == Kind::Flag)),
1820 ("isArray", Json::Bool(o.kind == Kind::Array)),
1821 ("required", Json::Bool(o.required)),
1822 ("validation", s(&o.rule)),
1823 (
1824 "choices",
1825 Json::Arr(
1826 o.rule.strip_prefix("choice:").map(|c| c.split(',').map(s).collect()).unwrap_or_default(),
1827 ),
1828 ),
1829 ("secret", Json::Bool(o.secret)),
1830 ])
1831 })
1832 .collect(),
1833 ),
1834 ),
1835 (
1836 "requiredCommands",
1837 Json::Arr(
1838 self.commands
1839 .iter()
1840 .map(|(c, d, h)| Json::Obj(vec![("command", s(c)), ("description", s(d)), ("installHint", s(h))]))
1841 .collect(),
1842 ),
1843 ),
1844 ("effects", Json::Arr(self.effects.iter().map(|e| s(e)).collect())),
1845 (
1846 "constraints",
1847 Json::Arr(
1848 self.constraints
1849 .iter()
1850 .map(|(k, l)| Json::Obj(vec![("type", s(k)), ("options", Json::Arr(l.iter().map(|o| s(o)).collect()))]))
1851 .collect(),
1852 ),
1853 ),
1854 ("stdin", stream(&self.stdin)),
1855 ("stdout", stream(&self.stdout)),
1856 ("commands", Json::Arr(self.children.iter().map(|c| Json::Obj(c.schema_node())).collect())),
1857 ]);
1858 fields
1859 }
1860
1861 pub fn completion_script(&self, shell: &str) -> Option<String> {
1864 let template = match shell {
1865 "bash" => completions::BASH,
1866 "zsh" => completions::ZSH,
1867 "fish" => completions::FISH,
1868 _ => return None,
1869 };
1870 let func: String = self.name.chars().map(|c| if c.is_ascii_alphanumeric() || c == '_' { c } else { '_' }).collect();
1871 Some(template.replace("__CLYOPS_FUNC__", &func).replace("__CLYOPS_PROG__", &self.name))
1872 }
1873
1874 pub fn completion_data(&mut self) -> String {
1876 self.completion_data_for(&[])
1877 }
1878
1879 pub fn completion_data_for(&mut self, words: &[&str]) -> String {
1882 self.ensure_help();
1883 let clean = |s: &str| s.replace(['\t', '\n'], " ");
1884 let mut out = String::from("#clyops-completion 1\n");
1885 let mut chain = vec![&*self];
1886 if !self.children.is_empty() {
1887 let mut skip = 0;
1888 for w in words {
1889 match chain[0].children.iter().find(|c| c.word == *w) {
1890 Some(c) => chain.insert(0, c),
1891 None => break,
1892 }
1893 skip += 1;
1894 }
1895 if !chain[0].children.is_empty() && skip < words.len() && !words[skip].starts_with('-') {
1896 return out;
1897 }
1898 let _ = writeln!(out, "skip\t{skip}");
1899 for c in &chain[0].children {
1900 let _ = writeln!(out, "cmd\t{}\t{}", c.word, clean(&c.description));
1901 }
1902 }
1903 for o in chain.iter().flat_map(|n| n.options.iter()) {
1904 let short = if o.short.is_empty() { "-".to_string() } else { format!("-{}", o.short) };
1905 if o.kind == Kind::Flag {
1906 let _ = writeln!(out, "opt\t--{}\t{short}\tflag\tnone\t-\t{}", o.long, clean(&o.description));
1907 } else {
1908 let (kind, values) = completion_kind(&o.rule, &o.search_dirs);
1909 let values = if values.is_empty() { "-".to_string() } else { values };
1910 let _ = writeln!(out, "opt\t--{}\t{short}\tvalue\t{kind}\t{values}\t{}", o.long, clean(&o.description));
1911 }
1912 if o.bool_like() {
1913 let _ = writeln!(out, "opt\t--no-{}\t-\tflag\tnone\t-\t{}", o.long, clean(&o.description));
1914 }
1915 }
1916 for a in &chain[0].args {
1917 let (kind, values) = completion_kind(&a.rule, &[]);
1918 let values = if values.is_empty() { "-".to_string() } else { values };
1919 let arity = if a.variadic { "variadic" } else { "single" };
1920 let _ = writeln!(out, "arg\t{}\t{arity}\t{kind}\t{values}\t{}", a.name, clean(&a.description));
1921 }
1922 out
1923 }
1924}