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