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 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 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 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
168pub 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
210pub 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
275pub 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
282pub 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
434pub 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
441pub 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
502pub 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
523pub 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
568pub 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}