anytype-rpc 0.5.0

Anytype gRPC client
Documentation
use std::env;
use std::fs;
use std::path::{Path, PathBuf};

use serde::Deserialize;

pub use crate::error::ConfigError;

/// Headless CLI config.json fields relevant for gRPC auth.
#[derive(Debug, Default, Deserialize)]
pub struct AnytypeHeadlessConfig {
    #[serde(default, rename = "accountKey")]
    pub account_key: Option<String>,

    #[serde(default, rename = "sessionToken")]
    pub session_token: Option<String>,

    #[serde(default, rename = "accountId")]
    pub account_id: Option<String>,
}

/// Returns the default configuration path used by the Anytype headless CLI.
pub fn default_headless_config_path() -> Result<PathBuf, ConfigError> {
    let home = env::var_os("HOME")
        .filter(|value| !value.is_empty())
        .or({
            #[cfg(windows)]
            {
                env::var_os("USERPROFILE").filter(|value| !value.is_empty())
            }
            #[cfg(not(windows))]
            {
                None
            }
        })
        .ok_or(ConfigError::MissingHome)?;
    Ok(PathBuf::from(home).join(".anytype").join("config.json"))
}

/// Loads the configuration file generated by the Anytype headless CLI.
///
/// Data from this file can be used to generate a gRPC authentication token.
/// A missing file returns `Ok(None)`; other I/O and parsing failures are
/// reported as errors.
pub fn load_headless_config(
    path: Option<&Path>,
) -> Result<Option<AnytypeHeadlessConfig>, ConfigError> {
    let path = match path {
        Some(path) => path.to_path_buf(),
        None => default_headless_config_path()?,
    };

    let content = match fs::read_to_string(&path) {
        Ok(content) => content,
        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
            match fs::symlink_metadata(&path) {
                Err(metadata_error) if metadata_error.kind() == std::io::ErrorKind::NotFound => {
                    return Ok(None);
                }
                _ => return Err(error.into()),
            }
        }
        Err(error) => return Err(error.into()),
    };
    let config = serde_json::from_str(&content)?;
    Ok(Some(config))
}

#[cfg(test)]
mod tests {
    use std::{
        fs,
        sync::atomic::{AtomicU64, Ordering},
    };

    use super::*;

    static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);

    #[test]
    fn missing_config_is_distinct_from_unreadable_config() {
        let root = temp_path("missing-vs-unreadable");
        fs::create_dir(&root).expect("create test directory");

        assert!(
            load_headless_config(Some(&root.join("missing.json")))
                .expect("missing config is allowed")
                .is_none()
        );
        assert!(load_headless_config(Some(&root)).is_err());

        fs::remove_dir(root).expect("remove test directory");
    }

    #[cfg(unix)]
    #[test]
    fn broken_config_symlink_is_not_treated_as_missing() {
        use std::os::unix::fs::symlink;

        let root = temp_path("broken-symlink");
        fs::create_dir(&root).expect("create test directory");
        let path = root.join("config.json");
        symlink(root.join("absent-target.json"), &path).expect("create broken symlink");

        assert!(load_headless_config(Some(&path)).is_err());

        fs::remove_dir_all(root).expect("remove test directory");
    }

    #[test]
    fn loads_account_credentials_from_headless_config() {
        let path = temp_path("credentials");
        fs::write(
            &path,
            r#"{"accountId":"account-id","accountKey":"account-key","sessionToken":"session-token"}"#,
        )
        .expect("write config");

        let config = load_headless_config(Some(&path))
            .expect("load config")
            .expect("config exists");
        assert_eq!(config.account_id.as_deref(), Some("account-id"));
        assert_eq!(config.account_key.as_deref(), Some("account-key"));
        assert_eq!(config.session_token.as_deref(), Some("session-token"));

        fs::remove_file(path).expect("remove config");
    }

    fn temp_path(label: &str) -> PathBuf {
        let unique = TEMP_COUNTER.fetch_add(1, Ordering::Relaxed);
        std::env::temp_dir().join(format!(
            "anytype-rpc-config-{label}-{}-{unique}",
            std::process::id()
        ))
    }
}