Skip to main content

anytype_rpc/
config.rs

1use std::env;
2use std::fs;
3use std::path::{Path, PathBuf};
4
5use serde::Deserialize;
6
7pub use crate::error::ConfigError;
8
9/// Headless CLI config.json fields relevant for gRPC auth.
10#[derive(Debug, Default, Deserialize)]
11pub struct AnytypeHeadlessConfig {
12    #[serde(default, rename = "accountKey")]
13    pub account_key: Option<String>,
14
15    #[serde(default, rename = "sessionToken")]
16    pub session_token: Option<String>,
17
18    #[serde(default, rename = "accountId")]
19    pub account_id: Option<String>,
20}
21
22/// Returns the default configuration path used by the Anytype headless CLI.
23pub fn default_headless_config_path() -> Result<PathBuf, ConfigError> {
24    let home = env::var_os("HOME")
25        .filter(|value| !value.is_empty())
26        .or({
27            #[cfg(windows)]
28            {
29                env::var_os("USERPROFILE").filter(|value| !value.is_empty())
30            }
31            #[cfg(not(windows))]
32            {
33                None
34            }
35        })
36        .ok_or(ConfigError::MissingHome)?;
37    Ok(PathBuf::from(home).join(".anytype").join("config.json"))
38}
39
40/// Loads the configuration file generated by the Anytype headless CLI.
41///
42/// Data from this file can be used to generate a gRPC authentication token.
43/// A missing file returns `Ok(None)`; other I/O and parsing failures are
44/// reported as errors.
45pub fn load_headless_config(
46    path: Option<&Path>,
47) -> Result<Option<AnytypeHeadlessConfig>, ConfigError> {
48    let path = match path {
49        Some(path) => path.to_path_buf(),
50        None => default_headless_config_path()?,
51    };
52
53    let content = match fs::read_to_string(&path) {
54        Ok(content) => content,
55        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
56            match fs::symlink_metadata(&path) {
57                Err(metadata_error) if metadata_error.kind() == std::io::ErrorKind::NotFound => {
58                    return Ok(None);
59                }
60                _ => return Err(error.into()),
61            }
62        }
63        Err(error) => return Err(error.into()),
64    };
65    let config = serde_json::from_str(&content)?;
66    Ok(Some(config))
67}
68
69#[cfg(test)]
70mod tests {
71    use std::{
72        fs,
73        sync::atomic::{AtomicU64, Ordering},
74    };
75
76    use super::*;
77
78    static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
79
80    #[test]
81    fn missing_config_is_distinct_from_unreadable_config() {
82        let root = temp_path("missing-vs-unreadable");
83        fs::create_dir(&root).expect("create test directory");
84
85        assert!(
86            load_headless_config(Some(&root.join("missing.json")))
87                .expect("missing config is allowed")
88                .is_none()
89        );
90        assert!(load_headless_config(Some(&root)).is_err());
91
92        fs::remove_dir(root).expect("remove test directory");
93    }
94
95    #[cfg(unix)]
96    #[test]
97    fn broken_config_symlink_is_not_treated_as_missing() {
98        use std::os::unix::fs::symlink;
99
100        let root = temp_path("broken-symlink");
101        fs::create_dir(&root).expect("create test directory");
102        let path = root.join("config.json");
103        symlink(root.join("absent-target.json"), &path).expect("create broken symlink");
104
105        assert!(load_headless_config(Some(&path)).is_err());
106
107        fs::remove_dir_all(root).expect("remove test directory");
108    }
109
110    #[test]
111    fn loads_account_credentials_from_headless_config() {
112        let path = temp_path("credentials");
113        fs::write(
114            &path,
115            r#"{"accountId":"account-id","accountKey":"account-key","sessionToken":"session-token"}"#,
116        )
117        .expect("write config");
118
119        let config = load_headless_config(Some(&path))
120            .expect("load config")
121            .expect("config exists");
122        assert_eq!(config.account_id.as_deref(), Some("account-id"));
123        assert_eq!(config.account_key.as_deref(), Some("account-key"));
124        assert_eq!(config.session_token.as_deref(), Some("session-token"));
125
126        fs::remove_file(path).expect("remove config");
127    }
128
129    fn temp_path(label: &str) -> PathBuf {
130        let unique = TEMP_COUNTER.fetch_add(1, Ordering::Relaxed);
131        std::env::temp_dir().join(format!(
132            "anytype-rpc-config-{label}-{}-{unique}",
133            std::process::id()
134        ))
135    }
136}