Skip to main content

imp_core/
config.rs

1use std::collections::{HashMap, HashSet};
2use std::path::{Path, PathBuf};
3
4use imp_llm::ThinkingLevel;
5use serde::{Deserialize, Serialize};
6
7use crate::error::Result;
8use crate::guardrails::GuardrailConfig;
9use crate::hooks::HookDef;
10use crate::personality::PersonalityConfig;
11use crate::roles::RoleDef;
12use crate::storage;
13use crate::tools::web::types::WebConfig;
14
15/// Agent mode — controls which tools and mana actions the agent may use.
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
17#[serde(rename_all = "kebab-case")]
18pub enum AgentMode {
19    /// Default. Full access to all tools. No filtering.
20    #[default]
21    Full,
22    /// Unit executor. Read + write + bash. No mana create/run.
23    Worker,
24    /// Plans and executes via mana. Cannot touch files directly.
25    Orchestrator,
26    /// Decomposes work. Can read and create mana units. Cannot run them.
27    Planner,
28    /// Read-only inspector. No mutations, no mana.
29    Reviewer,
30    /// Batch inspector. Reads code and mana state, produces reports.
31    Auditor,
32}
33
34const WORKER_TOOLS: &[&str] = &[
35    "read", "scan", "web", "recall", "write", "edit", "bash", "git", "mana", "ask_user",
36];
37const ORCHESTRATOR_TOOLS: &[&str] = &["read", "scan", "web", "recall", "mana", "git", "ask_user"];
38const PLANNER_TOOLS: &[&str] = &["read", "scan", "web", "recall", "git", "mana", "ask_user"];
39const REVIEWER_TOOLS: &[&str] = &["read", "scan", "web", "recall", "git", "ask_user"];
40const AUDITOR_TOOLS: &[&str] = &["read", "scan", "web", "recall", "git", "mana"];
41
42const WORKER_MANA_ACTIONS: &[&str] = &[
43    "show",
44    "update",
45    "status",
46    "list",
47    "logs",
48    "next",
49    "verify",
50    "notes_append",
51];
52const ORCHESTRATOR_MANA_ACTIONS: &[&str] = &[
53    "status",
54    "list",
55    "show",
56    "create",
57    "close",
58    "update",
59    "run",
60    "run_state",
61    "evaluate",
62    "claim",
63    "release",
64    "logs",
65    "agents",
66    "next",
67    "tree",
68    "reopen",
69    "verify",
70    "fail",
71    "delete",
72    "dep_add",
73    "dep_remove",
74    "fact_create",
75    "fact_verify",
76    "notes_append",
77    "decision_add",
78    "decision_resolve",
79];
80const PLANNER_MANA_ACTIONS: &[&str] = &[
81    "status",
82    "list",
83    "show",
84    "create",
85    "update",
86    "next",
87    "tree",
88    "dep_add",
89    "dep_remove",
90    "fact_create",
91    "notes_append",
92    "decision_add",
93    "decision_resolve",
94];
95const AUDITOR_MANA_ACTIONS: &[&str] = &[
96    "status",
97    "list",
98    "show",
99    "logs",
100    "agents",
101    "next",
102    "tree",
103    "verify",
104    "fact_verify",
105];
106
107impl AgentMode {
108    /// Tool names this mode permits. An empty slice means "allow all" (Full).
109    pub fn allowed_tool_names(&self) -> &'static [&'static str] {
110        match self {
111            AgentMode::Full => &[],
112            AgentMode::Worker => WORKER_TOOLS,
113            AgentMode::Orchestrator => ORCHESTRATOR_TOOLS,
114            AgentMode::Planner => PLANNER_TOOLS,
115            AgentMode::Reviewer => REVIEWER_TOOLS,
116            AgentMode::Auditor => AUDITOR_TOOLS,
117        }
118    }
119
120    /// Returns true if the mode allows the named tool.
121    pub fn allows_tool(&self, name: &str) -> bool {
122        match self {
123            AgentMode::Full => true,
124            _ => self.allowed_tool_names().contains(&name),
125        }
126    }
127
128    /// Mana sub-actions this mode permits. An empty slice means "allow all" (Full).
129    pub fn allowed_mana_actions(&self) -> &'static [&'static str] {
130        match self {
131            AgentMode::Full | AgentMode::Reviewer => &[],
132            AgentMode::Worker => WORKER_MANA_ACTIONS,
133            AgentMode::Orchestrator => ORCHESTRATOR_MANA_ACTIONS,
134            AgentMode::Planner => PLANNER_MANA_ACTIONS,
135            AgentMode::Auditor => AUDITOR_MANA_ACTIONS,
136        }
137    }
138
139    /// Returns true if the mode allows the named mana action.
140    pub fn allows_mana_action(&self, action: &str) -> bool {
141        match self {
142            AgentMode::Full => true,
143            AgentMode::Reviewer => false,
144            _ => self.allowed_mana_actions().contains(&action),
145        }
146    }
147
148    /// Parse a mode from a string name (e.g. `"worker"`, `"full"`).
149    ///
150    /// Returns `None` for unrecognised names. Used to read `IMP_MODE` from the
151    /// environment without requiring a full `FromStr` implementation.
152    pub fn from_name(s: &str) -> Option<Self> {
153        match s.to_lowercase().as_str() {
154            "full" => Some(AgentMode::Full),
155            "worker" => Some(AgentMode::Worker),
156            "orchestrator" => Some(AgentMode::Orchestrator),
157            "planner" => Some(AgentMode::Planner),
158            "reviewer" => Some(AgentMode::Reviewer),
159            "auditor" => Some(AgentMode::Auditor),
160            _ => None,
161        }
162    }
163
164    /// Mode-specific behavioral instruction for the system prompt, if any.
165    pub fn instructions(&self) -> Option<&'static str> {
166        match self {
167            AgentMode::Full => None,
168            AgentMode::Worker => Some(
169                "You are a worker agent. Your job is to implement the assigned unit as specified and stay within its scope. \
170                You may read files, write files, and run shell commands. Inspect the relevant files before making claims or changes, \
171                use fast scoped checks for local feedback while implementing, and record meaningful progress or failure context with `mana update`. \
172                Do not declare success if commands or checks fail; report the exact blocker and the next useful action. \
173                Treat mana units as execution contracts: use their scope, dependencies, acceptance criteria, and verify gate before broadening the work. \
174                You may not create, run, or close mana units — final verification and closure belong to the orchestrator workflow.",
175            ),
176            AgentMode::Orchestrator => Some(
177                "You are an orchestrator agent. Use mana as your primary execution substrate for non-trivial work. \
178                Inspect mana state before making claims about work status, avoid duplicating or fragmenting existing units, and enrich existing units when that is cleaner than creating new ones. \
179                Write detailed units, split larger efforts into child units with dependencies, dispatch workers through mana, and own the final verification, retry, and closure workflow. \
180                Use the full mana unit vocabulary when it helps: acceptance criteria, labels, dependencies, paths, requires, produces, decisions, and feature boundaries. \
181                Encode unresolved questions as decisions instead of burying ambiguity in prose. \
182                When the conversation itself is producing durable plans, architecture, migrations, or implementation structure, externalize that structure into mana during the conversation rather than waiting until the end. \
183                Prefer native mana actions, including scope-aware and append-style updates, over shell or direct file edits for maintaining the work graph. \
184                You may not read or write files directly — create and dispatch mana units for all file work. \
185                Update units with concrete failure context and do not retry unchanged failed plans. \
186                You are responsible for unit structure, completeness, and verify quality.",
187            ),
188            AgentMode::Planner => Some(
189                "You are a planner agent. Your job is to decompose work into mana units. \
190                Read enough code and context to ground the plan, cite concrete files or constraints when they matter, \
191                and make dependencies, sequencing, acceptance criteria, and verify commands explicit. \
192                Write worker-ready unit descriptions that include current state, concrete steps, file paths with intent, embedded context, scope boundaries, and what not to do. \
193                Record unresolved questions as decisions when autonomous execution would otherwise require guessing. \
194                Externalize durable planning structure into mana during the conversation, not only after the plan is complete. \
195                Prefer append-style mana updates to keep the graph current as ideas sharpen. \
196                You may read files and create units, but you may not run them — \
197                a human or orchestrator will approve execution.",
198            ),
199            AgentMode::Reviewer => Some(
200                "You are a reviewer agent. Your job is to read code and report findings. \
201                Ground findings in inspected code, cite exact files or symbols when useful, and distinguish confirmed issues from possible concerns. \
202                You may not write files, run commands, or use mana.",
203            ),
204            AgentMode::Auditor => Some(
205                "You are an auditor agent. Your job is to inspect code and mana state \
206                and produce structured reports. Ground conclusions in inspected evidence, cite the relevant files or mana objects, \
207                and clearly separate facts, risks, and open questions. You may read files and mana status, \
208                but you may not modify anything.",
209            ),
210        }
211    }
212}
213
214/// Write tool overwrite safety policy.
215#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
216#[serde(rename_all = "snake_case")]
217pub enum WriteOverwritePolicy {
218    /// Allow overwrites but return warnings for unread/stale files.
219    #[default]
220    Warn,
221    /// Block overwrites unless the file was read in this session and is not stale.
222    RequireRead,
223    /// Block only stale overwrites; unread overwrites still warn.
224    BlockStale,
225    /// Block all overwrites. New file creation is still allowed.
226    Deny,
227}
228
229/// File write tool settings.
230#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
231pub struct WriteConfig {
232    #[serde(default)]
233    pub overwrite_policy: WriteOverwritePolicy,
234}
235
236/// Shell backend selection for the Bash tool.
237#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
238#[serde(rename_all = "kebab-case")]
239pub enum ShellBackend {
240    /// Use a POSIX-compatible shell command. Defaults to `bash -c`.
241    #[default]
242    Sh,
243    /// Use the rush library API (`rush::run`). Falls back to the configured shell if
244    /// the `rush-backend` feature is not compiled in.
245    Rush,
246    /// Connect to a running rush daemon over Unix socket. Falls back to the configured shell
247    /// if the daemon is not reachable.
248    RushDaemon,
249}
250
251/// Shell-related configuration for the Bash tool.
252#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
253pub struct ShellConfig {
254    /// Which shell backend to use. Defaults to the standard command shell.
255    #[serde(default)]
256    pub backend: ShellBackend,
257    /// Shell executable used for command execution. Defaults to `bash`.
258    #[serde(default)]
259    pub command: Option<String>,
260}
261
262impl Default for ShellConfig {
263    fn default() -> Self {
264        Self {
265            backend: ShellBackend::Sh,
266            command: None,
267        }
268    }
269}
270
271/// Concrete capability policy for the shipped Lua extension runtime.
272#[derive(Debug, Clone, PartialEq, Eq)]
273pub struct LuaCapabilityPolicy {
274    pub allow_native_tool_calls: bool,
275    pub allow_shell_exec: bool,
276    pub allow_http: bool,
277    pub allow_secrets: bool,
278    pub allowed_env: HashSet<String>,
279}
280
281impl Default for LuaCapabilityPolicy {
282    fn default() -> Self {
283        Self {
284            allow_native_tool_calls: true,
285            allow_shell_exec: false,
286            allow_http: false,
287            allow_secrets: false,
288            allowed_env: HashSet::new(),
289        }
290    }
291}
292
293/// Configuration for the shipped Lua extension runtime.
294#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
295pub struct LuaConfig {
296    /// Whether `imp.tool()` calls from Lua are allowed.
297    pub allow_native_tool_calls: Option<bool>,
298    /// Whether `imp.exec()` is allowed.
299    pub allow_shell_exec: Option<bool>,
300    /// Whether `imp.http.*` is allowed.
301    pub allow_http: Option<bool>,
302    /// Whether `imp.secret()` / `imp.secret_fields()` are allowed.
303    pub allow_secrets: Option<bool>,
304    /// Env vars Lua extensions may read through `imp.env()`.
305    pub allowed_env: Option<Vec<String>>,
306}
307
308impl LuaConfig {
309    #[must_use]
310    pub fn resolve_policy(&self, mode: AgentMode) -> LuaCapabilityPolicy {
311        let mut policy = LuaCapabilityPolicy::default();
312        // Worker agents inherit the user's explicit Lua secret capability so
313        // agent-invoked extension tools behave the same as the parent session.
314        if matches!(mode, AgentMode::Worker) {
315            policy.allow_secrets = self.allow_secrets.unwrap_or(false);
316        }
317        if let Some(value) = self.allow_native_tool_calls {
318            policy.allow_native_tool_calls = value;
319        }
320        if let Some(value) = self.allow_shell_exec {
321            policy.allow_shell_exec = value;
322        }
323        if let Some(value) = self.allow_http {
324            policy.allow_http = value;
325        }
326        if let Some(value) = self.allow_secrets {
327            policy.allow_secrets = value;
328        }
329        if let Some(values) = &self.allowed_env {
330            policy.allowed_env = values.iter().cloned().collect();
331        }
332        policy
333    }
334}
335
336/// Native command secret-injection policy.
337#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
338pub struct SecretsConfig {
339    #[serde(default)]
340    pub commands: CommandSecretsConfig,
341}
342
343#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
344pub struct CommandSecretsConfig {
345    #[serde(default)]
346    pub enabled: bool,
347    #[serde(default)]
348    pub allowed: Vec<AllowedCommandSecret>,
349}
350
351#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
352pub struct AllowedCommandSecret {
353    pub name: String,
354}
355
356#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
357#[serde(rename_all = "kebab-case")]
358pub enum ManaScopePreference {
359    #[default]
360    Project,
361    Root,
362}
363
364#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
365pub struct ManaRunConfig {
366    /// Whether native mana runs should return immediately by default.
367    #[serde(default = "default_enabled")]
368    pub background: bool,
369    /// Number of units to run in parallel by default.
370    #[serde(default = "default_mana_run_jobs")]
371    pub max_workers: u32,
372    /// Continue running other ready units after one unit fails.
373    #[serde(default)]
374    pub continue_after_failure: bool,
375    /// Review or evaluate units after a native run completes.
376    #[serde(default)]
377    pub review_after_run: bool,
378}
379
380impl Default for ManaRunConfig {
381    fn default() -> Self {
382        Self {
383            background: true,
384            max_workers: 4,
385            continue_after_failure: false,
386            review_after_run: false,
387        }
388    }
389}
390
391impl ManaRunConfig {
392    fn is_default(&self) -> bool {
393        self == &Self::default()
394    }
395}
396
397fn default_enabled() -> bool {
398    true
399}
400
401fn default_mana_run_jobs() -> u32 {
402    4
403}
404
405#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
406pub struct ManaConfig {
407    /// Default mana graph selection for native mana calls.
408    #[serde(default)]
409    pub scope: ManaScopePreference,
410    /// Whether successful mana close operations should auto-commit mana changes.
411    #[serde(default)]
412    pub auto_commit: bool,
413    /// Whether mana should close completed parent units after closing a child.
414    #[serde(default = "default_true")]
415    pub auto_close_parent: bool,
416    /// Default verify timeout, in seconds, for close/create/verify flows.
417    #[serde(default)]
418    pub verify_timeout: Option<u64>,
419    /// Native mana run defaults.
420    #[serde(default, skip_serializing_if = "ManaRunConfig::is_default")]
421    pub run: ManaRunConfig,
422}
423
424impl Default for ManaConfig {
425    fn default() -> Self {
426        Self {
427            scope: ManaScopePreference::Project,
428            auto_commit: false,
429            auto_close_parent: true,
430            verify_timeout: None,
431            run: ManaRunConfig::default(),
432        }
433    }
434}
435
436/// Top-level configuration.
437#[derive(Debug, Clone, Default, Serialize, Deserialize)]
438pub struct Config {
439    /// Default model (alias or full ID).
440    pub model: Option<String>,
441
442    /// Default thinking level.
443    pub thinking: Option<ThinkingLevel>,
444
445    /// Default max output tokens per response.
446    pub max_tokens: Option<u32>,
447
448    /// Maximum agent turns.
449    pub max_turns: Option<u32>,
450
451    /// Active tool names (None = all).
452    pub tools: Option<Vec<String>>,
453
454    /// Named roles.
455    #[serde(default)]
456    pub roles: HashMap<String, RoleDef>,
457
458    /// Hook definitions.
459    #[serde(default)]
460    pub hooks: Vec<HookDef>,
461
462    /// Context management settings.
463    #[serde(default)]
464    pub context: ContextConfig,
465
466    /// Shell backend settings.
467    #[serde(default)]
468    pub shell: ShellConfig,
469
470    /// Write tool settings.
471    #[serde(default)]
472    pub write: WriteConfig,
473
474    /// Engineering guardrails — profile-aware guidance and post-write checks.
475    #[serde(default)]
476    pub guardrails: GuardrailConfig,
477
478    /// Agent mode — controls tool and mana action access.
479    #[serde(default)]
480    pub mode: AgentMode,
481
482    /// Enabled models for the model selector (None = show all).
483    /// Entries can be canonical IDs or aliases (e.g. "sonnet", "claude-sonnet-4-6").
484    #[serde(default)]
485    pub enabled_models: Option<Vec<String>>,
486
487    /// Theme name ("default", "light", or custom).
488    pub theme: Option<String>,
489
490    /// Learning loop settings (memory, skill nudges).
491    #[serde(default)]
492    pub learning: LearningConfig,
493
494    /// UI display settings.
495    #[serde(default)]
496    pub ui: UiConfig,
497
498    /// Web tool settings.
499    #[serde(default)]
500    pub web: WebConfig,
501
502    /// Mana tool settings.
503    #[serde(default)]
504    pub mana: ManaConfig,
505
506    /// Shipped Lua extension runtime policy.
507    #[serde(default)]
508    pub lua: LuaConfig,
509
510    /// Secret injection policy for native command execution.
511    #[serde(default)]
512    pub secrets: SecretsConfig,
513
514    /// Personality settings, including identity sentence and saved profiles.
515    #[serde(default)]
516    pub personality: PersonalityConfig,
517}
518
519// ── UI configuration ────────────────────────────────────────────
520
521/// How the sidebar displays tool calls.
522#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
523#[serde(rename_all = "lowercase")]
524pub enum SidebarStyle {
525    /// Contextual inspector for the selected tool call.
526    #[default]
527    Inspector,
528    /// Chronological stream of tool calls with inline results.
529    Stream,
530    /// Master-detail split: tool list (top) + selected output (bottom).
531    Split,
532}
533
534/// How much tool output to show per tool call.
535#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
536#[serde(rename_all = "lowercase")]
537pub enum ToolOutputDisplay {
538    /// Show all output lines (scrollable).
539    Full,
540    /// Show first N lines per tool (configurable via `tool_output_lines`).
541    #[default]
542    Compact,
543    /// Headers only — expand on click/enter.
544    Collapsed,
545}
546
547/// How tool calls appear inside the chat transcript.
548#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
549#[serde(rename_all = "kebab-case")]
550pub enum ChatToolDisplay {
551    /// Show tool calls inline where they occurred, preserving chronological order.
552    Interleaved,
553    /// Show a compact header in chat and leave details to the sidebar.
554    #[default]
555    Summary,
556    /// Hide tool calls in chat entirely.
557    Hidden,
558}
559
560/// UI animation intensity.
561#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
562#[serde(rename_all = "lowercase")]
563pub enum AnimationLevel {
564    /// No animated motion; show static state labels only.
565    None,
566    /// Basic spinner-only motion.
567    Spinner,
568    /// Restrained motion with concise state-specific labels.
569    #[default]
570    #[serde(alias = "full")]
571    Minimal,
572}
573
574/// Auto-continue policy for imp-local follow-on work.
575#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
576#[serde(rename_all = "kebab-case")]
577pub enum ContinuePolicy {
578    /// Never auto-continue on imp's own.
579    #[default]
580    Disabled,
581    /// Only auto-continue when the runtime evidence is especially strong.
582    Conservative,
583    /// Auto-continue on clear, visible, mana-backed next steps.
584    Balanced,
585    /// More willing to auto-continue when the local heuristic says confidence is high.
586    Aggressive,
587}
588
589/// UI display configuration.
590#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
591pub struct UiConfig {
592    /// Sidebar layout style.
593    #[serde(default)]
594    pub sidebar_style: SidebarStyle,
595
596    /// How much tool output to show.
597    #[serde(default)]
598    pub tool_output: ToolOutputDisplay,
599
600    /// Max lines per tool in compact mode. Default: 10.
601    #[serde(default = "default_tool_output_lines")]
602    pub tool_output_lines: usize,
603
604    /// Max lines the read tool returns before truncating. 0 disables line
605    /// truncation for file reads. Default: 500.
606    #[serde(default = "default_read_max_lines")]
607    pub read_max_lines: usize,
608
609    /// Sidebar width as percentage of screen (20-80). Default: 40.
610    #[serde(default = "default_sidebar_width")]
611    pub sidebar_width: u16,
612
613    /// Word-wrap long lines in tool output. Default: true.
614    #[serde(default = "default_true")]
615    pub word_wrap: bool,
616
617    /// Animation intensity for the TUI. Default: minimal.
618    #[serde(default)]
619    pub animations: AnimationLevel,
620
621    /// Legacy compatibility flag for older configs. Prefer `chat_tool_display`.
622    #[serde(default)]
623    pub hide_tools_in_chat: bool,
624
625    /// How tool calls should appear in the chat transcript.
626    #[serde(default)]
627    pub chat_tool_display: ChatToolDisplay,
628
629    /// Auto-open the sidebar on the first tool call. Default: true.
630    #[serde(default = "default_true")]
631    pub auto_open_sidebar: bool,
632
633    /// Minimum terminal width to auto-open sidebar. Default: 120.
634    #[serde(default = "default_sidebar_auto_open_width")]
635    pub sidebar_auto_open_width: u16,
636
637    /// Number of thinking lines to show in the rolling tail. Default: 5.
638    #[serde(default = "default_thinking_lines")]
639    pub thinking_lines: usize,
640
641    /// Number of streaming tool output lines to retain. Default: 5.
642    #[serde(default = "default_streaming_lines")]
643    pub streaming_lines: usize,
644
645    /// Mouse wheel scroll speed in lines. Default: 3.
646    #[serde(default = "default_mouse_scroll_lines")]
647    pub mouse_scroll_lines: usize,
648
649    /// Keyboard/page scroll speed in lines. Default: 20.
650    #[serde(default = "default_keyboard_scroll_lines")]
651    pub keyboard_scroll_lines: usize,
652
653    /// Deprecated: mouse capture is now always enabled. This field is retained
654    /// only for backwards-compatible deserialization of existing config files.
655    #[serde(default)]
656    #[doc(hidden)]
657    pub mouse_capture: bool,
658
659    /// Show timestamps in chat. Default: false.
660    #[serde(default)]
661    pub show_timestamps: bool,
662
663    /// Show cost in the top bar. Default: true.
664    #[serde(default = "default_true")]
665    pub show_cost: bool,
666
667    /// Show context usage in the top bar. Default: true.
668    #[serde(default = "default_true")]
669    pub show_context_usage: bool,
670
671    /// Emit a terminal bell when an agent run fully completes in the TUI.
672    /// Default: true.
673    #[serde(default = "default_true")]
674    pub notify_on_agent_complete: bool,
675
676    /// Policy for imp-local automatic continuation after a visible, high-confidence turn.
677    /// Default: disabled.
678    #[serde(default)]
679    pub continue_policy: ContinuePolicy,
680
681    /// Maximum number of automatic Build-mode task turns before pausing.
682    /// Default: 20.
683    #[serde(default = "default_build_auto_turn_budget")]
684    pub build_auto_turn_budget: u32,
685
686    /// Maximum number of automatic Improve-mode research turns before pausing.
687    /// Default: 5.
688    #[serde(default = "default_improve_auto_turn_budget")]
689    pub improve_auto_turn_budget: u32,
690
691    /// Maximum number of `/loop` automatic turns before pausing.
692    /// 0 disables the imp-level loop cap. Default: 0.
693    #[serde(default)]
694    pub loop_turn_budget: u32,
695}
696
697fn default_tool_output_lines() -> usize {
698    10
699}
700fn default_read_max_lines() -> usize {
701    500
702}
703fn default_sidebar_width() -> u16 {
704    40
705}
706fn default_sidebar_auto_open_width() -> u16 {
707    120
708}
709fn default_thinking_lines() -> usize {
710    5
711}
712fn default_streaming_lines() -> usize {
713    5
714}
715fn default_mouse_scroll_lines() -> usize {
716    3
717}
718fn default_keyboard_scroll_lines() -> usize {
719    20
720}
721fn default_build_auto_turn_budget() -> u32 {
722    20
723}
724fn default_improve_auto_turn_budget() -> u32 {
725    5
726}
727
728impl Default for UiConfig {
729    fn default() -> Self {
730        Self {
731            sidebar_style: SidebarStyle::default(),
732            tool_output: ToolOutputDisplay::default(),
733            tool_output_lines: default_tool_output_lines(),
734            read_max_lines: default_read_max_lines(),
735            sidebar_width: default_sidebar_width(),
736            word_wrap: default_true(),
737            animations: AnimationLevel::default(),
738            hide_tools_in_chat: false,
739            chat_tool_display: ChatToolDisplay::default(),
740            auto_open_sidebar: default_true(),
741            sidebar_auto_open_width: default_sidebar_auto_open_width(),
742            thinking_lines: default_thinking_lines(),
743            streaming_lines: default_streaming_lines(),
744            mouse_scroll_lines: default_mouse_scroll_lines(),
745            keyboard_scroll_lines: default_keyboard_scroll_lines(),
746            mouse_capture: false,
747            show_timestamps: false,
748            show_cost: true,
749            show_context_usage: true,
750            notify_on_agent_complete: true,
751            continue_policy: ContinuePolicy::Disabled,
752            build_auto_turn_budget: default_build_auto_turn_budget(),
753            improve_auto_turn_budget: default_improve_auto_turn_budget(),
754            loop_turn_budget: 0,
755        }
756    }
757}
758
759impl UiConfig {
760    pub fn effective_chat_tool_display(&self) -> ChatToolDisplay {
761        if self.hide_tools_in_chat && self.sidebar_style != SidebarStyle::Inspector {
762            ChatToolDisplay::Hidden
763        } else if self.sidebar_style == SidebarStyle::Inspector {
764            ChatToolDisplay::Summary
765        } else {
766            self.chat_tool_display
767        }
768    }
769}
770
771/// Learning loop configuration.
772#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
773pub struct LearningConfig {
774    /// Master switch for memory + skill nudges. Default: true.
775    #[serde(default = "default_true")]
776    pub enabled: bool,
777
778    /// Tool call count before suggesting skill creation. Default: 8.
779    #[serde(default = "default_nudge_threshold")]
780    pub skill_nudge_threshold: u32,
781
782    /// Character limit for memory.md. Default: 2200.
783    #[serde(default = "default_memory_limit")]
784    pub memory_char_limit: usize,
785
786    /// Character limit for user.md. Default: 1400.
787    #[serde(default = "default_user_limit")]
788    pub user_char_limit: usize,
789}
790
791fn default_true() -> bool {
792    true
793}
794fn default_nudge_threshold() -> u32 {
795    8
796}
797fn default_memory_limit() -> usize {
798    2200
799}
800fn default_user_limit() -> usize {
801    1400
802}
803
804impl Default for LearningConfig {
805    fn default() -> Self {
806        Self {
807            enabled: default_true(),
808            skill_nudge_threshold: default_nudge_threshold(),
809            memory_char_limit: default_memory_limit(),
810            user_char_limit: default_user_limit(),
811        }
812    }
813}
814
815#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
816#[serde(rename_all = "kebab-case")]
817pub enum AutoCompactionMode {
818    /// Automatic context compaction is disabled; manual `/compact` only.
819    #[default]
820    Disabled,
821    /// Reserved placeholder for future near-threshold auto-compaction.
822    NearThreshold,
823    /// Reserved placeholder for future aggressive auto-compaction.
824    Aggressive,
825}
826
827#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
828pub struct AutoCompactionConfig {
829    /// Placeholder mode selection for future auto-compaction design.
830    #[serde(default)]
831    pub mode: AutoCompactionMode,
832}
833
834impl Default for AutoCompactionConfig {
835    fn default() -> Self {
836        Self {
837            mode: AutoCompactionMode::Disabled,
838        }
839    }
840}
841
842#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
843pub struct ContextConfig {
844    /// Mask old tool outputs at this ratio (default: 0.6).
845    pub observation_mask_threshold: f64,
846
847    /// Keep last N turns unmasked (default: 10).
848    pub mask_window: usize,
849
850    /// Placeholder auto-compaction settings. Disabled by default.
851    #[serde(default)]
852    pub auto_compaction: AutoCompactionConfig,
853}
854
855impl Default for ContextConfig {
856    fn default() -> Self {
857        Self {
858            observation_mask_threshold: 0.6,
859            mask_window: 10,
860            auto_compaction: AutoCompactionConfig::default(),
861        }
862    }
863}
864
865impl Config {
866    /// Load config from a TOML file.
867    pub fn load(path: &Path) -> Result<Self> {
868        if !path.exists() {
869            return Ok(Self::default());
870        }
871        let content = std::fs::read_to_string(path)?;
872        let config: Config = toml::from_str(&content)?;
873        Ok(config)
874    }
875
876    /// Resolve the full config by merging: defaults < user < project < env < CLI.
877    pub fn resolve(user_config_dir: &Path, project_dir: Option<&Path>) -> Result<Self> {
878        let mut config = Self::default();
879
880        // User config
881        let user_path = user_config_dir.join("config.toml");
882        if user_path.exists() {
883            let user = Self::load(&user_path)?;
884            config.merge(user);
885        }
886
887        // Project config
888        if let Some(project) = project_dir {
889            let project_path = project.join(".imp").join("config.toml");
890            if project_path.exists() {
891                let project = Self::load(&project_path)?;
892                config.merge(project);
893            }
894        }
895
896        // Env overrides
897        if let Ok(model) = std::env::var("IMP_MODEL") {
898            config.model = Some(model);
899        }
900        if let Ok(thinking) = std::env::var("IMP_THINKING") {
901            config.thinking = parse_thinking_level(&thinking);
902        }
903        if let Ok(max_tokens) = std::env::var("IMP_MAX_TOKENS") {
904            if let Ok(parsed) = max_tokens.parse::<u32>() {
905                config.max_tokens = Some(parsed);
906            }
907        }
908        if let Ok(mode) = std::env::var("IMP_MODE") {
909            if let Some(m) = parse_agent_mode(&mode) {
910                config.mode = m;
911            }
912        }
913        if let Ok(provider) = std::env::var("IMP_WEB_PROVIDER") {
914            config.web.search_provider = match provider.to_lowercase().as_str() {
915                "tavily" => Some(crate::tools::web::types::SearchProvider::Tavily),
916                "exa" => Some(crate::tools::web::types::SearchProvider::Exa),
917                "linkup" => Some(crate::tools::web::types::SearchProvider::Linkup),
918                "perplexity" => Some(crate::tools::web::types::SearchProvider::Perplexity),
919                _ => config.web.search_provider,
920            };
921        }
922
923        Ok(config)
924    }
925
926    fn merge(&mut self, other: Config) {
927        if other.model.is_some() {
928            self.model = other.model;
929        }
930        if other.thinking.is_some() {
931            self.thinking = other.thinking;
932        }
933        if other.max_tokens.is_some() {
934            self.max_tokens = other.max_tokens;
935        }
936        if other.max_turns.is_some() {
937            self.max_turns = other.max_turns;
938        }
939        if other.tools.is_some() {
940            self.tools = other.tools;
941        }
942        if other.context != ContextConfig::default() {
943            self.context = other.context;
944        }
945        if other.shell != ShellConfig::default() {
946            self.shell = other.shell;
947        }
948        self.guardrails.merge(other.guardrails);
949        if other.mode != AgentMode::default() {
950            self.mode = other.mode;
951        }
952        if other.enabled_models.is_some() {
953            self.enabled_models = other.enabled_models;
954        }
955        if other.theme.is_some() {
956            self.theme = other.theme;
957        }
958        if other.learning != LearningConfig::default() {
959            self.learning = other.learning;
960        }
961        if other.ui != UiConfig::default() {
962            self.ui = other.ui;
963        }
964        if other.web != WebConfig::default() {
965            self.web = other.web;
966        }
967        if other.mana != ManaConfig::default() {
968            self.mana = other.mana;
969        }
970        if other.lua != LuaConfig::default() {
971            self.lua = other.lua;
972        }
973        if other.secrets != SecretsConfig::default() {
974            self.secrets = other.secrets;
975        }
976        if other.personality != PersonalityConfig::default() {
977            self.personality.merge(other.personality);
978        }
979        self.roles.extend(other.roles);
980        self.hooks.extend(other.hooks);
981    }
982
983    /// Default user config directory.
984    pub fn user_config_dir() -> PathBuf {
985        storage::global_root()
986    }
987
988    /// Default session directory.
989    pub fn session_dir() -> PathBuf {
990        storage::global_sessions_dir()
991    }
992
993    /// Save config to a TOML file. Creates parent directories if needed.
994    pub fn save(&self, path: &Path) -> Result<()> {
995        if let Some(parent) = path.parent() {
996            std::fs::create_dir_all(parent)?;
997        }
998        let content =
999            toml::to_string_pretty(self).map_err(|e| crate::error::Error::Config(e.to_string()))?;
1000        std::fs::write(path, content)?;
1001        Ok(())
1002    }
1003
1004    /// Path to the user config.toml file.
1005    pub fn user_config_path() -> PathBuf {
1006        storage::global_config_path()
1007    }
1008}
1009
1010fn parse_agent_mode(s: &str) -> Option<AgentMode> {
1011    AgentMode::from_name(s)
1012}
1013
1014fn parse_thinking_level(s: &str) -> Option<ThinkingLevel> {
1015    match s.to_lowercase().as_str() {
1016        "off" => Some(ThinkingLevel::Off),
1017        "minimal" => Some(ThinkingLevel::Minimal),
1018        "low" => Some(ThinkingLevel::Low),
1019        "medium" => Some(ThinkingLevel::Medium),
1020        "high" => Some(ThinkingLevel::High),
1021        "xhigh" => Some(ThinkingLevel::XHigh),
1022        _ => None,
1023    }
1024}
1025
1026#[cfg(test)]
1027mod tests {
1028    use super::*;
1029    use std::fs;
1030    use tempfile::TempDir;
1031
1032    #[test]
1033    fn config_default_values() {
1034        let config = Config::default();
1035        assert!(config.model.is_none());
1036        assert!(config.thinking.is_none());
1037        assert!(config.max_tokens.is_none());
1038        assert!(config.max_turns.is_none());
1039        assert!(config.tools.is_none());
1040        assert_eq!(config.ui.read_max_lines, 500);
1041        assert_eq!(config.ui.sidebar_style, SidebarStyle::Inspector);
1042        assert_eq!(config.ui.chat_tool_display, ChatToolDisplay::Summary);
1043        assert_eq!(config.ui.tool_output, ToolOutputDisplay::Compact);
1044        assert_eq!(config.web, WebConfig::default());
1045        assert_eq!(config.personality, PersonalityConfig::default());
1046        assert!(config.roles.is_empty());
1047        assert!(config.hooks.is_empty());
1048        assert!((config.context.observation_mask_threshold - 0.6).abs() < f64::EPSILON);
1049        assert_eq!(config.context.mask_window, 10);
1050        assert_eq!(
1051            config.context.auto_compaction.mode,
1052            AutoCompactionMode::Disabled
1053        );
1054        assert_eq!(config.guardrails, GuardrailConfig::default());
1055    }
1056
1057    #[test]
1058    fn inspector_sidebar_keeps_tool_calls_in_chat_summary() {
1059        let mut ui = UiConfig {
1060            sidebar_style: SidebarStyle::Inspector,
1061            chat_tool_display: ChatToolDisplay::Interleaved,
1062            ..Default::default()
1063        };
1064        assert_eq!(ui.effective_chat_tool_display(), ChatToolDisplay::Summary);
1065
1066        ui.chat_tool_display = ChatToolDisplay::Hidden;
1067        ui.hide_tools_in_chat = true;
1068        assert_eq!(ui.effective_chat_tool_display(), ChatToolDisplay::Summary);
1069
1070        ui.sidebar_style = SidebarStyle::Stream;
1071        assert_eq!(ui.effective_chat_tool_display(), ChatToolDisplay::Hidden);
1072    }
1073
1074    #[test]
1075    fn config_load_from_toml() {
1076        let dir = TempDir::new().unwrap();
1077        let config_path = dir.path().join("config.toml");
1078        fs::write(
1079            &config_path,
1080            r#"
1081model = "sonnet"
1082thinking = "high"
1083max_tokens = 2048
1084max_turns = 50
1085tools = ["read", "write", "bash"]
1086
1087[guardrails]
1088enabled = true
1089level = "enforce"
1090profile = "zig"
1091critical_paths = ["src/**"]
1092after_write = ["zig fmt --check ."]
1093
1094[context]
1095observation_mask_threshold = 0.5
1096mask_window = 5
1097
1098[shell]
1099command = "zsh"
1100
1101[web]
1102search_provider = "exa"
1103"#,
1104        )
1105        .unwrap();
1106
1107        let config = Config::load(&config_path).unwrap();
1108        assert_eq!(config.model.as_deref(), Some("sonnet"));
1109        assert_eq!(config.thinking, Some(ThinkingLevel::High));
1110        assert_eq!(config.max_tokens, Some(2048));
1111        assert_eq!(config.max_turns, Some(50));
1112        assert_eq!(config.tools.as_ref().unwrap().len(), 3);
1113        assert_eq!(config.guardrails.enabled, Some(true));
1114        assert_eq!(config.ui.read_max_lines, 500);
1115        assert_eq!(
1116            config.guardrails.profile,
1117            Some(crate::guardrails::GuardrailProfile::Zig)
1118        );
1119        assert_eq!(
1120            config.guardrails.after_write,
1121            Some(vec!["zig fmt --check .".into()])
1122        );
1123        assert_eq!(config.shell.command.as_deref(), Some("zsh"));
1124        assert_eq!(
1125            config.web.search_provider,
1126            Some(crate::tools::web::types::SearchProvider::Exa)
1127        );
1128        assert!((config.context.observation_mask_threshold - 0.5).abs() < f64::EPSILON);
1129        assert_eq!(config.context.mask_window, 5);
1130        assert_eq!(
1131            config.context.auto_compaction.mode,
1132            AutoCompactionMode::Disabled
1133        );
1134    }
1135
1136    #[test]
1137    fn config_load_missing_file_returns_default() {
1138        let dir = TempDir::new().unwrap();
1139        let config_path = dir.path().join("nonexistent.toml");
1140        let config = Config::load(&config_path).unwrap();
1141        assert!(config.model.is_none());
1142    }
1143
1144    #[test]
1145    fn config_loads_personality_section() {
1146        let dir = TempDir::new().unwrap();
1147        let config_path = dir.path().join("config.toml");
1148        fs::write(
1149            &config_path,
1150            r#"
1151[personality.profile.identity]
1152name = "Nova"
1153work_style = "careful"
1154voice = "clear"
1155focus = "research"
1156role = "assistant"
1157
1158[personality.profile.sliders]
1159autonomy = "low"
1160verbosity = "high"
1161caution = "very-high"
1162warmth = "high"
1163planning_depth = "very-high"
1164
1165[personality.profiles]
1166active = "researcher"
1167
1168[personality.profiles.saved.researcher.identity]
1169name = "Nova"
1170work_style = "careful"
1171voice = "clear"
1172focus = "research"
1173role = "assistant"
1174"#,
1175        )
1176        .unwrap();
1177
1178        let config = Config::load(&config_path).unwrap();
1179        assert_eq!(config.personality.profile.identity.name, "Nova");
1180        assert_eq!(
1181            config.personality.profile.identity.render_sentence(),
1182            "You are Nova, a careful, clear, research assistant."
1183        );
1184        assert_eq!(
1185            config.personality.profiles.active.as_deref(),
1186            Some("researcher")
1187        );
1188        assert!(config.personality.profiles.saved.contains_key("researcher"));
1189    }
1190
1191    #[test]
1192    fn config_merge_personality_project_overrides_user_and_keeps_saved_profiles() {
1193        let mut user = Config::default();
1194        user.personality.profile.identity.name = "imp".into();
1195        user.personality.profiles.active = Some("builder".into());
1196        user.personality.profiles.saved.insert(
1197            "builder".into(),
1198            crate::personality::PersonalityProfile::default(),
1199        );
1200
1201        let mut project = Config::default();
1202        project.personality.profile.identity.name = "Patch".into();
1203        project.personality.profiles.active = Some("reviewer".into());
1204        project.personality.profiles.saved.insert(
1205            "reviewer".into(),
1206            crate::personality::PersonalityProfile::default(),
1207        );
1208
1209        user.merge(project);
1210
1211        assert_eq!(user.personality.profile.identity.name, "Patch");
1212        assert_eq!(
1213            user.personality.profiles.active.as_deref(),
1214            Some("reviewer")
1215        );
1216        assert!(user.personality.profiles.saved.contains_key("builder"));
1217        assert!(user.personality.profiles.saved.contains_key("reviewer"));
1218    }
1219
1220    #[test]
1221    fn config_merge_project_overrides_user() {
1222        let mut user = Config {
1223            model: Some("haiku".into()),
1224            max_tokens: Some(1024),
1225            max_turns: Some(20),
1226            ..Default::default()
1227        };
1228
1229        let project = Config {
1230            model: Some("sonnet".into()),
1231            max_tokens: None,
1232            max_turns: None, // not set → user value preserved
1233            ..Default::default()
1234        };
1235
1236        user.merge(project);
1237        assert_eq!(user.model.as_deref(), Some("sonnet"));
1238        assert_eq!(user.max_tokens, Some(1024));
1239        assert_eq!(user.max_turns, Some(20));
1240    }
1241
1242    #[test]
1243    fn config_merge_roles_extend() {
1244        let mut base = Config::default();
1245        base.roles.insert(
1246            "worker".into(),
1247            RoleDef {
1248                model: Some("haiku".into()),
1249                thinking: None,
1250                tools: None,
1251                readonly: false,
1252                instructions: None,
1253            },
1254        );
1255
1256        let overlay = Config {
1257            roles: {
1258                let mut m = HashMap::new();
1259                m.insert(
1260                    "reviewer".into(),
1261                    RoleDef {
1262                        model: Some("sonnet".into()),
1263                        thinking: Some(ThinkingLevel::High),
1264                        tools: None,
1265                        readonly: true,
1266                        instructions: None,
1267                    },
1268                );
1269                m
1270            },
1271            ..Default::default()
1272        };
1273
1274        base.merge(overlay);
1275        assert!(base.roles.contains_key("worker"));
1276        assert!(base.roles.contains_key("reviewer"));
1277    }
1278
1279    #[test]
1280    fn config_merge_hooks_extend() {
1281        let mut base = Config::default();
1282        base.hooks.push(HookDef {
1283            event: "after_file_write".into(),
1284            match_pattern: None,
1285            action: "log".into(),
1286            command: None,
1287            blocking: false,
1288            threshold: None,
1289        });
1290
1291        let overlay = Config {
1292            hooks: vec![HookDef {
1293                event: "before_tool_call".into(),
1294                match_pattern: None,
1295                action: "block".into(),
1296                command: None,
1297                blocking: true,
1298                threshold: None,
1299            }],
1300            ..Default::default()
1301        };
1302
1303        base.merge(overlay);
1304        assert_eq!(base.hooks.len(), 2);
1305    }
1306
1307    #[test]
1308    fn config_merge_context_overrides_default() {
1309        let mut base = Config::default();
1310
1311        let overlay = Config {
1312            context: ContextConfig {
1313                observation_mask_threshold: 0.5,
1314                mask_window: 5,
1315                auto_compaction: AutoCompactionConfig {
1316                    mode: AutoCompactionMode::NearThreshold,
1317                },
1318            },
1319            ..Default::default()
1320        };
1321
1322        base.merge(overlay);
1323        assert!((base.context.observation_mask_threshold - 0.5).abs() < f64::EPSILON);
1324        assert_eq!(base.context.mask_window, 5);
1325        assert_eq!(
1326            base.context.auto_compaction.mode,
1327            AutoCompactionMode::NearThreshold
1328        );
1329    }
1330
1331    #[test]
1332    fn config_merge_includes_theme_learning_and_lua() {
1333        let mut base = Config::default();
1334        let overlay = Config {
1335            theme: Some("light".into()),
1336            learning: LearningConfig {
1337                enabled: false,
1338                skill_nudge_threshold: 3,
1339                memory_char_limit: 1000,
1340                user_char_limit: 700,
1341            },
1342            lua: LuaConfig {
1343                allow_native_tool_calls: Some(false),
1344                allow_shell_exec: Some(true),
1345                allow_http: None,
1346                allow_secrets: None,
1347                allowed_env: Some(vec!["HOME".into()]),
1348            },
1349            ..Default::default()
1350        };
1351
1352        base.merge(overlay);
1353
1354        assert_eq!(base.theme.as_deref(), Some("light"));
1355        assert_eq!(base.learning.skill_nudge_threshold, 3);
1356        assert!(!base.learning.enabled);
1357        assert_eq!(base.lua.allow_native_tool_calls, Some(false));
1358        assert_eq!(base.lua.allow_shell_exec, Some(true));
1359        assert_eq!(base.lua.allowed_env, Some(vec!["HOME".into()]));
1360    }
1361
1362    #[test]
1363    fn config_merge_guardrails_preserves_unspecified_fields() {
1364        let mut base = Config::default();
1365        base.guardrails.enabled = Some(true);
1366        base.guardrails.profile = Some(crate::guardrails::GuardrailProfile::Rust);
1367        base.guardrails.critical_paths = Some(vec!["src/**".into()]);
1368
1369        let mut overlay = Config::default();
1370        overlay.guardrails.level = Some(crate::guardrails::GuardrailLevel::Enforce);
1371        overlay.guardrails.after_write = Some(vec!["cargo test".into()]);
1372
1373        base.merge(overlay);
1374
1375        assert_eq!(base.guardrails.enabled, Some(true));
1376        assert_eq!(
1377            base.guardrails.profile,
1378            Some(crate::guardrails::GuardrailProfile::Rust)
1379        );
1380        assert_eq!(base.guardrails.critical_paths, Some(vec!["src/**".into()]));
1381        assert_eq!(
1382            base.guardrails.level,
1383            Some(crate::guardrails::GuardrailLevel::Enforce)
1384        );
1385        assert_eq!(base.guardrails.after_write, Some(vec!["cargo test".into()]));
1386    }
1387
1388    #[test]
1389    fn config_resolve_user_then_project() {
1390        // Clean env to avoid interference from parallel tests
1391        std::env::remove_var("IMP_MODEL");
1392        std::env::remove_var("IMP_THINKING");
1393
1394        let dir = TempDir::new().unwrap();
1395        let user_dir = dir.path().join("user");
1396        let project_dir = dir.path().join("project");
1397        fs::create_dir_all(&user_dir).unwrap();
1398        fs::create_dir_all(project_dir.join(".imp")).unwrap();
1399
1400        // User config: model=haiku, max_turns=20, custom context
1401        fs::write(
1402            user_dir.join("config.toml"),
1403            r#"
1404model = "haiku"
1405max_turns = 20
1406
1407[context]
1408observation_mask_threshold = 0.55
1409mask_window = 9
1410
1411[context.auto_compaction]
1412mode = "disabled"
1413"#,
1414        )
1415        .unwrap();
1416
1417        // Project config: model=sonnet (overrides user), custom context overrides user context
1418        fs::write(
1419            project_dir.join(".imp").join("config.toml"),
1420            r#"
1421model = "sonnet"
1422
1423[context]
1424observation_mask_threshold = 0.5
1425mask_window = 5
1426
1427[context.auto_compaction]
1428mode = "disabled"
1429"#,
1430        )
1431        .unwrap();
1432
1433        let config = Config::resolve(&user_dir, Some(&project_dir)).unwrap();
1434        assert_eq!(config.model.as_deref(), Some("sonnet"));
1435        assert_eq!(config.max_turns, Some(20));
1436        assert!((config.context.observation_mask_threshold - 0.5).abs() < f64::EPSILON);
1437        assert_eq!(config.context.mask_window, 5);
1438    }
1439
1440    #[test]
1441    fn config_resolve_env_overrides() {
1442        // Test env override logic without relying on process-global state
1443        // (env vars are inherently racy in parallel tests).
1444        // We test that the override *mechanism* works by simulating it.
1445        let mut config = Config {
1446            model: Some("haiku".into()),
1447            thinking: Some(ThinkingLevel::Low),
1448            max_tokens: Some(2048),
1449            ..Default::default()
1450        };
1451
1452        // Simulate IMP_MODEL override
1453        let env_model = "opus";
1454        config.model = Some(env_model.into());
1455
1456        // Simulate IMP_THINKING override
1457        let env_thinking = "high";
1458        config.thinking = parse_thinking_level(env_thinking);
1459
1460        // Simulate IMP_MAX_TOKENS override
1461        let env_max_tokens = "1024";
1462        config.max_tokens = env_max_tokens.parse::<u32>().ok();
1463
1464        assert_eq!(config.model.as_deref(), Some("opus"));
1465        assert_eq!(config.thinking, Some(ThinkingLevel::High));
1466        assert_eq!(config.max_tokens, Some(1024));
1467    }
1468
1469    #[test]
1470    fn config_resolve_missing_files_uses_defaults() {
1471        let dir = TempDir::new().unwrap();
1472        let config = Config::resolve(dir.path(), None).unwrap();
1473        assert!(config.model.is_none());
1474        assert!(config.thinking.is_none());
1475        assert!(config.max_tokens.is_none());
1476        assert!(config.max_turns.is_none());
1477    }
1478
1479    #[test]
1480    fn config_load_with_roles_and_hooks() {
1481        let dir = TempDir::new().unwrap();
1482        let config_path = dir.path().join("config.toml");
1483        fs::write(
1484            &config_path,
1485            r#"
1486model = "sonnet"
1487
1488[roles.coder]
1489model = "opus"
1490thinking = "high"
1491readonly = false
1492
1493[roles.reader]
1494readonly = true
1495
1496[[hooks]]
1497event = "after_file_write"
1498action = "log"
1499blocking = false
1500"#,
1501        )
1502        .unwrap();
1503
1504        let config = Config::load(&config_path).unwrap();
1505        assert_eq!(config.roles.len(), 2);
1506        assert!(config.roles.contains_key("coder"));
1507        assert!(config.roles.contains_key("reader"));
1508        assert_eq!(config.roles["coder"].model.as_deref(), Some("opus"));
1509        assert!(config.roles["reader"].readonly);
1510        assert_eq!(config.hooks.len(), 1);
1511        assert_eq!(config.hooks[0].event, "after_file_write");
1512    }
1513
1514    #[test]
1515    fn config_parse_thinking_levels() {
1516        assert_eq!(parse_thinking_level("off"), Some(ThinkingLevel::Off));
1517        assert_eq!(
1518            parse_thinking_level("minimal"),
1519            Some(ThinkingLevel::Minimal)
1520        );
1521        assert_eq!(parse_thinking_level("low"), Some(ThinkingLevel::Low));
1522        assert_eq!(parse_thinking_level("medium"), Some(ThinkingLevel::Medium));
1523        assert_eq!(parse_thinking_level("high"), Some(ThinkingLevel::High));
1524        assert_eq!(parse_thinking_level("xhigh"), Some(ThinkingLevel::XHigh));
1525        assert_eq!(parse_thinking_level("OFF"), Some(ThinkingLevel::Off));
1526        assert_eq!(parse_thinking_level("High"), Some(ThinkingLevel::High));
1527        assert_eq!(parse_thinking_level("invalid"), None);
1528        assert_eq!(parse_thinking_level(""), None);
1529    }
1530
1531    #[test]
1532    fn config_partial_toml_fills_defaults() {
1533        let dir = TempDir::new().unwrap();
1534        let config_path = dir.path().join("config.toml");
1535        fs::write(
1536            &config_path,
1537            r#"
1538model = "sonnet"
1539"#,
1540        )
1541        .unwrap();
1542
1543        let config = Config::load(&config_path).unwrap();
1544        assert_eq!(config.model.as_deref(), Some("sonnet"));
1545        // Unspecified fields use defaults
1546        assert!(config.thinking.is_none());
1547        assert!(config.max_tokens.is_none());
1548        assert!(config.max_turns.is_none());
1549        assert!((config.context.observation_mask_threshold - 0.6).abs() < f64::EPSILON);
1550    }
1551
1552    // --- AgentMode tests ---
1553
1554    #[test]
1555    fn agent_mode_default_is_full() {
1556        let config = Config::default();
1557        assert_eq!(config.mode, AgentMode::Full);
1558        assert_eq!(AgentMode::default(), AgentMode::Full);
1559    }
1560
1561    #[test]
1562    fn lua_config_resolves_capability_policy() {
1563        let config = LuaConfig {
1564            allow_native_tool_calls: Some(false),
1565            allow_shell_exec: Some(true),
1566            allow_http: Some(true),
1567            allow_secrets: Some(true),
1568            allowed_env: Some(vec!["OPENAI_API_KEY".to_string(), "HOME".to_string()]),
1569        };
1570
1571        let policy = config.resolve_policy(AgentMode::Worker);
1572        assert!(!policy.allow_native_tool_calls);
1573        assert!(policy.allow_shell_exec);
1574        assert!(policy.allow_http);
1575        assert!(policy.allow_secrets);
1576        assert!(policy.allowed_env.contains("OPENAI_API_KEY"));
1577        assert!(policy.allowed_env.contains("HOME"));
1578    }
1579
1580    #[test]
1581    fn worker_lua_policy_preserves_configured_secret_access() {
1582        let enabled = LuaConfig {
1583            allow_secrets: Some(true),
1584            ..Default::default()
1585        };
1586        assert!(enabled.resolve_policy(AgentMode::Worker).allow_secrets);
1587
1588        let disabled = LuaConfig {
1589            allow_secrets: Some(false),
1590            ..Default::default()
1591        };
1592        assert!(!disabled.resolve_policy(AgentMode::Worker).allow_secrets);
1593
1594        assert!(
1595            !LuaConfig::default()
1596                .resolve_policy(AgentMode::Worker)
1597                .allow_secrets
1598        );
1599    }
1600
1601    #[test]
1602    fn agent_mode_full_allows_all_tools() {
1603        let mode = AgentMode::Full;
1604        assert!(mode.allows_tool("anything"));
1605        assert!(mode.allows_tool("read"));
1606        assert!(mode.allows_tool("bash"));
1607        assert!(mode.allows_tool("nonexistent_future_tool"));
1608        assert_eq!(mode.allowed_tool_names(), &[] as &[&str]);
1609    }
1610
1611    #[test]
1612    fn agent_mode_orchestrator_allows_read() {
1613        let mode = AgentMode::Orchestrator;
1614        assert!(mode.allows_tool("read"));
1615        assert!(mode.allows_tool("scan"));
1616        assert!(mode.allows_tool("web"));
1617        assert!(mode.allows_tool("git"));
1618        assert!(mode.allows_tool("recall"));
1619        assert!(mode.allows_tool("mana"));
1620        assert!(mode.allows_tool("ask_user"));
1621    }
1622
1623    #[test]
1624    fn agent_mode_orchestrator_blocks_write() {
1625        let mode = AgentMode::Orchestrator;
1626        assert!(!mode.allows_tool("write"));
1627        assert!(!mode.allows_tool("edit"));
1628        assert!(!mode.allows_tool("bash"));
1629    }
1630
1631    #[test]
1632    fn non_full_modes_block_removed_ask_agent() {
1633        for mode in [
1634            AgentMode::Worker,
1635            AgentMode::Orchestrator,
1636            AgentMode::Planner,
1637            AgentMode::Reviewer,
1638            AgentMode::Auditor,
1639        ] {
1640            assert!(
1641                !mode.allows_tool("ask_agent"),
1642                "mode {mode:?} should block removed ask_agent"
1643            );
1644        }
1645    }
1646
1647    #[test]
1648    fn agent_mode_planner_allows_mana_create() {
1649        let mode = AgentMode::Planner;
1650        assert!(mode.allows_mana_action("create"));
1651        assert!(mode.allows_mana_action("status"));
1652        assert!(mode.allows_mana_action("list"));
1653        assert!(mode.allows_mana_action("show"));
1654        assert!(mode.allows_tool("git"));
1655    }
1656
1657    #[test]
1658    fn agent_mode_planner_blocks_mana_close_and_run() {
1659        let mode = AgentMode::Planner;
1660        assert!(!mode.allows_mana_action("close"));
1661        assert!(!mode.allows_mana_action("run"));
1662        assert!(mode.allows_mana_action("update"));
1663        assert!(mode.allows_tool("git"));
1664    }
1665
1666    #[test]
1667    fn agent_mode_worker_blocks_mana_create() {
1668        let mode = AgentMode::Worker;
1669        assert!(!mode.allows_mana_action("create"));
1670        assert!(!mode.allows_mana_action("run"));
1671        assert!(!mode.allows_mana_action("close"));
1672        assert!(mode.allows_tool("git"));
1673    }
1674
1675    #[test]
1676    fn agent_mode_worker_allows_mana_update() {
1677        let mode = AgentMode::Worker;
1678        assert!(mode.allows_mana_action("update"));
1679        assert!(mode.allows_mana_action("show"));
1680        assert!(mode.allows_mana_action("status"));
1681        assert!(mode.allows_mana_action("list"));
1682    }
1683
1684    #[test]
1685    fn agent_mode_reviewer_no_mana() {
1686        let mode = AgentMode::Reviewer;
1687        assert!(!mode.allows_mana_action("status"));
1688        assert!(!mode.allows_mana_action("list"));
1689        assert!(!mode.allows_mana_action("show"));
1690        assert!(!mode.allows_mana_action("create"));
1691        assert!(!mode.allows_mana_action("run"));
1692        // Reviewer also has no mana tool access
1693        assert!(!mode.allows_tool("mana"));
1694        assert!(mode.allows_tool("git"));
1695    }
1696
1697    #[test]
1698    fn agent_mode_auditor_mana_readonly() {
1699        let mode = AgentMode::Auditor;
1700        assert!(mode.allows_mana_action("status"));
1701        assert!(mode.allows_mana_action("list"));
1702        assert!(mode.allows_mana_action("show"));
1703        assert!(!mode.allows_mana_action("create"));
1704        assert!(!mode.allows_mana_action("close"));
1705        assert!(!mode.allows_mana_action("run"));
1706        assert!(!mode.allows_mana_action("update"));
1707        assert!(mode.allows_tool("git"));
1708    }
1709
1710    #[test]
1711    fn agent_mode_config_deserialize() {
1712        let dir = TempDir::new().unwrap();
1713        let config_path = dir.path().join("config.toml");
1714        fs::write(&config_path, r#"mode = "orchestrator""#).unwrap();
1715        let config = Config::load(&config_path).unwrap();
1716        assert_eq!(config.mode, AgentMode::Orchestrator);
1717    }
1718
1719    #[test]
1720    fn agent_mode_instructions() {
1721        assert!(AgentMode::Full.instructions().is_none());
1722        assert!(AgentMode::Worker.instructions().is_some());
1723        assert!(AgentMode::Orchestrator.instructions().is_some());
1724        assert!(AgentMode::Planner.instructions().is_some());
1725        assert!(AgentMode::Reviewer.instructions().is_some());
1726        assert!(AgentMode::Auditor.instructions().is_some());
1727
1728        // Spot-check content is mode-specific
1729        let worker = AgentMode::Worker.instructions().unwrap();
1730        assert!(worker.contains("worker"));
1731        assert!(worker.contains("implement the assigned unit as specified"));
1732        assert!(
1733            worker.contains("final verification and closure belong to the orchestrator workflow")
1734        );
1735
1736        let orchestrator = AgentMode::Orchestrator.instructions().unwrap();
1737        assert!(orchestrator.contains("orchestrator agent"));
1738        assert!(orchestrator.contains("primary execution substrate"));
1739        assert!(orchestrator.contains("final verification, retry, and closure workflow"));
1740
1741        let reviewer = AgentMode::Reviewer.instructions().unwrap();
1742        assert!(reviewer.contains("reviewer") || reviewer.contains("read"));
1743    }
1744}