1use 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#[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 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}