1use serde::{Deserialize, Serialize};
2use std::path::PathBuf;
3
4#[derive(Debug, Default, Deserialize, Serialize)]
9#[serde(default)]
10pub struct HyphaConfig {
11 pub defaults: Defaults,
12 pub cache: CacheConfig,
13}
14
15#[derive(Debug, Default, Deserialize, Serialize)]
16#[serde(default)]
17pub struct Defaults {
18 pub synapse: Option<String>,
20 pub domain: Option<String>,
22 pub taste: TasteDefaults,
26}
27
28#[derive(Debug, Default, Deserialize, Serialize)]
29#[serde(default)]
30pub struct TasteDefaults {
31 pub synapse: Option<String>,
33 pub domain: Option<String>,
35}
36
37#[derive(Debug, Clone, Copy, Default, Deserialize, Serialize, PartialEq, Eq)]
38#[serde(rename_all = "snake_case")]
39pub enum KeyTrustRefreshMode {
40 #[default]
42 Expired,
43 Always,
45 Offline,
47}
48
49#[derive(Debug, Clone, Copy, Default, Deserialize, Serialize, PartialEq, Eq)]
50#[serde(rename_all = "snake_case")]
51pub enum SynapseWitnessMode {
52 #[default]
54 Allow,
55 RequireDomain,
57}
58
59#[derive(Debug, Deserialize, Serialize)]
60#[serde(default)]
61pub struct CacheConfig {
62 #[serde(skip_serializing_if = "Option::is_none")]
64 pub path: Option<String>,
65 pub cmn_ttl_s: u64,
67 pub key_trust_ttl_s: u64,
69 pub key_trust_refresh_mode: KeyTrustRefreshMode,
71 pub key_trust_synapse_witness_mode: SynapseWitnessMode,
73 pub max_download_bytes: u64,
75 pub max_extract_bytes: u64,
77 pub max_extract_files: u64,
79 pub max_extract_file_bytes: u64,
81 pub clock_skew_tolerance_s: u64,
85 pub require_domain_first_key: bool,
89}
90
91impl Default for CacheConfig {
92 fn default() -> Self {
93 Self {
94 path: None,
95 cmn_ttl_s: 300,
96 key_trust_ttl_s: 604800, key_trust_refresh_mode: KeyTrustRefreshMode::Expired,
98 key_trust_synapse_witness_mode: SynapseWitnessMode::Allow,
99 max_download_bytes: 1024 * 1024 * 1024, max_extract_bytes: 512 * 1024 * 1024, max_extract_files: 100_000,
102 max_extract_file_bytes: 256 * 1024 * 1024, clock_skew_tolerance_s: 300, require_domain_first_key: true,
105 }
106 }
107}
108
109impl HyphaConfig {
110 pub fn load() -> Self {
111 let path = config_path();
112 match std::fs::read_to_string(&path) {
113 Ok(content) => match toml::from_str(&content) {
114 Ok(cfg) => cfg,
115 Err(e) => {
116 #[allow(clippy::print_stdout)]
117 {
118 let v = agent_first_data::build_json_error(
119 &format!("failed to parse {}: {}", path.display(), e),
120 Some("fix the file or remove it to use defaults"),
121 None,
122 );
123 println!("{}", agent_first_data::output_json(&v));
124 }
125 std::process::exit(1);
126 }
127 },
128 Err(_) => Self::default(),
129 }
130 }
131
132 pub fn save(&self) -> Result<(), crate::sink::HyphaError> {
133 use crate::sink::HyphaError;
134 let path = config_path();
135 if let Some(parent) = path.parent() {
136 std::fs::create_dir_all(parent).map_err(|e| {
137 HyphaError::new(
138 "config_save_failed",
139 format!("Failed to create config directory: {}", e),
140 )
141 })?;
142 }
143 let content = toml::to_string_pretty(self).map_err(|e| {
144 HyphaError::new(
145 "config_save_failed",
146 format!("Failed to serialize config: {}", e),
147 )
148 })?;
149
150 let tmp_path = path.with_extension("toml.tmp");
152 std::fs::write(&tmp_path, &content).map_err(|e| {
153 HyphaError::new(
154 "config_save_failed",
155 format!("Failed to write temp config: {}", e),
156 )
157 })?;
158 std::fs::rename(&tmp_path, &path).map_err(|e| {
159 HyphaError::new(
160 "config_save_failed",
161 format!("Failed to rename config.toml: {}", e),
162 )
163 })
164 }
165}
166
167pub fn config_path() -> PathBuf {
168 hypha_dir().join("config.toml")
169}
170
171pub fn hypha_dir() -> PathBuf {
173 crate::site::get_cmn_home().join("hypha")
174}
175
176use crate::api::Output;
181use std::process::ExitCode;
182
183pub fn handle_list(out: &Output) -> ExitCode {
185 let cfg = HyphaConfig::load();
186 let path = config_path();
187
188 let data = serde_json::json!({
189 "path": path.display().to_string(),
190 "exists": path.exists(),
191 "config": serde_json::to_value(&cfg).unwrap_or_default(),
192 });
193
194 out.ok(data)
195}
196
197pub fn handle_set(out: &Output, key: &str, value: &str) -> ExitCode {
199 let mut cfg = HyphaConfig::load();
200
201 match key {
202 "cache.path" => cfg.cache.path = Some(value.to_string()),
203 "cache.cmn_ttl_s" => match value.parse::<u64>() {
204 Ok(v) => cfg.cache.cmn_ttl_s = v,
205 Err(_) => return out.error("invalid_value", &format!("Expected integer for {}", key)),
206 },
207 "cache.key_trust_ttl_s" => match value.parse::<u64>() {
208 Ok(v) => cfg.cache.key_trust_ttl_s = v,
209 Err(_) => return out.error("invalid_value", &format!("Expected integer for {}", key)),
210 },
211 "cache.key_trust_refresh_mode" => match value {
212 "expired" => cfg.cache.key_trust_refresh_mode = KeyTrustRefreshMode::Expired,
213 "always" => cfg.cache.key_trust_refresh_mode = KeyTrustRefreshMode::Always,
214 "offline" => cfg.cache.key_trust_refresh_mode = KeyTrustRefreshMode::Offline,
215 _ => {
216 return out.error(
217 "invalid_value",
218 &format!(
219 "Expected one of: expired, always, offline for {}",
220 key
221 ),
222 )
223 }
224 },
225 "cache.key_trust_synapse_witness_mode" => match value {
226 "allow" => cfg.cache.key_trust_synapse_witness_mode = SynapseWitnessMode::Allow,
227 "require_domain" => {
228 cfg.cache.key_trust_synapse_witness_mode = SynapseWitnessMode::RequireDomain
229 }
230 _ => {
231 return out.error(
232 "invalid_value",
233 &format!("Expected one of: allow, require_domain for {}", key),
234 )
235 }
236 },
237 "cache.max_download_bytes" => match value.parse::<u64>() {
238 Ok(v) => cfg.cache.max_download_bytes = v,
239 Err(_) => return out.error("invalid_value", &format!("Expected integer for {}", key)),
240 },
241 "cache.max_extract_bytes" => match value.parse::<u64>() {
242 Ok(v) => cfg.cache.max_extract_bytes = v,
243 Err(_) => return out.error("invalid_value", &format!("Expected integer for {}", key)),
244 },
245 "cache.max_extract_files" => match value.parse::<u64>() {
246 Ok(v) => cfg.cache.max_extract_files = v,
247 Err(_) => return out.error("invalid_value", &format!("Expected integer for {}", key)),
248 },
249 "cache.max_extract_file_bytes" => match value.parse::<u64>() {
250 Ok(v) => cfg.cache.max_extract_file_bytes = v,
251 Err(_) => return out.error("invalid_value", &format!("Expected integer for {}", key)),
252 },
253 "cache.clock_skew_tolerance_s" => match value.parse::<u64>() {
254 Ok(v) => cfg.cache.clock_skew_tolerance_s = v,
255 Err(_) => return out.error("invalid_value", &format!("Expected integer for {}", key)),
256 },
257 "cache.require_domain_first_key" => match value {
258 "true" => cfg.cache.require_domain_first_key = true,
259 "false" => cfg.cache.require_domain_first_key = false,
260 _ => {
261 return out.error(
262 "invalid_value",
263 &format!("Expected one of: true, false for {}", key),
264 )
265 }
266 },
267 "defaults.synapse" => cfg.defaults.synapse = Some(value.to_string()),
268 "defaults.domain" => cfg.defaults.domain = Some(value.to_string()),
269 "defaults.taste.synapse" => cfg.defaults.taste.synapse = Some(value.to_string()),
270 "defaults.taste.domain" => cfg.defaults.taste.domain = Some(value.to_string()),
271 _ => return out.error("unknown_key", &format!(
272 "Unknown config key '{}'. Valid keys: cache.path, cache.cmn_ttl_s, cache.key_trust_ttl_s, cache.key_trust_refresh_mode, cache.key_trust_synapse_witness_mode, cache.max_download_bytes, cache.max_extract_bytes, cache.max_extract_files, cache.max_extract_file_bytes, cache.clock_skew_tolerance_s, cache.require_domain_first_key, defaults.synapse, defaults.domain, defaults.taste.synapse, defaults.taste.domain",
273 key
274 )),
275 }
276
277 match cfg.save() {
278 Ok(()) => out.ok(serde_json::json!({
279 "key": key,
280 "value": value,
281 })),
282 Err(e) => out.error_hypha(&e),
283 }
284}
285
286#[derive(Debug, Clone, Deserialize, Serialize)]
291pub struct SynapseNode {
292 pub url: String,
293 #[serde(skip_serializing_if = "Option::is_none")]
294 pub token_secret: Option<String>,
295}
296
297pub struct ResolvedSynapse {
299 pub url: String,
300 pub token_secret: Option<String>,
301}
302
303fn validate_synapse_domain(domain: &str) -> Result<(), crate::sink::HyphaError> {
304 use crate::sink::HyphaError;
305 if domain.is_empty() {
306 return Err(HyphaError::new(
307 "invalid_synapse_domain",
308 "Synapse domain must not be empty",
309 ));
310 }
311 if domain.chars().any(|c| c.is_control()) {
312 return Err(HyphaError::new(
313 "invalid_synapse_domain",
314 format!(
315 "Invalid synapse domain '{}': contains control characters",
316 domain
317 ),
318 ));
319 }
320
321 let mut components = std::path::Path::new(domain).components();
322 let single_normal_component =
323 matches!(components.next(), Some(std::path::Component::Normal(_)))
324 && components.next().is_none();
325 if !single_normal_component {
326 return Err(HyphaError::new(
327 "invalid_synapse_domain",
328 format!(
329 "Invalid synapse domain '{}': must be a single path segment",
330 domain
331 ),
332 ));
333 }
334
335 Ok(())
336}
337
338pub fn synapse_node_dir(domain: &str) -> PathBuf {
340 hypha_dir().join("synapse").join(domain)
341}
342
343pub fn load_synapse_node(domain: &str) -> Option<SynapseNode> {
345 if validate_synapse_domain(domain).is_err() {
346 return None;
347 }
348 let path = synapse_node_dir(domain).join("config.toml");
349 let content = std::fs::read_to_string(&path).ok()?;
350 toml::from_str(&content).ok()
351}
352
353pub fn save_synapse_node(domain: &str, node: &SynapseNode) -> Result<(), crate::sink::HyphaError> {
355 use crate::sink::HyphaError;
356 validate_synapse_domain(domain)?;
357 let dir = synapse_node_dir(domain);
358 std::fs::create_dir_all(&dir).map_err(|e| {
359 HyphaError::new(
360 "synapse_node_save_failed",
361 format!("Failed to create synapse node directory: {}", e),
362 )
363 })?;
364 #[cfg(unix)]
365 {
366 use std::os::unix::fs::PermissionsExt;
367 std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700)).map_err(|e| {
368 HyphaError::new(
369 "synapse_node_save_failed",
370 format!("Failed to protect synapse node directory: {}", e),
371 )
372 })?;
373 }
374
375 let path = dir.join("config.toml");
376 let content = toml::to_string_pretty(node).map_err(|e| {
377 HyphaError::new(
378 "synapse_node_save_failed",
379 format!("Failed to serialize node config: {}", e),
380 )
381 })?;
382 std::fs::write(&path, &content).map_err(|e| {
383 HyphaError::new(
384 "synapse_node_save_failed",
385 format!("Failed to write node config: {}", e),
386 )
387 })?;
388
389 #[cfg(unix)]
391 {
392 use std::os::unix::fs::PermissionsExt;
393 let mut perms = std::fs::metadata(&path)
394 .map_err(|e| {
395 HyphaError::new(
396 "synapse_node_save_failed",
397 format!("Failed to read node config metadata: {}", e),
398 )
399 })?
400 .permissions();
401 perms.set_mode(0o600);
402 std::fs::set_permissions(&path, perms).map_err(|e| {
403 HyphaError::new(
404 "synapse_node_save_failed",
405 format!("Failed to set node config permissions: {}", e),
406 )
407 })?;
408 }
409
410 Ok(())
411}
412
413pub fn remove_synapse_node(domain: &str) -> Result<(), crate::sink::HyphaError> {
415 use crate::sink::HyphaError;
416 validate_synapse_domain(domain)?;
417 let dir = synapse_node_dir(domain);
418 if dir.exists() {
419 std::fs::remove_dir_all(&dir).map_err(|e| {
420 HyphaError::new(
421 "synapse_node_remove_failed",
422 format!("Failed to remove synapse node directory: {}", e),
423 )
424 })?;
425 }
426 Ok(())
427}
428
429pub fn list_synapse_domains() -> Vec<String> {
431 let synapse_dir = hypha_dir().join("synapse");
432 let entries = match std::fs::read_dir(&synapse_dir) {
433 Ok(e) => e,
434 Err(_) => return Vec::new(),
435 };
436
437 let mut domains: Vec<String> = entries
438 .filter_map(|e| e.ok())
439 .filter(|e| e.path().join("config.toml").exists())
440 .filter_map(|e| e.file_name().into_string().ok())
441 .collect();
442 domains.sort();
443 domains
444}
445
446pub fn domain_from_url(url: &str) -> Result<String, crate::sink::HyphaError> {
448 use crate::sink::HyphaError;
449 let parsed = reqwest::Url::parse(url)
450 .map_err(|e| HyphaError::new("invalid_url", format!("Invalid URL '{}': {}", url, e)))?;
451 parsed
452 .host_str()
453 .map(|h| h.to_string())
454 .ok_or_else(|| HyphaError::new("invalid_url", format!("URL '{}' has no host", url)))
455}
456
457pub fn resolve_synapse(
464 value: Option<&str>,
465 token_override: Option<&str>,
466) -> Result<ResolvedSynapse, crate::sink::HyphaError> {
467 use crate::sink::HyphaError;
468 let mut resolved =
469 match value {
470 Some(v) if reqwest::Url::parse(v).is_ok() => {
471 let parsed = reqwest::Url::parse(v).map_err(|e| {
473 HyphaError::new(
474 "invalid_synapse_url",
475 format!("Invalid synapse URL '{}': {}", v, e),
476 )
477 })?;
478 if parsed.scheme() != "http" && parsed.scheme() != "https" {
479 return Err(HyphaError::new(
480 "invalid_synapse_url",
481 format!("Invalid synapse URL '{}': scheme must be http or https", v),
482 ));
483 }
484 let domain = domain_from_url(v)?;
485 let node = load_synapse_node(&domain);
486 ResolvedSynapse {
487 url: v.to_string(),
488 token_secret: node.and_then(|n| n.token_secret),
489 }
490 }
491 Some(domain) => {
492 validate_synapse_domain(domain)?;
493 match load_synapse_node(domain) {
495 Some(node) => ResolvedSynapse {
496 url: node.url,
497 token_secret: node.token_secret,
498 },
499 None => {
500 return Err(HyphaError::with_hint(
501 "synapse_not_found",
502 format!("Synapse '{}' not found", domain),
503 "run: hypha synapse add <url>",
504 ))
505 }
506 }
507 }
508 None => {
509 let config = HyphaConfig::load();
511 match &config.defaults.synapse {
512 Some(default_domain) => match load_synapse_node(default_domain) {
513 Some(node) => ResolvedSynapse {
514 url: node.url,
515 token_secret: node.token_secret,
516 },
517 None => return Err(HyphaError::with_hint(
518 "synapse_not_found",
519 format!("Default synapse '{}' not found", default_domain),
520 "run: hypha synapse add <url>",
521 )),
522 },
523 None => return Err(HyphaError::with_hint(
524 "synapse_not_configured",
525 "No synapse specified and no default configured",
526 "use -s <url> or run: hypha synapse add <url> && hypha synapse use <domain>",
527 )),
528 }
529 }
530 };
531
532 if let Ok(ts) = std::env::var("SYNAPSE_TOKEN_SECRET") {
534 resolved.token_secret = if ts.is_empty() { None } else { Some(ts) };
535 }
536
537 if let Some(ts) = token_override {
539 resolved.token_secret = if ts.is_empty() {
540 None
541 } else {
542 Some(ts.to_string())
543 };
544 }
545
546 Ok(resolved)
547}
548
549#[cfg(test)]
550pub static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
551
552#[cfg(test)]
553#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
554mod tests {
555
556 use super::*;
557
558 #[test]
559 fn test_default_values() {
560 let cfg = HyphaConfig::default();
561 assert_eq!(cfg.cache.cmn_ttl_s, 300);
562 assert!(cfg.defaults.synapse.is_none());
563 }
564
565 #[test]
566 fn test_parse_full_toml() {
567 let toml_str = r#"
568[defaults]
569synapse = "synapse.cmn.dev"
570
571[cache]
572cmn_ttl_s = 60
573"#;
574 let cfg: HyphaConfig = toml::from_str(toml_str).unwrap();
575 assert_eq!(cfg.cache.cmn_ttl_s, 60);
576 assert_eq!(cfg.defaults.synapse.as_deref(), Some("synapse.cmn.dev"));
577 }
578
579 #[test]
580 fn test_parse_partial_toml_cmn_only() {
581 let toml_str = r#"
582[cache]
583cmn_ttl_s = 10
584"#;
585 let cfg: HyphaConfig = toml::from_str(toml_str).unwrap();
586 assert_eq!(cfg.cache.cmn_ttl_s, 10);
587 }
588
589 #[test]
590 fn test_parse_empty_toml() {
591 let cfg: HyphaConfig = toml::from_str("").unwrap();
592 assert_eq!(cfg.cache.cmn_ttl_s, 300);
593 }
594
595 #[test]
596 fn test_parse_empty_cache_section() {
597 let toml_str = "[cache]\n";
598 let cfg: HyphaConfig = toml::from_str(toml_str).unwrap();
599 assert_eq!(cfg.cache.cmn_ttl_s, 300);
600 }
601
602 #[test]
603 fn test_invalid_toml_falls_back_to_default() {
604 let bad_toml = "this is not valid toml {{{{";
605 let cfg: HyphaConfig = toml::from_str(bad_toml).unwrap_or_default();
606 assert_eq!(cfg.cache.cmn_ttl_s, 300);
607 }
608
609 #[test]
610 fn test_require_domain_first_key_default_true() {
611 let cfg = HyphaConfig::default();
612 assert!(cfg.cache.require_domain_first_key);
613 }
614
615 #[test]
616 fn test_require_domain_first_key_toml_parse() {
617 let toml_str = r#"
618[cache]
619require_domain_first_key = false
620"#;
621 let cfg: HyphaConfig = toml::from_str(toml_str).unwrap();
622 assert!(!cfg.cache.require_domain_first_key);
623 }
624
625 #[test]
626 fn test_require_domain_first_key_absent_defaults_true() {
627 let toml_str = r#"
628[cache]
629cmn_ttl_s = 60
630"#;
631 let cfg: HyphaConfig = toml::from_str(toml_str).unwrap();
632 assert!(cfg.cache.require_domain_first_key);
633 }
634
635 #[test]
636 fn test_zero_ttl_allowed() {
637 let toml_str = r#"
638[cache]
639cmn_ttl_s = 0
640"#;
641 let cfg: HyphaConfig = toml::from_str(toml_str).unwrap();
642 assert_eq!(cfg.cache.cmn_ttl_s, 0);
643 }
644
645 #[test]
646 fn test_config_save_load() {
647 let _lock = super::ENV_LOCK.lock().unwrap();
648 let dir = tempfile::tempdir().unwrap();
649 std::env::set_var("CMN_HOME", dir.path().to_str().unwrap());
650
651 let mut cfg = HyphaConfig::default();
652 cfg.defaults.synapse = Some("synapse.cmn.dev".to_string());
653 cfg.cache.cmn_ttl_s = 999;
654 cfg.save().unwrap();
655
656 let loaded = HyphaConfig::load();
657 assert_eq!(loaded.defaults.synapse.as_deref(), Some("synapse.cmn.dev"));
658 assert_eq!(loaded.cache.cmn_ttl_s, 999);
659
660 std::env::remove_var("CMN_HOME");
661 }
662
663 #[test]
668 fn test_synapse_node_roundtrip() {
669 let toml_str = r#"
670url = "https://synapse.cmn.dev"
671token_secret = "sk-abc123"
672"#;
673 let node: SynapseNode = toml::from_str(toml_str).unwrap();
674 assert_eq!(node.url, "https://synapse.cmn.dev");
675 assert_eq!(node.token_secret.as_deref(), Some("sk-abc123"));
676
677 let serialized = toml::to_string_pretty(&node).unwrap();
678 let parsed: SynapseNode = toml::from_str(&serialized).unwrap();
679 assert_eq!(parsed.url, "https://synapse.cmn.dev");
680 assert_eq!(parsed.token_secret.as_deref(), Some("sk-abc123"));
681 }
682
683 #[test]
684 fn test_synapse_node_no_token() {
685 let toml_str = "url = \"https://synapse.cmn.dev\"\n";
686 let node: SynapseNode = toml::from_str(toml_str).unwrap();
687 assert!(node.token_secret.is_none());
688
689 let serialized = toml::to_string_pretty(&node).unwrap();
691 assert!(!serialized.contains("token_secret"));
692 }
693
694 #[test]
695 fn test_save_load_synapse_node() {
696 let _lock = super::ENV_LOCK.lock().unwrap();
697 let dir = tempfile::tempdir().unwrap();
698 std::env::set_var("CMN_HOME", dir.path().to_str().unwrap());
699
700 let node = SynapseNode {
701 url: "https://synapse.cmn.dev".to_string(),
702 token_secret: Some("tok".to_string()),
703 };
704 save_synapse_node("synapse.cmn.dev", &node).unwrap();
705
706 let node_dir = dir
707 .path()
708 .join("hypha")
709 .join("synapse")
710 .join("synapse.cmn.dev");
711 assert!(node_dir.join("config.toml").exists());
712
713 #[cfg(unix)]
715 {
716 use std::os::unix::fs::PermissionsExt;
717 let mode = std::fs::metadata(node_dir.join("config.toml"))
718 .unwrap()
719 .permissions()
720 .mode();
721 assert_eq!(
722 mode & 0o777,
723 0o600,
724 "config.toml should be 0600, got {:o}",
725 mode & 0o777
726 );
727 }
728
729 let loaded = load_synapse_node("synapse.cmn.dev").unwrap();
730 assert_eq!(loaded.url, "https://synapse.cmn.dev");
731 assert_eq!(loaded.token_secret.as_deref(), Some("tok"));
732
733 std::env::remove_var("CMN_HOME");
734 }
735
736 #[test]
737 fn test_list_synapse_domains() {
738 let _lock = super::ENV_LOCK.lock().unwrap();
739 let dir = tempfile::tempdir().unwrap();
740 std::env::set_var("CMN_HOME", dir.path().to_str().unwrap());
741
742 save_synapse_node(
743 "beta.example.com",
744 &SynapseNode {
745 url: "https://beta.example.com".to_string(),
746 token_secret: None,
747 },
748 )
749 .unwrap();
750 save_synapse_node(
751 "alpha.example.com",
752 &SynapseNode {
753 url: "https://alpha.example.com".to_string(),
754 token_secret: None,
755 },
756 )
757 .unwrap();
758
759 let domains = list_synapse_domains();
760 assert_eq!(domains, vec!["alpha.example.com", "beta.example.com"]);
761
762 std::env::remove_var("CMN_HOME");
763 }
764
765 #[test]
766 fn test_remove_synapse_node() {
767 let _lock = super::ENV_LOCK.lock().unwrap();
768 let dir = tempfile::tempdir().unwrap();
769 std::env::set_var("CMN_HOME", dir.path().to_str().unwrap());
770
771 save_synapse_node(
772 "test.example.com",
773 &SynapseNode {
774 url: "https://test.example.com".to_string(),
775 token_secret: None,
776 },
777 )
778 .unwrap();
779
780 assert!(load_synapse_node("test.example.com").is_some());
781
782 remove_synapse_node("test.example.com").unwrap();
783 assert!(load_synapse_node("test.example.com").is_none());
784 assert!(list_synapse_domains().is_empty());
785
786 std::env::remove_var("CMN_HOME");
787 }
788
789 #[test]
790 fn test_domain_from_url() {
791 assert_eq!(
792 domain_from_url("https://synapse.cmn.dev").unwrap(),
793 "synapse.cmn.dev"
794 );
795 assert_eq!(
796 domain_from_url("http://localhost:8080").unwrap(),
797 "localhost"
798 );
799 assert_eq!(
800 domain_from_url("https://example.com/path").unwrap(),
801 "example.com"
802 );
803 assert!(domain_from_url("not-a-url").is_err());
804 }
805
806 #[test]
807 fn test_resolve_synapse_env_var_override() {
808 let _lock = super::ENV_LOCK.lock().unwrap();
809 let dir = tempfile::tempdir().unwrap();
810 std::env::set_var("CMN_HOME", dir.path().to_str().unwrap());
811
812 save_synapse_node(
814 "test.example.com",
815 &SynapseNode {
816 url: "https://test.example.com".to_string(),
817 token_secret: Some("config-token".to_string()),
818 },
819 )
820 .unwrap();
821
822 std::env::set_var("SYNAPSE_TOKEN_SECRET", "env-token");
824 let resolved = resolve_synapse(Some("test.example.com"), None).unwrap();
825 assert_eq!(resolved.token_secret.as_deref(), Some("env-token"));
826
827 let resolved = resolve_synapse(Some("test.example.com"), Some("cli-token")).unwrap();
829 assert_eq!(resolved.token_secret.as_deref(), Some("cli-token"));
830
831 let resolved = resolve_synapse(Some("test.example.com"), Some("")).unwrap();
833 assert!(resolved.token_secret.is_none());
834
835 std::env::remove_var("SYNAPSE_TOKEN_SECRET");
836
837 let resolved = resolve_synapse(Some("test.example.com"), None).unwrap();
839 assert_eq!(resolved.token_secret.as_deref(), Some("config-token"));
840
841 std::env::remove_var("CMN_HOME");
842 }
843
844 #[test]
845 fn test_load_missing_node_returns_none() {
846 let _lock = super::ENV_LOCK.lock().unwrap();
847 let dir = tempfile::tempdir().unwrap();
848 std::env::set_var("CMN_HOME", dir.path().to_str().unwrap());
849
850 assert!(load_synapse_node("nonexistent.example.com").is_none());
851
852 std::env::remove_var("CMN_HOME");
853 }
854}