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