Skip to main content

clawdentity_core/identity/
config.rs

1use std::fs;
2use std::io::ErrorKind;
3use std::path::{Path, PathBuf};
4
5use serde::{Deserialize, Serialize};
6
7use crate::error::{CoreError, Result};
8
9pub const DEFAULT_REGISTRY_URL: &str = "https://registry.clawdentity.com";
10const DEFAULT_DEV_REGISTRY_URL: &str = "https://dev.registry.clawdentity.com";
11const DEFAULT_LOCAL_REGISTRY_URL: &str = "http://127.0.0.1:8788";
12
13const CONFIG_ROOT_DIR: &str = ".clawdentity";
14const CONFIG_STATES_DIR: &str = "states";
15const CONFIG_ROUTER_FILE: &str = "router.json";
16const CONFIG_FILE: &str = "config.json";
17const FILE_MODE: u32 = 0o600;
18
19const PROD_REGISTRY_HOST: &str = "registry.clawdentity.com";
20const DEV_REGISTRY_HOST: &str = "dev.registry.clawdentity.com";
21
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub enum CliStateKind {
24    Prod,
25    Dev,
26    Local,
27}
28
29impl CliStateKind {
30    fn as_str(self) -> &'static str {
31        match self {
32            Self::Prod => "prod",
33            Self::Dev => "dev",
34            Self::Local => "local",
35        }
36    }
37
38    fn from_str(value: &str) -> Option<Self> {
39        match value {
40            "prod" => Some(Self::Prod),
41            "dev" => Some(Self::Dev),
42            "local" => Some(Self::Local),
43            _ => None,
44        }
45    }
46}
47
48#[derive(Debug, Clone, Default)]
49pub struct ConfigPathOptions {
50    pub home_dir: Option<PathBuf>,
51    pub registry_url_hint: Option<String>,
52}
53
54impl ConfigPathOptions {
55    /// TODO(clawdentity): document `with_registry_hint`.
56    pub fn with_registry_hint(&self, registry_url_hint: impl Into<String>) -> Self {
57        let mut next = self.clone();
58        next.registry_url_hint = Some(registry_url_hint.into());
59        next
60    }
61}
62
63#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
64#[serde(rename_all = "camelCase")]
65pub struct CliConfig {
66    pub registry_url: String,
67    #[serde(skip_serializing_if = "Option::is_none")]
68    pub proxy_url: Option<String>,
69    #[serde(skip_serializing_if = "Option::is_none")]
70    pub api_key: Option<String>,
71    #[serde(skip_serializing_if = "Option::is_none")]
72    pub human_name: Option<String>,
73}
74
75impl Default for CliConfig {
76    fn default() -> Self {
77        Self {
78            registry_url: DEFAULT_REGISTRY_URL.to_string(),
79            proxy_url: None,
80            api_key: None,
81            human_name: None,
82        }
83    }
84}
85
86#[derive(Debug, Clone, Copy, PartialEq, Eq)]
87pub enum ConfigKey {
88    RegistryUrl,
89    ProxyUrl,
90    ApiKey,
91    HumanName,
92}
93
94impl ConfigKey {
95    /// TODO(clawdentity): document `parse`.
96    pub fn parse(value: &str) -> Result<Self> {
97        match value {
98            "registryUrl" => Ok(Self::RegistryUrl),
99            "proxyUrl" => Ok(Self::ProxyUrl),
100            "apiKey" => Ok(Self::ApiKey),
101            "humanName" => Ok(Self::HumanName),
102            other => Err(CoreError::InvalidConfigKey(other.to_string())),
103        }
104    }
105
106    /// TODO(clawdentity): document `as_str`.
107    pub fn as_str(self) -> &'static str {
108        match self {
109            Self::RegistryUrl => "registryUrl",
110            Self::ProxyUrl => "proxyUrl",
111            Self::ApiKey => "apiKey",
112            Self::HumanName => "humanName",
113        }
114    }
115}
116
117#[derive(Debug, Default, Serialize, Deserialize)]
118#[serde(rename_all = "camelCase")]
119struct CliStateRouter {
120    #[serde(skip_serializing_if = "Option::is_none")]
121    last_registry_url: Option<String>,
122    #[serde(skip_serializing_if = "Option::is_none")]
123    last_state: Option<String>,
124    #[serde(skip_serializing_if = "Option::is_none")]
125    migrated_legacy_state: Option<bool>,
126}
127
128fn trim_non_empty(value: Option<String>) -> Option<String> {
129    value.and_then(|raw| {
130        let trimmed = raw.trim();
131        if trimmed.is_empty() {
132            None
133        } else {
134            Some(trimmed.to_string())
135        }
136    })
137}
138
139fn env_first_non_empty(keys: &[&str]) -> Option<String> {
140    keys.iter().find_map(|key| {
141        std::env::var(key).ok().and_then(|value| {
142            let trimmed = value.trim();
143            if trimmed.is_empty() {
144                None
145            } else {
146                Some(trimmed.to_string())
147            }
148        })
149    })
150}
151
152fn env_registry_override() -> Option<String> {
153    env_first_non_empty(&["CLAWDENTITY_REGISTRY_URL", "CLAWDENTITY_REGISTRY"])
154}
155
156fn env_proxy_override() -> Option<String> {
157    env_first_non_empty(&["CLAWDENTITY_PROXY_URL"])
158}
159
160fn env_api_key_override() -> Option<String> {
161    env_first_non_empty(&["CLAWDENTITY_API_KEY"])
162}
163
164fn env_human_name_override() -> Option<String> {
165    env_first_non_empty(&["CLAWDENTITY_HUMAN_NAME"])
166}
167
168/// TODO(clawdentity): document `resolve_state_kind_from_registry_url`.
169pub fn resolve_state_kind_from_registry_url(registry_url: &str) -> CliStateKind {
170    let parsed = match url::Url::parse(registry_url) {
171        Ok(parsed) => parsed,
172        Err(_) => return CliStateKind::Prod,
173    };
174
175    let host = match parsed.host_str() {
176        Some(host) => host.to_ascii_lowercase(),
177        None => return CliStateKind::Prod,
178    };
179
180    if host == DEV_REGISTRY_HOST {
181        return CliStateKind::Dev;
182    }
183
184    if host == PROD_REGISTRY_HOST {
185        return CliStateKind::Prod;
186    }
187
188    if host == "localhost" || host == "127.0.0.1" || host == "host.docker.internal" {
189        return CliStateKind::Local;
190    }
191
192    CliStateKind::Prod
193}
194
195fn default_registry_url_for_state(state_kind: CliStateKind) -> &'static str {
196    match state_kind {
197        CliStateKind::Prod => DEFAULT_REGISTRY_URL,
198        CliStateKind::Dev => DEFAULT_DEV_REGISTRY_URL,
199        CliStateKind::Local => DEFAULT_LOCAL_REGISTRY_URL,
200    }
201}
202
203fn resolve_home_dir(home_override: Option<&Path>) -> Result<PathBuf> {
204    if let Some(home) = home_override {
205        return Ok(home.to_path_buf());
206    }
207    dirs::home_dir().ok_or(CoreError::HomeDirectoryUnavailable)
208}
209
210/// TODO(clawdentity): document `get_config_root_dir`.
211pub fn get_config_root_dir(options: &ConfigPathOptions) -> Result<PathBuf> {
212    Ok(resolve_home_dir(options.home_dir.as_deref())?.join(CONFIG_ROOT_DIR))
213}
214
215fn get_states_dir(options: &ConfigPathOptions) -> Result<PathBuf> {
216    Ok(get_config_root_dir(options)?.join(CONFIG_STATES_DIR))
217}
218
219fn get_router_path(options: &ConfigPathOptions) -> Result<PathBuf> {
220    Ok(get_config_root_dir(options)?.join(CONFIG_ROUTER_FILE))
221}
222
223fn read_router(options: &ConfigPathOptions) -> Result<CliStateRouter> {
224    let path = get_router_path(options)?;
225    let raw = match fs::read_to_string(&path) {
226        Ok(raw) => raw,
227        Err(error) if error.kind() == ErrorKind::NotFound => {
228            return Ok(CliStateRouter::default());
229        }
230        Err(source) => {
231            return Err(CoreError::Io {
232                path: path.clone(),
233                source,
234            });
235        }
236    };
237
238    serde_json::from_str::<CliStateRouter>(&raw)
239        .map_err(|source| CoreError::JsonParse { path, source })
240}
241
242fn write_router(options: &ConfigPathOptions, router: &CliStateRouter) -> Result<()> {
243    let path = get_router_path(options)?;
244    write_secure_json(&path, router)
245}
246
247fn resolve_state_selection(
248    options: &ConfigPathOptions,
249    router: &CliStateRouter,
250) -> (CliStateKind, String) {
251    if let Some(hint) = trim_non_empty(options.registry_url_hint.clone()) {
252        let state = resolve_state_kind_from_registry_url(&hint);
253        return (state, hint);
254    }
255
256    if let Some(from_env) = env_registry_override() {
257        let state = resolve_state_kind_from_registry_url(&from_env);
258        return (state, from_env);
259    }
260
261    if let Some(last_registry_url) = trim_non_empty(router.last_registry_url.clone()) {
262        let state = resolve_state_kind_from_registry_url(&last_registry_url);
263        return (state, last_registry_url);
264    }
265
266    let state = router
267        .last_state
268        .as_deref()
269        .and_then(CliStateKind::from_str)
270        .unwrap_or(CliStateKind::Prod);
271
272    (state, default_registry_url_for_state(state).to_string())
273}
274
275/// TODO(clawdentity): document `get_config_dir`.
276pub fn get_config_dir(options: &ConfigPathOptions) -> Result<PathBuf> {
277    let router = read_router(options)?;
278    let (state, _) = resolve_state_selection(options, &router);
279    Ok(get_states_dir(options)?.join(state.as_str()))
280}
281
282/// TODO(clawdentity): document `get_config_file_path`.
283pub fn get_config_file_path(options: &ConfigPathOptions) -> Result<PathBuf> {
284    Ok(get_config_dir(options)?.join(CONFIG_FILE))
285}
286
287fn normalize_config(config: CliConfig) -> CliConfig {
288    let registry_url = if config.registry_url.trim().is_empty() {
289        DEFAULT_REGISTRY_URL.to_string()
290    } else {
291        config.registry_url
292    };
293
294    CliConfig {
295        registry_url,
296        proxy_url: trim_non_empty(config.proxy_url),
297        api_key: trim_non_empty(config.api_key),
298        human_name: trim_non_empty(config.human_name),
299    }
300}
301
302fn load_config_file(path: &Path) -> Result<CliConfig> {
303    let raw = match fs::read_to_string(path) {
304        Ok(raw) => raw,
305        Err(error) if error.kind() == ErrorKind::NotFound => {
306            return Ok(CliConfig::default());
307        }
308        Err(source) => {
309            return Err(CoreError::Io {
310                path: path.to_path_buf(),
311                source,
312            });
313        }
314    };
315
316    if raw.trim().is_empty() {
317        return Ok(CliConfig::default());
318    }
319
320    serde_json::from_str::<CliConfig>(&raw)
321        .map(normalize_config)
322        .map_err(|source| CoreError::JsonParse {
323            path: path.to_path_buf(),
324            source,
325        })
326}
327
328fn copy_recursively(source: &Path, target: &Path) -> Result<()> {
329    let metadata = fs::symlink_metadata(source).map_err(|source_error| CoreError::Io {
330        path: source.to_path_buf(),
331        source: source_error,
332    })?;
333
334    if metadata.is_dir() {
335        fs::create_dir_all(target).map_err(|source_error| CoreError::Io {
336            path: target.to_path_buf(),
337            source: source_error,
338        })?;
339
340        for entry in fs::read_dir(source).map_err(|source_error| CoreError::Io {
341            path: source.to_path_buf(),
342            source: source_error,
343        })? {
344            let entry = entry.map_err(|source_error| CoreError::Io {
345                path: source.to_path_buf(),
346                source: source_error,
347            })?;
348            let child_source = entry.path();
349            let child_target = target.join(entry.file_name());
350            copy_recursively(&child_source, &child_target)?;
351        }
352        return Ok(());
353    }
354
355    if metadata.is_file() {
356        if let Some(parent) = target.parent() {
357            fs::create_dir_all(parent).map_err(|source_error| CoreError::Io {
358                path: parent.to_path_buf(),
359                source: source_error,
360            })?;
361        }
362        fs::copy(source, target).map_err(|source_error| CoreError::Io {
363            path: target.to_path_buf(),
364            source: source_error,
365        })?;
366    }
367
368    Ok(())
369}
370
371#[allow(clippy::too_many_lines)]
372fn ensure_state_layout_migrated(options: &ConfigPathOptions) -> Result<()> {
373    let router = read_router(options)?;
374    if router.migrated_legacy_state == Some(true) {
375        return Ok(());
376    }
377
378    let root = get_config_root_dir(options)?;
379    let entries = match fs::read_dir(&root) {
380        Ok(entries) => entries,
381        Err(error) if error.kind() == ErrorKind::NotFound => return Ok(()),
382        Err(source) => {
383            return Err(CoreError::Io { path: root, source });
384        }
385    };
386
387    let mut legacy_entries: Vec<PathBuf> = Vec::new();
388    for entry in entries {
389        let entry = entry.map_err(|source| CoreError::Io {
390            path: root.clone(),
391            source,
392        })?;
393        let name = entry.file_name();
394        let name = name.to_string_lossy();
395        if name == CONFIG_STATES_DIR || name == CONFIG_ROUTER_FILE {
396            continue;
397        }
398        legacy_entries.push(entry.path());
399    }
400
401    if !legacy_entries.is_empty() {
402        let prod_state_dir = get_states_dir(options)?.join(CliStateKind::Prod.as_str());
403        fs::create_dir_all(&prod_state_dir).map_err(|source| CoreError::Io {
404            path: prod_state_dir.clone(),
405            source,
406        })?;
407
408        for source_path in legacy_entries {
409            let Some(file_name) = source_path.file_name() else {
410                continue;
411            };
412            let target_path = prod_state_dir.join(file_name);
413            if target_path.exists() {
414                continue;
415            }
416            copy_recursively(&source_path, &target_path)?;
417        }
418    }
419
420    let next_router = CliStateRouter {
421        last_registry_url: trim_non_empty(router.last_registry_url)
422            .or_else(|| Some(DEFAULT_REGISTRY_URL.to_string())),
423        last_state: router
424            .last_state
425            .and_then(|value| CliStateKind::from_str(&value).map(|_| value))
426            .or_else(|| Some(CliStateKind::Prod.as_str().to_string())),
427        migrated_legacy_state: Some(true),
428    };
429    write_router(options, &next_router)?;
430
431    Ok(())
432}
433
434/// TODO(clawdentity): document `read_config`.
435pub fn read_config(options: &ConfigPathOptions) -> Result<CliConfig> {
436    ensure_state_layout_migrated(options)?;
437    let path = get_config_file_path(options)?;
438    load_config_file(&path)
439}
440
441/// TODO(clawdentity): document `resolve_config`.
442pub fn resolve_config(options: &ConfigPathOptions) -> Result<CliConfig> {
443    let mut config = read_config(options)?;
444    if let Some(registry_url) = env_registry_override() {
445        config.registry_url = registry_url;
446    }
447    if let Some(proxy_url) = env_proxy_override() {
448        config.proxy_url = Some(proxy_url);
449    }
450    if let Some(api_key) = env_api_key_override() {
451        config.api_key = Some(api_key);
452    }
453    if let Some(human_name) = env_human_name_override() {
454        config.human_name = Some(human_name);
455    }
456
457    Ok(normalize_config(config))
458}
459
460fn write_secure_json<T: Serialize>(path: &Path, value: &T) -> Result<()> {
461    if let Some(parent) = path.parent() {
462        fs::create_dir_all(parent).map_err(|source| CoreError::Io {
463            path: parent.to_path_buf(),
464            source,
465        })?;
466    }
467
468    let body = serde_json::to_string_pretty(value)?;
469    let content = format!("{body}\n");
470    let tmp_path = path.with_extension("tmp");
471    fs::write(&tmp_path, content).map_err(|source| CoreError::Io {
472        path: tmp_path.clone(),
473        source,
474    })?;
475    set_secure_permissions(&tmp_path)?;
476
477    fs::rename(&tmp_path, path).map_err(|source| CoreError::Io {
478        path: path.to_path_buf(),
479        source,
480    })?;
481    set_secure_permissions(path)?;
482
483    Ok(())
484}
485
486#[cfg(unix)]
487fn set_secure_permissions(path: &Path) -> Result<()> {
488    use std::os::unix::fs::PermissionsExt;
489    let permissions = fs::Permissions::from_mode(FILE_MODE);
490    fs::set_permissions(path, permissions).map_err(|source| CoreError::Io {
491        path: path.to_path_buf(),
492        source,
493    })?;
494    Ok(())
495}
496
497#[cfg(not(unix))]
498fn set_secure_permissions(_path: &Path) -> Result<()> {
499    Ok(())
500}
501
502/// TODO(clawdentity): document `write_config`.
503pub fn write_config(config: &CliConfig, options: &ConfigPathOptions) -> Result<PathBuf> {
504    ensure_state_layout_migrated(options)?;
505
506    let normalized = normalize_config(config.clone());
507    let state = resolve_state_kind_from_registry_url(&normalized.registry_url);
508    let target_dir = get_states_dir(options)?.join(state.as_str());
509    let target_path = target_dir.join(CONFIG_FILE);
510    write_secure_json(&target_path, &normalized)?;
511
512    let current_router = read_router(options)?;
513    let router = CliStateRouter {
514        last_registry_url: Some(normalized.registry_url),
515        last_state: Some(state.as_str().to_string()),
516        migrated_legacy_state: Some(current_router.migrated_legacy_state == Some(true)),
517    };
518    write_router(options, &router)?;
519
520    Ok(target_path)
521}
522
523/// TODO(clawdentity): document `set_config_value`.
524pub fn set_config_value(
525    key: ConfigKey,
526    value: String,
527    options: &ConfigPathOptions,
528) -> Result<CliConfig> {
529    let mut config = read_config(options)?;
530    let trimmed = value.trim().to_string();
531
532    match key {
533        ConfigKey::RegistryUrl => {
534            config.registry_url = if trimmed.is_empty() {
535                DEFAULT_REGISTRY_URL.to_string()
536            } else {
537                trimmed
538            };
539        }
540        ConfigKey::ProxyUrl => {
541            config.proxy_url = if trimmed.is_empty() {
542                None
543            } else {
544                Some(trimmed)
545            };
546        }
547        ConfigKey::ApiKey => {
548            config.api_key = if trimmed.is_empty() {
549                None
550            } else {
551                Some(trimmed)
552            };
553        }
554        ConfigKey::HumanName => {
555            config.human_name = if trimmed.is_empty() {
556                None
557            } else {
558                Some(trimmed)
559            };
560        }
561    }
562
563    let normalized = normalize_config(config);
564    let _ = write_config(&normalized, options)?;
565    Ok(normalized)
566}
567
568/// TODO(clawdentity): document `get_config_value`.
569pub fn get_config_value(key: ConfigKey, options: &ConfigPathOptions) -> Result<Option<String>> {
570    let config = resolve_config(options)?;
571    Ok(match key {
572        ConfigKey::RegistryUrl => Some(config.registry_url),
573        ConfigKey::ProxyUrl => config.proxy_url,
574        ConfigKey::ApiKey => config.api_key,
575        ConfigKey::HumanName => config.human_name,
576    })
577}
578
579#[cfg(test)]
580mod tests {
581    use tempfile::TempDir;
582
583    use super::*;
584
585    fn opts(home: &Path) -> ConfigPathOptions {
586        ConfigPathOptions {
587            home_dir: Some(home.to_path_buf()),
588            registry_url_hint: None,
589        }
590    }
591
592    #[test]
593    fn state_kind_is_derived_from_registry_host() {
594        assert_eq!(
595            resolve_state_kind_from_registry_url("https://registry.clawdentity.com"),
596            CliStateKind::Prod
597        );
598        assert_eq!(
599            resolve_state_kind_from_registry_url("https://dev.registry.clawdentity.com"),
600            CliStateKind::Dev
601        );
602        assert_eq!(
603            resolve_state_kind_from_registry_url("http://127.0.0.1:8788"),
604            CliStateKind::Local
605        );
606    }
607
608    #[test]
609    fn write_config_routes_to_state_directory() {
610        let tmp = TempDir::new().expect("temp dir");
611        let options = opts(tmp.path());
612
613        let dev = CliConfig {
614            registry_url: "https://dev.registry.clawdentity.com".to_string(),
615            proxy_url: Some("https://proxy.dev.clawdentity.com".to_string()),
616            api_key: None,
617            human_name: None,
618        };
619        let dev_path = write_config(&dev, &options).expect("write dev");
620        assert!(dev_path.ends_with(".clawdentity/states/dev/config.json"));
621
622        let prod = CliConfig {
623            registry_url: "https://registry.clawdentity.com".to_string(),
624            proxy_url: None,
625            api_key: None,
626            human_name: None,
627        };
628        let prod_path = write_config(&prod, &options).expect("write prod");
629        assert!(prod_path.ends_with(".clawdentity/states/prod/config.json"));
630    }
631
632    #[test]
633    fn set_and_get_config_value_round_trips() {
634        let tmp = TempDir::new().expect("temp dir");
635        let options = opts(tmp.path());
636
637        let written = set_config_value(ConfigKey::HumanName, "Alice".to_string(), &options)
638            .expect("set config");
639        assert_eq!(written.human_name.as_deref(), Some("Alice"));
640
641        let read_back = get_config_value(ConfigKey::HumanName, &options).expect("get value");
642        assert_eq!(read_back.as_deref(), Some("Alice"));
643    }
644
645    #[test]
646    fn read_config_returns_default_when_missing() {
647        let tmp = TempDir::new().expect("temp dir");
648        let options = opts(tmp.path());
649        let config = read_config(&options).expect("read config");
650        assert_eq!(config.registry_url, DEFAULT_REGISTRY_URL);
651        assert!(config.proxy_url.is_none());
652    }
653
654    #[test]
655    fn migrate_legacy_root_entries_to_prod_state() {
656        let tmp = TempDir::new().expect("temp dir");
657        let options = opts(tmp.path());
658        let root = get_config_root_dir(&options).expect("root");
659        fs::create_dir_all(&root).expect("root dir");
660        fs::write(
661            root.join("config.json"),
662            "{\n  \"registryUrl\": \"https://dev.registry.clawdentity.com\"\n}\n",
663        )
664        .expect("legacy config");
665        fs::create_dir_all(root.join("agents")).expect("legacy agents dir");
666        fs::write(root.join("agents/legacy-agent.txt"), "legacy").expect("legacy file");
667
668        let config = read_config(&options).expect("read config");
669        assert_eq!(config.registry_url, "https://dev.registry.clawdentity.com");
670        assert!(root.join("states/prod/config.json").exists());
671        assert!(root.join("states/prod/agents/legacy-agent.txt").exists());
672
673        let router_raw = fs::read_to_string(root.join("router.json")).expect("router");
674        assert!(router_raw.contains("\"migratedLegacyState\": true"));
675    }
676}