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