1use 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 weft_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}