defaults_rs/
cli.rs

1// SPDX-License-Identifier: MIT
2
3//! CLI definition and argument helpers for defaults-rs.
4//
5// This module is responsible for:
6// - Defining the command-line interface (CLI) structure using clap.
7// - Specifying subcommands, arguments, and their relationships.
8// - Providing helpers for argument parsing and error reporting (if needed).
9//
10// No business logic or backend operations are performed here.
11// All CLI parsing is separated from preferences management and backend details.
12#[cfg(feature = "cli")]
13use crate::Domain;
14#[cfg(feature = "cli")]
15use crate::prettifier::prettify;
16#[cfg(feature = "cli")]
17use crate::{PrefValue, Preferences};
18#[cfg(feature = "cli")]
19use anyhow::{Context, Result, anyhow, bail};
20#[cfg(feature = "cli")]
21use clap::{Arg, ArgMatches, Command};
22#[cfg(feature = "cli")]
23use skim::prelude::*;
24#[cfg(feature = "cli")]
25use std::io::Cursor;
26#[cfg(feature = "cli")]
27use std::path::Path;
28
29#[cfg(feature = "cli")]
30pub fn build_cli() -> Command {
31    use clap::ArgAction;
32
33    let domain = |req| {
34        let mut a = Arg::new("domain")
35            .help("Domain (e.g. com.example.app / -g / NSGlobalDomain) or a system-recognized plist path")
36            .index(1)
37            .allow_hyphen_values(true);
38        if req {
39            a = a.required(true)
40        }
41        a
42    };
43
44    let key = |req| {
45        let mut a = Arg::new("key").help("Preference key").index(2);
46        if req {
47            a = a.required(true)
48        }
49        a
50    };
51
52    let path = Arg::new("path")
53        .help("Path to plist file")
54        .required(true)
55        .index(2);
56
57    Command::new("defaults-rs")
58        .about(env!("CARGO_PKG_DESCRIPTION"))
59        .version(env!("CARGO_PKG_VERSION"))
60        .subcommand_required(true)
61        .arg_required_else_help(true)
62        .subcommand(
63            Command::new("read")
64                .about("Read a value")
65                .arg(domain(false))
66                .arg(key(false)),
67        )
68        .subcommand(
69            Command::new("read-type")
70                .about("Show type")
71                .arg(domain(true))
72                .arg(key(true)),
73        )
74        .subcommand(
75            Command::new("write")
76                .about("Write value")
77                .arg(domain(true))
78                .arg(key(true))
79                .arg(
80                    Arg::new("force")
81                        .short('F')
82                        .long("force")
83                        .help("Disable domain check")
84                        .action(ArgAction::SetTrue),
85                )
86                .arg(
87                    Arg::new("int")
88                        .short('i')
89                        .long("int")
90                        .num_args(1)
91                        .value_name("VALUE")
92                        .help("Write an integer value")
93                        .conflicts_with_all(["float", "bool", "string", "array"]),
94                )
95                .arg(
96                    Arg::new("float")
97                        .short('f')
98                        .long("float")
99                        .num_args(1)
100                        .value_name("VALUE")
101                        .help("Write a float value")
102                        .conflicts_with_all(["int", "bool", "string", "array"]),
103                )
104                .arg(
105                    Arg::new("bool")
106                        .short('b')
107                        .long("bool")
108                        .num_args(1)
109                        .value_name("VALUE")
110                        .help("Write a boolean value (true/false/1/0/yes/no)")
111                        .conflicts_with_all(["int", "float", "string", "array"]),
112                )
113                .arg(
114                    Arg::new("string")
115                        .short('s')
116                        .long("string")
117                        .num_args(1)
118                        .value_name("VALUE")
119                        .help("Write a string value")
120                        .conflicts_with_all(["int", "float", "bool", "array"]),
121                )
122                .arg(
123                    Arg::new("array")
124                        .short('a')
125                        .long("array")
126                        .value_name("VALUE")
127                        .num_args(1..)
128                        .help("Write an array value")
129                        .conflicts_with_all(["int", "float", "bool", "string"]),
130                ),
131        )
132        .subcommand(
133            Command::new("delete")
134                .about("Delete key/domain")
135                .arg(domain(true))
136                .arg(key(false)),
137        )
138        .subcommand(
139            Command::new("rename")
140                .about("Rename key")
141                .arg(domain(true))
142                .arg(
143                    Arg::new("old_key")
144                        .help("Old/original key name")
145                        .required(true)
146                        .index(2),
147                )
148                .arg(
149                    Arg::new("new_key")
150                        .help("New key name")
151                        .required(true)
152                        .index(3),
153                ),
154        )
155        .subcommand(
156            Command::new("import")
157                .about("Import plist")
158                .arg(domain(true))
159                .arg(&path),
160        )
161        .subcommand(
162            Command::new("export")
163                .about("Export plist")
164                .arg(domain(true))
165                .arg(path),
166        )
167        .subcommand(
168            Command::new("domains").about("List domains").arg(
169                Arg::new("no-fuzzy")
170                    .short('n')
171                    .long("no-fuzzy")
172                    .help("Disable fuzzy-picker")
173                    .action(ArgAction::SetTrue),
174            ),
175        )
176        .subcommand(
177            Command::new("find").about("Search all domains").arg(
178                Arg::new("word")
179                    .help("Word to search for (case-insensitive)")
180                    .required(true)
181                    .index(1),
182            ),
183        )
184}
185
186/// Returns a domain object based on the kind of the argument that is passed.
187#[cfg(feature = "cli")]
188fn parse_domain_or_path(sub_m: &ArgMatches, force: bool) -> Result<Domain> {
189    let home_dir = dirs::home_dir().ok_or_else(|| anyhow!("could not resolve home directory"))?;
190
191    let mut domain = sub_m
192        .get_one::<String>("domain")
193        .context("domain argument is required")?
194        .to_string();
195
196    // filepath check
197    if let Ok(path) = Path::new(domain.as_str()).canonicalize()
198        && path.is_file()
199        && (path.starts_with(format!(
200            "{}/Library/Preferences/",
201            home_dir.to_string_lossy()
202        )) || path.starts_with("/Library/Preferences/")
203            || path.starts_with("/System/Library/Preferences/"))
204    {
205        domain = path
206            .file_stem()
207            .and_then(|s| s.to_str())
208            .ok_or_else(|| anyhow!("could not get file stem"))?
209            .to_string();
210    }
211
212    // domain check
213    match domain.strip_suffix(".plist").unwrap_or(&domain) {
214        "-g" | "NSGlobalDomain" | "-globalDomain" | ".GlobalPreferences" => Ok(Domain::Global),
215        other => {
216            if other.contains("..")
217                || other.contains('/')
218                || other.contains('\\')
219                || !other
220                    .chars()
221                    .all(|c| c.is_alphanumeric() || c == '.' || c == '_' || c == '-')
222            {
223                bail!("Invalid domain or plist path: {other}");
224            }
225
226            if !force
227                && !Preferences::list_domains()?
228                    .iter()
229                    .any(|dom| dom.to_string() == other)
230            {
231                bail!("Domain '{domain}' not found!.")
232            }
233
234            Ok(Domain::User(other.to_string()))
235        }
236    }
237}
238
239/// Extract the proper PrefValue to be writtem from the passed typeflag.
240///
241/// This is primarily used in the write command for determining types.
242#[cfg(feature = "cli")]
243fn extract_prefvalue_from_args(sub_m: &ArgMatches) -> Result<PrefValue> {
244    if let Some(val) = sub_m.get_one::<String>("int") {
245        let val = val
246            .parse::<i64>()
247            .map_err(|e| anyhow!("Failed to parse int: {e}"))?;
248        Ok(PrefValue::Integer(val))
249    } else if let Some(val) = sub_m.get_one::<String>("float") {
250        let val = val
251            .parse::<f64>()
252            .map_err(|e| anyhow!("Failed to parse int: {e}"))?;
253        Ok(PrefValue::Float(val))
254    } else if let Some(val) = sub_m.get_one::<String>("bool") {
255        match val.to_lowercase().as_str() {
256            "true" | "1" | "yes" => Ok(PrefValue::Boolean(true)),
257            "false" | "0" | "no" => Ok(PrefValue::Boolean(false)),
258            _ => bail!("Invalid boolean value (use true/false, yes/no or 1/0)"),
259        }
260    } else if let Some(val) = sub_m.get_many::<String>("array") {
261        let val: Vec<PrefValue> = val
262            .into_iter()
263            .map(|f| PrefValue::String(f.to_string()))
264            .collect();
265
266        Ok(PrefValue::Array(val))
267    } else if let Some(val) = sub_m.get_one::<String>("string") {
268        Ok(PrefValue::String(val.to_string()))
269    } else {
270        bail!(
271            "You must specify one of --int, --float, --bool, --array or --string for the value type."
272        )
273    }
274}
275
276/// Returns a required argument from the CLI.
277#[cfg(feature = "cli")]
278fn get_required_arg<'a>(sub_m: &'a clap::ArgMatches, name: &str) -> &'a str {
279    sub_m
280        .get_one::<String>(name)
281        .map(String::as_str)
282        .unwrap_or_else(|| {
283            eprintln!("Error: {name} required");
284            std::process::exit(1);
285        })
286}
287
288/// Fuzzy-picking helper for the CLI.
289#[cfg(feature = "cli")]
290fn pick_one(prompt: &str, items: &[String]) -> Result<Option<String>> {
291    let item_reader = SkimItemReader::default();
292    let skim_items = item_reader.of_bufread(Cursor::new(items.join("\n")));
293
294    let options = SkimOptionsBuilder::default()
295        .prompt(prompt.to_string())
296        .color(Some("bw".to_string()))
297        .case(CaseMatching::Smart)
298        .multi(false)
299        .build()
300        .context("Failed to build fuzzy-picker options; internal error in pick_one().")?;
301
302    let out = Skim::run_with(&options, Some(skim_items));
303
304    let out = match out {
305        Some(o) if !o.is_abort => o,
306        _ => return Ok(None),
307    };
308
309    Ok(out
310        .selected_items
311        .first()
312        .map(|item| item.output().to_string()))
313}
314
315/// Function to handle subcommand runs.
316#[cfg(feature = "cli")]
317pub fn handle_subcommand(cmd: &str, sub_m: &ArgMatches) -> Result<()> {
318    match cmd {
319        "domains" => {
320            let domains = Preferences::list_domains()?;
321            let domains_str: Vec<String> = domains.iter().map(|f| f.to_string()).collect();
322
323            if sub_m.get_flag("no-fuzzy") {
324                for dom in domains {
325                    println!("{dom}");
326                }
327            } else {
328                let picker = pick_one(
329                    "Viewing list of domains. Use arrow keys to navigate: ",
330                    &domains_str,
331                )?;
332
333                if let Some(picked_domain) = picker {
334                    println!("Domain: {picked_domain} (is {})", {
335                        match domains
336                            .iter()
337                            .find(|d| d.to_string() == picked_domain)
338                            .context("Domain-type checker failed.")?
339                        {
340                            Domain::User(_) => "user domain",
341                            Domain::Global => "global domain",
342                        }
343                    })
344                }
345            }
346
347            Ok(())
348        }
349        "find" => {
350            let word = get_required_arg(sub_m, "word");
351            let results = Preferences::find(word)?;
352            for (domain, matches) in results {
353                println!("Found {} matches for domain `{}`:", matches.len(), domain);
354                for m in matches {
355                    println!("    {} = {}", m.key, m.value);
356                }
357                println!();
358            }
359            Ok(())
360        }
361        "write" => {
362            let force = sub_m.get_flag("force");
363
364            let domain: Domain = if let Ok(val) = parse_domain_or_path(sub_m, force) {
365                val
366            } else {
367                bail!("Could not write to non-existing domain. If intentional, use -F/--force.")
368            };
369
370            let key = get_required_arg(sub_m, "key");
371
372            let value = extract_prefvalue_from_args(sub_m)?;
373            Preferences::write(domain, key, value)
374        }
375        "read" => {
376            let input_domain = sub_m.get_one::<String>("domain");
377            let input_key = sub_m.get_one::<String>("key");
378
379            let domain: Domain = if let Ok(val) = parse_domain_or_path(sub_m, false) {
380                val
381            } else if input_domain.is_none() && input_key.is_none() {
382                let domains = Preferences::list_domains()?;
383                let domains_str: Vec<String> = domains.iter().map(|f| f.to_string()).collect();
384
385                let chosen = pick_one(
386                    "Select a proper domain to read. Use arrow keys to navigate: ",
387                    &domains_str,
388                )?;
389
390                if let Some(chosen) = chosen {
391                    domains
392                        .into_iter()
393                        .find(|d| d.to_string() == chosen)
394                        .context("Unexpected domain mismatch here.")?
395                } else {
396                    bail!("No domain selected.")
397                }
398            } else {
399                bail!(
400                    "Invalid domain passed: {:?}. Please provide a valid domain name (e.g., 'com.example.app'), or use the fuzzy picker by omitting both domain and key arguments.",
401                    input_domain
402                )
403            };
404
405            let val = if let Some(key) = sub_m.get_one::<String>("key").map(String::as_str) {
406                Preferences::read(domain, key)?
407            } else {
408                Preferences::read_domain(domain)?
409            };
410
411            println!("{}", prettify(&val, 0));
412            Ok(())
413        }
414        "read-type" => {
415            let domain: Domain = parse_domain_or_path(sub_m, false)?;
416            let key = get_required_arg(sub_m, "key");
417            let val = Preferences::read(domain, key)?;
418
419            println!("Type is {}", val.get_type());
420            Ok(())
421        }
422        "delete" => {
423            let key = sub_m.get_one::<String>("key").map(String::as_str);
424            let domain: Domain = parse_domain_or_path(sub_m, false)?;
425
426            if let Some(key) = key {
427                Preferences::delete(domain, key)
428            } else {
429                Preferences::delete_domain(domain)
430            }
431        }
432        "rename" => {
433            let domain: Domain = parse_domain_or_path(sub_m, false)?;
434            let old_key = get_required_arg(sub_m, "old_key");
435            let new_key = get_required_arg(sub_m, "new_key");
436
437            Preferences::rename(domain, old_key, new_key)
438        }
439        "import" => {
440            let domain: Domain = parse_domain_or_path(sub_m, false)?;
441            let path = get_required_arg(sub_m, "path");
442
443            Preferences::import(domain, path)
444        }
445        "export" => {
446            let domain: Domain = parse_domain_or_path(sub_m, false)?;
447            let path = get_required_arg(sub_m, "path");
448
449            Preferences::export(domain, path)
450        }
451        _ => bail!("Not a proper subcommand."),
452    }
453}