Skip to main content

quavil_config/
lib.rs

1use std::collections::HashMap;
2use std::path::PathBuf;
3
4use anyhow::{Context, Result};
5use serde::{Deserialize, Serialize};
6
7#[derive(Debug, Clone, Default, Serialize, Deserialize)]
8pub struct Config {
9    #[serde(default)]
10    pub provider: ProviderConfig,
11    #[serde(default)]
12    pub models: ModelsConfig,
13    #[serde(default)]
14    pub tui: TuiConfig,
15    #[serde(default)]
16    pub agent: AgentSettings,
17    #[serde(default)]
18    pub mcp: McpConfig,
19    #[serde(default)]
20    pub external_notify: ExternalNotifyConfig,
21    #[serde(default)]
22    pub shell: ShellConfig,
23    #[serde(default)]
24    pub browser: BrowserConfig,
25    #[serde(default)]
26    pub memory: MemoryConfig,
27    #[serde(default)]
28    pub update: UpdateConfig,
29    #[serde(default)]
30    pub index: IndexConfig,
31}
32
33#[derive(Debug, Clone, Default, Serialize, Deserialize)]
34pub struct ShellConfig {
35    #[serde(default)]
36    pub path: Option<String>,
37    #[serde(default)]
38    pub env: HashMap<String, String>,
39    #[serde(default)]
40    pub startup_commands: Vec<String>,
41    #[serde(default)]
42    pub sandbox: SandboxSettings,
43}
44
45#[derive(Debug, Clone, Default, Serialize, Deserialize)]
46pub struct SandboxSettings {
47    #[serde(default)]
48    pub enabled: bool,
49    #[serde(default)]
50    pub allow_network: Vec<String>,
51    #[serde(default)]
52    pub allow_read: Vec<String>,
53    #[serde(default)]
54    pub allow_write: Vec<String>,
55    #[serde(default = "default_true")]
56    pub block_dotfiles: bool,
57}
58
59#[derive(Debug, Clone, Default, Serialize, Deserialize)]
60pub struct BrowserConfig {
61    #[serde(default)]
62    pub enabled: bool,
63    #[serde(default)]
64    pub executable_path: Option<String>,
65    #[serde(default = "default_true")]
66    pub headless: bool,
67}
68
69#[derive(Debug, Clone, Serialize, Deserialize)]
70pub struct MemoryConfig {
71    #[serde(default = "default_true")]
72    pub auto_memory: bool,
73}
74
75impl Default for MemoryConfig {
76    fn default() -> Self {
77        Self { auto_memory: true }
78    }
79}
80
81fn default_embedding_mode() -> String {
82    "auto".to_string()
83}
84
85fn default_embedding_model() -> String {
86    String::new()
87}
88
89fn default_auto_context_chunks() -> usize {
90    5
91}
92
93#[derive(Debug, Clone, Serialize, Deserialize)]
94pub struct IndexConfig {
95    #[serde(default = "default_true")]
96    pub enabled: bool,
97    /// "auto" | "voyage" | "openai" | "perplexity" | "tfidf"
98    #[serde(default = "default_embedding_mode")]
99    pub embedding: String,
100    /// Override model id (e.g. "voyage-code-3", "text-embedding-3-large").
101    /// Empty = use provider default.
102    #[serde(default = "default_embedding_model")]
103    pub embedding_model: String,
104    #[serde(default = "default_true")]
105    pub auto_context: bool,
106    #[serde(default = "default_auto_context_chunks")]
107    pub auto_context_chunks: usize,
108    #[serde(default)]
109    pub exclude: Vec<String>,
110}
111
112impl Default for IndexConfig {
113    fn default() -> Self {
114        Self {
115            enabled: true,
116            embedding: default_embedding_mode(),
117            embedding_model: default_embedding_model(),
118            auto_context: true,
119            auto_context_chunks: default_auto_context_chunks(),
120            exclude: vec![],
121        }
122    }
123}
124
125fn default_check_interval_hours() -> u32 {
126    4
127}
128
129fn default_release_url() -> String {
130    "https://get.quavil.com".to_string()
131}
132
133#[derive(Debug, Clone, Serialize, Deserialize)]
134pub struct UpdateConfig {
135    #[serde(default = "default_true")]
136    pub enabled: bool,
137    #[serde(default = "default_check_interval_hours")]
138    pub check_interval_hours: u32,
139    #[serde(default = "default_release_url")]
140    pub release_url: String,
141}
142
143impl Default for UpdateConfig {
144    fn default() -> Self {
145        Self {
146            enabled: true,
147            check_interval_hours: default_check_interval_hours(),
148            release_url: default_release_url(),
149        }
150    }
151}
152
153#[derive(Debug, Clone, Default, Serialize, Deserialize)]
154pub struct ExternalNotifyConfig {
155    #[serde(default)]
156    pub webhook_url: Option<String>,
157    #[serde(default)]
158    pub telegram_bot_token: Option<String>,
159    #[serde(default)]
160    pub telegram_chat_id: Option<String>,
161    #[serde(default)]
162    pub discord_webhook_url: Option<String>,
163    #[serde(default)]
164    pub slack_webhook_url: Option<String>,
165}
166
167#[derive(Debug, Clone, Default, Serialize, Deserialize)]
168pub struct McpConfig {
169    #[serde(default)]
170    pub servers: HashMap<String, McpServerConfig>,
171}
172
173#[derive(Debug, Clone, Serialize, Deserialize)]
174#[serde(untagged)]
175pub enum McpServerConfig {
176    Stdio {
177        command: String,
178        #[serde(default)]
179        args: Vec<String>,
180        #[serde(default)]
181        env: HashMap<String, String>,
182    },
183    Http {
184        url: String,
185        #[serde(default)]
186        headers: HashMap<String, String>,
187    },
188}
189
190#[derive(Debug, Clone, Default, Serialize, Deserialize)]
191pub struct AgentSettings {
192    #[serde(default)]
193    pub max_steps: Option<u32>,
194    #[serde(default)]
195    pub max_tokens: Option<u32>,
196    #[serde(default)]
197    pub custom_instructions: Option<String>,
198    #[serde(default)]
199    pub trust: TrustConfig,
200    #[serde(default)]
201    pub retry: RetrySettings,
202    #[serde(default)]
203    pub hooks: Vec<HookConfig>,
204    #[serde(default)]
205    pub commands: Vec<CommandConfig>,
206    #[serde(default)]
207    pub routing: RoutingConfig,
208    #[serde(default)]
209    pub auto_compact_threshold: Option<f64>,
210    #[serde(default)]
211    pub compact_instructions: Option<String>,
212    #[serde(default)]
213    pub enforce_todos: bool,
214    #[serde(default)]
215    pub auto_simplify: bool,
216    #[serde(default)]
217    pub verify: VerifyConfig,
218    #[serde(default)]
219    pub agents: AgentManagerConfig,
220    #[serde(default)]
221    pub auto_commit: bool,
222    #[serde(default)]
223    pub model_profile: Option<String>,
224    /// Model to use for subagent/exploration tasks (cost optimization).
225    #[serde(default)]
226    pub subagent_model: Option<String>,
227    /// Sharing settings for /share command.
228    #[serde(default)]
229    pub sharing: SharingConfig,
230    /// Voice input settings.
231    #[serde(default)]
232    pub voice: VoiceConfig,
233}
234
235#[derive(Debug, Clone, Default, Serialize, Deserialize)]
236pub struct SharingConfig {
237    #[serde(default)]
238    pub enabled: bool,
239    #[serde(default)]
240    pub pages_project: Option<String>,
241    #[serde(default)]
242    pub domain: Option<String>,
243    #[serde(default)]
244    pub redact_patterns: Vec<String>,
245}
246
247#[derive(Debug, Clone, Default, Serialize, Deserialize)]
248pub struct VoiceConfig {
249    #[serde(default)]
250    pub enabled: bool,
251    #[serde(default)]
252    pub api_key_env: Option<String>,
253    #[serde(default)]
254    pub model: Option<String>,
255}
256
257fn default_max_agents() -> usize {
258    4
259}
260
261fn default_max_agent_depth() -> u32 {
262    2
263}
264
265#[derive(Debug, Clone, Serialize, Deserialize)]
266pub struct AgentManagerConfig {
267    #[serde(default = "default_max_agents")]
268    pub max_threads: usize,
269    #[serde(default = "default_max_agent_depth")]
270    pub max_depth: u32,
271    #[serde(default)]
272    pub roles: HashMap<String, AgentRoleToml>,
273}
274
275impl Default for AgentManagerConfig {
276    fn default() -> Self {
277        Self {
278            max_threads: default_max_agents(),
279            max_depth: default_max_agent_depth(),
280            roles: HashMap::new(),
281        }
282    }
283}
284
285#[derive(Debug, Clone, Default, Serialize, Deserialize)]
286pub struct AgentRoleToml {
287    #[serde(default)]
288    pub description: Option<String>,
289    #[serde(default)]
290    pub config_file: Option<String>,
291    #[serde(default)]
292    pub system_prompt: Option<String>,
293    #[serde(default)]
294    pub model: Option<String>,
295    #[serde(default)]
296    pub max_steps: Option<u32>,
297    #[serde(default)]
298    pub read_only: Option<bool>,
299    #[serde(default)]
300    pub allowed_tools: Option<Vec<String>>,
301    #[serde(default)]
302    pub disallowed_tools: Option<Vec<String>>,
303}
304
305#[derive(Debug, Clone, Default, Serialize, Deserialize)]
306pub struct VerifyConfig {
307    #[serde(default)]
308    pub checks: Vec<VerifyCheckConfig>,
309}
310
311#[derive(Debug, Clone, Serialize, Deserialize)]
312pub struct VerifyCheckConfig {
313    pub kind: String,
314    pub command: String,
315}
316
317#[derive(Debug, Clone, Default, Serialize, Deserialize)]
318pub struct RoutingConfig {
319    #[serde(default)]
320    pub enabled: bool,
321    #[serde(default)]
322    pub low_keywords: Vec<String>,
323    #[serde(default)]
324    pub high_keywords: Vec<String>,
325}
326
327#[derive(Debug, Clone, Serialize, Deserialize)]
328pub struct CommandConfig {
329    pub name: String,
330    pub prompt: String,
331    #[serde(default)]
332    pub description: Option<String>,
333}
334
335fn default_hook_timeout() -> u64 {
336    30
337}
338
339#[derive(Debug, Clone, Serialize, Deserialize)]
340pub struct HookConfig {
341    pub event: HookEvent,
342    #[serde(default)]
343    pub command: String,
344    #[serde(default)]
345    pub hook_type: HookType,
346    #[serde(default)]
347    pub prompt: Option<String>,
348    #[serde(default)]
349    pub instructions: Option<String>,
350    #[serde(default)]
351    pub tools: Option<Vec<String>>,
352    #[serde(default)]
353    pub model: Option<String>,
354    #[serde(default)]
355    pub pattern: Option<String>,
356    #[serde(default)]
357    pub tool_name: Option<String>,
358    #[serde(default)]
359    pub block: bool,
360    #[serde(default = "default_hook_timeout")]
361    pub timeout: u64,
362}
363
364#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
365#[serde(rename_all = "snake_case")]
366pub enum HookType {
367    #[default]
368    Command,
369    Prompt,
370    Agent,
371}
372
373#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
374#[serde(rename_all = "snake_case")]
375pub enum HookEvent {
376    SessionStart,
377    UserPromptSubmit,
378    PreToolUse,
379    PostToolUse,
380    PostToolUseFailure,
381    PermissionRequest,
382    Notification,
383    AfterEdit,
384    AfterTurn,
385    SubagentStart,
386    SubagentEnd,
387    CompactContext,
388    WorktreeCreate,
389    WorktreeRemove,
390    ConfigChange,
391    TeammateIdle,
392    TaskCompleted,
393}
394
395impl std::fmt::Display for HookEvent {
396    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
397        match self {
398            HookEvent::SessionStart => write!(f, "session_start"),
399            HookEvent::UserPromptSubmit => write!(f, "user_prompt_submit"),
400            HookEvent::PreToolUse => write!(f, "pre_tool_use"),
401            HookEvent::PostToolUse => write!(f, "post_tool_use"),
402            HookEvent::PostToolUseFailure => write!(f, "post_tool_use_failure"),
403            HookEvent::PermissionRequest => write!(f, "permission_request"),
404            HookEvent::Notification => write!(f, "notification"),
405            HookEvent::AfterEdit => write!(f, "after_edit"),
406            HookEvent::AfterTurn => write!(f, "after_turn"),
407            HookEvent::SubagentStart => write!(f, "subagent_start"),
408            HookEvent::SubagentEnd => write!(f, "subagent_end"),
409            HookEvent::CompactContext => write!(f, "compact_context"),
410            HookEvent::WorktreeCreate => write!(f, "worktree_create"),
411            HookEvent::WorktreeRemove => write!(f, "worktree_remove"),
412            HookEvent::ConfigChange => write!(f, "config_change"),
413            HookEvent::TeammateIdle => write!(f, "teammate_idle"),
414            HookEvent::TaskCompleted => write!(f, "task_completed"),
415        }
416    }
417}
418
419fn default_max_retries() -> u32 {
420    3
421}
422
423fn default_initial_backoff_ms() -> u64 {
424    1000
425}
426
427fn default_max_backoff_ms() -> u64 {
428    30000
429}
430
431#[derive(Debug, Clone, Serialize, Deserialize)]
432pub struct RetrySettings {
433    #[serde(default = "default_max_retries")]
434    pub max_retries: u32,
435    #[serde(default = "default_initial_backoff_ms")]
436    pub initial_backoff_ms: u64,
437    #[serde(default = "default_max_backoff_ms")]
438    pub max_backoff_ms: u64,
439}
440
441impl Default for RetrySettings {
442    fn default() -> Self {
443        Self {
444            max_retries: default_max_retries(),
445            initial_backoff_ms: default_initial_backoff_ms(),
446            max_backoff_ms: default_max_backoff_ms(),
447        }
448    }
449}
450
451#[derive(Debug, Clone, Default, Serialize, Deserialize)]
452pub struct TrustConfig {
453    #[serde(default)]
454    pub mode: TrustMode,
455    #[serde(default)]
456    pub allow_tools: Vec<String>,
457    #[serde(default)]
458    pub allow_paths: Vec<String>,
459    #[serde(default)]
460    pub deny_tools: Vec<String>,
461    #[serde(default)]
462    pub deny_paths: Vec<String>,
463    #[serde(default)]
464    pub auto_approve: Vec<String>,
465    #[serde(default)]
466    pub always_ask: Vec<String>,
467    #[serde(default)]
468    pub remember_approvals: bool,
469}
470
471#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
472#[serde(rename_all = "lowercase")]
473pub enum TrustMode {
474    #[default]
475    Off,
476    Limited,
477    AutoEdit,
478    Full,
479}
480
481#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
482#[serde(rename_all = "kebab-case")]
483pub enum SandboxLevel {
484    ReadOnly,
485    #[default]
486    WorkspaceWrite,
487    FullAccess,
488}
489
490impl std::fmt::Display for SandboxLevel {
491    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
492        match self {
493            SandboxLevel::ReadOnly => write!(f, "read-only"),
494            SandboxLevel::WorkspaceWrite => write!(f, "workspace-write"),
495            SandboxLevel::FullAccess => write!(f, "full-access"),
496        }
497    }
498}
499
500impl std::str::FromStr for SandboxLevel {
501    type Err = String;
502    fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
503        match s.to_lowercase().as_str() {
504            "read-only" | "readonly" => Ok(SandboxLevel::ReadOnly),
505            "workspace-write" | "workspace" => Ok(SandboxLevel::WorkspaceWrite),
506            "full-access" | "full" | "danger-full-access" => Ok(SandboxLevel::FullAccess),
507            other => Err(format!(
508                "unknown sandbox level: {other} (use read-only, workspace-write, full-access)"
509            )),
510        }
511    }
512}
513
514impl std::fmt::Display for TrustMode {
515    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
516        match self {
517            TrustMode::Off => write!(f, "off"),
518            TrustMode::Limited => write!(f, "limited"),
519            TrustMode::AutoEdit => write!(f, "autoedit"),
520            TrustMode::Full => write!(f, "full"),
521        }
522    }
523}
524
525impl std::str::FromStr for TrustMode {
526    type Err = String;
527    fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
528        match s.to_lowercase().as_str() {
529            "off" => Ok(TrustMode::Off),
530            "limited" => Ok(TrustMode::Limited),
531            "autoedit" | "auto_edit" | "auto-edit" => Ok(TrustMode::AutoEdit),
532            "full" => Ok(TrustMode::Full),
533            other => Err(format!(
534                "unknown trust mode: {other} (use off, limited, autoedit, or full)"
535            )),
536        }
537    }
538}
539
540#[derive(Debug, Clone)]
541pub struct ProviderDef {
542    pub id: &'static str,
543    pub name: &'static str,
544    pub env_var: &'static str,
545    pub default_base_url: &'static str,
546    pub api_style: &'static str,
547    pub category: &'static str,
548    pub supports_oauth: bool,
549}
550
551pub const BUILT_IN_PROVIDERS: &[ProviderDef] = &[
552    ProviderDef {
553        id: "openai",
554        name: "OpenAI",
555        env_var: "OPENAI_API_KEY",
556        default_base_url: "https://api.openai.com/v1",
557        api_style: "openai",
558        category: "popular",
559        supports_oauth: true,
560    },
561    ProviderDef {
562        id: "anthropic",
563        name: "Anthropic",
564        env_var: "ANTHROPIC_API_KEY",
565        default_base_url: "https://api.anthropic.com/v1",
566        api_style: "anthropic",
567        category: "popular",
568        supports_oauth: true,
569    },
570    ProviderDef {
571        id: "gemini",
572        name: "Google Gemini",
573        env_var: "GEMINI_API_KEY",
574        default_base_url: "https://generativelanguage.googleapis.com/v1beta",
575        api_style: "gemini",
576        category: "popular",
577        supports_oauth: true,
578    },
579    ProviderDef {
580        id: "cursor",
581        name: "Cursor",
582        env_var: "CURSOR_API_KEY",
583        default_base_url: "https://api2.cursor.sh",
584        api_style: "cursor",
585        category: "popular",
586        supports_oauth: true,
587    },
588    ProviderDef {
589        id: "github-copilot",
590        name: "GitHub Copilot",
591        env_var: "GITHUB_COPILOT_TOKEN",
592        default_base_url: "https://api.githubcopilot.com",
593        api_style: "copilot",
594        category: "popular",
595        supports_oauth: true,
596    },
597    ProviderDef {
598        id: "openrouter",
599        name: "OpenRouter",
600        env_var: "OPENROUTER_API_KEY",
601        default_base_url: "https://openrouter.ai/api/v1",
602        api_style: "openai",
603        category: "popular",
604        supports_oauth: false,
605    },
606    ProviderDef {
607        id: "claude-sdk",
608        name: "Claude SDK Preset",
609        env_var: "ANTHROPIC_API_KEY",
610        default_base_url: "",
611        api_style: "claude-sdk",
612        category: "agents",
613        supports_oauth: false,
614    },
615    ProviderDef {
616        id: "codex",
617        name: "OpenAI Codex CLI",
618        env_var: "CODEX_API_KEY",
619        default_base_url: "",
620        api_style: "codex",
621        category: "agents",
622        supports_oauth: true,
623    },
624    ProviderDef {
625        id: "groq",
626        name: "Groq",
627        env_var: "GROQ_API_KEY",
628        default_base_url: "https://api.groq.com/openai/v1",
629        api_style: "openai",
630        category: "other",
631        supports_oauth: false,
632    },
633    ProviderDef {
634        id: "together",
635        name: "Together AI",
636        env_var: "TOGETHER_API_KEY",
637        default_base_url: "https://api.together.xyz/v1",
638        api_style: "openai",
639        category: "other",
640        supports_oauth: false,
641    },
642    ProviderDef {
643        id: "deepseek",
644        name: "DeepSeek",
645        env_var: "DEEPSEEK_API_KEY",
646        default_base_url: "https://api.deepseek.com/v1",
647        api_style: "openai",
648        category: "other",
649        supports_oauth: false,
650    },
651    ProviderDef {
652        id: "ollama",
653        name: "Ollama (local)",
654        env_var: "OLLAMA_API_KEY",
655        default_base_url: "http://localhost:11434/v1",
656        api_style: "openai",
657        category: "other",
658        supports_oauth: false,
659    },
660    ProviderDef {
661        id: "kimi",
662        name: "Kimi (Moonshot)",
663        env_var: "MOONSHOT_API_KEY",
664        default_base_url: "https://api.moonshot.ai/v1",
665        api_style: "openai",
666        category: "other",
667        supports_oauth: false,
668    },
669    ProviderDef {
670        id: "kimi-coding",
671        name: "Kimi Coding Plan",
672        env_var: "KIMI_CODING_API_KEY",
673        default_base_url: "https://api.kimi.com/coding",
674        api_style: "anthropic",
675        category: "other",
676        supports_oauth: false,
677    },
678    ProviderDef {
679        id: "minimax",
680        name: "MiniMax",
681        env_var: "MINIMAX_API_KEY",
682        default_base_url: "https://api.minimax.io/v1",
683        api_style: "openai",
684        category: "other",
685        supports_oauth: false,
686    },
687    ProviderDef {
688        id: "minimax-coding",
689        name: "MiniMax Coding Plan",
690        env_var: "MINIMAX_CODING_API_KEY",
691        default_base_url: "https://api.minimax.io/anthropic",
692        api_style: "anthropic",
693        category: "other",
694        supports_oauth: false,
695    },
696    ProviderDef {
697        id: "glm",
698        name: "GLM (Z.ai)",
699        env_var: "ZHIPU_API_KEY",
700        default_base_url: "https://api.z.ai/api/paas/v4",
701        api_style: "openai",
702        category: "other",
703        supports_oauth: false,
704    },
705    ProviderDef {
706        id: "glm-coding",
707        name: "GLM Coding Plan",
708        env_var: "ZHIPU_CODING_API_KEY",
709        default_base_url: "https://api.z.ai/api/coding/paas/v4",
710        api_style: "openai",
711        category: "other",
712        supports_oauth: false,
713    },
714];
715
716pub fn find_provider_def(id: &str) -> Option<&'static ProviderDef> {
717    BUILT_IN_PROVIDERS.iter().find(|p| p.id == id)
718}
719
720#[derive(Debug, Clone, Serialize, Deserialize)]
721pub struct ProviderConfig {
722    #[serde(default = "default_provider")]
723    pub default: String,
724    #[serde(default, flatten)]
725    pub providers: HashMap<String, ProviderEntry>,
726}
727
728#[derive(Debug, Clone, Default, Serialize, Deserialize)]
729pub struct ProviderEntry {
730    pub api_key: Option<String>,
731    pub base_url: Option<String>,
732    pub model: Option<String>,
733    pub api_style: Option<String>,
734    pub max_tokens: Option<u32>,
735    pub temperature: Option<f32>,
736}
737
738#[derive(Debug, Clone, Serialize, Deserialize)]
739pub struct ModelsConfig {
740    #[serde(default = "default_max_tokens")]
741    pub max_tokens: u32,
742    #[serde(default)]
743    pub temperature: Option<f32>,
744}
745
746#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
747#[serde(rename_all = "lowercase")]
748pub enum OutputStyle {
749    #[default]
750    Normal,
751    Verbose,
752    Minimal,
753    Structured,
754}
755
756impl std::fmt::Display for OutputStyle {
757    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
758        match self {
759            OutputStyle::Normal => write!(f, "normal"),
760            OutputStyle::Verbose => write!(f, "verbose"),
761            OutputStyle::Minimal => write!(f, "minimal"),
762            OutputStyle::Structured => write!(f, "structured"),
763        }
764    }
765}
766
767#[derive(Debug, Clone, Serialize, Deserialize)]
768pub struct TuiConfig {
769    #[serde(default = "default_true")]
770    pub markdown: bool,
771    #[serde(default = "default_true")]
772    pub streaming: bool,
773    #[serde(default = "default_theme")]
774    pub theme: String,
775    #[serde(default = "default_accent")]
776    pub accent: String,
777    #[serde(default)]
778    pub colors: ThemeOverrides,
779    #[serde(default)]
780    pub notify: NotifyConfig,
781    #[serde(default)]
782    pub output_style: OutputStyle,
783    #[serde(default = "default_true")]
784    pub show_thinking: bool,
785}
786
787#[derive(Debug, Clone, Serialize, Deserialize)]
788pub struct NotifyConfig {
789    #[serde(default = "default_true")]
790    pub bell: bool,
791    #[serde(default)]
792    pub desktop: bool,
793    #[serde(default = "default_min_duration_ms")]
794    pub min_duration_ms: u64,
795}
796
797fn default_min_duration_ms() -> u64 {
798    5000
799}
800
801impl Default for NotifyConfig {
802    fn default() -> Self {
803        Self {
804            bell: true,
805            desktop: false,
806            min_duration_ms: default_min_duration_ms(),
807        }
808    }
809}
810
811#[derive(Debug, Clone, Default, Serialize, Deserialize)]
812pub struct ThemeOverrides {
813    pub bg_page: Option<String>,
814    pub bg_surface: Option<String>,
815    pub bg_elevated: Option<String>,
816    pub bg_sunken: Option<String>,
817    pub text_primary: Option<String>,
818    pub text_secondary: Option<String>,
819    pub text_tertiary: Option<String>,
820    pub text_disabled: Option<String>,
821    pub border_default: Option<String>,
822    pub border_strong: Option<String>,
823    pub accent: Option<String>,
824    pub accent_muted: Option<String>,
825    pub success: Option<String>,
826    pub danger: Option<String>,
827    pub warning: Option<String>,
828    pub info: Option<String>,
829}
830
831fn default_provider() -> String {
832    "openai".to_string()
833}
834
835fn default_max_tokens() -> u32 {
836    4096
837}
838
839fn default_true() -> bool {
840    true
841}
842
843fn default_theme() -> String {
844    "dark".to_string()
845}
846
847fn default_accent() -> String {
848    "quavil-orange".to_string()
849}
850
851impl ProviderConfig {
852    pub fn entry(&self, name: &str) -> Option<&ProviderEntry> {
853        self.providers.get(name)
854    }
855}
856
857impl Default for ProviderConfig {
858    fn default() -> Self {
859        Self {
860            default: default_provider(),
861            providers: HashMap::new(),
862        }
863    }
864}
865
866impl Default for ModelsConfig {
867    fn default() -> Self {
868        Self {
869            max_tokens: default_max_tokens(),
870            temperature: None,
871        }
872    }
873}
874
875impl Default for TuiConfig {
876    fn default() -> Self {
877        Self {
878            markdown: true,
879            streaming: true,
880            theme: default_theme(),
881            accent: default_accent(),
882            colors: ThemeOverrides::default(),
883            notify: NotifyConfig::default(),
884            output_style: OutputStyle::Normal,
885            show_thinking: true,
886        }
887    }
888}
889
890impl Config {
891    pub fn user_root_dir() -> PathBuf {
892        std::env::var_os("QUAVIL_HOME")
893            .map(PathBuf::from)
894            .unwrap_or_else(|| {
895                dirs::home_dir()
896                    .unwrap_or_else(|| PathBuf::from("."))
897                    .join(".quavil")
898            })
899    }
900
901    pub fn load() -> Result<Self> {
902        let path = Self::config_path();
903        if path.exists() {
904            let content = std::fs::read_to_string(&path).context("Failed to read config file")?;
905            toml::from_str(&content).context("Failed to parse config file")
906        } else {
907            Ok(Self::default())
908        }
909    }
910
911    pub fn config_dir() -> PathBuf {
912        Self::user_root_dir()
913    }
914
915    pub fn config_path() -> PathBuf {
916        Self::config_dir().join("config.toml")
917    }
918
919    pub fn data_dir() -> PathBuf {
920        Self::user_root_dir()
921    }
922
923    pub fn ensure_dirs() -> Result<()> {
924        std::fs::create_dir_all(Self::config_dir())?;
925        std::fs::create_dir_all(Self::data_dir())?;
926        Ok(())
927    }
928
929    pub fn save(&self) -> Result<()> {
930        let path = Self::config_path();
931        Self::ensure_dirs()?;
932        let content = toml::to_string_pretty(self).context("Failed to serialize config")?;
933        std::fs::write(&path, content).context("Failed to write config file")?;
934        Ok(())
935    }
936
937    pub fn save_tui_preferences(theme: &str, accent: &str) -> Result<()> {
938        let mut config = Self::load()?;
939        config.tui.theme = theme.to_string();
940        config.tui.accent = accent.to_string();
941        config.save()
942    }
943
944    pub fn save_trust_mode(mode: &TrustMode) -> Result<()> {
945        let mut config = Self::load()?;
946        config.agent.trust.mode = mode.clone();
947        config.save()
948    }
949
950    pub fn save_provider_selection(provider_id: &str, model_id: Option<&str>) -> Result<()> {
951        let mut config = Self::load()?;
952        config.provider.default = provider_id.to_string();
953
954        if let Some(model_id) = model_id.map(str::trim).filter(|value| !value.is_empty()) {
955            config
956                .provider
957                .providers
958                .entry(provider_id.to_string())
959                .or_default()
960                .model = Some(model_id.to_string());
961        }
962
963        config.save()
964    }
965
966    pub fn preferred_model_for_provider(&self, provider_id: &str) -> Option<String> {
967        self.provider
968            .entry(provider_id)
969            .and_then(|entry| entry.model.clone())
970            .map(|value| value.trim().to_string())
971            .filter(|value| !value.is_empty())
972    }
973
974    pub fn load_project(project_root: &std::path::Path) -> Result<Option<Self>> {
975        let path = project_root.join(".quavil").join("config.toml");
976        if path.exists() {
977            let content =
978                std::fs::read_to_string(&path).context("Failed to read project config")?;
979            let config: Config =
980                toml::from_str(&content).context("Failed to parse project config")?;
981            Ok(Some(config))
982        } else {
983            Ok(None)
984        }
985    }
986
987    pub fn load_local(project_root: &std::path::Path) -> Result<Option<Self>> {
988        let path = project_root.join(".quavil").join("config.local.toml");
989        if path.exists() {
990            let content = std::fs::read_to_string(&path).context("Failed to read local config")?;
991            let config: Config =
992                toml::from_str(&content).context("Failed to parse local config")?;
993            Ok(Some(config))
994        } else {
995            Ok(None)
996        }
997    }
998
999    pub fn merge(global: &Config, project: &Config) -> Config {
1000        let provider = {
1001            let mut merged = global.provider.providers.clone();
1002            for (k, proj_entry) in &project.provider.providers {
1003                let base = merged.remove(k).unwrap_or_default();
1004                merged.insert(k.clone(), merge_provider_entry(&base, proj_entry));
1005            }
1006            ProviderConfig {
1007                default: if project.provider.default != default_provider() {
1008                    project.provider.default.clone()
1009                } else {
1010                    global.provider.default.clone()
1011                },
1012                providers: merged,
1013            }
1014        };
1015
1016        let mut mcp_servers = global.mcp.servers.clone();
1017        mcp_servers.extend(project.mcp.servers.clone());
1018
1019        Config {
1020            provider,
1021            models: ModelsConfig {
1022                max_tokens: if project.models.max_tokens != default_max_tokens() {
1023                    project.models.max_tokens
1024                } else {
1025                    global.models.max_tokens
1026                },
1027                temperature: project.models.temperature.or(global.models.temperature),
1028            },
1029            tui: global.tui.clone(),
1030            agent: AgentSettings {
1031                max_steps: project.agent.max_steps.or(global.agent.max_steps),
1032                max_tokens: project.agent.max_tokens.or(global.agent.max_tokens),
1033                custom_instructions: project
1034                    .agent
1035                    .custom_instructions
1036                    .clone()
1037                    .or_else(|| global.agent.custom_instructions.clone()),
1038                trust: {
1039                    let base = if project.agent.trust.mode != TrustMode::Off {
1040                        project.agent.trust.clone()
1041                    } else {
1042                        global.agent.trust.clone()
1043                    };
1044                    let mut deny_tools = global.agent.trust.deny_tools.clone();
1045                    deny_tools.extend(project.agent.trust.deny_tools.clone());
1046                    deny_tools.sort();
1047                    deny_tools.dedup();
1048                    let mut deny_paths = global.agent.trust.deny_paths.clone();
1049                    deny_paths.extend(project.agent.trust.deny_paths.clone());
1050                    deny_paths.sort();
1051                    deny_paths.dedup();
1052                    TrustConfig {
1053                        deny_tools,
1054                        deny_paths,
1055                        ..base
1056                    }
1057                },
1058                retry: RetrySettings {
1059                    max_retries: if project.agent.retry.max_retries != default_max_retries() {
1060                        project.agent.retry.max_retries
1061                    } else {
1062                        global.agent.retry.max_retries
1063                    },
1064                    initial_backoff_ms: if project.agent.retry.initial_backoff_ms
1065                        != default_initial_backoff_ms()
1066                    {
1067                        project.agent.retry.initial_backoff_ms
1068                    } else {
1069                        global.agent.retry.initial_backoff_ms
1070                    },
1071                    max_backoff_ms: if project.agent.retry.max_backoff_ms
1072                        != default_max_backoff_ms()
1073                    {
1074                        project.agent.retry.max_backoff_ms
1075                    } else {
1076                        global.agent.retry.max_backoff_ms
1077                    },
1078                },
1079                hooks: {
1080                    let mut hooks = global.agent.hooks.clone();
1081                    hooks.extend(project.agent.hooks.clone());
1082                    hooks
1083                },
1084                commands: {
1085                    let mut cmds = global.agent.commands.clone();
1086                    cmds.extend(project.agent.commands.clone());
1087                    cmds
1088                },
1089                routing: if project.agent.routing.enabled {
1090                    project.agent.routing.clone()
1091                } else {
1092                    global.agent.routing.clone()
1093                },
1094                auto_compact_threshold: project
1095                    .agent
1096                    .auto_compact_threshold
1097                    .or(global.agent.auto_compact_threshold),
1098                compact_instructions: project
1099                    .agent
1100                    .compact_instructions
1101                    .clone()
1102                    .or(global.agent.compact_instructions.clone()),
1103                enforce_todos: project.agent.enforce_todos || global.agent.enforce_todos,
1104                auto_simplify: project.agent.auto_simplify || global.agent.auto_simplify,
1105                verify: if !project.agent.verify.checks.is_empty() {
1106                    project.agent.verify.clone()
1107                } else {
1108                    global.agent.verify.clone()
1109                },
1110                agents: AgentManagerConfig {
1111                    max_threads: if project.agent.agents.max_threads != default_max_agents() {
1112                        project.agent.agents.max_threads
1113                    } else {
1114                        global.agent.agents.max_threads
1115                    },
1116                    max_depth: if project.agent.agents.max_depth != default_max_agent_depth() {
1117                        project.agent.agents.max_depth
1118                    } else {
1119                        global.agent.agents.max_depth
1120                    },
1121                    roles: {
1122                        let mut roles = global.agent.agents.roles.clone();
1123                        roles.extend(project.agent.agents.roles.clone());
1124                        roles
1125                    },
1126                },
1127                auto_commit: project.agent.auto_commit || global.agent.auto_commit,
1128                model_profile: project
1129                    .agent
1130                    .model_profile
1131                    .clone()
1132                    .or(global.agent.model_profile.clone()),
1133                subagent_model: project
1134                    .agent
1135                    .subagent_model
1136                    .clone()
1137                    .or(global.agent.subagent_model.clone()),
1138                sharing: if project.agent.sharing.enabled {
1139                    project.agent.sharing.clone()
1140                } else {
1141                    global.agent.sharing.clone()
1142                },
1143                voice: if project.agent.voice.enabled {
1144                    project.agent.voice.clone()
1145                } else {
1146                    global.agent.voice.clone()
1147                },
1148            },
1149            mcp: McpConfig {
1150                servers: mcp_servers,
1151            },
1152            external_notify: ExternalNotifyConfig {
1153                webhook_url: project
1154                    .external_notify
1155                    .webhook_url
1156                    .clone()
1157                    .or_else(|| global.external_notify.webhook_url.clone()),
1158                telegram_bot_token: project
1159                    .external_notify
1160                    .telegram_bot_token
1161                    .clone()
1162                    .or_else(|| global.external_notify.telegram_bot_token.clone()),
1163                telegram_chat_id: project
1164                    .external_notify
1165                    .telegram_chat_id
1166                    .clone()
1167                    .or_else(|| global.external_notify.telegram_chat_id.clone()),
1168                discord_webhook_url: project
1169                    .external_notify
1170                    .discord_webhook_url
1171                    .clone()
1172                    .or_else(|| global.external_notify.discord_webhook_url.clone()),
1173                slack_webhook_url: project
1174                    .external_notify
1175                    .slack_webhook_url
1176                    .clone()
1177                    .or_else(|| global.external_notify.slack_webhook_url.clone()),
1178            },
1179            shell: ShellConfig {
1180                path: project
1181                    .shell
1182                    .path
1183                    .clone()
1184                    .or_else(|| global.shell.path.clone()),
1185                env: {
1186                    let mut env = global.shell.env.clone();
1187                    env.extend(project.shell.env.clone());
1188                    env
1189                },
1190                startup_commands: if !project.shell.startup_commands.is_empty() {
1191                    project.shell.startup_commands.clone()
1192                } else {
1193                    global.shell.startup_commands.clone()
1194                },
1195                sandbox: if project.shell.sandbox.enabled {
1196                    project.shell.sandbox.clone()
1197                } else {
1198                    global.shell.sandbox.clone()
1199                },
1200            },
1201            browser: BrowserConfig {
1202                enabled: project.browser.enabled || global.browser.enabled,
1203                executable_path: project
1204                    .browser
1205                    .executable_path
1206                    .clone()
1207                    .or_else(|| global.browser.executable_path.clone()),
1208                headless: project.browser.headless && global.browser.headless,
1209            },
1210            memory: MemoryConfig {
1211                auto_memory: project.memory.auto_memory || global.memory.auto_memory,
1212            },
1213            update: UpdateConfig {
1214                enabled: global.update.enabled && project.update.enabled,
1215                check_interval_hours: if project.update.check_interval_hours
1216                    != default_check_interval_hours()
1217                {
1218                    project.update.check_interval_hours
1219                } else {
1220                    global.update.check_interval_hours
1221                },
1222                // release_url is ONLY settable from global config — never from project config.
1223                // Prevents a malicious repo from redirecting updates to an attacker server.
1224                release_url: global.update.release_url.clone(),
1225            },
1226            index: IndexConfig {
1227                enabled: global.index.enabled && project.index.enabled,
1228                embedding: if project.index.embedding != default_embedding_mode() {
1229                    project.index.embedding.clone()
1230                } else {
1231                    global.index.embedding.clone()
1232                },
1233                embedding_model: if project.index.embedding_model != default_embedding_model() {
1234                    project.index.embedding_model.clone()
1235                } else {
1236                    global.index.embedding_model.clone()
1237                },
1238                auto_context: global.index.auto_context && project.index.auto_context,
1239                auto_context_chunks: if project.index.auto_context_chunks
1240                    != default_auto_context_chunks()
1241                {
1242                    project.index.auto_context_chunks
1243                } else {
1244                    global.index.auto_context_chunks
1245                },
1246                exclude: {
1247                    let mut exc = global.index.exclude.clone();
1248                    exc.extend(project.index.exclude.clone());
1249                    exc.sort();
1250                    exc.dedup();
1251                    exc
1252                },
1253            },
1254        }
1255    }
1256}
1257
1258fn merge_provider_entry(global: &ProviderEntry, project: &ProviderEntry) -> ProviderEntry {
1259    ProviderEntry {
1260        api_key: project.api_key.clone().or_else(|| global.api_key.clone()),
1261        base_url: project.base_url.clone().or_else(|| global.base_url.clone()),
1262        model: project.model.clone().or_else(|| global.model.clone()),
1263        api_style: project
1264            .api_style
1265            .clone()
1266            .or_else(|| global.api_style.clone()),
1267        max_tokens: project.max_tokens.or(global.max_tokens),
1268        temperature: project.temperature.or(global.temperature),
1269    }
1270}
1271
1272#[cfg(test)]
1273mod tests {
1274    use super::*;
1275    use std::path::PathBuf;
1276    use std::sync::{Mutex, OnceLock};
1277
1278    fn env_lock() -> std::sync::MutexGuard<'static, ()> {
1279        static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
1280        LOCK.get_or_init(|| Mutex::new(())).lock().unwrap()
1281    }
1282
1283    fn unique_temp_dir(label: &str) -> PathBuf {
1284        let nanos = std::time::SystemTime::now()
1285            .duration_since(std::time::UNIX_EPOCH)
1286            .unwrap_or_default()
1287            .as_nanos();
1288        let dir = std::env::temp_dir().join(format!(
1289            "quavil-config-{label}-{}-{nanos}",
1290            std::process::id()
1291        ));
1292        std::fs::create_dir_all(&dir).unwrap();
1293        dir
1294    }
1295
1296    struct TestEnv {
1297        root: PathBuf,
1298        old_home: Option<std::ffi::OsString>,
1299        old_xdg_config_home: Option<std::ffi::OsString>,
1300        old_xdg_data_home: Option<std::ffi::OsString>,
1301    }
1302
1303    impl TestEnv {
1304        fn new(label: &str) -> Self {
1305            let root = unique_temp_dir(label);
1306            let home = root.join("home");
1307            let xdg_config = root.join("xdg-config");
1308            let xdg_data = root.join("xdg-data");
1309            std::fs::create_dir_all(&home).unwrap();
1310            std::fs::create_dir_all(&xdg_config).unwrap();
1311            std::fs::create_dir_all(&xdg_data).unwrap();
1312
1313            let old_home = std::env::var_os("HOME");
1314            let old_xdg_config_home = std::env::var_os("XDG_CONFIG_HOME");
1315            let old_xdg_data_home = std::env::var_os("XDG_DATA_HOME");
1316            std::env::set_var("HOME", &home);
1317            std::env::set_var("XDG_CONFIG_HOME", &xdg_config);
1318            std::env::set_var("XDG_DATA_HOME", &xdg_data);
1319
1320            Self {
1321                root,
1322                old_home,
1323                old_xdg_config_home,
1324                old_xdg_data_home,
1325            }
1326        }
1327    }
1328
1329    impl Drop for TestEnv {
1330        fn drop(&mut self) {
1331            match &self.old_home {
1332                Some(value) => std::env::set_var("HOME", value),
1333                None => std::env::remove_var("HOME"),
1334            }
1335            match &self.old_xdg_config_home {
1336                Some(value) => std::env::set_var("XDG_CONFIG_HOME", value),
1337                None => std::env::remove_var("XDG_CONFIG_HOME"),
1338            }
1339            match &self.old_xdg_data_home {
1340                Some(value) => std::env::set_var("XDG_DATA_HOME", value),
1341                None => std::env::remove_var("XDG_DATA_HOME"),
1342            }
1343            let _ = std::fs::remove_dir_all(&self.root);
1344        }
1345    }
1346
1347    #[test]
1348    fn save_provider_selection_updates_default_and_model_without_clobbering_other_settings() {
1349        let _guard = env_lock();
1350        let _env = TestEnv::new("provider-selection");
1351
1352        let mut config = Config::default();
1353        config.tui.theme = "amber".to_string();
1354        config
1355            .provider
1356            .providers
1357            .entry("anthropic".to_string())
1358            .or_default()
1359            .base_url = Some("https://api.anthropic.example".to_string());
1360        config.save().unwrap();
1361
1362        Config::save_provider_selection("anthropic", Some("claude-sonnet-4")).unwrap();
1363
1364        let saved = Config::load().unwrap();
1365        assert_eq!(saved.provider.default, "anthropic");
1366        assert_eq!(
1367            saved.preferred_model_for_provider("anthropic").as_deref(),
1368            Some("claude-sonnet-4")
1369        );
1370        assert_eq!(saved.tui.theme, "amber");
1371        assert_eq!(
1372            saved
1373                .provider
1374                .entry("anthropic")
1375                .and_then(|entry| entry.base_url.as_deref()),
1376            Some("https://api.anthropic.example")
1377        );
1378    }
1379
1380    #[test]
1381    fn config_defaults_to_quavil_orange_accent() {
1382        let config = Config::default();
1383        assert_eq!(config.tui.accent, "quavil-orange");
1384    }
1385}