#[cfg(feature = "cli")]
use crate::Domain;
#[cfg(feature = "cli")]
use crate::prettifier::prettify;
#[cfg(feature = "cli")]
use crate::{PrefValue, Preferences};
#[cfg(feature = "cli")]
use anyhow::{Context, Result, anyhow, bail};
#[cfg(feature = "cli")]
use clap::{Arg, ArgMatches, Command};
#[cfg(feature = "cli")]
use skim::prelude::*;
#[cfg(feature = "cli")]
use std::io::Cursor;
#[cfg(feature = "cli")]
use std::path::Path;
#[cfg(feature = "cli")]
pub fn build_cli() -> Command {
use clap::ArgAction;
let domain = |req| {
let mut a = Arg::new("domain")
.help("Domain (e.g. com.example.app / -g / NSGlobalDomain) or a system-recognized plist path")
.index(1)
.allow_hyphen_values(true);
if req {
a = a.required(true)
}
a
};
let key = |req| {
let mut a = Arg::new("key").help("Preference key").index(2);
if req {
a = a.required(true)
}
a
};
let path = Arg::new("path")
.help("Path to plist file")
.required(true)
.index(2);
Command::new("defaults-rs")
.about(env!("CARGO_PKG_DESCRIPTION"))
.version(env!("CARGO_PKG_VERSION"))
.subcommand_required(true)
.arg_required_else_help(true)
.subcommand(
Command::new("read")
.about("Read a value")
.arg(domain(false))
.arg(key(false)),
)
.subcommand(
Command::new("read-type")
.about("Show type")
.arg(domain(true))
.arg(key(true)),
)
.subcommand(
Command::new("write")
.about("Write value")
.arg(domain(true))
.arg(key(true))
.arg(
Arg::new("force")
.short('F')
.long("force")
.help("Disable domain check")
.action(ArgAction::SetTrue),
)
.arg(
Arg::new("int")
.short('i')
.long("int")
.num_args(1)
.value_name("VALUE")
.help("Write an integer value")
.conflicts_with_all(["float", "bool", "string", "array"]),
)
.arg(
Arg::new("float")
.short('f')
.long("float")
.num_args(1)
.value_name("VALUE")
.help("Write a float value")
.conflicts_with_all(["int", "bool", "string", "array"]),
)
.arg(
Arg::new("bool")
.short('b')
.long("bool")
.num_args(1)
.value_name("VALUE")
.help("Write a boolean value (true/false/1/0/yes/no)")
.conflicts_with_all(["int", "float", "string", "array"]),
)
.arg(
Arg::new("string")
.short('s')
.long("string")
.num_args(1)
.value_name("VALUE")
.help("Write a string value")
.conflicts_with_all(["int", "float", "bool", "array"]),
)
.arg(
Arg::new("array")
.short('a')
.long("array")
.value_name("VALUE")
.num_args(1..)
.help("Write an array value")
.conflicts_with_all(["int", "float", "bool", "string"]),
),
)
.subcommand(
Command::new("delete")
.about("Delete key/domain")
.arg(domain(true))
.arg(key(false)),
)
.subcommand(
Command::new("rename")
.about("Rename key")
.arg(domain(true))
.arg(
Arg::new("old_key")
.help("Old/original key name")
.required(true)
.index(2),
)
.arg(
Arg::new("new_key")
.help("New key name")
.required(true)
.index(3),
),
)
.subcommand(
Command::new("import")
.about("Import plist")
.arg(domain(true))
.arg(&path),
)
.subcommand(
Command::new("export")
.about("Export plist")
.arg(domain(true))
.arg(path),
)
.subcommand(
Command::new("domains").about("List domains").arg(
Arg::new("no-fuzzy")
.short('n')
.long("no-fuzzy")
.help("Disable fuzzy-picker")
.action(ArgAction::SetTrue),
),
)
.subcommand(
Command::new("find").about("Search all domains").arg(
Arg::new("word")
.help("Word to search for (case-insensitive)")
.required(true)
.index(1),
),
)
}
#[cfg(feature = "cli")]
fn parse_domain_or_path(sub_m: &ArgMatches, force: bool) -> Result<Domain> {
let home_dir = dirs::home_dir().ok_or_else(|| anyhow!("could not resolve home directory"))?;
let mut domain = sub_m
.get_one::<String>("domain")
.context("domain argument is required")?
.to_string();
if let Ok(path) = Path::new(domain.as_str()).canonicalize()
&& path.is_file()
&& (path.starts_with(format!(
"{}/Library/Preferences/",
home_dir.to_string_lossy()
)) || path.starts_with("/Library/Preferences/")
|| path.starts_with("/System/Library/Preferences/"))
{
domain = path
.file_stem()
.and_then(|s| s.to_str())
.ok_or_else(|| anyhow!("could not get file stem"))?
.to_string();
}
match domain.strip_suffix(".plist").unwrap_or(&domain) {
"-g" | "NSGlobalDomain" | "-globalDomain" | ".GlobalPreferences" => Ok(Domain::Global),
other => {
if other.contains("..")
|| other.contains('/')
|| other.contains('\\')
|| !other
.chars()
.all(|c| c.is_alphanumeric() || c == '.' || c == '_' || c == '-')
{
bail!("Invalid domain or plist path: {other}");
}
if !force
&& !Preferences::list_domains()?
.iter()
.any(|dom| dom.to_string() == other)
{
bail!("Domain '{domain}' not found!.")
}
Ok(Domain::User(other.to_string()))
}
}
}
#[cfg(feature = "cli")]
fn extract_prefvalue_from_args(sub_m: &ArgMatches) -> Result<PrefValue> {
if let Some(val) = sub_m.get_one::<String>("int") {
let val = val
.parse::<i64>()
.map_err(|e| anyhow!("Failed to parse int: {e}"))?;
Ok(PrefValue::Integer(val))
} else if let Some(val) = sub_m.get_one::<String>("float") {
let val = val
.parse::<f64>()
.map_err(|e| anyhow!("Failed to parse int: {e}"))?;
Ok(PrefValue::Float(val))
} else if let Some(val) = sub_m.get_one::<String>("bool") {
match val.to_lowercase().as_str() {
"true" | "1" | "yes" => Ok(PrefValue::Boolean(true)),
"false" | "0" | "no" => Ok(PrefValue::Boolean(false)),
_ => bail!("Invalid boolean value (use true/false, yes/no or 1/0)"),
}
} else if let Some(val) = sub_m.get_many::<String>("array") {
let val: Vec<PrefValue> = val
.into_iter()
.map(|f| PrefValue::String(f.to_string()))
.collect();
Ok(PrefValue::Array(val))
} else if let Some(val) = sub_m.get_one::<String>("string") {
Ok(PrefValue::String(val.to_string()))
} else {
bail!(
"You must specify one of --int, --float, --bool, --array or --string for the value type."
)
}
}
#[cfg(feature = "cli")]
fn get_required_arg<'a>(sub_m: &'a clap::ArgMatches, name: &str) -> &'a str {
sub_m
.get_one::<String>(name)
.map(String::as_str)
.unwrap_or_else(|| {
eprintln!("Error: {name} required");
std::process::exit(1);
})
}
#[cfg(feature = "cli")]
fn pick_one(prompt: &str, items: &[String]) -> Result<Option<String>> {
let item_reader = SkimItemReader::default();
let skim_items = item_reader.of_bufread(Cursor::new(items.join("\n")));
let options = SkimOptionsBuilder::default()
.prompt(prompt.to_string())
.color(Some("bw".to_string()))
.case(CaseMatching::Smart)
.multi(false)
.build()
.context("Failed to build fuzzy-picker options; internal error in pick_one().")?;
let out = Skim::run_with(&options, Some(skim_items));
let out = match out {
Some(o) if !o.is_abort => o,
_ => return Ok(None),
};
Ok(out
.selected_items
.first()
.map(|item| item.output().to_string()))
}
#[cfg(feature = "cli")]
pub fn handle_subcommand(cmd: &str, sub_m: &ArgMatches) -> Result<()> {
match cmd {
"domains" => {
let domains = Preferences::list_domains()?;
let domains_str: Vec<String> = domains.iter().map(|f| f.to_string()).collect();
if sub_m.get_flag("no-fuzzy") {
for dom in domains {
println!("{dom}");
}
} else {
let picker = pick_one(
"Viewing list of domains. Use arrow keys to navigate: ",
&domains_str,
)?;
if let Some(picked_domain) = picker {
println!("Domain: {picked_domain} (is {})", {
match domains
.iter()
.find(|d| d.to_string() == picked_domain)
.context("Domain-type checker failed.")?
{
Domain::User(_) => "user domain",
Domain::Global => "global domain",
}
})
}
}
Ok(())
}
"find" => {
let word = get_required_arg(sub_m, "word");
let results = Preferences::find(word)?;
for (domain, matches) in results {
println!("Found {} matches for domain `{}`:", matches.len(), domain);
for m in matches {
println!(" {} = {}", m.key, m.value);
}
println!();
}
Ok(())
}
"write" => {
let force = sub_m.get_flag("force");
let domain: Domain = if let Ok(val) = parse_domain_or_path(sub_m, force) {
val
} else {
bail!("Could not write to non-existing domain. If intentional, use -F/--force.")
};
let key = get_required_arg(sub_m, "key");
let value = extract_prefvalue_from_args(sub_m)?;
Preferences::write(domain, key, value)
}
"read" => {
let input_domain = sub_m.get_one::<String>("domain");
let input_key = sub_m.get_one::<String>("key");
let domain: Domain = if let Ok(val) = parse_domain_or_path(sub_m, false) {
val
} else if input_domain.is_none() && input_key.is_none() {
let domains = Preferences::list_domains()?;
let domains_str: Vec<String> = domains.iter().map(|f| f.to_string()).collect();
let chosen = pick_one(
"Select a proper domain to read. Use arrow keys to navigate: ",
&domains_str,
)?;
if let Some(chosen) = chosen {
domains
.into_iter()
.find(|d| d.to_string() == chosen)
.context("Unexpected domain mismatch here.")?
} else {
bail!("No domain selected.")
}
} else {
bail!(
"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.",
input_domain
)
};
let val = if let Some(key) = sub_m.get_one::<String>("key").map(String::as_str) {
Preferences::read(domain, key)?
} else {
Preferences::read_domain(domain)?
};
println!("{}", prettify(&val, 0));
Ok(())
}
"read-type" => {
let domain: Domain = parse_domain_or_path(sub_m, false)?;
let key = get_required_arg(sub_m, "key");
let val = Preferences::read(domain, key)?;
println!("Type is {}", val.get_type());
Ok(())
}
"delete" => {
let key = sub_m.get_one::<String>("key").map(String::as_str);
let domain: Domain = parse_domain_or_path(sub_m, false)?;
if let Some(key) = key {
Preferences::delete(domain, key)
} else {
Preferences::delete_domain(domain)
}
}
"rename" => {
let domain: Domain = parse_domain_or_path(sub_m, false)?;
let old_key = get_required_arg(sub_m, "old_key");
let new_key = get_required_arg(sub_m, "new_key");
Preferences::rename(domain, old_key, new_key)
}
"import" => {
let domain: Domain = parse_domain_or_path(sub_m, false)?;
let path = get_required_arg(sub_m, "path");
Preferences::import(domain, path)
}
"export" => {
let domain: Domain = parse_domain_or_path(sub_m, false)?;
let path = get_required_arg(sub_m, "path");
Preferences::export(domain, path)
}
_ => bail!("Not a proper subcommand."),
}
}