Skip to main content

cli_shared/config/
user_config.rs

1// SPDX-License-Identifier: Apache-2.0
2use std::{
3    collections::BTreeMap,
4    env, fs,
5    io::Read,
6    path::{Path, PathBuf},
7};
8
9use objects::fs_atomic::write_file_atomic_secret;
10use repo::{FsMonitorMode, FsMonitorSettings, OutputFormat, WorktreeStatusOptions};
11use serde::{Deserialize, Serialize};
12use wire::AuthToken;
13
14use crate::client_config::ClientConfig;
15
16#[derive(Debug, Clone, Serialize, Deserialize, Default)]
17pub struct UserConfig {
18    #[serde(default)]
19    pub principal: Option<UserPrincipalConfig>,
20    #[serde(default)]
21    pub agent: UserAgentConfig,
22    #[serde(default)]
23    pub capture: UserCaptureConfig,
24    #[serde(default)]
25    pub output: UserOutputConfig,
26    #[serde(default)]
27    pub display: UserDisplayConfig,
28    #[serde(default)]
29    pub worktree: UserWorktreeConfig,
30    #[serde(default)]
31    pub logging: UserLoggingConfig,
32    #[serde(default)]
33    pub remote: UserRemoteConfig,
34    #[serde(default)]
35    pub harness: UserHarnessConfig,
36    #[serde(default)]
37    pub land: UserLandConfig,
38}
39
40#[derive(Debug, Clone, Serialize, Deserialize)]
41pub struct UserPrincipalConfig {
42    pub name: String,
43    pub email: String,
44}
45
46#[derive(Debug, Clone, Serialize, Deserialize, Default)]
47pub struct UserAgentConfig {
48    #[serde(default)]
49    pub provider: Option<String>,
50    #[serde(default)]
51    pub model: Option<String>,
52    #[serde(default)]
53    pub default_policy: Option<String>,
54    #[serde(default = "default_confidence")]
55    pub confidence: f32,
56}
57
58#[derive(Debug, Clone, Serialize, Deserialize, Default)]
59pub struct UserCaptureConfig {
60    #[serde(default)]
61    pub auto: UserAutoCaptureMode,
62}
63
64#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
65#[serde(rename_all = "lowercase")]
66pub enum UserAutoCaptureMode {
67    #[default]
68    Off,
69    Command,
70}
71
72#[derive(Debug, Clone, Serialize, Deserialize, Default)]
73pub struct UserOutputConfig {
74    #[serde(default)]
75    pub format: OutputFormat,
76}
77
78#[derive(Debug, Clone, Serialize, Deserialize)]
79pub struct UserDisplayConfig {
80    #[serde(default = "default_hash_length")]
81    pub hash_length: usize,
82    #[serde(default = "default_change_id_format")]
83    pub change_id_format: String,
84}
85
86#[derive(Debug, Clone, Serialize, Deserialize, Default)]
87pub struct UserWorktreeConfig {
88    #[serde(default)]
89    pub fsmonitor: UserFsMonitorConfig,
90    #[serde(default)]
91    pub thread_workspace: UserThreadWorkspaceConfig,
92}
93
94#[derive(Debug, Clone, Serialize, Deserialize, Default)]
95pub struct UserFsMonitorConfig {
96    #[serde(default)]
97    pub mode: Option<FsMonitorMode>,
98}
99
100/// User-config default for thread workspace mode. Same vocabulary
101/// as [`crate::config::repo::ThreadMode`] and the `--workspace` flag,
102/// so a user setting `top_level_default = "materialized"` reads
103/// uniformly across the CLI surface and the thread record on disk.
104#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
105#[serde(rename_all = "kebab-case")]
106pub enum UserThreadWorkspaceMode {
107    #[default]
108    Auto,
109    Materialized,
110    Virtualized,
111    Solid,
112}
113
114#[derive(Debug, Clone, Serialize, Deserialize, Default)]
115pub struct UserThreadWorkspaceConfig {
116    #[serde(default)]
117    pub top_level_default: UserThreadWorkspaceMode,
118    #[serde(default)]
119    pub delegated_default: Option<UserThreadWorkspaceMode>,
120}
121
122#[derive(Debug, Clone, Serialize, Deserialize, Default)]
123pub struct UserLoggingConfig {
124    #[serde(default)]
125    pub format: Option<String>,
126    #[serde(default)]
127    pub include_location: bool,
128    #[serde(default)]
129    pub include_thread_ids: bool,
130    #[serde(default)]
131    pub log_spans: bool,
132    #[serde(default)]
133    pub otel_service_name: Option<String>,
134    #[serde(default)]
135    pub otel_endpoint: Option<String>,
136    #[serde(default)]
137    pub otel_traces_endpoint: Option<String>,
138    #[serde(default)]
139    pub otel_metrics_endpoint: Option<String>,
140}
141
142#[derive(Debug, Clone, Serialize, Deserialize, Default)]
143pub struct UserRemoteConfig {
144    #[serde(default)]
145    pub token: Option<String>,
146    #[serde(default)]
147    pub tls_enabled: bool,
148    #[serde(default)]
149    pub tls_domain_name: Option<String>,
150    #[serde(default)]
151    pub tls_ca_certificate_path: Option<PathBuf>,
152    #[serde(default)]
153    pub auth_proof_key_pem_path: Option<PathBuf>,
154    /// Allow cleartext connections to non-loopback hosts without TLS.
155    /// Prefer enabling TLS; this is an explicit opt-in for lab/VPN testing.
156    #[serde(default)]
157    pub insecure: bool,
158}
159
160#[derive(Debug, Clone, Serialize, Deserialize)]
161pub struct UserLandConfig {
162    #[serde(default = "default_land_squash")]
163    pub squash: bool,
164}
165
166#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
167#[serde(rename_all = "lowercase")]
168pub enum HarnessMode {
169    #[default]
170    Auto,
171    Off,
172    Required,
173}
174
175#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
176#[serde(rename_all = "lowercase")]
177pub enum HarnessTransport {
178    #[default]
179    Spool,
180    Direct,
181    End,
182}
183
184#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
185#[serde(rename_all = "lowercase")]
186pub enum HarnessTranscriptMode {
187    #[default]
188    Off,
189    Summary,
190    Full,
191}
192
193#[derive(Debug, Clone, Serialize, Deserialize, Default)]
194pub struct UserHarnessOverride {
195    #[serde(default)]
196    pub provider: Option<String>,
197    #[serde(default)]
198    pub model: Option<String>,
199    #[serde(default)]
200    pub thinking_level: Option<String>,
201    #[serde(default)]
202    pub policy: Option<String>,
203}
204
205#[derive(Debug, Clone, Serialize, Deserialize)]
206pub struct UserHarnessConfig {
207    #[serde(default)]
208    pub mode: HarnessMode,
209    #[serde(default)]
210    pub transport: HarnessTransport,
211    #[serde(default)]
212    pub transcript: HarnessTranscriptMode,
213    #[serde(default = "default_auto_infer")]
214    pub auto_infer: bool,
215    #[serde(default)]
216    pub threading: UserHarnessThreadingConfig,
217    #[serde(default)]
218    pub harnesses: BTreeMap<String, UserHarnessOverride>,
219}
220
221#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
222#[serde(rename_all = "kebab-case")]
223pub enum UserHarnessRootThreadPolicy {
224    CreateNew,
225    #[default]
226    AttachCurrent,
227}
228
229#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
230#[serde(rename_all = "kebab-case")]
231pub enum UserHarnessSubagentThreadPolicy {
232    AttachCurrent,
233    #[default]
234    CreateChild,
235}
236
237#[derive(Debug, Clone, Serialize, Deserialize, Default)]
238pub struct UserHarnessThreadingConfig {
239    #[serde(default)]
240    pub root_actor: UserHarnessRootThreadPolicy,
241    #[serde(default)]
242    pub subagent: UserHarnessSubagentThreadPolicy,
243    #[serde(default)]
244    pub workspace_default: Option<UserThreadWorkspaceMode>,
245}
246
247fn default_confidence() -> f32 {
248    0.8
249}
250
251fn default_hash_length() -> usize {
252    8
253}
254
255fn default_change_id_format() -> String {
256    "short".to_string()
257}
258
259fn default_auto_infer() -> bool {
260    true
261}
262
263fn default_land_squash() -> bool {
264    true
265}
266
267impl Default for UserDisplayConfig {
268    fn default() -> Self {
269        Self {
270            hash_length: default_hash_length(),
271            change_id_format: default_change_id_format(),
272        }
273    }
274}
275
276impl Default for UserHarnessConfig {
277    fn default() -> Self {
278        Self {
279            mode: HarnessMode::Auto,
280            transport: HarnessTransport::Spool,
281            transcript: HarnessTranscriptMode::Off,
282            auto_infer: default_auto_infer(),
283            threading: UserHarnessThreadingConfig::default(),
284            harnesses: BTreeMap::new(),
285        }
286    }
287}
288
289impl Default for UserLandConfig {
290    fn default() -> Self {
291        Self {
292            squash: default_land_squash(),
293        }
294    }
295}
296
297impl UserConfig {
298    pub fn default_path() -> Option<PathBuf> {
299        if let Ok(path) = std::env::var("HEDDLE_CONFIG")
300            && !path.is_empty()
301        {
302            return Some(PathBuf::from(path));
303        }
304        if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME")
305            && !xdg.is_empty()
306        {
307            return Some(PathBuf::from(xdg).join("heddle").join("config.toml"));
308        }
309        if let Ok(home) = std::env::var("HOME")
310            && !home.is_empty()
311        {
312            return Some(PathBuf::from(home).join(".config/heddle/config.toml"));
313        }
314        None
315    }
316
317    pub fn load(path: &Path) -> anyhow::Result<Self> {
318        let mut file = fs::File::open(path)?;
319        let mut contents = String::new();
320        file.read_to_string(&mut contents)?;
321        let resolved = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
322        if let Some(value) = invalid_output_format_value(&contents) {
323            return Err(objects::error::HeddleError::ConfigInvalidValue {
324                path: resolved,
325                key: "output.format".to_string(),
326                value,
327                valid_values: vec!["'text'".to_string(), "'json'".to_string()],
328            }
329            .into());
330        }
331        // Route TOML parse failures through `HeddleError::ConfigParse` so
332        // the CLI error envelope (see `print_error_with_hint`) can
333        // classify them and render the *actual* source file in the
334        // recovery advice — not a hard-coded `.heddle/config.toml`
335        // (Codex R3 cid 3313132711 on #271). The path is canonicalized
336        // so the rendered hint is copy/paste-safe even when the caller
337        // passed a relative or env-derived path.
338        toml::from_str::<Self>(&contents).map_err(|err| {
339            objects::error::HeddleError::ConfigParse {
340                path: resolved,
341                source: err,
342            }
343            .into()
344        })
345    }
346
347    pub fn load_default() -> anyhow::Result<Self> {
348        match Self::default_path() {
349            Some(path) => match Self::load(&path) {
350                Ok(config) => Ok(config),
351                Err(err) if path_missing(&err) => Ok(Self::default()),
352                Err(err) => Err(err),
353            },
354            None => Ok(Self::default()),
355        }
356    }
357
358    pub fn save_default(&self) -> anyhow::Result<PathBuf> {
359        let path = Self::default_path()
360            .ok_or_else(|| anyhow::anyhow!("unable to determine user config path"))?;
361        self.save(&path)?;
362        Ok(path)
363    }
364
365    pub fn save(&self, path: &Path) -> anyhow::Result<()> {
366        if let Some(parent) = path.parent() {
367            fs::create_dir_all(parent)?;
368        }
369        let contents = toml::to_string_pretty(self)?;
370        write_file_atomic_secret(path, contents.as_bytes())?;
371        Ok(())
372    }
373
374    pub fn set_principal(&mut self, name: impl Into<String>, email: impl Into<String>) {
375        self.principal = Some(UserPrincipalConfig {
376            name: name.into(),
377            email: email.into(),
378        });
379    }
380
381    pub fn remote_token(&self) -> anyhow::Result<Option<AuthToken>> {
382        match env::var("HEDDLE_REMOTE_TOKEN") {
383            Ok(token) if !token.is_empty() => Ok(Some(AuthToken::new(token, "env"))),
384            Ok(_) | Err(env::VarError::NotPresent) => Ok(self
385                .remote
386                .token
387                .clone()
388                .map(|token| AuthToken::new(token, "user-config"))),
389            Err(err @ env::VarError::NotUnicode(_)) => Err(security_config_error(
390                "HEDDLE_REMOTE_TOKEN",
391                format!("read environment value: {err}"),
392            )),
393        }
394    }
395
396    pub fn command_auto_capture_enabled(&self) -> anyhow::Result<bool> {
397        let mut mode = self.capture.auto;
398        match env::var("HEDDLE_AUTO_CAPTURE") {
399            Ok(value) if !value.trim().is_empty() => {
400                mode = parse_auto_capture_env("HEDDLE_AUTO_CAPTURE", &value)?;
401            }
402            Ok(_) | Err(env::VarError::NotPresent) => {}
403            Err(err @ env::VarError::NotUnicode(_)) => {
404                return Err(config_value_error(
405                    "HEDDLE_AUTO_CAPTURE",
406                    format!("read environment value: {err}"),
407                ));
408            }
409        }
410        Ok(matches!(mode, UserAutoCaptureMode::Command))
411    }
412
413    pub fn heddle_client_config(
414        &self,
415        token_override: Option<AuthToken>,
416    ) -> anyhow::Result<ClientConfig> {
417        let token = match token_override {
418            Some(token) => Some(token),
419            None => self.remote_token()?,
420        };
421        let mut config = token
422            .map(|token| ClientConfig::default().with_token(token))
423            .unwrap_or_default();
424
425        if self.remote.tls_enabled {
426            config = config.with_tls(false);
427        }
428        if self.remote.insecure {
429            config = config.with_allow_insecure(true);
430        }
431        if let Some(domain) = &self.remote.tls_domain_name {
432            config = config.with_tls_domain_name(domain.clone());
433        }
434        if let Some(path) = &self.remote.tls_ca_certificate_path {
435            let pem = read_security_config_file("remote.tls_ca_certificate_path", path)?;
436            config = config.with_tls_ca_certificate_pem(pem);
437        }
438        if let Some(path) = &self.remote.auth_proof_key_pem_path {
439            let pem = read_security_config_file("remote.auth_proof_key_pem_path", path)?;
440            config = config.with_auth_proof_key_pem(pem);
441        }
442
443        if env_bool("HEDDLE_REMOTE_TLS")? {
444            config = config.with_tls(false);
445        }
446        if env_bool("HEDDLE_REMOTE_INSECURE")? {
447            config = config.with_allow_insecure(true);
448        }
449        match env::var("HEDDLE_REMOTE_TLS_DOMAIN") {
450            Ok(domain) => config = config.with_tls_domain_name(domain),
451            Err(env::VarError::NotPresent) => {}
452            Err(err @ env::VarError::NotUnicode(_)) => {
453                return Err(security_config_error(
454                    "HEDDLE_REMOTE_TLS_DOMAIN",
455                    format!("read environment value: {err}"),
456                ));
457            }
458        }
459        match env::var("HEDDLE_REMOTE_TLS_CA_CERT") {
460            Ok(path) => {
461                let pem =
462                    read_security_config_file("HEDDLE_REMOTE_TLS_CA_CERT", &PathBuf::from(path))?;
463                config = config.with_tls_ca_certificate_pem(pem);
464            }
465            Err(env::VarError::NotPresent) => {}
466            Err(err @ env::VarError::NotUnicode(_)) => {
467                return Err(security_config_error(
468                    "HEDDLE_REMOTE_TLS_CA_CERT",
469                    format!("read environment value: {err}"),
470                ));
471            }
472        }
473        Ok(config)
474    }
475
476    pub fn worktree_status_options(
477        &self,
478        repo_config: Option<&repo::RepoConfig>,
479    ) -> WorktreeStatusOptions {
480        let mut mode = self
481            .worktree
482            .fsmonitor
483            .mode
484            .or_else(|| repo_config.map(|config| config.worktree.fsmonitor.mode))
485            .unwrap_or(FsMonitorMode::Off);
486        if let Ok(value) = std::env::var("HEDDLE_FSMONITOR")
487            && let Some(parsed) = FsMonitorMode::parse(&value)
488        {
489            mode = parsed;
490        }
491
492        WorktreeStatusOptions {
493            fsmonitor: FsMonitorSettings { mode },
494        }
495    }
496}
497
498fn parse_auto_capture_env(setting: &str, value: &str) -> anyhow::Result<UserAutoCaptureMode> {
499    match value.trim().to_ascii_lowercase().as_str() {
500        "1" | "true" | "yes" | "on" | "command" | "commands" => Ok(UserAutoCaptureMode::Command),
501        "0" | "false" | "no" | "off" => Ok(UserAutoCaptureMode::Off),
502        _ => Err(config_value_error(
503            setting,
504            format!(
505                "parse auto-capture value {value:?}; expected one of off, command, true, or false"
506            ),
507        )),
508    }
509}
510
511fn invalid_output_format_value(contents: &str) -> Option<String> {
512    let value = toml::from_str::<toml::Value>(contents).ok()?;
513    let format = value
514        .get("output")
515        .and_then(|output| output.get("format"))
516        .and_then(toml::Value::as_str)?;
517    (!matches!(format, "text" | "json")).then(|| format.to_string())
518}
519
520fn read_security_config_file(setting: &str, path: &Path) -> anyhow::Result<String> {
521    fs::read_to_string(path).map_err(|err| {
522        security_config_error(
523            setting,
524            format!("read configured file {}: {err}", path.display()),
525        )
526    })
527}
528
529fn env_bool(name: &str) -> anyhow::Result<bool> {
530    let value = match env::var(name) {
531        Ok(value) => value,
532        Err(env::VarError::NotPresent) => return Ok(false),
533        Err(err @ env::VarError::NotUnicode(_)) => {
534            return Err(security_config_error(
535                name,
536                format!("read environment value: {err}"),
537            ));
538        }
539    };
540    match value.trim().to_ascii_lowercase().as_str() {
541        "1" | "true" | "yes" | "on" => Ok(true),
542        "0" | "false" | "no" | "off" => Ok(false),
543        _ => Err(security_config_error(
544            name,
545            format!(
546                "parse boolean value {value:?}; expected one of 1/0, true/false, yes/no, or on/off"
547            ),
548        )),
549    }
550}
551
552fn config_value_error(setting: &str, reason: String) -> anyhow::Error {
553    anyhow::anyhow!("fatal configuration error for `{setting}`: {reason}")
554}
555
556fn security_config_error(setting: &str, reason: String) -> anyhow::Error {
557    anyhow::anyhow!(
558        "fatal TLS/auth configuration error for `{setting}`: {reason}; refusing to proceed with an ambiguous security posture"
559    )
560}
561
562fn path_missing(err: &anyhow::Error) -> bool {
563    err.downcast_ref::<std::io::Error>()
564        .is_some_and(|io| io.kind() == std::io::ErrorKind::NotFound)
565}
566
567#[cfg(test)]
568mod tests {
569    use std::{
570        ffi::OsString,
571        fs,
572        path::PathBuf,
573        sync::MutexGuard,
574        time::{SystemTime, UNIX_EPOCH},
575    };
576
577    use repo::{FsMonitorMode, RepoConfig};
578
579    use super::{
580        HarnessMode, HarnessTranscriptMode, HarnessTransport, UserAutoCaptureMode,
581        UserCaptureConfig, UserConfig, UserRemoteConfig,
582    };
583
584    static TEST_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
585    const REMOTE_ENV_KEYS: &[&str] = &[
586        "HEDDLE_REMOTE_TOKEN",
587        "HEDDLE_REMOTE_TLS",
588        "HEDDLE_REMOTE_TLS_DOMAIN",
589        "HEDDLE_REMOTE_TLS_CA_CERT",
590        "HEDDLE_REMOTE_INSECURE",
591        "HEDDLE_AUTO_CAPTURE",
592    ];
593
594    struct RemoteEnvGuard {
595        _guard: MutexGuard<'static, ()>,
596        saved: Vec<(&'static str, Option<OsString>)>,
597    }
598
599    impl RemoteEnvGuard {
600        fn clean() -> Self {
601            let guard = TEST_ENV_LOCK
602                .lock()
603                .unwrap_or_else(|poisoned| poisoned.into_inner());
604            let saved = REMOTE_ENV_KEYS
605                .iter()
606                .map(|key| (*key, std::env::var_os(key)))
607                .collect();
608            for key in REMOTE_ENV_KEYS {
609                unsafe { std::env::remove_var(key) };
610            }
611            Self {
612                _guard: guard,
613                saved,
614            }
615        }
616
617        fn set(&self, key: &str, value: impl AsRef<std::ffi::OsStr>) {
618            unsafe { std::env::set_var(key, value) };
619        }
620    }
621
622    impl Drop for RemoteEnvGuard {
623        fn drop(&mut self) {
624            for (key, value) in &self.saved {
625                unsafe {
626                    if let Some(value) = value {
627                        std::env::set_var(key, value);
628                    } else {
629                        std::env::remove_var(key);
630                    }
631                }
632            }
633        }
634    }
635
636    fn unique_temp_path(prefix: &str) -> PathBuf {
637        let unique = SystemTime::now()
638            .duration_since(UNIX_EPOCH)
639            .expect("system time before unix epoch")
640            .as_nanos();
641        std::env::temp_dir().join(format!("{prefix}-{}-{unique}", std::process::id()))
642    }
643
644    #[test]
645    fn user_worktree_status_options_fall_back_to_repo_config() {
646        let mut repo = RepoConfig::default();
647        repo.worktree.fsmonitor.mode = FsMonitorMode::Watchman;
648
649        let config = UserConfig::default();
650        let options = config.worktree_status_options(Some(&repo));
651
652        assert_eq!(options.fsmonitor.mode, FsMonitorMode::Watchman);
653    }
654
655    #[test]
656    fn harness_config_defaults_are_magical_but_safe() {
657        let config = UserConfig::default();
658        assert_eq!(config.harness.mode, HarnessMode::Auto);
659        assert_eq!(config.harness.transport, HarnessTransport::Spool);
660        assert_eq!(config.harness.transcript, HarnessTranscriptMode::Off);
661        assert!(config.harness.auto_infer);
662        assert!(config.harness.harnesses.is_empty());
663    }
664
665    #[test]
666    fn command_auto_capture_defaults_off() {
667        let _env = RemoteEnvGuard::clean();
668
669        let config = UserConfig::default();
670
671        assert!(!config.command_auto_capture_enabled().unwrap());
672    }
673
674    #[test]
675    fn command_auto_capture_reads_user_config() {
676        let _env = RemoteEnvGuard::clean();
677        let config = UserConfig {
678            capture: UserCaptureConfig {
679                auto: UserAutoCaptureMode::Command,
680            },
681            ..UserConfig::default()
682        };
683
684        assert!(config.command_auto_capture_enabled().unwrap());
685    }
686
687    #[test]
688    fn command_auto_capture_env_overrides_user_config() {
689        let env = RemoteEnvGuard::clean();
690        env.set("HEDDLE_AUTO_CAPTURE", "off");
691        let config = UserConfig {
692            capture: UserCaptureConfig {
693                auto: UserAutoCaptureMode::Command,
694            },
695            ..UserConfig::default()
696        };
697
698        assert!(!config.command_auto_capture_enabled().unwrap());
699
700        env.set("HEDDLE_AUTO_CAPTURE", "command");
701        assert!(
702            UserConfig::default()
703                .command_auto_capture_enabled()
704                .unwrap()
705        );
706    }
707
708    #[test]
709    fn user_config_toml_parses_capture_auto_command() {
710        let parsed: UserConfig = toml::from_str(
711            r#"
712                [capture]
713                auto = "command"
714            "#,
715        )
716        .expect("capture auto config should parse");
717
718        assert_eq!(parsed.capture.auto, UserAutoCaptureMode::Command);
719    }
720
721    #[test]
722    fn heddle_client_config_absent_security_settings_uses_defaults() {
723        let _env = RemoteEnvGuard::clean();
724        let config = UserConfig::default()
725            .heddle_client_config(None)
726            .expect("absent optional settings should not error");
727
728        assert!(!config.tls_enabled);
729        assert!(!config.tls_skip_verify);
730        assert!(config.tls_ca_certificate_pem.is_none());
731        assert!(config.auth_proof_key_pem.is_none());
732        assert!(config.token.is_none());
733    }
734
735    #[test]
736    fn heddle_client_config_valid_security_files_are_applied() {
737        let _env = RemoteEnvGuard::clean();
738        let dir = unique_temp_path("heddle-user-config-valid-security");
739        fs::create_dir_all(&dir).expect("create temp dir");
740        let ca_path = dir.join("ca.pem");
741        let key_path = dir.join("proof-key.pem");
742        fs::write(&ca_path, "test ca pem").expect("write ca pem");
743        fs::write(&key_path, "test key pem").expect("write key pem");
744        let user = UserConfig {
745            remote: UserRemoteConfig {
746                tls_ca_certificate_path: Some(ca_path),
747                auth_proof_key_pem_path: Some(key_path),
748                ..UserRemoteConfig::default()
749            },
750            ..UserConfig::default()
751        };
752
753        let config = user
754            .heddle_client_config(None)
755            .expect("valid TLS/auth files should load");
756
757        assert!(config.tls_enabled);
758        assert_eq!(
759            config.tls_ca_certificate_pem.as_deref(),
760            Some("test ca pem")
761        );
762        assert_eq!(config.auth_proof_key_pem.as_deref(), Some("test key pem"));
763
764        fs::remove_dir_all(dir).expect("remove temp dir");
765    }
766
767    #[test]
768    fn heddle_client_config_missing_tls_ca_path_fails_closed() {
769        let _env = RemoteEnvGuard::clean();
770        let missing = unique_temp_path("heddle-user-config-missing-ca").join("ca.pem");
771        let user = UserConfig {
772            remote: UserRemoteConfig {
773                tls_ca_certificate_path: Some(missing),
774                ..UserRemoteConfig::default()
775            },
776            ..UserConfig::default()
777        };
778
779        let err = user
780            .heddle_client_config(None)
781            .expect_err("missing configured CA path must fail closed");
782        let message = err.to_string();
783
784        assert!(message.contains("fatal TLS/auth configuration error"));
785        assert!(message.contains("remote.tls_ca_certificate_path"));
786    }
787
788    #[test]
789    fn heddle_client_config_missing_auth_proof_key_path_fails_closed() {
790        let _env = RemoteEnvGuard::clean();
791        let missing = unique_temp_path("heddle-user-config-missing-key").join("proof-key.pem");
792        let user = UserConfig {
793            remote: UserRemoteConfig {
794                auth_proof_key_pem_path: Some(missing),
795                ..UserRemoteConfig::default()
796            },
797            ..UserConfig::default()
798        };
799
800        let err = user
801            .heddle_client_config(None)
802            .expect_err("missing configured proof key path must fail closed");
803        let message = err.to_string();
804
805        assert!(message.contains("fatal TLS/auth configuration error"));
806        assert!(message.contains("remote.auth_proof_key_pem_path"));
807    }
808
809    #[test]
810    fn heddle_client_config_missing_env_tls_ca_path_fails_closed() {
811        let env = RemoteEnvGuard::clean();
812        let missing = unique_temp_path("heddle-user-config-missing-env-ca").join("ca.pem");
813        env.set("HEDDLE_REMOTE_TLS_CA_CERT", missing);
814
815        let err = UserConfig::default()
816            .heddle_client_config(None)
817            .expect_err("missing env CA path must fail closed");
818        let message = err.to_string();
819
820        assert!(message.contains("fatal TLS/auth configuration error"));
821        assert!(message.contains("HEDDLE_REMOTE_TLS_CA_CERT"));
822    }
823
824    #[test]
825    fn heddle_client_config_invalid_env_tls_value_fails_closed() {
826        let env = RemoteEnvGuard::clean();
827        env.set("HEDDLE_REMOTE_TLS", "enabled");
828
829        let err = UserConfig::default()
830            .heddle_client_config(None)
831            .expect_err("invalid TLS env value must fail closed");
832        let message = err.to_string();
833
834        assert!(message.contains("fatal TLS/auth configuration error"));
835        assert!(message.contains("HEDDLE_REMOTE_TLS"));
836    }
837}