bear-cli 0.1.0

A native Rust CLI for Bear.app on macOS using Bear's SQLite database for reads and x-callback-url actions for writes
Documentation
use std::fmt::Write as _;
use std::process::Command;

use anyhow::{Context, Result, bail};

pub fn open_bear_action(action: &str, params: &[(String, String)]) -> Result<()> {
    let url = build_bear_url(action, params);
    let status = Command::new("open")
        .arg(&url)
        .status()
        .with_context(|| format!("failed to launch Bear URL for action {action}"))?;

    if !status.success() {
        bail!("Bear URL action failed: {action}");
    }

    Ok(())
}

pub fn build_bear_url(action: &str, params: &[(String, String)]) -> String {
    let mut url = format!("bear://x-callback-url/{}", percent_encode(action));
    if !params.is_empty() {
        url.push('?');
        for (index, (key, value)) in params.iter().enumerate() {
            if index > 0 {
                url.push('&');
            }
            let _ = write!(url, "{}={}", percent_encode(key), percent_encode(value));
        }
    }
    url
}

pub fn maybe_push(query: &mut Vec<(String, String)>, key: &str, value: Option<String>) {
    if let Some(value) = value {
        query.push((key.to_string(), value));
    }
}

pub fn maybe_push_bool(query: &mut Vec<(String, String)>, key: &str, enabled: bool) {
    if enabled {
        query.push((key.to_string(), "yes".to_string()));
    }
}

pub fn join_tags(tags: &[String]) -> Option<String> {
    if tags.is_empty() {
        None
    } else {
        Some(tags.join(","))
    }
}

fn percent_encode(input: &str) -> String {
    let mut encoded = String::with_capacity(input.len());
    for byte in input.bytes() {
        match byte {
            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
                encoded.push(char::from(byte))
            }
            _ => {
                let _ = write!(encoded, "%{byte:02X}");
            }
        }
    }
    encoded
}

#[cfg(test)]
mod tests {
    use super::{build_bear_url, join_tags};

    #[test]
    fn encodes_bear_urls() {
        let url = build_bear_url(
            "add-text",
            &[
                ("id".into(), "ABC 123".into()),
                ("text".into(), "hello/world".into()),
            ],
        );
        assert_eq!(
            url,
            "bear://x-callback-url/add-text?id=ABC%20123&text=hello%2Fworld"
        );
    }

    #[test]
    fn joins_tags() {
        assert_eq!(join_tags(&["a".into(), "b".into()]), Some("a,b".into()));
        assert_eq!(join_tags(&[]), None);
    }
}