bear-cli 0.2.2

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::env;
use std::fs;
use std::path::{Path, PathBuf};

use anyhow::{Context, Result, anyhow};
use base64::Engine as _;
use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;

const BEAR_GROUP_CONTAINER_SUFFIX: &str = ".net.shinyfrog.bear";
const BEAR_DATABASE_SUFFIX: &str = "Application Data/database.sqlite";

pub fn expand_tilde(path: &str) -> Result<PathBuf> {
    if let Some(rest) = path.strip_prefix("~/") {
        let home = env::var_os("HOME").ok_or_else(|| anyhow!("$HOME is not set"))?;
        return Ok(PathBuf::from(home).join(rest));
    }

    Ok(PathBuf::from(path))
}

pub fn app_support_dir() -> Result<PathBuf> {
    let home = env::var_os("HOME").ok_or_else(|| anyhow!("$HOME is not set"))?;
    Ok(PathBuf::from(home)
        .join("Library")
        .join("Application Support")
        .join("bear-cli"))
}

pub fn resolve_database_path(override_path: Option<&str>) -> Result<PathBuf> {
    if let Some(path) = override_path {
        return expand_tilde(path);
    }

    let home = env::var_os("HOME").ok_or_else(|| anyhow!("$HOME is not set"))?;
    let group_containers = PathBuf::from(home).join("Library").join("Group Containers");

    find_bear_database_in(&group_containers)
}

fn find_bear_database_in(group_containers: &Path) -> Result<PathBuf> {
    let entries = fs::read_dir(group_containers).with_context(|| {
        format!(
            "failed to read Bear group containers from {}",
            group_containers.display()
        )
    })?;

    let mut matches = entries
        .filter_map(|entry| entry.ok())
        .map(|entry| entry.path())
        .filter(|path| path.is_dir())
        .filter(|path| {
            path.file_name()
                .and_then(|name| name.to_str())
                .is_some_and(|name| name.ends_with(BEAR_GROUP_CONTAINER_SUFFIX))
        })
        .map(|path| path.join(BEAR_DATABASE_SUFFIX))
        .filter(|path| path.is_file())
        .collect::<Vec<_>>();

    matches.sort();

    matches.into_iter().next().ok_or_else(|| {
        anyhow!(
            "could not locate Bear database under {} matching *{}",
            group_containers.display(),
            BEAR_GROUP_CONTAINER_SUFFIX
        )
    })
}

pub fn token_path() -> Result<PathBuf> {
    Ok(app_support_dir()?.join("token"))
}

pub fn save_token(token: &str) -> Result<()> {
    let dir = app_support_dir()?;
    fs::create_dir_all(&dir).with_context(|| format!("failed to create {}", dir.display()))?;
    fs::write(token_path()?, format!("{token}\n")).context("failed to write token file")?;
    Ok(())
}

pub fn load_token() -> Result<Option<String>> {
    let path = token_path()?;
    match fs::read_to_string(&path) {
        Ok(contents) => Ok(Some(contents.trim().to_string())),
        Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None),
        Err(err) => Err(err).with_context(|| format!("failed to read {}", path.display())),
    }
}

pub fn encode_file(path: &Path) -> Result<String> {
    let bytes = fs::read(path).with_context(|| format!("failed to read {}", path.display()))?;
    Ok(BASE64_STANDARD.encode(bytes))
}

#[cfg(test)]
mod tests {
    use std::fs;

    use super::{expand_tilde, find_bear_database_in};

    #[test]
    fn expands_tilde() {
        let expanded = expand_tilde("~/tmp").expect("tilde should expand");
        assert!(expanded.to_string_lossy().ends_with("/tmp"));
    }

    #[test]
    fn finds_database_dynamically() {
        let base =
            std::env::temp_dir().join(format!("bear-cli-config-test-{}", std::process::id()));
        let _ = fs::remove_dir_all(&base);
        let group = base.join("ABC123.net.shinyfrog.bear");
        let database = group.join("Application Data").join("database.sqlite");
        fs::create_dir_all(database.parent().expect("database parent"))
            .expect("test directories should be created");
        fs::write(&database, b"").expect("database file should be created");

        let found = find_bear_database_in(&base).expect("database should be discovered");
        assert_eq!(found, database);

        let _ = fs::remove_dir_all(&base);
    }
}