use std::process::Stdio;
use crate::result::Result;
#[derive(Debug, Clone, PartialEq)]
pub struct DefaultsRequest {
pub domain: String,
pub key: String,
pub value: DefaultsValue,
}
impl std::fmt::Display for DefaultsRequest {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{} {} = {}", self.domain, self.key, self.value)
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum DefaultsValue {
Bool(bool),
Int(i64),
Float(f64),
Str(String),
}
impl DefaultsValue {
pub fn from_toml(value: &toml::Value) -> Option<Self> {
match value {
toml::Value::Boolean(b) => Some(Self::Bool(*b)),
toml::Value::Integer(i) => Some(Self::Int(*i)),
toml::Value::Float(f) => Some(Self::Float(*f)),
toml::Value::String(s) => Some(Self::Str(s.clone())),
_ => None,
}
}
pub fn write_args(&self) -> Vec<String> {
match self {
Self::Bool(b) => vec!["-bool".into(), b.to_string()],
Self::Int(i) => vec!["-int".into(), i.to_string()],
Self::Float(f) => vec!["-float".into(), f.to_string()],
Self::Str(s) => vec!["-string".into(), s.clone()],
}
}
pub fn to_json(&self) -> serde_json::Value {
match self {
Self::Bool(b) => (*b).into(),
Self::Int(i) => (*i).into(),
Self::Float(f) => (*f).into(),
Self::Str(s) => s.clone().into(),
}
}
fn matches(&self, read_type: &str, raw: &str) -> bool {
match self {
Self::Bool(b) => read_type == "boolean" && raw == if *b { "1" } else { "0" },
Self::Int(i) => read_type == "integer" && raw.parse::<i64>() == Ok(*i),
Self::Float(f) => {
read_type == "float" && raw.parse::<f64>().is_ok_and(|v| (v - f).abs() < 1e-9)
}
Self::Str(s) => read_type == "string" && raw == s,
}
}
}
impl std::fmt::Display for DefaultsValue {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Bool(b) => write!(f, "{b}"),
Self::Int(i) => write!(f, "{i}"),
Self::Float(v) => write!(f, "{v}"),
Self::Str(s) => write!(f, "{s}"),
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum DefaultsState {
Set,
Differs { current: String },
Unset,
}
#[derive(Debug, Clone)]
pub struct DefaultsStatus {
pub request: DefaultsRequest,
pub state: DefaultsState,
}
pub fn is_available() -> bool {
cfg!(target_os = "macos") && crate::file::which("defaults").is_some()
}
pub fn unavailable_reason() -> String {
if cfg!(target_os = "macos") {
"`defaults` not found".to_string()
} else {
"only available on macos".to_string()
}
}
pub async fn status(requests: &[DefaultsRequest]) -> Result<Vec<DefaultsStatus>> {
let mut out = vec![];
for req in requests {
let state = match read(&req.domain, &req.key).await? {
Some((read_type, raw)) => {
if req.value.matches(&read_type, &raw) {
DefaultsState::Set
} else {
let current = if raw == req.value.to_string() {
format!("{raw} ({read_type})")
} else {
raw
};
DefaultsState::Differs { current }
}
}
None => DefaultsState::Unset,
};
out.push(DefaultsStatus {
request: req.clone(),
state,
});
}
Ok(out)
}
pub async fn apply(requests: &[DefaultsRequest], dry_run: bool) -> Result<()> {
for req in requests {
let mut args = vec!["write".to_string(), req.domain.clone(), req.key.clone()];
args.extend(req.value.write_args());
let display = shell_words::join(&args);
if dry_run {
miseprintln!("defaults {display}");
continue;
}
debug!("$ defaults {display}");
let output = tokio::process::Command::new("defaults")
.args(&args)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()
.await?;
if !output.status.success() {
eyre::bail!(
"`defaults {display}` failed: {}",
String::from_utf8_lossy(&output.stderr).trim()
);
}
}
Ok(())
}
async fn read(domain: &str, key: &str) -> Result<Option<(String, String)>> {
let Some(read_type) = defaults_cmd(&["read-type", domain, key]).await? else {
return Ok(None);
};
let read_type = read_type
.strip_prefix("Type is ")
.unwrap_or(&read_type)
.to_string();
let Some(raw) = defaults_cmd(&["read", domain, key]).await? else {
return Ok(None);
};
Ok(Some((read_type, raw)))
}
async fn defaults_cmd(args: &[&str]) -> Result<Option<String>> {
debug!("$ defaults {}", shell_words::join(args));
let output = tokio::process::Command::new("defaults")
.args(args)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()
.await?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
if stderr.contains("does not exist") {
return Ok(None);
}
eyre::bail!(
"`defaults {}` failed: {}",
shell_words::join(args),
stderr.trim()
);
}
let stdout = String::from_utf8_lossy(&output.stdout);
Ok(Some(stdout.trim_end_matches(['\r', '\n']).to_string()))
}
#[cfg(test)]
mod tests {
use super::*;
fn val(s: &str) -> toml::Value {
s.parse().unwrap()
}
#[test]
fn test_from_toml() {
assert_eq!(
DefaultsValue::from_toml(&val("true")),
Some(DefaultsValue::Bool(true))
);
assert_eq!(
DefaultsValue::from_toml(&val("48")),
Some(DefaultsValue::Int(48))
);
assert_eq!(
DefaultsValue::from_toml(&val("1.5")),
Some(DefaultsValue::Float(1.5))
);
assert_eq!(
DefaultsValue::from_toml(&val(r#""right""#)),
Some(DefaultsValue::Str("right".into()))
);
assert_eq!(DefaultsValue::from_toml(&val("[1, 2]")), None);
assert_eq!(DefaultsValue::from_toml(&val("{ a = 1 }")), None);
}
#[test]
fn test_write_args() {
assert_eq!(DefaultsValue::Bool(true).write_args(), ["-bool", "true"]);
assert_eq!(DefaultsValue::Bool(false).write_args(), ["-bool", "false"]);
assert_eq!(DefaultsValue::Int(2).write_args(), ["-int", "2"]);
assert_eq!(DefaultsValue::Float(0.5).write_args(), ["-float", "0.5"]);
assert_eq!(
DefaultsValue::Str("left".into()).write_args(),
["-string", "left"]
);
}
#[test]
fn test_matches() {
assert!(DefaultsValue::Bool(true).matches("boolean", "1"));
assert!(DefaultsValue::Bool(false).matches("boolean", "0"));
assert!(!DefaultsValue::Bool(true).matches("boolean", "0"));
assert!(!DefaultsValue::Bool(true).matches("integer", "1"));
assert!(DefaultsValue::Int(2).matches("integer", "2"));
assert!(!DefaultsValue::Int(2).matches("integer", "3"));
assert!(!DefaultsValue::Int(2).matches("float", "2"));
assert!(DefaultsValue::Float(48.0).matches("float", "48"));
assert!(DefaultsValue::Float(0.5).matches("float", "0.5"));
assert!(!DefaultsValue::Float(0.5).matches("float", "0.6"));
assert!(DefaultsValue::Str("left".into()).matches("string", "left"));
assert!(!DefaultsValue::Str("left".into()).matches("string", "right"));
}
}