Skip to main content

harness/
settings.rs

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/// User settings loaded from `~/.config/harness/config.toml` and optionally
10/// merged with a project-level `.harnessrc.toml`.
11#[derive(Debug, Clone, Serialize, Deserialize, Default)]
12pub struct Settings {
13    /// Default agent to use if `--agent` is omitted.
14    #[serde(default)]
15    pub default_agent: Option<String>,
16
17    /// Default model to use if `--model` is omitted.
18    #[serde(default)]
19    pub default_model: Option<String>,
20
21    /// Default permission mode (`full-access` or `read-only`).
22    #[serde(default)]
23    pub default_permissions: Option<String>,
24
25    /// Default timeout in seconds.
26    #[serde(default)]
27    pub default_timeout_secs: Option<u64>,
28
29    /// Log level for tracing output (e.g. "debug", "info", "warn").
30    #[serde(default)]
31    pub log_level: Option<String>,
32
33    /// Per-agent configuration overrides.
34    #[serde(default)]
35    pub agents: HashMap<String, AgentSettings>,
36}
37
38/// Per-agent settings.
39#[derive(Debug, Clone, Serialize, Deserialize, Default)]
40pub struct AgentSettings {
41    /// Override binary path for this agent.
42    #[serde(default)]
43    pub binary: Option<String>,
44
45    /// Default model for this agent.
46    #[serde(default)]
47    pub model: Option<String>,
48
49    /// Extra args always prepended for this agent.
50    #[serde(default)]
51    pub extra_args: Vec<String>,
52}
53
54impl Settings {
55    /// Load settings from the default config file, optionally merged with a
56    /// project-level `.harnessrc.toml` found by walking up from `cwd`.
57    pub fn load() -> Self {
58        Self::load_from(Self::config_path())
59    }
60
61    /// Load global settings, then merge project-level overrides from `cwd`.
62    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    /// Load settings from a specific path.
73    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    /// Walk up from `start` looking for `.harnessrc.toml`. Returns the parsed
100    /// settings if found, `None` otherwise.
101    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    /// Merge another settings into this one. `other` (project) wins for scalar
116    /// fields; `extra_args` in agent settings are concatenated.
117    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        // Merge per-agent settings.
139        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            // Concatenate extra_args (global first, then project).
151            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    /// Default config file path: `~/.config/harness/config.toml`.
160    pub fn config_path() -> Option<PathBuf> {
161        dirs::config_dir().map(|d| d.join("harness").join("config.toml"))
162    }
163
164    /// Generate a template config file as a TOML string.
165    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    /// Resolve the default agent from settings.
196    pub fn resolve_default_agent(&self) -> Option<AgentKind> {
197        self.default_agent.as_ref()?.parse().ok()
198    }
199
200    /// Get agent-specific settings.
201    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    /// Resolve the binary path for a given agent from settings.
212    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    /// Resolve the model for a given agent from settings.
219    pub fn agent_model(&self, kind: AgentKind) -> Option<String> {
220        // Agent-specific model takes precedence over global default.
221        self.agent_settings(kind)
222            .and_then(|s| s.model.clone())
223            .or_else(|| self.default_model.clone())
224    }
225
226    /// Get agent-specific extra_args from settings.
227    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/// Project-level configuration loaded from `harness.toml` in the project directory.
235///
236/// This is the new config format — replaces `~/.config/harness/config.toml` (global)
237/// and `.harnessrc.toml` (walk-up). Contains the same fields as `Settings` plus a
238/// `[models]` section for project-level model overrides.
239#[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    /// Per-agent configuration overrides.
257    #[serde(default)]
258    pub agents: HashMap<String, AgentSettings>,
259
260    /// Project-level model overrides / additions.
261    #[serde(default)]
262    pub models: HashMap<String, ModelEntry>,
263}
264
265impl ProjectConfig {
266    /// Load `harness.toml` by walking up from `dir` to find the nearest one.
267    pub fn load(dir: &Path) -> Option<Self> {
268        let (config, _path) = Self::load_with_path(dir)?;
269        Some(config)
270    }
271
272    /// Load `harness.toml` by walking up from `dir`, returning both the config
273    /// and the path where it was found.
274    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    /// Extract the `[models]` section as a `ModelRegistry`.
302    pub fn model_registry(&self) -> ModelRegistry {
303        ModelRegistry {
304            models: self.models.clone(),
305        }
306    }
307
308    /// Resolve the default agent from this config.
309    pub fn resolve_default_agent(&self) -> Option<AgentKind> {
310        self.default_agent.as_ref()?.parse().ok()
311    }
312
313    /// Get agent-specific settings.
314    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    /// Resolve the binary path for a given agent.
325    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    /// Resolve the model for a given agent.
332    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    /// Get agent-specific extra_args.
339    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    /// Generate a template `harness.toml` file.
346    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())); // kept from global
495        assert_eq!(merged.default_model, Some("sonnet".into())); // overridden by project
496        assert_eq!(merged.default_timeout_secs, Some(300)); // kept from global
497        assert_eq!(merged.default_permissions, Some("read-only".into())); // from project
498    }
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        // Place .harnessrc.toml at `a/` level.
542        let rc_path = tmp.path().join("a").join(".harnessrc.toml");
543        std::fs::write(&rc_path, "default_agent = \"codex\"\n").unwrap();
544
545        // Starting from `a/b/c`, should find `a/.harnessrc.toml`.
546        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        // The template should parse (all lines are commented out).
575        let result: std::result::Result<Settings, _> = toml::from_str(Settings::template());
576        assert!(result.is_ok());
577    }
578
579    // ─── ProjectConfig tests ─────────────────────────────────────
580
581    #[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        // Place harness.toml at `a/` level.
645        std::fs::write(
646            tmp.path().join("a").join("harness.toml"),
647            "default_agent = \"codex\"\n",
648        )
649        .unwrap();
650
651        // Starting from `a/b/c`, should find `a/harness.toml`.
652        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}