1#[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#[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 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 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#[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#[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#[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#[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}