Skip to main content

hypha/config/
mod.rs

1use serde::{Deserialize, Serialize};
2use std::path::PathBuf;
3
4// ═══════════════════════════════════════════
5// Hypha config — $CMN_HOME/hypha/config.toml
6// ═══════════════════════════════════════════
7
8#[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    /// Default synapse for queries (sense, lineage, search)
19    pub synapse: Option<String>,
20    /// Default domain for publishing (release)
21    pub domain: Option<String>,
22    /// Taste-specific overrides for auto-submission.
23    /// Separate because taste may use a different domain (hub subdomain)
24    /// and synapse (cmnhub.com) than general queries/publishing.
25    pub taste: TasteDefaults,
26}
27
28#[derive(Debug, Default, Deserialize, Serialize)]
29#[serde(default)]
30pub struct TasteDefaults {
31    /// Synapse to submit taste reports to
32    pub synapse: Option<String>,
33    /// Domain to sign taste reports with
34    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    /// Refresh key trust only when trust cache is expired/missing.
41    #[default]
42    Expired,
43    /// Always refresh key trust from network sources.
44    Always,
45    /// Never refresh from network; rely on local trust cache only.
46    Offline,
47}
48
49#[derive(Debug, Clone, Copy, Default, Deserialize, Serialize, PartialEq, Eq)]
50#[serde(rename_all = "snake_case")]
51pub enum SynapseWitnessMode {
52    /// Allow Synapse key witness when domain confirmation is unavailable.
53    #[default]
54    Allow,
55    /// Require direct domain confirmation (or cached trust); do not use Synapse witness.
56    RequireDomain,
57}
58
59#[derive(Debug, Deserialize, Serialize)]
60#[serde(default)]
61pub struct CacheConfig {
62    /// Custom cache directory path (default: $CMN_HOME/hypha/cache/)
63    #[serde(skip_serializing_if = "Option::is_none")]
64    pub path: Option<String>,
65    /// cmn.json cache TTL in seconds (default: 300 = 5 minutes)
66    pub cmn_ttl_s: u64,
67    /// Key trust cache TTL in seconds (default: 604800 = 7 days)
68    pub key_trust_ttl_s: u64,
69    /// Key trust refresh strategy (default: expired)
70    pub key_trust_refresh_mode: KeyTrustRefreshMode,
71    /// Key trust fallback policy when domain is unreachable (default: allow)
72    pub key_trust_synapse_witness_mode: SynapseWitnessMode,
73    /// Maximum HTTP response body size in bytes (default: 1 GB)
74    pub max_download_bytes: u64,
75    /// Maximum total bytes to extract from an archive (default: 512 MB)
76    pub max_extract_bytes: u64,
77    /// Maximum number of files to extract from an archive (default: 100_000)
78    pub max_extract_files: u64,
79    /// Maximum size of a single file in an archive in bytes (default: 256 MB)
80    pub max_extract_file_bytes: u64,
81    /// Clock skew tolerance in seconds for key trust TTL checks (default: 300 = 5 minutes).
82    /// Adds a grace period to prevent false "key_untrusted" errors caused by clock drift
83    /// between the local machine and the publishing domain.
84    pub clock_skew_tolerance_s: u64,
85    /// Whether the initial key for a domain must come from the domain itself (TOFU).
86    /// true = more secure, first contact requires domain to be online.
87    /// false = allows synapse to provide initial key (less secure).
88    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, // 7 days
97            key_trust_refresh_mode: KeyTrustRefreshMode::Expired,
98            key_trust_synapse_witness_mode: SynapseWitnessMode::Allow,
99            max_download_bytes: 1024 * 1024 * 1024, // 1 GB
100            max_extract_bytes: 512 * 1024 * 1024,   // 512 MB
101            max_extract_files: 100_000,
102            max_extract_file_bytes: 256 * 1024 * 1024, // 256 MB
103            clock_skew_tolerance_s: 300,               // 5 minutes
104            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        // Atomic write via temp file + rename to prevent concurrent corruption
151        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
171/// $CMN_HOME/hypha/
172pub fn hypha_dir() -> PathBuf {
173    crate::site::get_cmn_home().join("hypha")
174}
175
176// ═══════════════════════════════════════════
177// CLI handlers: hypha config list/set
178// ═══════════════════════════════════════════
179
180use crate::api::Output;
181use std::process::ExitCode;
182
183/// Handle `hypha config list`
184pub 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
197/// Handle `hypha config set <key> <value>`
198pub 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// ═══════════════════════════════════════════
287// Per-node synapse config — $CMN_HOME/hypha/synapse/<domain>/config.toml
288// ═══════════════════════════════════════════
289
290#[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
297/// Resolved synapse: URL + optional auth token
298pub 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
338/// Directory for a synapse node: $CMN_HOME/hypha/synapse/<domain>/
339pub fn synapse_node_dir(domain: &str) -> PathBuf {
340    hypha_dir().join("synapse").join(domain)
341}
342
343/// Load a synapse node config from its directory
344pub 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
353/// Save a synapse node config to its directory (0600 permissions)
354pub 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    // Protect config.toml (0600) — may contain token_secret
390    #[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
413/// Remove a synapse node directory
414pub 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
429/// List all configured synapse node domains by scanning the synapse directory
430pub 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
446/// Extract domain (host) from a URL
447pub 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
457/// Resolve a synapse CLI argument (domain or URL) to a URL + optional token.
458///
459/// - If value starts with `http`, treat as URL (check nodes for matching domain)
460/// - If value is a domain, look up node directory
461/// - If `None`, use `defaults.synapse` from config.toml
462/// - `token_override` from `--synapse-token-secret` takes priority over config
463pub 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                // Raw URL — check if a node exists for this domain
472                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                // Look up by domain
494                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                // Use default from config.toml
510                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    // env var SYNAPSE_TOKEN_SECRET overrides config
533    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    // CLI --synapse-token-secret overrides env var
538    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    // ═══════════════════════════════════════════
664    // Synapse node tests
665    // ═══════════════════════════════════════════
666
667    #[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        // Verify token_secret is not serialized when None
690        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        // Verify permissions
714        #[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        // Set up a node with a token
813        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        // Env var overrides config
823        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        // CLI flag overrides env var
828        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        // Empty CLI flag clears even when env var is set
832        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        // Without env var, config token is used
838        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}