Skip to main content

claudectl/
config.rs

1use std::fs;
2use std::path::PathBuf;
3
4use crate::brain::agents::AgentConfig;
5use crate::models::{ModelOverride, ModelProfile};
6use crate::rules::{AutoRule, RuleAction};
7
8/// Configuration loaded from TOML files, merged with CLI flags.
9/// Priority: CLI flags > project config > global config > defaults.
10#[derive(Debug, Clone)]
11pub struct Config {
12    pub interval: u64,
13    pub notify: bool,
14    pub debug: bool,
15    pub grouped: bool,
16    pub sort: Option<String>,
17    pub budget: Option<f64>,
18    pub kill_on_budget: bool,
19    pub webhook: Option<String>,
20    pub webhook_on: Option<Vec<String>>,
21    pub daily_limit: Option<f64>,
22    pub weekly_limit: Option<f64>,
23    pub context_warn_threshold: u8, // 0-100, fires on_context_high when context % crosses this
24    pub model_overrides: Vec<ModelOverride>,
25    pub rules: Vec<AutoRule>,
26    pub health: HealthThresholds,
27    pub file_conflicts: bool, // Detect file-level conflicts across sessions
28    pub auto_deny_file_conflicts: bool, // Auto-deny writes to conflicting files
29    pub brain: Option<BrainConfig>,
30    pub relay: Option<RelayConfig>,
31    pub hive: Option<HiveConfig>,
32    pub lifecycle: LifecycleConfig,
33    pub idle: IdleConfig,
34    pub agents: Vec<AgentConfig>,
35}
36
37/// Configurable thresholds for session health checks.
38/// All thresholds have sensible defaults; users only need to override what they want.
39#[derive(Debug, Clone)]
40pub struct HealthThresholds {
41    pub cache_critical_pct: f64, // Cache hit ratio below this = critical (default 10%)
42    pub cache_warning_pct: f64,  // Cache hit ratio below this = warning (default 30%)
43    pub cache_min_tokens: u64,   // Ignore cache check until this many input tokens (default 10k)
44    pub cost_spike_critical: f64, // Burn rate > Nx average = critical (default 5.0)
45    pub cost_spike_warning: f64, // Burn rate > Nx average = warning (default 2.5)
46    pub loop_max_calls: u32,     // Tool calls with errors to trigger loop warning (default 10)
47    pub stall_min_cost: f64,     // Min cost in USD to trigger stall check (default 5.0)
48    pub stall_min_minutes: u64,  // Min minutes with no edits to trigger stall (default 10)
49    pub context_critical_pct: f64, // Context usage above this = critical (default 90%)
50    pub context_warning_pct: f64, // Context usage above this = warning (default 80%)
51    pub decay_compaction_pct: f64, // Context % to suggest proactive compaction (default 50%)
52    pub efficiency_critical_factor: f64, // Tokens-per-edit ratio vs baseline to trigger (default 2.0)
53    pub error_accel_factor: f64,         // Error rate ratio vs baseline to trigger (default 2.0)
54    pub repetition_threshold: u32,       // File re-read count without edit to trigger (default 3)
55}
56
57impl Default for HealthThresholds {
58    fn default() -> Self {
59        Self {
60            cache_critical_pct: 10.0,
61            cache_warning_pct: 30.0,
62            cache_min_tokens: 10_000,
63            cost_spike_critical: 5.0,
64            cost_spike_warning: 2.5,
65            loop_max_calls: 10,
66            stall_min_cost: 5.0,
67            stall_min_minutes: 10,
68            context_critical_pct: 90.0,
69            context_warning_pct: 80.0,
70            decay_compaction_pct: 50.0,
71            efficiency_critical_factor: 2.0,
72            error_accel_factor: 2.0,
73            repetition_threshold: 3,
74        }
75    }
76}
77
78/// Raw TOML representation for health thresholds — all fields optional.
79#[derive(Debug, Default)]
80struct RawHealthThresholds {
81    cache_critical_pct: Option<f64>,
82    cache_warning_pct: Option<f64>,
83    cache_min_tokens: Option<u64>,
84    cost_spike_critical: Option<f64>,
85    cost_spike_warning: Option<f64>,
86    loop_max_calls: Option<u32>,
87    stall_min_cost: Option<f64>,
88    stall_min_minutes: Option<u64>,
89    context_critical_pct: Option<f64>,
90    context_warning_pct: Option<f64>,
91    decay_compaction_pct: Option<f64>,
92    efficiency_critical_factor: Option<f64>,
93    error_accel_factor: Option<f64>,
94    repetition_threshold: Option<u32>,
95}
96
97/// Configuration for the optional local LLM brain.
98/// When `None`, brain is completely disabled with zero overhead.
99#[derive(Debug, Clone)]
100pub struct BrainConfig {
101    pub enabled: bool,
102    pub endpoint: String,
103    pub model: String,
104    pub auto_mode: bool,
105    pub timeout_ms: u64,
106    pub max_context_tokens: u32,
107    pub few_shot_count: usize,
108    pub max_sessions: usize,
109    pub orchestrate: bool,
110    pub orchestrate_interval_secs: u64,
111}
112
113impl Default for BrainConfig {
114    fn default() -> Self {
115        Self {
116            enabled: true,
117            endpoint: "http://localhost:11434/api/generate".into(),
118            model: "gemma4:e4b".into(),
119            auto_mode: false,
120            timeout_ms: 5000,
121            max_context_tokens: 4000,
122            few_shot_count: 5,
123            max_sessions: 10,
124            orchestrate: false,
125            orchestrate_interval_secs: 30,
126        }
127    }
128}
129
130/// Configuration for session lifecycle management (auto-restart on context saturation).
131#[derive(Debug, Clone)]
132pub struct LifecycleConfig {
133    pub auto_restart: bool,
134    pub restart_threshold_pct: f64,
135    pub restart_only_when_idle: bool,
136}
137
138impl Default for LifecycleConfig {
139    fn default() -> Self {
140        Self {
141            auto_restart: false,
142            restart_threshold_pct: 90.0,
143            restart_only_when_idle: true,
144        }
145    }
146}
147
148/// Configuration for idle mode and unattended work queue.
149#[allow(dead_code)]
150#[derive(Debug, Clone)]
151pub struct IdleConfig {
152    pub enabled: bool,
153    pub after_idle_mins: u64,
154    pub max_concurrent: usize,
155    pub max_cost_usd: f64,
156    pub tasks: Vec<IdleTask>,
157}
158
159#[allow(dead_code)]
160#[derive(Debug, Clone)]
161pub struct IdleTask {
162    pub prompt: String,
163    pub cwd: Option<String>,
164    pub priority: u32,
165}
166
167impl Default for IdleConfig {
168    fn default() -> Self {
169        Self {
170            enabled: false,
171            after_idle_mins: 15,
172            max_concurrent: 2,
173            max_cost_usd: 5.0,
174            tasks: Vec::new(),
175        }
176    }
177}
178
179/// Configuration for relay transport (cross-machine collaboration).
180#[derive(Debug, Clone)]
181pub struct RelayConfig {
182    pub enabled: bool,
183    pub listen_port: u16,
184    pub listen_addr: String,
185    pub max_peers: u8,
186    pub heartbeat_interval_secs: u64,
187    pub reconnect_max_secs: u64,
188    pub auto_connect: Vec<String>,
189}
190
191impl Default for RelayConfig {
192    fn default() -> Self {
193        Self {
194            enabled: false,
195            listen_port: 9847,
196            listen_addr: "0.0.0.0".into(),
197            max_peers: 8,
198            heartbeat_interval_secs: 30,
199            reconnect_max_secs: 60,
200            auto_connect: Vec::new(),
201        }
202    }
203}
204
205/// Configuration for hive mind knowledge sharing.
206#[derive(Debug, Clone)]
207pub struct HiveConfig {
208    pub enabled: bool,
209    pub default_trust: f64,
210    pub auto_trust_drift: bool,
211    pub max_propagation: u32,
212    pub export_min_evidence: u32,
213    pub export_min_tool_decisions: u32,
214    pub knowledge_ttl_days: u32,
215    pub inject_unverified: bool,
216    /// Which knowledge categories to share. Empty = share all shareable categories.
217    /// Valid values: "best_practice", "technique", "workflow"
218    pub share_categories: Vec<String>,
219    /// Tools to exclude from sharing (e.g., ["Write"] to keep file-write patterns private).
220    pub exclude_tools: Vec<String>,
221    /// Command patterns to exclude from sharing (substring match).
222    pub exclude_commands: Vec<String>,
223    /// Maximum knowledge units to keep in the store. Oldest/lowest-confidence evicted.
224    pub max_units: usize,
225    /// Maximum knowledge units to inject into the brain prompt per evaluation.
226    pub max_prompt_units: usize,
227    /// Days after which a peer's knowledge is pruned if the peer hasn't been seen.
228    pub stale_peer_days: u32,
229}
230
231impl Default for HiveConfig {
232    fn default() -> Self {
233        Self {
234            enabled: false,
235            default_trust: 0.5,
236            auto_trust_drift: true,
237            max_propagation: 5,
238            export_min_evidence: 5,
239            export_min_tool_decisions: 10,
240            knowledge_ttl_days: 30,
241            inject_unverified: true,
242            share_categories: Vec::new(),
243            exclude_tools: Vec::new(),
244            exclude_commands: Vec::new(),
245            max_units: 500,
246            max_prompt_units: 20,
247            stale_peer_days: 90,
248        }
249    }
250}
251
252impl Default for Config {
253    fn default() -> Self {
254        Self {
255            interval: 2000,
256            notify: false,
257            debug: false,
258            grouped: false,
259            sort: None,
260            budget: None,
261            kill_on_budget: false,
262            webhook: None,
263            webhook_on: None,
264            daily_limit: None,
265            weekly_limit: None,
266            context_warn_threshold: 75,
267            model_overrides: Vec::new(),
268            rules: Vec::new(),
269            health: HealthThresholds::default(),
270            file_conflicts: true,
271            auto_deny_file_conflicts: false,
272            brain: None,
273            relay: None,
274            hive: None,
275            lifecycle: LifecycleConfig::default(),
276            idle: IdleConfig::default(),
277            agents: Vec::new(),
278        }
279    }
280}
281
282/// Raw TOML representation — all fields optional for partial overrides.
283#[derive(Debug, Default)]
284struct RawConfig {
285    interval: Option<u64>,
286    notify: Option<bool>,
287    debug: Option<bool>,
288    grouped: Option<bool>,
289    sort: Option<String>,
290    budget: Option<f64>,
291    kill_on_budget: Option<bool>,
292    webhook_url: Option<String>,
293    webhook_events: Option<Vec<String>>,
294    daily_limit: Option<f64>,
295    weekly_limit: Option<f64>,
296    context_warn_threshold: Option<u8>,
297    model_overrides: Vec<ModelOverride>,
298    rules: Vec<AutoRule>,
299    health: Option<RawHealthThresholds>,
300    file_conflicts: Option<bool>,
301    auto_deny_file_conflicts: Option<bool>,
302    brain: Option<BrainConfig>,
303    relay: Option<RawRelayConfig>,
304    hive: Option<RawHiveConfig>,
305    lifecycle: Option<RawLifecycleConfig>,
306    idle: Option<RawIdleConfig>,
307    agents: Vec<AgentConfig>,
308}
309
310#[derive(Debug, Default)]
311struct RawRelayConfig {
312    enabled: Option<bool>,
313    listen_port: Option<u16>,
314    listen_addr: Option<String>,
315    max_peers: Option<u8>,
316    heartbeat_interval_secs: Option<u64>,
317    reconnect_max_secs: Option<u64>,
318    auto_connect: Option<Vec<String>>,
319}
320
321#[derive(Debug, Default)]
322struct RawHiveConfig {
323    enabled: Option<bool>,
324    default_trust: Option<f64>,
325    auto_trust_drift: Option<bool>,
326    max_propagation: Option<u32>,
327    export_min_evidence: Option<u32>,
328    export_min_tool_decisions: Option<u32>,
329    knowledge_ttl_days: Option<u32>,
330    inject_unverified: Option<bool>,
331    share_categories: Vec<String>,
332    exclude_tools: Vec<String>,
333    exclude_commands: Vec<String>,
334    max_units: Option<usize>,
335    max_prompt_units: Option<usize>,
336    stale_peer_days: Option<u32>,
337}
338
339#[derive(Debug, Default)]
340struct RawLifecycleConfig {
341    auto_restart: Option<bool>,
342    restart_threshold_pct: Option<f64>,
343    restart_only_when_idle: Option<bool>,
344}
345
346#[derive(Debug, Default)]
347struct RawIdleConfig {
348    enabled: Option<bool>,
349    after_idle_mins: Option<u64>,
350    max_concurrent: Option<usize>,
351    max_cost_usd: Option<f64>,
352}
353
354impl Config {
355    /// Load configuration from global and project config files.
356    pub fn load() -> Self {
357        let mut config = Config::default();
358
359        // Layer 1: Global config
360        if let Some(global) = global_config_path() {
361            if let Some(raw) = parse_config_file(&global) {
362                config.apply(raw);
363            }
364        }
365
366        // Layer 2: Project config (.claudectl.toml in cwd)
367        if let Some(raw) = parse_config_file(&PathBuf::from(".claudectl.toml")) {
368            config.apply(raw);
369        }
370
371        config
372    }
373
374    /// Apply a raw config layer on top, overriding only set fields.
375    fn apply(&mut self, raw: RawConfig) {
376        if let Some(v) = raw.interval {
377            self.interval = v;
378        }
379        if let Some(v) = raw.notify {
380            self.notify = v;
381        }
382        if let Some(v) = raw.debug {
383            self.debug = v;
384        }
385        if let Some(v) = raw.grouped {
386            self.grouped = v;
387        }
388        if let Some(v) = raw.sort {
389            self.sort = Some(v);
390        }
391        if let Some(v) = raw.budget {
392            self.budget = Some(v);
393        }
394        if let Some(v) = raw.kill_on_budget {
395            self.kill_on_budget = v;
396        }
397        if let Some(v) = raw.webhook_url {
398            self.webhook = Some(v);
399        }
400        if let Some(v) = raw.webhook_events {
401            self.webhook_on = Some(v);
402        }
403        if let Some(v) = raw.daily_limit {
404            self.daily_limit = Some(v);
405        }
406        if let Some(v) = raw.weekly_limit {
407            self.weekly_limit = Some(v);
408        }
409        if let Some(v) = raw.context_warn_threshold {
410            self.context_warn_threshold = v.min(100);
411        }
412        if let Some(h) = raw.health {
413            if let Some(v) = h.cache_critical_pct {
414                self.health.cache_critical_pct = v;
415            }
416            if let Some(v) = h.cache_warning_pct {
417                self.health.cache_warning_pct = v;
418            }
419            if let Some(v) = h.cache_min_tokens {
420                self.health.cache_min_tokens = v;
421            }
422            if let Some(v) = h.cost_spike_critical {
423                self.health.cost_spike_critical = v;
424            }
425            if let Some(v) = h.cost_spike_warning {
426                self.health.cost_spike_warning = v;
427            }
428            if let Some(v) = h.loop_max_calls {
429                self.health.loop_max_calls = v;
430            }
431            if let Some(v) = h.stall_min_cost {
432                self.health.stall_min_cost = v;
433            }
434            if let Some(v) = h.stall_min_minutes {
435                self.health.stall_min_minutes = v;
436            }
437            if let Some(v) = h.context_critical_pct {
438                self.health.context_critical_pct = v;
439            }
440            if let Some(v) = h.context_warning_pct {
441                self.health.context_warning_pct = v;
442            }
443            if let Some(v) = h.decay_compaction_pct {
444                self.health.decay_compaction_pct = v;
445            }
446            if let Some(v) = h.efficiency_critical_factor {
447                self.health.efficiency_critical_factor = v;
448            }
449            if let Some(v) = h.error_accel_factor {
450                self.health.error_accel_factor = v;
451            }
452            if let Some(v) = h.repetition_threshold {
453                self.health.repetition_threshold = v;
454            }
455        }
456        if let Some(v) = raw.file_conflicts {
457            self.file_conflicts = v;
458        }
459        if let Some(v) = raw.auto_deny_file_conflicts {
460            self.auto_deny_file_conflicts = v;
461        }
462        for override_ in raw.model_overrides {
463            upsert_model_override(&mut self.model_overrides, override_);
464        }
465        for rule in raw.rules {
466            // Replace rule with same name, or append
467            if let Some(pos) = self.rules.iter().position(|r| r.name == rule.name) {
468                self.rules[pos] = rule;
469            } else {
470                self.rules.push(rule);
471            }
472        }
473        if let Some(brain) = raw.brain {
474            self.brain = Some(brain);
475        }
476        if let Some(raw_relay) = raw.relay {
477            let relay = self.relay.get_or_insert_with(RelayConfig::default);
478            if let Some(v) = raw_relay.enabled {
479                relay.enabled = v;
480            }
481            if let Some(v) = raw_relay.listen_port {
482                relay.listen_port = v;
483            }
484            if let Some(v) = raw_relay.listen_addr {
485                relay.listen_addr = v;
486            }
487            if let Some(v) = raw_relay.max_peers {
488                relay.max_peers = v;
489            }
490            if let Some(v) = raw_relay.heartbeat_interval_secs {
491                relay.heartbeat_interval_secs = v;
492            }
493            if let Some(v) = raw_relay.reconnect_max_secs {
494                relay.reconnect_max_secs = v;
495            }
496            if let Some(v) = raw_relay.auto_connect {
497                relay.auto_connect = v;
498            }
499        }
500        if let Some(raw_hive) = raw.hive {
501            let hive = self.hive.get_or_insert_with(HiveConfig::default);
502            if let Some(v) = raw_hive.enabled {
503                hive.enabled = v;
504            }
505            if let Some(v) = raw_hive.default_trust {
506                hive.default_trust = v.clamp(0.0, 1.0);
507            }
508            if let Some(v) = raw_hive.auto_trust_drift {
509                hive.auto_trust_drift = v;
510            }
511            if let Some(v) = raw_hive.max_propagation {
512                hive.max_propagation = v;
513            }
514            if let Some(v) = raw_hive.export_min_evidence {
515                hive.export_min_evidence = v;
516            }
517            if let Some(v) = raw_hive.export_min_tool_decisions {
518                hive.export_min_tool_decisions = v;
519            }
520            if let Some(v) = raw_hive.knowledge_ttl_days {
521                hive.knowledge_ttl_days = v;
522            }
523            if let Some(v) = raw_hive.inject_unverified {
524                hive.inject_unverified = v;
525            }
526            if !raw_hive.share_categories.is_empty() {
527                hive.share_categories = raw_hive.share_categories;
528            }
529            if !raw_hive.exclude_tools.is_empty() {
530                hive.exclude_tools = raw_hive.exclude_tools;
531            }
532            if !raw_hive.exclude_commands.is_empty() {
533                hive.exclude_commands = raw_hive.exclude_commands;
534            }
535            if let Some(v) = raw_hive.max_units {
536                hive.max_units = v;
537            }
538            if let Some(v) = raw_hive.max_prompt_units {
539                hive.max_prompt_units = v;
540            }
541            if let Some(v) = raw_hive.stale_peer_days {
542                hive.stale_peer_days = v;
543            }
544        }
545        if let Some(lc) = raw.lifecycle {
546            if let Some(v) = lc.auto_restart {
547                self.lifecycle.auto_restart = v;
548            }
549            if let Some(v) = lc.restart_threshold_pct {
550                self.lifecycle.restart_threshold_pct = v;
551            }
552            if let Some(v) = lc.restart_only_when_idle {
553                self.lifecycle.restart_only_when_idle = v;
554            }
555        }
556        if let Some(idle) = raw.idle {
557            if let Some(v) = idle.enabled {
558                self.idle.enabled = v;
559            }
560            if let Some(v) = idle.after_idle_mins {
561                self.idle.after_idle_mins = v;
562            }
563            if let Some(v) = idle.max_concurrent {
564                self.idle.max_concurrent = v;
565            }
566            if let Some(v) = idle.max_cost_usd {
567                self.idle.max_cost_usd = v;
568            }
569        }
570        for agent in raw.agents {
571            if let Some(pos) = self.agents.iter().position(|a| a.name == agent.name) {
572                self.agents[pos] = agent;
573            } else {
574                self.agents.push(agent);
575            }
576        }
577    }
578
579    /// Show resolved config and file locations (for `claudectl config`).
580    pub fn print_resolved(&self) {
581        println!("Resolved configuration:");
582        println!();
583
584        if let Some(p) = global_config_path() {
585            if p.exists() {
586                println!("  Global config: {}", p.display());
587            } else {
588                println!("  Global config: {} (not found)", p.display());
589            }
590        }
591
592        let project_path = PathBuf::from(".claudectl.toml");
593        if project_path.exists() {
594            println!("  Project config: {}", project_path.display());
595        } else {
596            println!("  Project config: .claudectl.toml (not found)");
597        }
598
599        println!();
600        println!("  interval:       {}ms", self.interval);
601        println!("  notify:         {}", self.notify);
602        println!("  debug:          {}", self.debug);
603        println!("  grouped:        {}", self.grouped);
604        println!(
605            "  sort:           {}",
606            self.sort.as_deref().unwrap_or("default")
607        );
608        println!(
609            "  budget:         {}",
610            self.budget
611                .map(|b| format!("${b:.2}"))
612                .unwrap_or_else(|| "none".into())
613        );
614        println!("  kill_on_budget: {}", self.kill_on_budget);
615        println!(
616            "  webhook:        {}",
617            self.webhook.as_deref().unwrap_or("none")
618        );
619        println!(
620            "  webhook_on:     {}",
621            self.webhook_on
622                .as_ref()
623                .map(|v| v.join(", "))
624                .unwrap_or_else(|| "all".into())
625        );
626        println!(
627            "  daily_limit:    {}",
628            self.daily_limit
629                .map(|b| format!("${b:.2}"))
630                .unwrap_or_else(|| "none".into())
631        );
632        println!(
633            "  weekly_limit:   {}",
634            self.weekly_limit
635                .map(|b| format!("${b:.2}"))
636                .unwrap_or_else(|| "none".into())
637        );
638        println!("  context_warn: {}%", self.context_warn_threshold);
639        println!();
640        println!("  [orchestrate]");
641        println!("  file_conflicts:           {}", self.file_conflicts);
642        println!(
643            "  auto_deny_file_conflicts: {}",
644            self.auto_deny_file_conflicts
645        );
646        println!();
647        println!("  [health]");
648        println!(
649            "  cache:    critical <{:.0}%, warning <{:.0}%, min {}",
650            self.health.cache_critical_pct,
651            self.health.cache_warning_pct,
652            self.health.cache_min_tokens,
653        );
654        println!(
655            "  cost:     critical >{:.1}x, warning >{:.1}x",
656            self.health.cost_spike_critical, self.health.cost_spike_warning,
657        );
658        println!("  loop:     {} calls", self.health.loop_max_calls);
659        println!(
660            "  stall:    >${:.0} and >{}min",
661            self.health.stall_min_cost, self.health.stall_min_minutes,
662        );
663        println!(
664            "  context:  critical >{:.0}%, warning >{:.0}%",
665            self.health.context_critical_pct, self.health.context_warning_pct,
666        );
667        println!(
668            "  decay:    compact >{:.0}%, efficiency >{:.1}x, errors >{:.1}x, repeats >{}",
669            self.health.decay_compaction_pct,
670            self.health.efficiency_critical_factor,
671            self.health.error_accel_factor,
672            self.health.repetition_threshold,
673        );
674        if self.model_overrides.is_empty() {
675            println!("  model_overrides: none");
676        } else {
677            println!("  model_overrides:");
678            for override_ in &self.model_overrides {
679                println!(
680                    "    {} => in ${:.2}/M, out ${:.2}/M, ctx {}",
681                    override_.name,
682                    override_.profile.input_per_m,
683                    override_.profile.output_per_m,
684                    override_.profile.context_max
685                );
686            }
687        }
688    }
689
690    /// Print an annotated default config template to stdout.
691    pub fn print_template() {
692        print!(
693            r#"# claudectl configuration
694# Place this file at:
695#   Project: .claudectl.toml (in your project root)
696#   Global:  ~/.config/claudectl/config.toml
697#
698# Priority: CLI flags > project config > global config > defaults
699# Only set values you want to override — unset keys use defaults.
700
701# ── General ─────────────────────────────────────────────────────────
702
703[defaults]
704# Refresh interval in milliseconds
705# interval = 2000
706
707# Enable desktop notifications on NeedsInput transitions
708# notify = false
709
710# Show debug timing metrics in the footer
711# debug = false
712
713# Group sessions by project in the table view
714# grouped = false
715
716# Default sort column: "Status", "Context", "Cost", "$/hr", "Elapsed"
717# sort = "Status"
718
719# Per-session budget in USD (alert at 80%, optionally kill at 100%)
720# budget = 10.00
721
722# Auto-kill sessions that exceed the budget (requires budget)
723# kill_on_budget = false
724
725# ── Webhook ─────────────────────────────────────────────────────────
726
727[webhook]
728# POST JSON on status changes
729# url = "https://hooks.slack.com/services/..."
730
731# Only fire on these status transitions (omit for all)
732# events = ["NeedsInput", "Finished"]
733
734# ── Budget Limits ───────────────────────────────────────────────────
735
736[budget]
737# Daily spending limit in USD
738# daily_limit = 50.00
739
740# Weekly spending limit in USD
741# weekly_limit = 200.00
742
743# ── Context ─────────────────────────────────────────────────────────
744
745[context]
746# Fire on_context_high hook when context usage crosses this percentage
747# warn_threshold = 75
748
749# ── Orchestration ───────────────────────────────────────────────────
750
751[orchestrate]
752# Detect file-level conflicts when multiple sessions edit the same file
753# file_conflicts = true
754
755# Auto-deny writes to files being edited by another session
756# auto_deny_file_conflicts = false
757
758# ── Health Check Thresholds ─────────────────────────────────────────
759
760[health]
761# Cache hit ratio thresholds (percentage, 0-100)
762# cache_critical_pct = 10.0
763# cache_warning_pct = 30.0
764# cache_min_tokens = 10000
765
766# Cost spike detection (multiplier of session average burn rate)
767# cost_spike_critical = 5.0
768# cost_spike_warning = 2.5
769
770# Loop detection (tool call count threshold when errors are present)
771# loop_max_calls = 10
772
773# Stall detection (minimum cost in USD and minutes with no file edits)
774# stall_min_cost = 5.0
775# stall_min_minutes = 10
776
777# Context saturation thresholds (percentage, 0-100)
778# context_critical_pct = 90.0
779# context_warning_pct = 80.0
780
781# Cognitive decay detection
782# decay_compaction_pct = 50.0         # Context % to suggest proactive /compact
783# efficiency_critical_factor = 2.0    # Tokens-per-edit ratio vs baseline to trigger
784# error_accel_factor = 2.0            # Error rate ratio vs baseline to trigger
785# repetition_threshold = 3            # File re-reads without edit to trigger
786
787# ── Model Pricing Overrides ─────────────────────────────────────────
788# Override built-in pricing for specific models.
789#
790# [models."my-custom-model"]
791# input_per_m = 3.00
792# output_per_m = 15.00
793# cache_read_per_m = 0.30
794# cache_write_per_m = 3.75
795# context_max = 200000
796
797# ── Auto-Rules ──────────────────────────────────────────────────────
798# Match sessions by status/tool/command/project/cost, then take action.
799# Deny rules always take precedence regardless of order.
800#
801# [rules.approve_reads]
802# match_status = ["Needs Input"]
803# match_tool = ["Read", "Glob", "Grep"]
804# action = "approve"
805#
806# [rules.deny_destructive]
807# match_tool = ["Bash"]
808# match_command = ["rm -rf", "git push --force"]
809# action = "deny"
810#
811# [rules.kill_runaway]
812# match_cost_above = 20.0
813# action = "terminate"
814#
815# [rules.auto_continue]
816# match_status = ["Waiting"]
817# action = "send"
818# message = "continue"
819
820# ── Event Hooks ─────────────────────────────────────────────────────
821# Run shell commands on session events.
822#
823# [hooks.on_needs_input]
824# run = "notify-send 'Session needs input'"
825#
826# [hooks.on_finished]
827# run = "say 'Session finished'"
828#
829# Available events: on_needs_input, on_finished, on_budget_80,
830#   on_budget_exceeded, on_context_high, on_status_change
831
832# ── Brain (Local LLM) ──────────────────────────────────────────────
833
834# [brain]
835# enabled = true
836# endpoint = "http://localhost:11434/api/generate"
837# model = "gemma4:e4b"
838# auto = false
839# timeout_ms = 5000
840# max_context_tokens = 4000
841# few_shot_count = 5
842# max_sessions = 10
843# orchestrate = false
844# orchestrate_interval = 30
845
846# ── Relay / Hive (feature: relay) ─────────────────────────────────────
847#
848# [relay]
849# enabled = false
850# listen_addr = "0.0.0.0"
851# listen_port = 9847
852# max_peers = 8
853# heartbeat_interval_secs = 30
854# reconnect_max_secs = 60
855# auto_connect = []
856#
857# [hive]
858# enabled = false
859# default_trust = 0.5
860# auto_trust_drift = true
861# max_propagation = 5
862# export_min_evidence = 5
863# export_min_tool_decisions = 10
864# knowledge_ttl_days = 30
865# inject_unverified = true
866
867# ── External Agents ─────────────────────────────────────────────────
868#
869# [agents.codex]
870# type = "codex"
871# command = "codex --quiet"
872# capabilities = ["code-review", "refactoring"]
873# cwd = "/path/to/project"
874"#
875        );
876    }
877}
878
879fn global_config_path() -> Option<PathBuf> {
880    std::env::var_os("HOME").map(|home| {
881        PathBuf::from(home)
882            .join(".config")
883            .join("claudectl")
884            .join("config.toml")
885    })
886}
887
888/// Minimal TOML parser — avoids adding a toml crate dependency.
889/// Supports: key = value pairs, [sections], # comments, strings, numbers, booleans, arrays.
890fn parse_config_file(path: &PathBuf) -> Option<RawConfig> {
891    let content = fs::read_to_string(path).ok()?;
892    let mut raw = RawConfig::default();
893    let mut section = String::new();
894
895    for line in content.lines() {
896        let line = line.trim();
897
898        // Skip empty lines and comments
899        if line.is_empty() || line.starts_with('#') {
900            continue;
901        }
902
903        // Section headers
904        if line.starts_with('[') && line.ends_with(']') {
905            section = line[1..line.len() - 1].trim().to_string();
906            continue;
907        }
908
909        // Key = value
910        let Some((key, value)) = line.split_once('=') else {
911            continue;
912        };
913        let key = key.trim();
914        let value = value.trim();
915
916        // Strip inline comments
917        let value = value.split('#').next().unwrap_or(value).trim();
918
919        match (section.as_str(), key) {
920            ("" | "defaults", "interval") => {
921                raw.interval = value.parse().ok();
922            }
923            ("" | "defaults", "notify") => {
924                raw.notify = parse_bool(value);
925            }
926            ("" | "defaults", "debug") => {
927                raw.debug = parse_bool(value);
928            }
929            ("" | "defaults", "grouped") => {
930                raw.grouped = parse_bool(value);
931            }
932            ("" | "defaults", "sort") => {
933                raw.sort = Some(unquote(value));
934            }
935            ("" | "defaults", "budget") => {
936                raw.budget = value.parse().ok();
937            }
938            ("" | "defaults", "kill_on_budget") => {
939                raw.kill_on_budget = parse_bool(value);
940            }
941            ("webhook", "url") => {
942                raw.webhook_url = Some(unquote(value));
943            }
944            ("webhook", "events") => {
945                raw.webhook_events = Some(parse_string_array(value));
946            }
947            ("budget", "daily_limit") => {
948                raw.daily_limit = value.parse().ok();
949            }
950            ("budget", "weekly_limit") => {
951                raw.weekly_limit = value.parse().ok();
952            }
953            ("context", "warn_threshold") => {
954                raw.context_warn_threshold = value.parse().ok();
955            }
956            ("orchestrate", "file_conflicts") => {
957                raw.file_conflicts = parse_bool(value);
958            }
959            ("orchestrate", "auto_deny_file_conflicts") => {
960                raw.auto_deny_file_conflicts = parse_bool(value);
961            }
962            ("health", key) => {
963                let h = raw.health.get_or_insert_with(RawHealthThresholds::default);
964                match key {
965                    "cache_critical_pct" => h.cache_critical_pct = value.parse().ok(),
966                    "cache_warning_pct" => h.cache_warning_pct = value.parse().ok(),
967                    "cache_min_tokens" => h.cache_min_tokens = value.parse().ok(),
968                    "cost_spike_critical" => h.cost_spike_critical = value.parse().ok(),
969                    "cost_spike_warning" => h.cost_spike_warning = value.parse().ok(),
970                    "loop_max_calls" => h.loop_max_calls = value.parse().ok(),
971                    "stall_min_cost" => h.stall_min_cost = value.parse().ok(),
972                    "stall_min_minutes" => h.stall_min_minutes = value.parse().ok(),
973                    "context_critical_pct" => h.context_critical_pct = value.parse().ok(),
974                    "context_warning_pct" => h.context_warning_pct = value.parse().ok(),
975                    "decay_compaction_pct" => h.decay_compaction_pct = value.parse().ok(),
976                    "efficiency_critical_factor" => {
977                        h.efficiency_critical_factor = value.parse().ok()
978                    }
979                    "error_accel_factor" => h.error_accel_factor = value.parse().ok(),
980                    "repetition_threshold" => h.repetition_threshold = value.parse().ok(),
981                    _ => {}
982                }
983            }
984            _ if parse_model_section(&section).is_some() => {
985                let Some(model_name) = parse_model_section(&section) else {
986                    continue;
987                };
988                let profile = ensure_model_override(&mut raw.model_overrides, &model_name);
989                match key {
990                    "input_per_m" => {
991                        profile.input_per_m = value.parse().unwrap_or(profile.input_per_m);
992                    }
993                    "output_per_m" => {
994                        profile.output_per_m = value.parse().unwrap_or(profile.output_per_m);
995                    }
996                    "cache_read_per_m" => {
997                        profile.cache_read_per_m =
998                            value.parse().unwrap_or(profile.cache_read_per_m);
999                    }
1000                    "cache_write_per_m" => {
1001                        profile.cache_write_per_m =
1002                            value.parse().unwrap_or(profile.cache_write_per_m);
1003                    }
1004                    "context_max" => {
1005                        profile.context_max = value.parse().unwrap_or(profile.context_max);
1006                    }
1007                    _ => {}
1008                }
1009            }
1010            _ if parse_rule_section(&section).is_some() => {
1011                let Some(rule_name) = parse_rule_section(&section) else {
1012                    continue;
1013                };
1014                let rule = ensure_rule(&mut raw.rules, &rule_name);
1015                match key {
1016                    "match_status" => rule.match_status = parse_string_array(value),
1017                    "match_tool" => rule.match_tool = parse_string_array(value),
1018                    "match_command" => rule.match_command = parse_string_array(value),
1019                    "match_project" => rule.match_project = parse_string_array(value),
1020                    "match_cost_above" => rule.match_cost_above = value.parse().ok(),
1021                    "match_last_error" => rule.match_last_error = parse_bool(value),
1022                    "match_file_conflict" => rule.match_file_conflict = parse_bool(value),
1023                    "action" => {
1024                        if let Some(a) = RuleAction::parse(&unquote(value)) {
1025                            rule.action = a;
1026                        }
1027                    }
1028                    "message" => rule.message = Some(unquote(value)),
1029                    _ => {}
1030                }
1031            }
1032            ("lifecycle", key) => {
1033                let lc = raw
1034                    .lifecycle
1035                    .get_or_insert_with(RawLifecycleConfig::default);
1036                match key {
1037                    "auto_restart" => lc.auto_restart = parse_bool(value),
1038                    "restart_threshold_pct" => lc.restart_threshold_pct = value.parse().ok(),
1039                    "restart_only_when_idle" => lc.restart_only_when_idle = parse_bool(value),
1040                    _ => {}
1041                }
1042            }
1043            ("idle", key) => {
1044                let idle = raw.idle.get_or_insert_with(RawIdleConfig::default);
1045                match key {
1046                    "enabled" => idle.enabled = parse_bool(value),
1047                    "after_idle_mins" => idle.after_idle_mins = value.parse().ok(),
1048                    "max_concurrent" => idle.max_concurrent = value.parse().ok(),
1049                    "max_cost_usd" => idle.max_cost_usd = value.parse().ok(),
1050                    _ => {}
1051                }
1052            }
1053            ("brain", _) => {
1054                let brain = raw.brain.get_or_insert_with(BrainConfig::default);
1055                match key {
1056                    "enabled" => {
1057                        if let Some(v) = parse_bool(value) {
1058                            brain.enabled = v;
1059                        }
1060                    }
1061                    "endpoint" => brain.endpoint = unquote(value),
1062                    "model" => brain.model = unquote(value),
1063                    "auto" => {
1064                        if let Some(v) = parse_bool(value) {
1065                            brain.auto_mode = v;
1066                        }
1067                    }
1068                    "timeout_ms" => {
1069                        if let Ok(v) = value.parse() {
1070                            brain.timeout_ms = v;
1071                        }
1072                    }
1073                    "max_context_tokens" => {
1074                        if let Ok(v) = value.parse() {
1075                            brain.max_context_tokens = v;
1076                        }
1077                    }
1078                    "few_shot_count" => {
1079                        if let Ok(v) = value.parse() {
1080                            brain.few_shot_count = v;
1081                        }
1082                    }
1083                    "max_sessions" => {
1084                        if let Ok(v) = value.parse() {
1085                            brain.max_sessions = v;
1086                        }
1087                    }
1088                    "orchestrate" => {
1089                        if let Some(v) = parse_bool(value) {
1090                            brain.orchestrate = v;
1091                        }
1092                    }
1093                    "orchestrate_interval" => {
1094                        if let Ok(v) = value.parse() {
1095                            brain.orchestrate_interval_secs = v;
1096                        }
1097                    }
1098                    _ => {}
1099                }
1100            }
1101            ("relay", _) => {
1102                let relay = raw.relay.get_or_insert_with(RawRelayConfig::default);
1103                match key {
1104                    "enabled" => {
1105                        relay.enabled = parse_bool(value);
1106                    }
1107                    "listen_port" | "port" => {
1108                        relay.listen_port = value.parse().ok();
1109                    }
1110                    "listen_addr" | "addr" => relay.listen_addr = Some(unquote(value)),
1111                    "max_peers" => {
1112                        relay.max_peers = value.parse().ok();
1113                    }
1114                    "heartbeat_interval" | "heartbeat_interval_secs" => {
1115                        relay.heartbeat_interval_secs = value.parse().ok();
1116                    }
1117                    "reconnect_max" | "reconnect_max_secs" => {
1118                        relay.reconnect_max_secs = value.parse().ok();
1119                    }
1120                    "auto_connect" => {
1121                        relay.auto_connect = Some(parse_string_array(value));
1122                    }
1123                    _ => {}
1124                }
1125            }
1126            ("hive", _) => {
1127                let hive = raw.hive.get_or_insert_with(RawHiveConfig::default);
1128                match key {
1129                    "enabled" => {
1130                        hive.enabled = parse_bool(value);
1131                    }
1132                    "default_trust" => {
1133                        hive.default_trust = value.parse().ok();
1134                    }
1135                    "auto_trust_drift" => {
1136                        hive.auto_trust_drift = parse_bool(value);
1137                    }
1138                    "max_propagation" => {
1139                        hive.max_propagation = value.parse().ok();
1140                    }
1141                    "export_min_evidence" => {
1142                        hive.export_min_evidence = value.parse().ok();
1143                    }
1144                    "export_min_tool_decisions" => {
1145                        hive.export_min_tool_decisions = value.parse().ok();
1146                    }
1147                    "knowledge_ttl_days" => {
1148                        hive.knowledge_ttl_days = value.parse().ok();
1149                    }
1150                    "inject_unverified" => {
1151                        hive.inject_unverified = parse_bool(value);
1152                    }
1153                    "share_categories" => {
1154                        hive.share_categories = parse_string_array(value);
1155                    }
1156                    "exclude_tools" => {
1157                        hive.exclude_tools = parse_string_array(value);
1158                    }
1159                    "exclude_commands" => {
1160                        hive.exclude_commands = parse_string_array(value);
1161                    }
1162                    "max_units" => {
1163                        hive.max_units = value.parse().ok();
1164                    }
1165                    "max_prompt_units" => {
1166                        hive.max_prompt_units = value.parse().ok();
1167                    }
1168                    "stale_peer_days" => {
1169                        hive.stale_peer_days = value.parse().ok();
1170                    }
1171                    _ => {}
1172                }
1173            }
1174            _ if parse_agent_section(&section).is_some() => {
1175                let Some(agent_name) = parse_agent_section(&section) else {
1176                    continue;
1177                };
1178                let agent = ensure_agent(&mut raw.agents, &agent_name);
1179                match key {
1180                    "type" => agent.agent_type = unquote(value),
1181                    "command" => agent.command = unquote(value),
1182                    "capabilities" => agent.capabilities = parse_string_array(value),
1183                    "cwd" => agent.cwd = unquote(value),
1184                    _ => {}
1185                }
1186            }
1187            _ => {} // Ignore unknown keys
1188        }
1189    }
1190
1191    Some(raw)
1192}
1193
1194/// Load hooks from global and project config files.
1195pub fn load_hooks() -> crate::hooks::HookRegistry {
1196    let mut registry = crate::hooks::HookRegistry::new();
1197
1198    if let Some(global) = global_config_path() {
1199        parse_hooks_from_file(&global, &mut registry);
1200    }
1201    parse_hooks_from_file(&PathBuf::from(".claudectl.toml"), &mut registry);
1202
1203    registry
1204}
1205
1206fn parse_hooks_from_file(path: &PathBuf, registry: &mut crate::hooks::HookRegistry) {
1207    let content = match fs::read_to_string(path) {
1208        Ok(c) => c,
1209        Err(_) => return,
1210    };
1211
1212    let mut section = String::new();
1213
1214    for line in content.lines() {
1215        let line = line.trim();
1216        if line.is_empty() || line.starts_with('#') {
1217            continue;
1218        }
1219
1220        if line.starts_with('[') && line.ends_with(']') {
1221            section = line[1..line.len() - 1].trim().to_string();
1222            continue;
1223        }
1224
1225        // Only process hooks sections
1226        if !section.starts_with("hooks.") {
1227            continue;
1228        }
1229
1230        let Some((key, value)) = line.split_once('=') else {
1231            continue;
1232        };
1233        let key = key.trim();
1234        let value = value.trim();
1235        let value = value.split('#').next().unwrap_or(value).trim();
1236
1237        if key == "run" {
1238            if let Some(event) = crate::hooks::HookEvent::from_section(&section) {
1239                registry.add(event, unquote(value));
1240            }
1241        }
1242    }
1243}
1244
1245fn parse_bool(s: &str) -> Option<bool> {
1246    match s {
1247        "true" => Some(true),
1248        "false" => Some(false),
1249        _ => None,
1250    }
1251}
1252
1253fn unquote(s: &str) -> String {
1254    s.trim_matches('"').trim_matches('\'').to_string()
1255}
1256
1257fn parse_string_array(s: &str) -> Vec<String> {
1258    let s = s.trim_start_matches('[').trim_end_matches(']');
1259    s.split(',')
1260        .map(|item| unquote(item.trim()))
1261        .filter(|item| !item.is_empty())
1262        .collect()
1263}
1264
1265fn parse_model_section(section: &str) -> Option<String> {
1266    section.strip_prefix("models.").map(unquote)
1267}
1268
1269fn ensure_model_override<'a>(
1270    overrides: &'a mut Vec<ModelOverride>,
1271    model_name: &str,
1272) -> &'a mut ModelProfile {
1273    if let Some(index) = overrides.iter().position(|item| item.name == model_name) {
1274        return &mut overrides[index].profile;
1275    }
1276
1277    overrides.push(ModelOverride {
1278        name: model_name.to_string(),
1279        profile: ModelProfile {
1280            input_per_m: 0.0,
1281            output_per_m: 0.0,
1282            cache_read_per_m: 0.0,
1283            cache_write_per_m: 0.0,
1284            context_max: 0,
1285        },
1286    });
1287
1288    &mut overrides
1289        .last_mut()
1290        .expect("override was just pushed")
1291        .profile
1292}
1293
1294fn upsert_model_override(overrides: &mut Vec<ModelOverride>, incoming: ModelOverride) {
1295    if let Some(existing) = overrides.iter_mut().find(|item| item.name == incoming.name) {
1296        *existing = incoming;
1297    } else {
1298        overrides.push(incoming);
1299    }
1300}
1301
1302fn parse_rule_section(section: &str) -> Option<String> {
1303    section.strip_prefix("rules.").map(unquote)
1304}
1305
1306fn ensure_rule<'a>(rules: &'a mut Vec<AutoRule>, name: &str) -> &'a mut AutoRule {
1307    if let Some(index) = rules.iter().position(|r| r.name == name) {
1308        return &mut rules[index];
1309    }
1310    rules.push(AutoRule::new(name.to_string(), RuleAction::Approve));
1311    rules.last_mut().expect("rule was just pushed")
1312}
1313
1314fn parse_agent_section(section: &str) -> Option<String> {
1315    section.strip_prefix("agents.").map(unquote)
1316}
1317
1318fn ensure_agent<'a>(agents: &'a mut Vec<AgentConfig>, name: &str) -> &'a mut AgentConfig {
1319    if let Some(index) = agents.iter().position(|a| a.name == name) {
1320        return &mut agents[index];
1321    }
1322    agents.push(AgentConfig::new(name.to_string()));
1323    agents.last_mut().expect("agent was just pushed")
1324}
1325
1326#[cfg(test)]
1327mod tests {
1328    use super::*;
1329
1330    #[test]
1331    fn test_parse_bool() {
1332        assert_eq!(parse_bool("true"), Some(true));
1333        assert_eq!(parse_bool("false"), Some(false));
1334        assert_eq!(parse_bool("yes"), None);
1335    }
1336
1337    #[test]
1338    fn test_unquote() {
1339        assert_eq!(unquote("\"hello\""), "hello");
1340        assert_eq!(unquote("'hello'"), "hello");
1341        assert_eq!(unquote("hello"), "hello");
1342    }
1343
1344    #[test]
1345    fn test_parse_string_array() {
1346        let result = parse_string_array("[\"NeedsInput\", \"Finished\"]");
1347        assert_eq!(result, vec!["NeedsInput", "Finished"]);
1348    }
1349
1350    #[test]
1351    fn test_parse_config_file() {
1352        use std::io::Write;
1353        let mut file = tempfile::NamedTempFile::new().unwrap();
1354        writeln!(
1355            file,
1356            r#"
1357# Global claudectl config
1358[defaults]
1359interval = 1000
1360notify = true
1361grouped = true
1362sort = "cost"
1363budget = 5.00
1364kill_on_budget = false
1365
1366[webhook]
1367url = "https://hooks.slack.com/test"
1368events = ["NeedsInput", "Finished"]
1369
1370[models."gpt-4o"]
1371input_per_m = 1.25
1372output_per_m = 5.0
1373cache_read_per_m = 0.15
1374cache_write_per_m = 0.9
1375context_max = 128000
1376"#
1377        )
1378        .unwrap();
1379        file.flush().unwrap();
1380
1381        let raw = parse_config_file(&file.path().to_path_buf()).unwrap();
1382        assert_eq!(raw.interval, Some(1000));
1383        assert_eq!(raw.notify, Some(true));
1384        assert_eq!(raw.grouped, Some(true));
1385        assert_eq!(raw.sort, Some("cost".into()));
1386        assert_eq!(raw.budget, Some(5.0));
1387        assert_eq!(raw.kill_on_budget, Some(false));
1388        assert_eq!(raw.webhook_url, Some("https://hooks.slack.com/test".into()));
1389        assert_eq!(
1390            raw.webhook_events,
1391            Some(vec!["NeedsInput".into(), "Finished".into()])
1392        );
1393        assert_eq!(raw.model_overrides.len(), 1);
1394        assert_eq!(raw.model_overrides[0].name, "gpt-4o");
1395        assert_eq!(raw.model_overrides[0].profile.context_max, 128_000);
1396    }
1397
1398    #[test]
1399    fn test_config_layering() {
1400        let mut config = Config::default();
1401        assert_eq!(config.interval, 2000);
1402        assert!(!config.notify);
1403
1404        // Apply global config
1405        config.apply(RawConfig {
1406            interval: Some(1000),
1407            notify: Some(true),
1408            budget: Some(5.0),
1409            ..RawConfig::default()
1410        });
1411        assert_eq!(config.interval, 1000);
1412        assert!(config.notify);
1413        assert_eq!(config.budget, Some(5.0));
1414
1415        // Apply project config — overrides some fields
1416        config.apply(RawConfig {
1417            budget: Some(10.0),
1418            grouped: Some(true),
1419            ..RawConfig::default()
1420        });
1421        assert_eq!(config.interval, 1000); // Unchanged
1422        assert!(config.notify); // Unchanged
1423        assert_eq!(config.budget, Some(10.0)); // Overridden
1424        assert!(config.grouped); // New
1425
1426        // Partial relay/hive sections layer without resetting omitted fields.
1427        config.apply(RawConfig {
1428            relay: Some(RawRelayConfig {
1429                enabled: Some(true),
1430                listen_port: Some(9000),
1431                ..RawRelayConfig::default()
1432            }),
1433            hive: Some(RawHiveConfig {
1434                enabled: Some(true),
1435                default_trust: Some(0.7),
1436                ..RawHiveConfig::default()
1437            }),
1438            ..RawConfig::default()
1439        });
1440        config.apply(RawConfig {
1441            relay: Some(RawRelayConfig {
1442                max_peers: Some(4),
1443                ..RawRelayConfig::default()
1444            }),
1445            hive: Some(RawHiveConfig {
1446                knowledge_ttl_days: Some(14),
1447                ..RawHiveConfig::default()
1448            }),
1449            ..RawConfig::default()
1450        });
1451        let relay = config.relay.as_ref().unwrap();
1452        assert!(relay.enabled);
1453        assert_eq!(relay.listen_port, 9000);
1454        assert_eq!(relay.max_peers, 4);
1455        let hive = config.hive.as_ref().unwrap();
1456        assert!(hive.enabled);
1457        assert_eq!(hive.default_trust, 0.7);
1458        assert_eq!(hive.knowledge_ttl_days, 14);
1459    }
1460
1461    #[test]
1462    fn test_parse_rules_from_config() {
1463        use std::io::Write;
1464        let mut file = tempfile::NamedTempFile::new().unwrap();
1465        writeln!(
1466            file,
1467            r#"
1468[rules.approve_reads]
1469match_status = ["Needs Input"]
1470match_tool = ["Read", "Glob", "Grep"]
1471action = "approve"
1472
1473[rules.deny_destructive]
1474match_status = ["Needs Input"]
1475match_tool = ["Bash"]
1476match_command = ["rm -rf", "git push --force"]
1477action = "deny"
1478
1479[rules.auto_continue]
1480match_status = ["Waiting"]
1481action = "send"
1482message = "continue"
1483
1484[rules.kill_expensive]
1485match_cost_above = 10.0
1486action = "terminate"
1487"#
1488        )
1489        .unwrap();
1490        file.flush().unwrap();
1491
1492        let raw = parse_config_file(&file.path().to_path_buf()).unwrap();
1493        assert_eq!(raw.rules.len(), 4);
1494
1495        let r0 = &raw.rules[0];
1496        assert_eq!(r0.name, "approve_reads");
1497        assert_eq!(r0.match_tool, vec!["Read", "Glob", "Grep"]);
1498        assert_eq!(r0.action, RuleAction::Approve);
1499
1500        let r1 = &raw.rules[1];
1501        assert_eq!(r1.name, "deny_destructive");
1502        assert_eq!(r1.match_command, vec!["rm -rf", "git push --force"]);
1503        assert_eq!(r1.action, RuleAction::Deny);
1504
1505        let r2 = &raw.rules[2];
1506        assert_eq!(r2.name, "auto_continue");
1507        assert_eq!(r2.action, RuleAction::Send);
1508        assert_eq!(r2.message, Some("continue".into()));
1509
1510        let r3 = &raw.rules[3];
1511        assert_eq!(r3.name, "kill_expensive");
1512        assert_eq!(r3.match_cost_above, Some(10.0));
1513        assert_eq!(r3.action, RuleAction::Terminate);
1514    }
1515
1516    #[test]
1517    fn test_parse_brain_config() {
1518        use std::io::Write;
1519        let mut file = tempfile::NamedTempFile::new().unwrap();
1520        writeln!(
1521            file,
1522            r#"
1523[brain]
1524enabled = true
1525endpoint = "http://localhost:8080/v1/chat"
1526model = "llama3:8b"
1527auto = true
1528timeout_ms = 3000
1529max_context_tokens = 8000
1530"#
1531        )
1532        .unwrap();
1533        file.flush().unwrap();
1534
1535        let raw = parse_config_file(&file.path().to_path_buf()).unwrap();
1536        let brain = raw.brain.expect("brain config should be parsed");
1537        assert!(brain.enabled);
1538        assert_eq!(brain.endpoint, "http://localhost:8080/v1/chat");
1539        assert_eq!(brain.model, "llama3:8b");
1540        assert!(brain.auto_mode);
1541        assert_eq!(brain.timeout_ms, 3000);
1542        assert_eq!(brain.max_context_tokens, 8000);
1543    }
1544
1545    #[test]
1546    fn test_no_brain_config_returns_none() {
1547        use std::io::Write;
1548        let mut file = tempfile::NamedTempFile::new().unwrap();
1549        writeln!(file, "[defaults]\ninterval = 1000").unwrap();
1550        file.flush().unwrap();
1551
1552        let raw = parse_config_file(&file.path().to_path_buf()).unwrap();
1553        assert!(raw.brain.is_none());
1554    }
1555
1556    #[test]
1557    fn test_parse_relay_hive_config() {
1558        use std::io::Write;
1559        let mut file = tempfile::NamedTempFile::new().unwrap();
1560        writeln!(
1561            file,
1562            r#"
1563[relay]
1564enabled = true
1565listen_port = 9999
1566listen_addr = "127.0.0.1"
1567max_peers = 3
1568auto_connect = ["peer-a:9847"]
1569
1570[hive]
1571enabled = true
1572default_trust = 0.65
1573max_propagation = 2
1574knowledge_ttl_days = 7
1575inject_unverified = false
1576"#
1577        )
1578        .unwrap();
1579        file.flush().unwrap();
1580
1581        let raw = parse_config_file(&file.path().to_path_buf()).unwrap();
1582        let relay = raw.relay.expect("relay config should be parsed");
1583        assert_eq!(relay.enabled, Some(true));
1584        assert_eq!(relay.listen_port, Some(9999));
1585        assert_eq!(relay.listen_addr.as_deref(), Some("127.0.0.1"));
1586        assert_eq!(relay.max_peers, Some(3));
1587        assert_eq!(relay.auto_connect, Some(vec!["peer-a:9847".into()]));
1588
1589        let hive = raw.hive.expect("hive config should be parsed");
1590        assert_eq!(hive.enabled, Some(true));
1591        assert_eq!(hive.default_trust, Some(0.65));
1592        assert_eq!(hive.max_propagation, Some(2));
1593        assert_eq!(hive.knowledge_ttl_days, Some(7));
1594        assert_eq!(hive.inject_unverified, Some(false));
1595    }
1596
1597    #[test]
1598    fn test_parse_agents_from_config() {
1599        use std::io::Write;
1600        let mut file = tempfile::NamedTempFile::new().unwrap();
1601        writeln!(
1602            file,
1603            r#"
1604[agents.codex]
1605type = "codex"
1606command = "codex --quiet"
1607capabilities = ["code-review", "refactoring"]
1608cwd = "/tmp/project"
1609
1610[agents.aider]
1611type = "aider"
1612command = "aider --yes"
1613capabilities = ["implementation", "debugging"]
1614"#
1615        )
1616        .unwrap();
1617        file.flush().unwrap();
1618
1619        let raw = parse_config_file(&file.path().to_path_buf()).unwrap();
1620        assert_eq!(raw.agents.len(), 2);
1621
1622        let codex = &raw.agents[0];
1623        assert_eq!(codex.name, "codex");
1624        assert_eq!(codex.agent_type, "codex");
1625        assert_eq!(codex.command, "codex --quiet");
1626        assert_eq!(codex.capabilities, vec!["code-review", "refactoring"]);
1627        assert_eq!(codex.cwd, "/tmp/project");
1628
1629        let aider = &raw.agents[1];
1630        assert_eq!(aider.name, "aider");
1631        assert_eq!(aider.command, "aider --yes");
1632    }
1633
1634    #[test]
1635    fn test_parse_health_thresholds() {
1636        use std::io::Write;
1637        let mut file = tempfile::NamedTempFile::new().unwrap();
1638        writeln!(
1639            file,
1640            r#"
1641[health]
1642cache_critical_pct = 5.0
1643cache_warning_pct = 20.0
1644cache_min_tokens = 50000
1645cost_spike_critical = 8.0
1646cost_spike_warning = 3.0
1647loop_max_calls = 15
1648stall_min_cost = 10.0
1649stall_min_minutes = 20
1650context_critical_pct = 95.0
1651context_warning_pct = 85.0
1652"#
1653        )
1654        .unwrap();
1655        file.flush().unwrap();
1656
1657        let raw = parse_config_file(&file.path().to_path_buf()).unwrap();
1658        let h = raw.health.expect("health config should be parsed");
1659        assert_eq!(h.cache_critical_pct, Some(5.0));
1660        assert_eq!(h.cache_warning_pct, Some(20.0));
1661        assert_eq!(h.cache_min_tokens, Some(50000));
1662        assert_eq!(h.cost_spike_critical, Some(8.0));
1663        assert_eq!(h.cost_spike_warning, Some(3.0));
1664        assert_eq!(h.loop_max_calls, Some(15));
1665        assert_eq!(h.stall_min_cost, Some(10.0));
1666        assert_eq!(h.stall_min_minutes, Some(20));
1667        assert_eq!(h.context_critical_pct, Some(95.0));
1668        assert_eq!(h.context_warning_pct, Some(85.0));
1669    }
1670
1671    #[test]
1672    fn test_health_thresholds_layering() {
1673        let mut config = Config::default();
1674        assert_eq!(config.health.cache_critical_pct, 10.0); // default
1675
1676        config.apply(RawConfig {
1677            health: Some(RawHealthThresholds {
1678                cache_critical_pct: Some(5.0),
1679                ..RawHealthThresholds::default()
1680            }),
1681            ..RawConfig::default()
1682        });
1683        assert_eq!(config.health.cache_critical_pct, 5.0); // overridden
1684        assert_eq!(config.health.cache_warning_pct, 30.0); // unchanged default
1685    }
1686
1687    #[test]
1688    fn test_parse_orchestrate_config() {
1689        use std::io::Write;
1690        let mut file = tempfile::NamedTempFile::new().unwrap();
1691        writeln!(
1692            file,
1693            r#"
1694[orchestrate]
1695file_conflicts = true
1696auto_deny_file_conflicts = true
1697"#
1698        )
1699        .unwrap();
1700        file.flush().unwrap();
1701
1702        let raw = parse_config_file(&file.path().to_path_buf()).unwrap();
1703        assert_eq!(raw.file_conflicts, Some(true));
1704        assert_eq!(raw.auto_deny_file_conflicts, Some(true));
1705    }
1706
1707    #[test]
1708    fn test_orchestrate_defaults() {
1709        let config = Config::default();
1710        assert!(config.file_conflicts); // on by default
1711        assert!(!config.auto_deny_file_conflicts); // off by default
1712    }
1713}