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