Skip to main content

cli_shared/config/
user_config.rs

1// SPDX-License-Identifier: Apache-2.0
2use std::{
3    collections::BTreeMap,
4    fs,
5    io::Read,
6    path::{Path, PathBuf},
7};
8
9use objects::fs_atomic::write_file_atomic;
10use proto::AuthToken;
11use repo::{FsMonitorMode, FsMonitorSettings, OutputFormat, WorktreeStatusOptions};
12use serde::{Deserialize, Serialize};
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 output: UserOutputConfig,
24    #[serde(default)]
25    pub display: UserDisplayConfig,
26    #[serde(default)]
27    pub worktree: UserWorktreeConfig,
28    #[serde(default)]
29    pub logging: UserLoggingConfig,
30    #[serde(default)]
31    pub remote: UserRemoteConfig,
32    #[serde(default)]
33    pub harness: UserHarnessConfig,
34}
35
36#[derive(Debug, Clone, Serialize, Deserialize)]
37pub struct UserPrincipalConfig {
38    pub name: String,
39    pub email: String,
40}
41
42#[derive(Debug, Clone, Serialize, Deserialize, Default)]
43pub struct UserAgentConfig {
44    #[serde(default)]
45    pub provider: Option<String>,
46    #[serde(default)]
47    pub model: Option<String>,
48    #[serde(default)]
49    pub default_policy: Option<String>,
50    #[serde(default = "default_confidence")]
51    pub confidence: f32,
52}
53
54#[derive(Debug, Clone, Serialize, Deserialize, Default)]
55pub struct UserOutputConfig {
56    #[serde(default)]
57    pub format: OutputFormat,
58}
59
60#[derive(Debug, Clone, Serialize, Deserialize)]
61pub struct UserDisplayConfig {
62    #[serde(default = "default_hash_length")]
63    pub hash_length: usize,
64    #[serde(default = "default_change_id_format")]
65    pub change_id_format: String,
66}
67
68#[derive(Debug, Clone, Serialize, Deserialize, Default)]
69pub struct UserWorktreeConfig {
70    #[serde(default)]
71    pub fsmonitor: UserFsMonitorConfig,
72    #[serde(default)]
73    pub thread_workspace: UserThreadWorkspaceConfig,
74}
75
76#[derive(Debug, Clone, Serialize, Deserialize, Default)]
77pub struct UserFsMonitorConfig {
78    #[serde(default)]
79    pub mode: Option<FsMonitorMode>,
80}
81
82#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
83#[serde(rename_all = "kebab-case")]
84pub enum UserThreadWorkspaceMode {
85    #[default]
86    Auto,
87    Heavy,
88    Light,
89}
90
91#[derive(Debug, Clone, Serialize, Deserialize, Default)]
92pub struct UserThreadWorkspaceConfig {
93    #[serde(default)]
94    pub top_level_default: UserThreadWorkspaceMode,
95    #[serde(default)]
96    pub delegated_default: Option<UserThreadWorkspaceMode>,
97}
98
99#[derive(Debug, Clone, Serialize, Deserialize, Default)]
100pub struct UserLoggingConfig {
101    #[serde(default)]
102    pub format: Option<String>,
103    #[serde(default)]
104    pub include_location: bool,
105    #[serde(default)]
106    pub include_thread_ids: bool,
107    #[serde(default)]
108    pub log_spans: bool,
109    #[serde(default)]
110    pub otel_service_name: Option<String>,
111    #[serde(default)]
112    pub otel_endpoint: Option<String>,
113    #[serde(default)]
114    pub otel_traces_endpoint: Option<String>,
115    #[serde(default)]
116    pub otel_metrics_endpoint: Option<String>,
117}
118
119#[derive(Debug, Clone, Serialize, Deserialize, Default)]
120pub struct UserRemoteConfig {
121    #[serde(default)]
122    pub token: Option<String>,
123    #[serde(default)]
124    pub tls_enabled: bool,
125    #[serde(default)]
126    pub tls_domain_name: Option<String>,
127    #[serde(default)]
128    pub tls_ca_certificate_path: Option<PathBuf>,
129    #[serde(default)]
130    pub auth_proof_key_pem_path: Option<PathBuf>,
131}
132
133#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
134#[serde(rename_all = "lowercase")]
135pub enum HarnessMode {
136    #[default]
137    Auto,
138    Off,
139    Required,
140}
141
142#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
143#[serde(rename_all = "lowercase")]
144pub enum HarnessTransport {
145    #[default]
146    Spool,
147    Direct,
148    End,
149}
150
151#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
152#[serde(rename_all = "lowercase")]
153pub enum HarnessTranscriptMode {
154    #[default]
155    Off,
156    Summary,
157    Full,
158}
159
160#[derive(Debug, Clone, Serialize, Deserialize, Default)]
161pub struct UserHarnessOverride {
162    #[serde(default)]
163    pub provider: Option<String>,
164    #[serde(default)]
165    pub model: Option<String>,
166    #[serde(default)]
167    pub thinking_level: Option<String>,
168    #[serde(default)]
169    pub policy: Option<String>,
170}
171
172#[derive(Debug, Clone, Serialize, Deserialize)]
173pub struct UserHarnessConfig {
174    #[serde(default)]
175    pub mode: HarnessMode,
176    #[serde(default)]
177    pub transport: HarnessTransport,
178    #[serde(default)]
179    pub transcript: HarnessTranscriptMode,
180    #[serde(default = "default_auto_infer")]
181    pub auto_infer: bool,
182    #[serde(default)]
183    pub threading: UserHarnessThreadingConfig,
184    #[serde(default)]
185    pub harnesses: BTreeMap<String, UserHarnessOverride>,
186}
187
188#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
189#[serde(rename_all = "kebab-case")]
190pub enum UserHarnessRootThreadPolicy {
191    CreateNew,
192    #[default]
193    AttachCurrent,
194}
195
196#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
197#[serde(rename_all = "kebab-case")]
198pub enum UserHarnessSubagentThreadPolicy {
199    AttachCurrent,
200    #[default]
201    CreateChild,
202}
203
204#[derive(Debug, Clone, Serialize, Deserialize, Default)]
205pub struct UserHarnessThreadingConfig {
206    #[serde(default)]
207    pub root_actor: UserHarnessRootThreadPolicy,
208    #[serde(default)]
209    pub subagent: UserHarnessSubagentThreadPolicy,
210    #[serde(default)]
211    pub workspace_default: Option<UserThreadWorkspaceMode>,
212}
213
214fn default_confidence() -> f32 {
215    0.8
216}
217
218fn default_hash_length() -> usize {
219    8
220}
221
222fn default_change_id_format() -> String {
223    "short".to_string()
224}
225
226fn default_auto_infer() -> bool {
227    true
228}
229
230impl Default for UserDisplayConfig {
231    fn default() -> Self {
232        Self {
233            hash_length: default_hash_length(),
234            change_id_format: default_change_id_format(),
235        }
236    }
237}
238
239impl Default for UserHarnessConfig {
240    fn default() -> Self {
241        Self {
242            mode: HarnessMode::Auto,
243            transport: HarnessTransport::Spool,
244            transcript: HarnessTranscriptMode::Off,
245            auto_infer: default_auto_infer(),
246            threading: UserHarnessThreadingConfig::default(),
247            harnesses: BTreeMap::new(),
248        }
249    }
250}
251
252impl UserConfig {
253    pub fn default_path() -> Option<PathBuf> {
254        if let Ok(path) = std::env::var("HEDDLE_CONFIG")
255            && !path.is_empty()
256        {
257            return Some(PathBuf::from(path));
258        }
259        if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME")
260            && !xdg.is_empty()
261        {
262            return Some(PathBuf::from(xdg).join("heddle").join("config.toml"));
263        }
264        if let Ok(home) = std::env::var("HOME")
265            && !home.is_empty()
266        {
267            return Some(PathBuf::from(home).join(".config/heddle/config.toml"));
268        }
269        None
270    }
271
272    pub fn load(path: &Path) -> anyhow::Result<Self> {
273        let mut file = fs::File::open(path)?;
274        let mut contents = String::new();
275        file.read_to_string(&mut contents)?;
276        Ok(toml::from_str(&contents)?)
277    }
278
279    pub fn load_default() -> anyhow::Result<Self> {
280        match Self::default_path() {
281            Some(path) => match Self::load(&path) {
282                Ok(config) => Ok(config),
283                Err(err) if path_missing(&err) => Ok(Self::default()),
284                Err(err) => Err(err),
285            },
286            None => Ok(Self::default()),
287        }
288    }
289
290    pub fn save_default(&self) -> anyhow::Result<PathBuf> {
291        let path = Self::default_path()
292            .ok_or_else(|| anyhow::anyhow!("unable to determine user config path"))?;
293        self.save(&path)?;
294        Ok(path)
295    }
296
297    pub fn save(&self, path: &Path) -> anyhow::Result<()> {
298        if let Some(parent) = path.parent() {
299            fs::create_dir_all(parent)?;
300        }
301        let contents = toml::to_string_pretty(self)?;
302        write_file_atomic(path, contents.as_bytes())?;
303        Ok(())
304    }
305
306    pub fn set_principal(&mut self, name: impl Into<String>, email: impl Into<String>) {
307        self.principal = Some(UserPrincipalConfig {
308            name: name.into(),
309            email: email.into(),
310        });
311    }
312
313    pub fn remote_token(&self) -> Option<AuthToken> {
314        std::env::var("HEDDLE_REMOTE_TOKEN")
315            .ok()
316            .filter(|token| !token.is_empty())
317            .map(|token| AuthToken::new(token, "env"))
318            .or_else(|| {
319                self.remote
320                    .token
321                    .clone()
322                    .map(|token| AuthToken::new(token, "user-config"))
323            })
324    }
325
326    pub fn heddle_client_config(&self, token_override: Option<AuthToken>) -> ClientConfig {
327        let token = token_override.or_else(|| self.remote_token());
328        let mut config = token
329            .map(|token| ClientConfig::default().with_token(token))
330            .unwrap_or_default();
331
332        if self.remote.tls_enabled {
333            config = config.with_tls(false);
334        }
335        if let Some(domain) = &self.remote.tls_domain_name {
336            config = config.with_tls_domain_name(domain.clone());
337        }
338        if let Some(path) = &self.remote.tls_ca_certificate_path
339            && let Ok(pem) = fs::read_to_string(path)
340        {
341            config = config.with_tls_ca_certificate_pem(pem);
342        }
343        if let Some(path) = &self.remote.auth_proof_key_pem_path
344            && let Ok(pem) = fs::read_to_string(path)
345        {
346            config = config.with_auth_proof_key_pem(pem);
347        }
348
349        if std::env::var("HEDDLE_REMOTE_TLS")
350            .ok()
351            .is_some_and(|value| {
352                matches!(
353                    value.to_ascii_lowercase().as_str(),
354                    "1" | "true" | "yes" | "on"
355                )
356            })
357        {
358            config = config.with_tls(false);
359        }
360        if let Ok(domain) = std::env::var("HEDDLE_REMOTE_TLS_DOMAIN") {
361            config = config.with_tls_domain_name(domain);
362        }
363        if let Ok(path) = std::env::var("HEDDLE_REMOTE_TLS_CA_CERT")
364            && let Ok(pem) = fs::read_to_string(path)
365        {
366            config = config.with_tls_ca_certificate_pem(pem);
367        }
368        config
369    }
370
371    pub fn worktree_status_options(
372        &self,
373        repo_config: Option<&repo::RepoConfig>,
374    ) -> WorktreeStatusOptions {
375        let mut mode = self
376            .worktree
377            .fsmonitor
378            .mode
379            .or_else(|| repo_config.map(|config| config.worktree.fsmonitor.mode))
380            .unwrap_or(FsMonitorMode::Off);
381        if let Ok(value) = std::env::var("HEDDLE_FSMONITOR")
382            && let Some(parsed) = FsMonitorMode::parse(&value)
383        {
384            mode = parsed;
385        }
386
387        WorktreeStatusOptions {
388            fsmonitor: FsMonitorSettings { mode },
389        }
390    }
391}
392
393fn path_missing(err: &anyhow::Error) -> bool {
394    err.downcast_ref::<std::io::Error>()
395        .is_some_and(|io| io.kind() == std::io::ErrorKind::NotFound)
396}
397
398#[cfg(test)]
399mod tests {
400    use repo::{FsMonitorMode, RepoConfig};
401
402    use super::{HarnessMode, HarnessTranscriptMode, HarnessTransport, UserConfig};
403
404    #[test]
405    fn user_worktree_status_options_fall_back_to_repo_config() {
406        let mut repo = RepoConfig::default();
407        repo.worktree.fsmonitor.mode = FsMonitorMode::Watchman;
408
409        let config = UserConfig::default();
410        let options = config.worktree_status_options(Some(&repo));
411
412        assert_eq!(options.fsmonitor.mode, FsMonitorMode::Watchman);
413    }
414
415    #[test]
416    fn harness_config_defaults_are_magical_but_safe() {
417        let config = UserConfig::default();
418        assert_eq!(config.harness.mode, HarnessMode::Auto);
419        assert_eq!(config.harness.transport, HarnessTransport::Spool);
420        assert_eq!(config.harness.transcript, HarnessTranscriptMode::Off);
421        assert!(config.harness.auto_infer);
422        assert!(config.harness.harnesses.is_empty());
423    }
424}