1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3
4use serde::{Deserialize, Serialize};
5
6use crate::config::AgentKind;
7use crate::models::{ModelEntry, ModelRegistry};
8
9#[derive(Debug, Clone, Serialize, Deserialize, Default)]
12pub struct Settings {
13 #[serde(default)]
15 pub default_agent: Option<String>,
16
17 #[serde(default)]
19 pub default_model: Option<String>,
20
21 #[serde(default)]
23 pub default_permissions: Option<String>,
24
25 #[serde(default)]
27 pub default_timeout_secs: Option<u64>,
28
29 #[serde(default)]
31 pub log_level: Option<String>,
32
33 #[serde(default)]
35 pub agents: HashMap<String, AgentSettings>,
36}
37
38#[derive(Debug, Clone, Serialize, Deserialize, Default)]
40pub struct AgentSettings {
41 #[serde(default)]
43 pub binary: Option<String>,
44
45 #[serde(default)]
47 pub model: Option<String>,
48
49 #[serde(default)]
51 pub extra_args: Vec<String>,
52}
53
54impl Settings {
55 pub fn load() -> Self {
58 Self::load_from(Self::config_path())
59 }
60
61 pub fn load_with_project(cwd: Option<&Path>) -> Self {
63 let global = Self::load();
64 if let Some(dir) = cwd {
65 if let Some(project) = Self::load_project(dir) {
66 return global.merge(&project);
67 }
68 }
69 global
70 }
71
72 pub fn load_from(path: Option<PathBuf>) -> Self {
74 let Some(path) = path else {
75 return Self::default();
76 };
77
78 if !path.exists() {
79 return Self::default();
80 }
81
82 let content = match std::fs::read_to_string(&path) {
83 Ok(c) => c,
84 Err(e) => {
85 tracing::warn!("failed to read config file {}: {e}", path.display());
86 return Self::default();
87 }
88 };
89
90 match toml::from_str(&content) {
91 Ok(s) => s,
92 Err(e) => {
93 tracing::warn!("failed to parse config file {}: {e}", path.display());
94 Self::default()
95 }
96 }
97 }
98
99 pub fn load_project(start: &Path) -> Option<Self> {
102 let mut dir = start.to_path_buf();
103 loop {
104 let candidate = dir.join(".harnessrc.toml");
105 if candidate.exists() {
106 return Some(Self::load_from(Some(candidate)));
107 }
108 if !dir.pop() {
109 break;
110 }
111 }
112 None
113 }
114
115 pub fn merge(&self, other: &Settings) -> Settings {
118 let mut merged = self.clone();
119
120 if other.default_agent.is_some() {
121 merged.default_agent.clone_from(&other.default_agent);
122 }
123 if other.default_model.is_some() {
124 merged.default_model.clone_from(&other.default_model);
125 }
126 if other.default_permissions.is_some() {
127 merged
128 .default_permissions
129 .clone_from(&other.default_permissions);
130 }
131 if other.default_timeout_secs.is_some() {
132 merged.default_timeout_secs = other.default_timeout_secs;
133 }
134 if other.log_level.is_some() {
135 merged.log_level.clone_from(&other.log_level);
136 }
137
138 for (key, other_agent) in &other.agents {
140 let entry = merged
141 .agents
142 .entry(key.clone())
143 .or_default();
144 if other_agent.binary.is_some() {
145 entry.binary.clone_from(&other_agent.binary);
146 }
147 if other_agent.model.is_some() {
148 entry.model.clone_from(&other_agent.model);
149 }
150 if !other_agent.extra_args.is_empty() {
152 entry.extra_args.extend(other_agent.extra_args.clone());
153 }
154 }
155
156 merged
157 }
158
159 pub fn config_path() -> Option<PathBuf> {
161 dirs::config_dir().map(|d| d.join("harness").join("config.toml"))
162 }
163
164 pub fn template() -> &'static str {
166 r#"# harness configuration — ~/.config/harness/config.toml
167
168# Default agent when --agent is omitted.
169# default_agent = "claude"
170
171# Default model when --model is omitted.
172# default_model = "claude-opus-4-6"
173
174# Default permission mode: "full-access" or "read-only".
175# default_permissions = "full-access"
176
177# Default timeout in seconds.
178# default_timeout_secs = 300
179
180# Log level: "error", "warn", "info", "debug", "trace".
181# log_level = "warn"
182
183# Per-agent settings.
184# [agents.claude]
185# binary = "/opt/claude/bin/claude"
186# model = "claude-opus-4-6"
187# extra_args = ["--verbose"]
188
189# [agents.codex]
190# model = "gpt-5-codex"
191# extra_args = []
192"#
193 }
194
195 pub fn resolve_default_agent(&self) -> Option<AgentKind> {
197 self.default_agent.as_ref()?.parse().ok()
198 }
199
200 pub fn agent_settings(&self, kind: AgentKind) -> Option<&AgentSettings> {
202 let key = match kind {
203 AgentKind::Claude => "claude",
204 AgentKind::OpenCode => "opencode",
205 AgentKind::Codex => "codex",
206 AgentKind::Cursor => "cursor",
207 };
208 self.agents.get(key)
209 }
210
211 pub fn agent_binary(&self, kind: AgentKind) -> Option<PathBuf> {
213 self.agent_settings(kind)
214 .and_then(|s| s.binary.as_ref())
215 .map(PathBuf::from)
216 }
217
218 pub fn agent_model(&self, kind: AgentKind) -> Option<String> {
220 self.agent_settings(kind)
222 .and_then(|s| s.model.clone())
223 .or_else(|| self.default_model.clone())
224 }
225
226 pub fn agent_extra_args(&self, kind: AgentKind) -> Vec<String> {
228 self.agent_settings(kind)
229 .map(|s| s.extra_args.clone())
230 .unwrap_or_default()
231 }
232}
233
234#[derive(Debug, Clone, Serialize, Deserialize, Default)]
240pub struct ProjectConfig {
241 #[serde(default)]
242 pub default_agent: Option<String>,
243
244 #[serde(default)]
245 pub default_model: Option<String>,
246
247 #[serde(default)]
248 pub default_permissions: Option<String>,
249
250 #[serde(default)]
251 pub default_timeout_secs: Option<u64>,
252
253 #[serde(default)]
254 pub log_level: Option<String>,
255
256 #[serde(default)]
258 pub agents: HashMap<String, AgentSettings>,
259
260 #[serde(default)]
262 pub models: HashMap<String, ModelEntry>,
263}
264
265impl ProjectConfig {
266 pub fn load(dir: &Path) -> Option<Self> {
268 let (config, _path) = Self::load_with_path(dir)?;
269 Some(config)
270 }
271
272 pub fn load_with_path(dir: &Path) -> Option<(Self, PathBuf)> {
275 let mut current = dir.to_path_buf();
276 loop {
277 let path = current.join("harness.toml");
278 if path.exists() {
279 let content = match std::fs::read_to_string(&path) {
280 Ok(c) => c,
281 Err(e) => {
282 tracing::warn!("failed to read {}: {e}", path.display());
283 return None;
284 }
285 };
286 return match toml::from_str(&content) {
287 Ok(c) => Some((c, path)),
288 Err(e) => {
289 tracing::warn!("failed to parse {}: {e}", path.display());
290 None
291 }
292 };
293 }
294 if !current.pop() {
295 break;
296 }
297 }
298 None
299 }
300
301 pub fn model_registry(&self) -> ModelRegistry {
303 ModelRegistry {
304 models: self.models.clone(),
305 }
306 }
307
308 pub fn resolve_default_agent(&self) -> Option<AgentKind> {
310 self.default_agent.as_ref()?.parse().ok()
311 }
312
313 pub fn agent_settings(&self, kind: AgentKind) -> Option<&AgentSettings> {
315 let key = match kind {
316 AgentKind::Claude => "claude",
317 AgentKind::OpenCode => "opencode",
318 AgentKind::Codex => "codex",
319 AgentKind::Cursor => "cursor",
320 };
321 self.agents.get(key)
322 }
323
324 pub fn agent_binary(&self, kind: AgentKind) -> Option<PathBuf> {
326 self.agent_settings(kind)
327 .and_then(|s| s.binary.as_ref())
328 .map(PathBuf::from)
329 }
330
331 pub fn agent_model(&self, kind: AgentKind) -> Option<String> {
333 self.agent_settings(kind)
334 .and_then(|s| s.model.clone())
335 .or_else(|| self.default_model.clone())
336 }
337
338 pub fn agent_extra_args(&self, kind: AgentKind) -> Vec<String> {
340 self.agent_settings(kind)
341 .map(|s| s.extra_args.clone())
342 .unwrap_or_default()
343 }
344
345 pub fn template() -> &'static str {
347 r#"# harness project configuration — harness.toml
348#
349# Place this file in your project root.
350
351# Default agent when --agent is omitted.
352# default_agent = "claude"
353
354# Default model when --model is omitted (uses model registry for translation).
355# default_model = "sonnet"
356
357# Default permission mode: "full-access" or "read-only".
358# default_permissions = "full-access"
359
360# Default timeout in seconds.
361# default_timeout_secs = 300
362
363# Log level: "error", "warn", "info", "debug", "trace".
364# log_level = "warn"
365
366# Per-agent settings.
367# [agents.claude]
368# binary = "/opt/claude/bin/claude"
369# model = "sonnet"
370# extra_args = ["--verbose"]
371
372# Model registry overrides.
373# These override or extend the canonical registry for this project.
374# [models.my-model]
375# description = "My custom model"
376# provider = "anthropic"
377# claude = "my-custom-model-id"
378"#
379 }
380}
381
382#[cfg(test)]
383mod tests {
384 use super::*;
385
386 #[test]
387 fn parse_empty_config() {
388 let settings: Settings = toml::from_str("").unwrap();
389 assert!(settings.default_agent.is_none());
390 assert!(settings.agents.is_empty());
391 }
392
393 #[test]
394 fn parse_full_config() {
395 let toml = r#"
396default_agent = "claude"
397default_model = "claude-opus-4-6"
398
399[agents.claude]
400binary = "/opt/claude/bin/claude"
401
402[agents.codex]
403model = "gpt-5-codex"
404"#;
405 let settings: Settings = toml::from_str(toml).unwrap();
406 assert_eq!(settings.default_agent, Some("claude".to_string()));
407 assert_eq!(settings.default_model, Some("claude-opus-4-6".to_string()));
408 assert_eq!(
409 settings.agents["claude"].binary,
410 Some("/opt/claude/bin/claude".to_string())
411 );
412 assert_eq!(
413 settings.agents["codex"].model,
414 Some("gpt-5-codex".to_string())
415 );
416 }
417
418 #[test]
419 fn parse_expanded_config() {
420 let toml = r#"
421default_agent = "claude"
422default_model = "opus"
423default_permissions = "read-only"
424default_timeout_secs = 300
425log_level = "debug"
426
427[agents.claude]
428binary = "/usr/bin/claude"
429model = "sonnet"
430extra_args = ["--verbose", "--no-color"]
431"#;
432 let settings: Settings = toml::from_str(toml).unwrap();
433 assert_eq!(settings.default_permissions, Some("read-only".into()));
434 assert_eq!(settings.default_timeout_secs, Some(300));
435 assert_eq!(settings.log_level, Some("debug".into()));
436 let claude = settings.agent_settings(AgentKind::Claude).unwrap();
437 assert_eq!(claude.extra_args, vec!["--verbose", "--no-color"]);
438 }
439
440 #[test]
441 fn resolve_default_agent() {
442 let settings = Settings {
443 default_agent: Some("claude".to_string()),
444 ..Default::default()
445 };
446 assert_eq!(settings.resolve_default_agent(), Some(AgentKind::Claude));
447 }
448
449 #[test]
450 fn agent_model_prefers_specific() {
451 let mut agents = HashMap::new();
452 agents.insert(
453 "claude".to_string(),
454 AgentSettings {
455 model: Some("sonnet".to_string()),
456 ..Default::default()
457 },
458 );
459 let settings = Settings {
460 default_model: Some("opus".to_string()),
461 agents,
462 ..Default::default()
463 };
464 assert_eq!(
465 settings.agent_model(AgentKind::Claude),
466 Some("sonnet".to_string())
467 );
468 assert_eq!(
469 settings.agent_model(AgentKind::Codex),
470 Some("opus".to_string())
471 );
472 }
473
474 #[test]
475 fn load_nonexistent_returns_default() {
476 let settings = Settings::load_from(Some(PathBuf::from("/nonexistent/path/config.toml")));
477 assert!(settings.default_agent.is_none());
478 }
479
480 #[test]
481 fn merge_project_overrides() {
482 let global = Settings {
483 default_agent: Some("claude".into()),
484 default_model: Some("opus".into()),
485 default_timeout_secs: Some(300),
486 ..Default::default()
487 };
488 let project = Settings {
489 default_model: Some("sonnet".into()),
490 default_permissions: Some("read-only".into()),
491 ..Default::default()
492 };
493 let merged = global.merge(&project);
494 assert_eq!(merged.default_agent, Some("claude".into())); assert_eq!(merged.default_model, Some("sonnet".into())); assert_eq!(merged.default_timeout_secs, Some(300)); assert_eq!(merged.default_permissions, Some("read-only".into())); }
499
500 #[test]
501 fn merge_agent_extra_args_concatenate() {
502 let mut global_agents = HashMap::new();
503 global_agents.insert(
504 "claude".to_string(),
505 AgentSettings {
506 extra_args: vec!["--verbose".into()],
507 ..Default::default()
508 },
509 );
510 let global = Settings {
511 agents: global_agents,
512 ..Default::default()
513 };
514
515 let mut project_agents = HashMap::new();
516 project_agents.insert(
517 "claude".to_string(),
518 AgentSettings {
519 extra_args: vec!["--no-color".into()],
520 model: Some("sonnet".into()),
521 ..Default::default()
522 },
523 );
524 let project = Settings {
525 agents: project_agents,
526 ..Default::default()
527 };
528
529 let merged = global.merge(&project);
530 let claude = merged.agent_settings(AgentKind::Claude).unwrap();
531 assert_eq!(claude.extra_args, vec!["--verbose", "--no-color"]);
532 assert_eq!(claude.model, Some("sonnet".into()));
533 }
534
535 #[test]
536 fn load_project_walks_up() {
537 let tmp = tempfile::tempdir().unwrap();
538 let deep = tmp.path().join("a").join("b").join("c");
539 std::fs::create_dir_all(&deep).unwrap();
540
541 let rc_path = tmp.path().join("a").join(".harnessrc.toml");
543 std::fs::write(&rc_path, "default_agent = \"codex\"\n").unwrap();
544
545 let found = Settings::load_project(&deep);
547 assert!(found.is_some());
548 assert_eq!(found.unwrap().default_agent, Some("codex".into()));
549 }
550
551 #[test]
552 fn agent_extra_args_from_settings() {
553 let mut agents = HashMap::new();
554 agents.insert(
555 "claude".to_string(),
556 AgentSettings {
557 extra_args: vec!["--verbose".into()],
558 ..Default::default()
559 },
560 );
561 let settings = Settings {
562 agents,
563 ..Default::default()
564 };
565 assert_eq!(
566 settings.agent_extra_args(AgentKind::Claude),
567 vec!["--verbose"]
568 );
569 assert!(settings.agent_extra_args(AgentKind::Codex).is_empty());
570 }
571
572 #[test]
573 fn template_parses_as_valid_toml() {
574 let result: std::result::Result<Settings, _> = toml::from_str(Settings::template());
576 assert!(result.is_ok());
577 }
578
579 #[test]
582 fn project_config_parse_empty() {
583 let config: ProjectConfig = toml::from_str("").unwrap();
584 assert!(config.default_agent.is_none());
585 assert!(config.models.is_empty());
586 }
587
588 #[test]
589 fn project_config_parse_with_models() {
590 let toml = r#"
591default_agent = "claude"
592default_model = "sonnet"
593
594[agents.claude]
595binary = "/usr/bin/claude"
596
597[models.my-model]
598description = "Custom"
599provider = "custom"
600claude = "custom-id"
601"#;
602 let config: ProjectConfig = toml::from_str(toml).unwrap();
603 assert_eq!(config.default_agent, Some("claude".into()));
604 assert_eq!(config.default_model, Some("sonnet".into()));
605 assert!(config.models.contains_key("my-model"));
606 assert_eq!(
607 config.models["my-model"].claude,
608 Some("custom-id".into())
609 );
610 }
611
612 #[test]
613 fn project_config_model_registry() {
614 let toml = r#"
615[models.test]
616description = "Test"
617provider = "test"
618claude = "test-id"
619"#;
620 let config: ProjectConfig = toml::from_str(toml).unwrap();
621 let reg = config.model_registry();
622 assert!(reg.models.contains_key("test"));
623 }
624
625 #[test]
626 fn project_config_load_from_dir() {
627 let tmp = tempfile::tempdir().unwrap();
628 std::fs::write(
629 tmp.path().join("harness.toml"),
630 "default_agent = \"claude\"\n",
631 )
632 .unwrap();
633 let config = ProjectConfig::load(tmp.path());
634 assert!(config.is_some());
635 assert_eq!(config.unwrap().default_agent, Some("claude".into()));
636 }
637
638 #[test]
639 fn project_config_load_walks_up() {
640 let tmp = tempfile::tempdir().unwrap();
641 let deep = tmp.path().join("a").join("b").join("c");
642 std::fs::create_dir_all(&deep).unwrap();
643
644 std::fs::write(
646 tmp.path().join("a").join("harness.toml"),
647 "default_agent = \"codex\"\n",
648 )
649 .unwrap();
650
651 let config = ProjectConfig::load(&deep);
653 assert!(config.is_some());
654 assert_eq!(config.unwrap().default_agent, Some("codex".into()));
655 }
656
657 #[test]
658 fn project_config_load_missing_returns_none() {
659 let tmp = tempfile::tempdir().unwrap();
660 assert!(ProjectConfig::load(tmp.path()).is_none());
661 }
662
663 #[test]
664 fn project_config_template_parses() {
665 let result: std::result::Result<ProjectConfig, _> =
666 toml::from_str(ProjectConfig::template());
667 assert!(result.is_ok());
668 }
669}