Skip to main content

vtcode_config/loader/
mod.rs

1#[cfg(feature = "bootstrap")]
2pub mod bootstrap;
3pub mod layers;
4
5use crate::acp::AgentClientProtocolConfig;
6use crate::context::ContextFeaturesConfig;
7use crate::core::{
8    AgentConfig, AnthropicConfig, AutomationConfig, CommandsConfig, ModelConfig, PermissionsConfig,
9    PromptCachingConfig, SandboxConfig, SecurityConfig, SkillsConfig, ToolsConfig,
10};
11use crate::debug::DebugConfig;
12use crate::defaults::{self, ConfigDefaultsProvider, SyntaxHighlightingDefaults};
13use crate::hooks::HooksConfig;
14use crate::mcp::McpClientConfig;
15use crate::optimization::OptimizationConfig;
16use crate::output_styles::OutputStyleConfig;
17use crate::root::{PtyConfig, UiConfig};
18use crate::subagent::SubagentsConfig;
19use crate::telemetry::TelemetryConfig;
20use crate::timeouts::TimeoutsConfig;
21use anyhow::{Context, Result, ensure};
22use serde::{Deserialize, Serialize};
23use std::fs;
24use std::path::{Path, PathBuf};
25
26/// Syntax highlighting configuration
27#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
28#[derive(Debug, Clone, Deserialize, Serialize)]
29pub struct SyntaxHighlightingConfig {
30    /// Enable syntax highlighting for tool output
31    #[serde(default = "defaults::syntax_highlighting::enabled")]
32    pub enabled: bool,
33
34    /// Theme to use for syntax highlighting
35    #[serde(default = "defaults::syntax_highlighting::theme")]
36    pub theme: String,
37
38    /// Enable theme caching for better performance
39    #[serde(default = "defaults::syntax_highlighting::cache_themes")]
40    pub cache_themes: bool,
41
42    /// Maximum file size for syntax highlighting (in MB)
43    #[serde(default = "defaults::syntax_highlighting::max_file_size_mb")]
44    pub max_file_size_mb: usize,
45
46    /// Languages to enable syntax highlighting for
47    #[serde(default = "defaults::syntax_highlighting::enabled_languages")]
48    pub enabled_languages: Vec<String>,
49
50    /// Performance settings - highlight timeout in milliseconds
51    #[serde(default = "defaults::syntax_highlighting::highlight_timeout_ms")]
52    pub highlight_timeout_ms: u64,
53}
54
55impl Default for SyntaxHighlightingConfig {
56    fn default() -> Self {
57        Self {
58            enabled: defaults::syntax_highlighting::enabled(),
59            theme: defaults::syntax_highlighting::theme(),
60            cache_themes: defaults::syntax_highlighting::cache_themes(),
61            max_file_size_mb: defaults::syntax_highlighting::max_file_size_mb(),
62            enabled_languages: defaults::syntax_highlighting::enabled_languages(),
63            highlight_timeout_ms: defaults::syntax_highlighting::highlight_timeout_ms(),
64        }
65    }
66}
67
68impl SyntaxHighlightingConfig {
69    pub fn validate(&self) -> Result<()> {
70        if !self.enabled {
71            return Ok(());
72        }
73
74        ensure!(
75            self.max_file_size_mb >= SyntaxHighlightingDefaults::min_file_size_mb(),
76            "Syntax highlighting max_file_size_mb must be at least {} MB",
77            SyntaxHighlightingDefaults::min_file_size_mb()
78        );
79
80        ensure!(
81            self.highlight_timeout_ms >= SyntaxHighlightingDefaults::min_highlight_timeout_ms(),
82            "Syntax highlighting highlight_timeout_ms must be at least {} ms",
83            SyntaxHighlightingDefaults::min_highlight_timeout_ms()
84        );
85
86        ensure!(
87            !self.theme.trim().is_empty(),
88            "Syntax highlighting theme must not be empty"
89        );
90
91        ensure!(
92            self.enabled_languages
93                .iter()
94                .all(|lang| !lang.trim().is_empty()),
95            "Syntax highlighting languages must not contain empty entries"
96        );
97
98        Ok(())
99    }
100}
101
102/// Provider-specific configuration
103#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
104#[derive(Debug, Clone, Deserialize, Serialize, Default)]
105pub struct ProviderConfig {
106    /// Anthropic provider configuration
107    #[serde(default)]
108    pub anthropic: AnthropicConfig,
109}
110
111/// Main configuration structure for VT Code
112#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
113#[derive(Debug, Clone, Deserialize, Serialize, Default)]
114pub struct VTCodeConfig {
115    /// Agent-wide settings
116    #[serde(default)]
117    pub agent: AgentConfig,
118
119    /// Tool execution policies
120    #[serde(default)]
121    pub tools: ToolsConfig,
122
123    /// Unix command permissions
124    #[serde(default)]
125    pub commands: CommandsConfig,
126
127    /// Permission system settings (resolution, audit logging, caching)
128    #[serde(default)]
129    pub permissions: PermissionsConfig,
130
131    /// Security settings
132    #[serde(default)]
133    pub security: SecurityConfig,
134
135    /// Sandbox settings for command execution isolation
136    #[serde(default)]
137    pub sandbox: SandboxConfig,
138
139    /// UI settings
140    #[serde(default)]
141    pub ui: UiConfig,
142
143    /// PTY settings
144    #[serde(default)]
145    pub pty: PtyConfig,
146
147    /// Debug and tracing settings
148    #[serde(default)]
149    pub debug: DebugConfig,
150
151    /// Context features (e.g., Decision Ledger)
152    #[serde(default)]
153    pub context: ContextFeaturesConfig,
154
155    /// Telemetry configuration (logging, trajectory)
156    #[serde(default)]
157    pub telemetry: TelemetryConfig,
158
159    /// Performance optimization settings
160    #[serde(default)]
161    pub optimization: OptimizationConfig,
162
163    /// Syntax highlighting configuration
164    #[serde(default)]
165    pub syntax_highlighting: SyntaxHighlightingConfig,
166
167    /// Timeout ceilings and UI warning thresholds
168    #[serde(default)]
169    pub timeouts: TimeoutsConfig,
170
171    /// Automation configuration
172    #[serde(default)]
173    pub automation: AutomationConfig,
174
175    /// Prompt cache configuration (local + provider integration)
176    #[serde(default)]
177    pub prompt_cache: PromptCachingConfig,
178
179    /// Model Context Protocol configuration
180    #[serde(default)]
181    pub mcp: McpClientConfig,
182
183    /// Agent Client Protocol configuration
184    #[serde(default)]
185    pub acp: AgentClientProtocolConfig,
186
187    /// Lifecycle hooks configuration
188    #[serde(default)]
189    pub hooks: HooksConfig,
190
191    /// Model-specific behavior configuration
192    #[serde(default)]
193    pub model: ModelConfig,
194
195    /// Provider-specific configuration
196    #[serde(default)]
197    pub provider: ProviderConfig,
198
199    /// Skills system configuration (Agent Skills spec)
200    #[serde(default)]
201    pub skills: SkillsConfig,
202
203    /// Subagent system configuration
204    #[serde(default)]
205    pub subagents: SubagentsConfig,
206
207    /// Output style configuration
208    #[serde(default)]
209    pub output_style: OutputStyleConfig,
210}
211
212impl VTCodeConfig {
213    pub fn validate(&self) -> Result<()> {
214        self.syntax_highlighting
215            .validate()
216            .context("Invalid syntax_highlighting configuration")?;
217
218        self.context
219            .validate()
220            .context("Invalid context configuration")?;
221
222        self.hooks
223            .validate()
224            .context("Invalid hooks configuration")?;
225
226        self.timeouts
227            .validate()
228            .context("Invalid timeouts configuration")?;
229
230        self.prompt_cache
231            .validate()
232            .context("Invalid prompt_cache configuration")?;
233
234        self.ui
235            .keyboard_protocol
236            .validate()
237            .context("Invalid keyboard_protocol configuration")?;
238
239        Ok(())
240    }
241
242    #[cfg(feature = "bootstrap")]
243    /// Bootstrap project with config + gitignore
244    pub fn bootstrap_project<P: AsRef<Path>>(workspace: P, force: bool) -> Result<Vec<String>> {
245        Self::bootstrap_project_with_options(workspace, force, false)
246    }
247
248    #[cfg(feature = "bootstrap")]
249    /// Bootstrap project with config + gitignore, with option to create in home directory
250    pub fn bootstrap_project_with_options<P: AsRef<Path>>(
251        workspace: P,
252        force: bool,
253        use_home_dir: bool,
254    ) -> Result<Vec<String>> {
255        let workspace = workspace.as_ref().to_path_buf();
256        defaults::with_config_defaults(|provider| {
257            Self::bootstrap_project_with_provider(&workspace, force, use_home_dir, provider)
258        })
259    }
260
261    #[cfg(feature = "bootstrap")]
262    /// Bootstrap project files using the supplied [`ConfigDefaultsProvider`].
263    pub fn bootstrap_project_with_provider<P: AsRef<Path>>(
264        workspace: P,
265        force: bool,
266        use_home_dir: bool,
267        defaults_provider: &dyn ConfigDefaultsProvider,
268    ) -> Result<Vec<String>> {
269        let workspace = workspace.as_ref();
270        let config_file_name = defaults_provider.config_file_name().to_string();
271        let (config_path, gitignore_path) = bootstrap::determine_bootstrap_targets(
272            workspace,
273            use_home_dir,
274            &config_file_name,
275            defaults_provider,
276        )?;
277
278        bootstrap::ensure_parent_dir(&config_path)?;
279        bootstrap::ensure_parent_dir(&gitignore_path)?;
280
281        let mut created_files = Vec::new();
282
283        if !config_path.exists() || force {
284            let config_content = Self::default_vtcode_toml_template();
285
286            fs::write(&config_path, config_content).with_context(|| {
287                format!("Failed to write config file: {}", config_path.display())
288            })?;
289
290            if let Some(file_name) = config_path.file_name().and_then(|name| name.to_str()) {
291                created_files.push(file_name.to_string());
292            }
293        }
294
295        if !gitignore_path.exists() || force {
296            let gitignore_content = Self::default_vtcode_gitignore();
297            fs::write(&gitignore_path, gitignore_content).with_context(|| {
298                format!(
299                    "Failed to write gitignore file: {}",
300                    gitignore_path.display()
301                )
302            })?;
303
304            if let Some(file_name) = gitignore_path.file_name().and_then(|name| name.to_str()) {
305                created_files.push(file_name.to_string());
306            }
307        }
308
309        Ok(created_files)
310    }
311
312    #[cfg(feature = "bootstrap")]
313    /// Generate the default `vtcode.toml` template used by bootstrap helpers.
314    fn default_vtcode_toml_template() -> String {
315        r#"# VT Code Configuration File (Example)
316# Getting-started reference; see docs/config/CONFIGURATION_PRECEDENCE.md for override order.
317# Copy this file to vtcode.toml and customize as needed.
318
319# Core agent behavior; see docs/config/CONFIGURATION_PRECEDENCE.md.
320[agent]
321# Primary LLM provider to use (e.g., "openai", "gemini", "anthropic", "openrouter")
322provider = "openai"
323
324# Environment variable containing the API key for the provider
325api_key_env = "OPENAI_API_KEY"
326
327# Default model to use when no specific model is specified
328default_model = "gpt-5-nano"
329
330# Visual theme for the terminal interface
331theme = "ciapre-dark"
332
333# Enable TODO planning helper mode for structured task management
334todo_planning_mode = true
335
336# UI surface to use ("auto", "alternate", "inline")
337ui_surface = "auto"
338
339# Maximum number of conversation turns before rotating context (affects memory usage)
340# Lower values reduce memory footprint but may lose context; higher values preserve context but use more memory
341max_conversation_turns = 50
342
343# Reasoning effort level ("low", "medium", "high") - affects model usage and response speed
344reasoning_effort = "low"
345
346# Enable self-review loop to check and improve responses (increases API calls)
347enable_self_review = false
348
349# Maximum number of review passes when self-review is enabled
350max_review_passes = 1
351
352# Enable prompt refinement loop for improved prompt quality (increases processing time)
353refine_prompts_enabled = false
354
355# Maximum passes for prompt refinement when enabled
356refine_prompts_max_passes = 1
357
358# Optional alternate model for refinement (leave empty to use default)
359refine_prompts_model = ""
360
361# Maximum size of project documentation to include in context (in bytes)
362project_doc_max_bytes = 16384
363
364# Maximum size of instruction files to process (in bytes)
365instruction_max_bytes = 16384
366
367# List of additional instruction files to include in context
368instruction_files = []
369
370# Default editing mode on startup: "edit" or "plan"
371# "edit" - Full tool access for file modifications and command execution (default)
372# "plan" - Read-only mode that produces implementation plans without making changes
373# Toggle during session with Shift+Tab or /plan command
374default_editing_mode = "edit"
375
376# Onboarding configuration - Customize the startup experience
377[agent.onboarding]
378# Enable the onboarding welcome message on startup
379enabled = true
380
381# Custom introduction text shown on startup
382intro_text = "Let's get oriented. I preloaded workspace context so we can move fast."
383
384# Include project overview information in welcome
385include_project_overview = true
386
387# Include language summary information in welcome
388include_language_summary = false
389
390# Include key guideline highlights from AGENTS.md
391include_guideline_highlights = true
392
393# Include usage tips in the welcome message
394include_usage_tips_in_welcome = false
395
396# Include recommended actions in the welcome message
397include_recommended_actions_in_welcome = false
398
399# Maximum number of guideline highlights to show
400guideline_highlight_limit = 3
401
402# List of usage tips shown during onboarding
403usage_tips = [
404    "Describe your current coding goal or ask for a quick status overview.",
405    "Reference AGENTS.md guidelines when proposing changes.",
406    "Prefer asking for targeted file reads or diffs before editing.",
407]
408
409# List of recommended actions shown during onboarding
410recommended_actions = [
411    "Review the highlighted guidelines and share the task you want to tackle.",
412    "Ask for a workspace tour if you need more context.",
413]
414
415# Custom prompts configuration - Define personal assistant commands
416[agent.custom_prompts]
417# Enable the custom prompts feature with /prompt:<name> syntax
418enabled = true
419
420# Directory where custom prompt files are stored
421directory = "~/.vtcode/prompts"
422
423# Additional directories to search for custom prompts
424extra_directories = []
425
426# Maximum file size for custom prompts (in kilobytes)
427max_file_size_kb = 64
428
429# Custom API keys for specific providers
430[agent.custom_api_keys]
431# Moonshot AI API key (for specific provider access)
432moonshot = "sk-sDj3JUXDbfARCYKNL4q7iGWRtWuhL1M4O6zzgtDpN3Yxt9EA"
433
434# Checkpointing configuration for session persistence
435[agent.checkpointing]
436# Enable automatic session checkpointing
437enabled = false
438
439# Maximum number of checkpoints to keep on disk
440max_snapshots = 50
441
442# Maximum age of checkpoints to keep (in days)
443max_age_days = 30
444
445# Subagent system (opt-in)
446[subagents]
447# Enable subagents (default: false)
448enabled = false
449
450# Maximum concurrent subagents
451# max_concurrent = 3
452
453# Default timeout for subagent execution (seconds)
454# default_timeout_seconds = 300
455
456# Default model for subagents (override per-agent model if set)
457# default_model = ""
458
459# Additional directories to search for subagent definitions
460# additional_agent_dirs = []
461
462# Tool security configuration
463[tools]
464# Default policy when no specific policy is defined ("allow", "prompt", "deny")
465# "allow" - Execute without confirmation
466# "prompt" - Ask for confirmation
467# "deny" - Block the tool
468default_policy = "prompt"
469
470# Maximum number of tool loops allowed per turn (prevents infinite loops)
471# Higher values allow more complex operations but risk performance issues
472# Recommended: 20 for most tasks, 50 for complex multi-step workflows
473max_tool_loops = 20
474
475# Maximum number of repeated identical tool calls (prevents stuck loops)
476max_repeated_tool_calls = 2
477
478# Specific tool policies - Override default policy for individual tools
479[tools.policies]
480apply_patch = "prompt"            # Apply code patches (requires confirmation)
481close_pty_session = "allow"        # Close PTY sessions (no confirmation needed)
482create_pty_session = "allow"       # Create PTY sessions (no confirmation needed)
483edit_file = "allow"               # Edit files directly (no confirmation needed)
484grep_file = "allow"               # Sole content-search tool (ripgrep-backed)
485list_files = "allow"              # List directory contents (no confirmation needed)
486list_pty_sessions = "allow"       # List PTY sessions (no confirmation needed)
487read_file = "allow"               # Read files (no confirmation needed)
488read_pty_session = "allow"        # Read PTY session output (no no confirmation needed)
489resize_pty_session = "allow"      # Resize PTY sessions (no confirmation needed)
490run_pty_cmd = "prompt"            # Run commands in PTY (requires confirmation)
491exec_command = "prompt"           # Execute command in unified session (requires confirmation)
492write_stdin = "prompt"            # Write to stdin in unified session (requires confirmation)
493
494send_pty_input = "prompt"         # Send input to PTY (requires confirmation)
495write_file = "allow"              # Write files (no confirmation needed)
496
497# Command security - Define safe and dangerous command patterns
498[commands]
499# Commands that are always allowed without confirmation
500allow_list = [
501    "ls",           # List directory contents
502    "pwd",          # Print working directory
503    "git status",   # Show git status
504    "git diff",     # Show git differences
505    "cargo check",  # Check Rust code
506    "echo",         # Print text
507]
508
509# Commands that are never allowed
510deny_list = [
511    "rm -rf /",        # Delete root directory (dangerous)
512    "rm -rf ~",        # Delete home directory (dangerous)
513    "shutdown",        # Shut down system (dangerous)
514    "reboot",          # Reboot system (dangerous)
515    "sudo *",          # Any sudo command (dangerous)
516    ":(){ :|:& };:",   # Fork bomb (dangerous)
517]
518
519# Command patterns that are allowed (supports glob patterns)
520allow_glob = [
521    "git *",        # All git commands
522    "cargo *",      # All cargo commands
523    "python -m *",  # Python module commands
524]
525
526# Command patterns that are denied (supports glob patterns)
527deny_glob = [
528    "rm *",         # All rm commands
529    "sudo *",       # All sudo commands
530    "chmod *",      # All chmod commands
531    "chown *",      # All chown commands
532    "kubectl *",    # All kubectl commands (admin access)
533]
534
535# Regular expression patterns for allowed commands (if needed)
536allow_regex = []
537
538# Regular expression patterns for denied commands (if needed)
539deny_regex = []
540
541# Security configuration - Safety settings for automated operations
542[security]
543# Require human confirmation for potentially dangerous actions
544human_in_the_loop = true
545
546# Require explicit write tool usage for claims about file modifications
547require_write_tool_for_claims = true
548
549# Auto-apply patches without prompting (DANGEROUS - disable for safety)
550auto_apply_detected_patches = false
551
552# UI configuration - Terminal and display settings
553[ui]
554# Tool output display mode
555# "compact" - Concise tool output
556# "full" - Detailed tool output
557tool_output_mode = "compact"
558
559# Maximum number of lines to display in tool output (prevents transcript flooding)
560# Lines beyond this limit are truncated to a tail preview
561tool_output_max_lines = 600
562
563# Maximum bytes threshold for spooling tool output to disk
564# Output exceeding this size is written to .vtcode/tool-output/*.log
565tool_output_spool_bytes = 200000
566
567# Optional custom directory for spooled tool output logs
568# If not set, defaults to .vtcode/tool-output/
569# tool_output_spool_dir = "/path/to/custom/spool/dir"
570
571# Allow ANSI escape sequences in tool output (enables colors but may cause layout issues)
572allow_tool_ansi = false
573
574# Number of rows to allocate for inline UI viewport
575inline_viewport_rows = 16
576
577# Show timeline navigation panel
578show_timeline_pane = false
579
580# Status line configuration
581[ui.status_line]
582# Status line mode ("auto", "command", "hidden")
583mode = "auto"
584
585# How often to refresh status line (milliseconds)
586refresh_interval_ms = 2000
587
588# Timeout for command execution in status line (milliseconds)
589command_timeout_ms = 200
590
591# PTY (Pseudo Terminal) configuration - For interactive command execution
592[pty]
593# Enable PTY support for interactive commands
594enabled = true
595
596# Default number of terminal rows for PTY sessions
597default_rows = 24
598
599# Default number of terminal columns for PTY sessions
600default_cols = 80
601
602# Maximum number of concurrent PTY sessions
603max_sessions = 10
604
605# Command timeout in seconds (prevents hanging commands)
606command_timeout_seconds = 300
607
608# Number of recent lines to show in PTY output
609stdout_tail_lines = 20
610
611# Total lines to keep in PTY scrollback buffer
612scrollback_lines = 400
613
614# Context management configuration - Controls conversation memory
615[context]
616# Maximum number of tokens to keep in context (affects model cost and performance)
617# Higher values preserve more context but cost more and may hit token limits
618max_context_tokens = 90000
619
620# Percentage to trim context to when it gets too large
621trim_to_percent = 60
622
623# Number of recent conversation turns to always preserve
624preserve_recent_turns = 6
625
626# Decision ledger configuration - Track important decisions
627[context.ledger]
628# Enable decision tracking and persistence
629enabled = true
630
631# Maximum number of decisions to keep in ledger
632max_entries = 12
633
634# Include ledger summary in model prompts
635include_in_prompt = true
636
637# Preserve ledger during context compression
638preserve_in_compression = true
639
640# AI model routing - Intelligent model selection
641# Telemetry and analytics
642[telemetry]
643# Enable trajectory logging for usage analysis
644trajectory_enabled = true
645
646# Syntax highlighting configuration
647[syntax_highlighting]
648# Enable syntax highlighting for code in tool output
649enabled = true
650
651# Theme for syntax highlighting
652theme = "base16-ocean.dark"
653
654# Cache syntax highlighting themes for performance
655cache_themes = true
656
657# Maximum file size for syntax highlighting (in MB)
658max_file_size_mb = 10
659
660# Programming languages to enable syntax highlighting for
661enabled_languages = [
662    "rust",
663    "python",
664    "javascript",
665    "typescript",
666    "go",
667    "java",
668]
669
670# Timeout for syntax highlighting operations (milliseconds)
671highlight_timeout_ms = 1000
672
673# Automation features - Full-auto mode settings
674[automation.full_auto]
675# Enable full automation mode (DANGEROUS - requires careful oversight)
676enabled = false
677
678# Maximum number of turns before asking for human input
679max_turns = 30
680
681# Tools allowed in full automation mode
682allowed_tools = [
683    "write_file",
684    "read_file",
685    "list_files",
686    "grep_file",
687]
688
689# Require profile acknowledgment before using full auto
690require_profile_ack = true
691
692# Path to full auto profile configuration
693profile_path = "automation/full_auto_profile.toml"
694
695# Prompt caching - Cache model responses for efficiency
696[prompt_cache]
697# Enable prompt caching (reduces API calls for repeated prompts)
698enabled = false
699
700# Directory for cache storage
701cache_dir = "~/.vtcode/cache/prompts"
702
703# Maximum number of cache entries to keep
704max_entries = 1000
705
706# Maximum age of cache entries (in days)
707max_age_days = 30
708
709# Enable automatic cache cleanup
710enable_auto_cleanup = true
711
712# Minimum quality threshold to keep cache entries
713min_quality_threshold = 0.7
714
715# Prompt cache configuration for OpenAI
716    [prompt_cache.providers.openai]
717    enabled = true
718    min_prefix_tokens = 1024
719    idle_expiration_seconds = 3600
720    surface_metrics = true
721    # Optional: server-side prompt cache retention for OpenAI Responses API
722    # Example: "24h" (leave commented out for default behavior)
723    # prompt_cache_retention = "24h"
724
725# Prompt cache configuration for Anthropic
726[prompt_cache.providers.anthropic]
727enabled = true
728default_ttl_seconds = 300
729extended_ttl_seconds = 3600
730max_breakpoints = 4
731cache_system_messages = true
732cache_user_messages = true
733
734# Prompt cache configuration for Gemini
735[prompt_cache.providers.gemini]
736enabled = true
737mode = "implicit"
738min_prefix_tokens = 1024
739explicit_ttl_seconds = 3600
740
741# Prompt cache configuration for OpenRouter
742[prompt_cache.providers.openrouter]
743enabled = true
744propagate_provider_capabilities = true
745report_savings = true
746
747# Prompt cache configuration for Moonshot
748[prompt_cache.providers.moonshot]
749enabled = true
750
751# Prompt cache configuration for xAI
752[prompt_cache.providers.xai]
753enabled = true
754
755# Prompt cache configuration for DeepSeek
756[prompt_cache.providers.deepseek]
757enabled = true
758surface_metrics = true
759
760# Prompt cache configuration for Z.AI
761[prompt_cache.providers.zai]
762enabled = false
763
764# Model Context Protocol (MCP) - Connect external tools and services
765[mcp]
766# Enable Model Context Protocol (may impact startup time if services unavailable)
767enabled = true
768max_concurrent_connections = 5
769request_timeout_seconds = 30
770retry_attempts = 3
771
772# MCP UI configuration
773[mcp.ui]
774mode = "compact"
775max_events = 50
776show_provider_names = true
777
778# MCP renderer profiles for different services
779[mcp.ui.renderers]
780sequential-thinking = "sequential-thinking"
781context7 = "context7"
782
783# MCP provider configuration - External services that connect via MCP
784[[mcp.providers]]
785name = "time"
786command = "uvx"
787args = ["mcp-server-time"]
788enabled = true
789max_concurrent_requests = 3
790[mcp.providers.env]
791
792# Agent Client Protocol (ACP) - IDE integration
793[acp]
794enabled = true
795
796[acp.zed]
797enabled = true
798transport = "stdio"
799workspace_trust = "full_auto"
800
801[acp.zed.tools]
802read_file = true
803list_files = true"#.to_string()
804    }
805
806    #[cfg(feature = "bootstrap")]
807    fn default_vtcode_gitignore() -> String {
808        r#"# Security-focused exclusions
809.env, .env.local, secrets/, .aws/, .ssh/
810
811# Development artifacts
812target/, build/, dist/, node_modules/, vendor/
813
814# Database files
815*.db, *.sqlite, *.sqlite3
816
817# Binary files
818*.exe, *.dll, *.so, *.dylib, *.bin
819
820# IDE files (comprehensive)
821.vscode/, .idea/, *.swp, *.swo
822"#
823        .to_string()
824    }
825
826    #[cfg(feature = "bootstrap")]
827    /// Create sample configuration file
828    pub fn create_sample_config<P: AsRef<Path>>(output: P) -> Result<()> {
829        let output = output.as_ref();
830        let config_content = Self::default_vtcode_toml_template();
831
832        fs::write(output, config_content)
833            .with_context(|| format!("Failed to write config file: {}", output.display()))?;
834
835        Ok(())
836    }
837}
838
839use crate::loader::layers::{ConfigLayerEntry, ConfigLayerSource, ConfigLayerStack};
840
841/// Configuration manager for loading and validating configurations
842#[derive(Clone)]
843pub struct ConfigManager {
844    config: VTCodeConfig,
845    config_path: Option<PathBuf>,
846    workspace_root: Option<PathBuf>,
847    config_file_name: String,
848    layer_stack: ConfigLayerStack,
849}
850
851impl ConfigManager {
852    /// Load configuration from the default locations
853    pub fn load() -> Result<Self> {
854        Self::load_from_workspace(std::env::current_dir()?)
855    }
856
857    /// Load configuration from a specific workspace
858    pub fn load_from_workspace(workspace: impl AsRef<Path>) -> Result<Self> {
859        let workspace = workspace.as_ref();
860        let defaults_provider = defaults::current_config_defaults();
861        let workspace_paths = defaults_provider.workspace_paths_for(workspace);
862        let workspace_root = workspace_paths.workspace_root().to_path_buf();
863        let config_dir = workspace_paths.config_dir();
864        let config_file_name = defaults_provider.config_file_name().to_string();
865
866        let mut layer_stack = ConfigLayerStack::default();
867
868        // 1. System config (e.g., /etc/vtcode/vtcode.toml)
869        #[cfg(unix)]
870        {
871            let system_config = PathBuf::from("/etc/vtcode/vtcode.toml");
872            if system_config.exists()
873                && let Ok(toml) = Self::load_toml_from_file(&system_config)
874            {
875                layer_stack.push(ConfigLayerEntry::new(
876                    ConfigLayerSource::System {
877                        file: system_config,
878                    },
879                    toml,
880                ));
881            }
882        }
883
884        // 2. User home config (~/.vtcode/vtcode.toml)
885        for home_config_path in defaults_provider.home_config_paths(&config_file_name) {
886            if home_config_path.exists()
887                && let Ok(toml) = Self::load_toml_from_file(&home_config_path)
888            {
889                layer_stack.push(ConfigLayerEntry::new(
890                    ConfigLayerSource::User {
891                        file: home_config_path,
892                    },
893                    toml,
894                ));
895            }
896        }
897
898        // 2. Project-specific config (.vtcode/projects/<project>/config/vtcode.toml)
899        if let Some(project_config_path) =
900            Self::project_config_path(&config_dir, &workspace_root, &config_file_name)
901            && let Ok(toml) = Self::load_toml_from_file(&project_config_path)
902        {
903            layer_stack.push(ConfigLayerEntry::new(
904                ConfigLayerSource::Project {
905                    file: project_config_path,
906                },
907                toml,
908            ));
909        }
910
911        // 3. Config directory fallback (.vtcode/vtcode.toml)
912        let fallback_path = config_dir.join(&config_file_name);
913        let workspace_config_path = workspace_root.join(&config_file_name);
914        if fallback_path.exists()
915            && fallback_path != workspace_config_path
916            && let Ok(toml) = Self::load_toml_from_file(&fallback_path)
917        {
918            layer_stack.push(ConfigLayerEntry::new(
919                ConfigLayerSource::Workspace {
920                    file: fallback_path,
921                },
922                toml,
923            ));
924        }
925
926        // 4. Workspace config (vtcode.toml in workspace root)
927        if workspace_config_path.exists()
928            && let Ok(toml) = Self::load_toml_from_file(&workspace_config_path)
929        {
930            layer_stack.push(ConfigLayerEntry::new(
931                ConfigLayerSource::Workspace {
932                    file: workspace_config_path.clone(),
933                },
934                toml,
935            ));
936        }
937
938        // If no layers found, use default config
939        if layer_stack.layers().is_empty() {
940            let config = VTCodeConfig::default();
941            config
942                .validate()
943                .context("Default configuration failed validation")?;
944
945            return Ok(Self {
946                config,
947                config_path: None,
948                workspace_root: Some(workspace_root),
949                config_file_name,
950                layer_stack,
951            });
952        }
953
954        let effective_toml = layer_stack.effective_config();
955        let config: VTCodeConfig = effective_toml
956            .try_into()
957            .context("Failed to deserialize effective configuration")?;
958
959        config
960            .validate()
961            .context("Configuration failed validation")?;
962
963        let config_path = layer_stack.layers().last().and_then(|l| match &l.source {
964            ConfigLayerSource::User { file } => Some(file.clone()),
965            ConfigLayerSource::Project { file } => Some(file.clone()),
966            ConfigLayerSource::Workspace { file } => Some(file.clone()),
967            ConfigLayerSource::System { file } => Some(file.clone()),
968            ConfigLayerSource::Runtime => None,
969        });
970
971        Ok(Self {
972            config,
973            config_path,
974            workspace_root: Some(workspace_root),
975            config_file_name,
976            layer_stack,
977        })
978    }
979
980    fn load_toml_from_file(path: &Path) -> Result<toml::Value> {
981        let content = fs::read_to_string(path)
982            .with_context(|| format!("Failed to read config file: {}", path.display()))?;
983        let value: toml::Value = toml::from_str(&content)
984            .with_context(|| format!("Failed to parse config file: {}", path.display()))?;
985        Ok(value)
986    }
987
988    /// Load configuration from a specific file
989    pub fn load_from_file(path: impl AsRef<Path>) -> Result<Self> {
990        let path = path.as_ref();
991        let defaults_provider = defaults::current_config_defaults();
992        let config_file_name = path
993            .file_name()
994            .and_then(|name| name.to_str().map(ToOwned::to_owned))
995            .unwrap_or_else(|| defaults_provider.config_file_name().to_string());
996
997        let mut layer_stack = ConfigLayerStack::default();
998
999        // 1. System config
1000        #[cfg(unix)]
1001        {
1002            let system_config = PathBuf::from("/etc/vtcode/vtcode.toml");
1003            if system_config.exists()
1004                && let Ok(toml) = Self::load_toml_from_file(&system_config)
1005            {
1006                layer_stack.push(ConfigLayerEntry::new(
1007                    ConfigLayerSource::System {
1008                        file: system_config,
1009                    },
1010                    toml,
1011                ));
1012            }
1013        }
1014
1015        // 2. User home config
1016        for home_config_path in defaults_provider.home_config_paths(&config_file_name) {
1017            if home_config_path.exists()
1018                && let Ok(toml) = Self::load_toml_from_file(&home_config_path)
1019            {
1020                layer_stack.push(ConfigLayerEntry::new(
1021                    ConfigLayerSource::User {
1022                        file: home_config_path,
1023                    },
1024                    toml,
1025                ));
1026            }
1027        }
1028
1029        // 3. The specific file provided (Workspace layer)
1030        let toml = Self::load_toml_from_file(path)?;
1031        layer_stack.push(ConfigLayerEntry::new(
1032            ConfigLayerSource::Workspace {
1033                file: path.to_path_buf(),
1034            },
1035            toml,
1036        ));
1037
1038        let effective_toml = layer_stack.effective_config();
1039        let config: VTCodeConfig = effective_toml.try_into().with_context(|| {
1040            format!(
1041                "Failed to parse effective config with file: {}",
1042                path.display()
1043            )
1044        })?;
1045
1046        config.validate().with_context(|| {
1047            format!(
1048                "Failed to validate effective config with file: {}",
1049                path.display()
1050            )
1051        })?;
1052
1053        Ok(Self {
1054            config,
1055            config_path: Some(path.to_path_buf()),
1056            workspace_root: path.parent().map(Path::to_path_buf),
1057            config_file_name,
1058            layer_stack,
1059        })
1060    }
1061
1062    /// Get the loaded configuration
1063    pub fn config(&self) -> &VTCodeConfig {
1064        &self.config
1065    }
1066
1067    /// Get the configuration file path (if loaded from file)
1068    pub fn config_path(&self) -> Option<&Path> {
1069        self.config_path.as_deref()
1070    }
1071
1072    /// Get the configuration layer stack
1073    pub fn layer_stack(&self) -> &ConfigLayerStack {
1074        &self.layer_stack
1075    }
1076
1077    /// Get the effective TOML configuration
1078    pub fn effective_config(&self) -> toml::Value {
1079        self.layer_stack.effective_config()
1080    }
1081
1082    /// Get session duration from agent config
1083    pub fn session_duration(&self) -> std::time::Duration {
1084        std::time::Duration::from_secs(60 * 60) // Default 1 hour
1085    }
1086
1087    /// Persist configuration to a specific path, preserving comments
1088    pub fn save_config_to_path(path: impl AsRef<Path>, config: &VTCodeConfig) -> Result<()> {
1089        let path = path.as_ref();
1090
1091        // If file exists, preserve comments by using toml_edit
1092        if path.exists() {
1093            let original_content = fs::read_to_string(path)
1094                .with_context(|| format!("Failed to read existing config: {}", path.display()))?;
1095
1096            let mut doc = original_content
1097                .parse::<toml_edit::DocumentMut>()
1098                .with_context(|| format!("Failed to parse existing config: {}", path.display()))?;
1099
1100            // Serialize new config to TOML value
1101            let new_value =
1102                toml::to_string_pretty(config).context("Failed to serialize configuration")?;
1103            let new_doc: toml_edit::DocumentMut = new_value
1104                .parse()
1105                .context("Failed to parse serialized configuration")?;
1106
1107            // Update values while preserving structure and comments
1108            Self::merge_toml_documents(&mut doc, &new_doc);
1109
1110            fs::write(path, doc.to_string())
1111                .with_context(|| format!("Failed to write config file: {}", path.display()))?;
1112        } else {
1113            // New file, just write normally
1114            let content =
1115                toml::to_string_pretty(config).context("Failed to serialize configuration")?;
1116            fs::write(path, content)
1117                .with_context(|| format!("Failed to write config file: {}", path.display()))?;
1118        }
1119
1120        Ok(())
1121    }
1122
1123    /// Merge TOML documents, preserving comments and structure from original
1124    fn merge_toml_documents(original: &mut toml_edit::DocumentMut, new: &toml_edit::DocumentMut) {
1125        for (key, new_value) in new.iter() {
1126            if let Some(original_value) = original.get_mut(key) {
1127                Self::merge_toml_items(original_value, new_value);
1128            } else {
1129                original[key] = new_value.clone();
1130            }
1131        }
1132    }
1133
1134    /// Recursively merge TOML items
1135    fn merge_toml_items(original: &mut toml_edit::Item, new: &toml_edit::Item) {
1136        match (original, new) {
1137            (toml_edit::Item::Table(orig_table), toml_edit::Item::Table(new_table)) => {
1138                for (key, new_value) in new_table.iter() {
1139                    if let Some(orig_value) = orig_table.get_mut(key) {
1140                        Self::merge_toml_items(orig_value, new_value);
1141                    } else {
1142                        orig_table[key] = new_value.clone();
1143                    }
1144                }
1145            }
1146            (orig, new) => {
1147                *orig = new.clone();
1148            }
1149        }
1150    }
1151
1152    fn project_config_path(
1153        config_dir: &Path,
1154        workspace_root: &Path,
1155        config_file_name: &str,
1156    ) -> Option<PathBuf> {
1157        let project_name = Self::identify_current_project(workspace_root)?;
1158        let project_config_path = config_dir
1159            .join("projects")
1160            .join(project_name)
1161            .join("config")
1162            .join(config_file_name);
1163
1164        if project_config_path.exists() {
1165            Some(project_config_path)
1166        } else {
1167            None
1168        }
1169    }
1170
1171    fn identify_current_project(workspace_root: &Path) -> Option<String> {
1172        let project_file = workspace_root.join(".vtcode-project");
1173        if let Ok(contents) = fs::read_to_string(&project_file) {
1174            let name = contents.trim();
1175            if !name.is_empty() {
1176                return Some(name.to_string());
1177            }
1178        }
1179
1180        workspace_root
1181            .file_name()
1182            .and_then(|name| name.to_str())
1183            .map(|name| name.to_string())
1184    }
1185}
1186
1187/// Builder for creating a [`ConfigManager`] with custom overrides.
1188#[derive(Debug, Clone, Default)]
1189pub struct ConfigBuilder {
1190    workspace: Option<PathBuf>,
1191    config_file: Option<PathBuf>,
1192    cli_overrides: Vec<(String, toml::Value)>,
1193}
1194
1195impl ConfigBuilder {
1196    /// Create a new configuration builder.
1197    pub fn new() -> Self {
1198        Self::default()
1199    }
1200
1201    /// Set the workspace directory.
1202    pub fn workspace(mut self, path: PathBuf) -> Self {
1203        self.workspace = Some(path);
1204        self
1205    }
1206
1207    /// Set a specific configuration file to use instead of the default workspace config.
1208    pub fn config_file(mut self, path: PathBuf) -> Self {
1209        self.config_file = Some(path);
1210        self
1211    }
1212
1213    /// Add a CLI override (e.g., "agent.provider", "openai").
1214    pub fn cli_override(mut self, key: String, value: toml::Value) -> Self {
1215        self.cli_overrides.push((key, value));
1216        self
1217    }
1218
1219    /// Add multiple CLI overrides from string pairs.
1220    ///
1221    /// Values are parsed as TOML. If parsing fails, they are treated as strings.
1222    pub fn cli_overrides(mut self, overrides: &[(String, String)]) -> Self {
1223        for (key, value) in overrides {
1224            let toml_value = value
1225                .parse::<toml::Value>()
1226                .unwrap_or_else(|_| toml::Value::String(value.clone()));
1227            self.cli_overrides.push((key.clone(), toml_value));
1228        }
1229        self
1230    }
1231
1232    /// Build the [`ConfigManager`].
1233    pub fn build(self) -> Result<ConfigManager> {
1234        let workspace = self
1235            .workspace
1236            .clone()
1237            .unwrap_or_else(|| std::env::current_dir().unwrap_or_default());
1238
1239        let mut manager = if let Some(config_file) = self.config_file {
1240            ConfigManager::load_from_file(config_file)?
1241        } else {
1242            ConfigManager::load_from_workspace(workspace)?
1243        };
1244
1245        if !self.cli_overrides.is_empty() {
1246            let mut runtime_toml = toml::Table::new();
1247            for (key, value) in self.cli_overrides {
1248                Self::insert_dotted_key(&mut runtime_toml, &key, value);
1249            }
1250
1251            let runtime_layer =
1252                ConfigLayerEntry::new(ConfigLayerSource::Runtime, toml::Value::Table(runtime_toml));
1253
1254            manager.layer_stack.push(runtime_layer);
1255
1256            // Re-evaluate config
1257            let effective_toml = manager.layer_stack.effective_config();
1258            manager.config = effective_toml
1259                .try_into()
1260                .context("Failed to deserialize effective configuration after runtime overrides")?;
1261            manager
1262                .config
1263                .validate()
1264                .context("Configuration failed validation after runtime overrides")?;
1265        }
1266
1267        Ok(manager)
1268    }
1269
1270    fn insert_dotted_key(table: &mut toml::Table, key: &str, value: toml::Value) {
1271        let parts: Vec<&str> = key.split('.').collect();
1272        let mut current = table;
1273        for (i, part) in parts.iter().enumerate() {
1274            if i == parts.len() - 1 {
1275                current.insert(part.to_string(), value);
1276                return;
1277            }
1278
1279            if !current.contains_key(*part) || !current[*part].is_table() {
1280                current.insert(part.to_string(), toml::Value::Table(toml::Table::new()));
1281            }
1282
1283            current = current
1284                .get_mut(*part)
1285                .and_then(|v| v.as_table_mut())
1286                .expect("Value must be a table");
1287        }
1288    }
1289}
1290
1291/// Recursively merge two TOML values.
1292///
1293/// If both values are tables, they are merged recursively.
1294/// Otherwise, the `overlay` value replaces the `base` value.
1295pub fn merge_toml_values(base: &mut toml::Value, overlay: &toml::Value) {
1296    match (base, overlay) {
1297        (toml::Value::Table(base_table), toml::Value::Table(overlay_table)) => {
1298            for (key, value) in overlay_table {
1299                if let Some(base_value) = base_table.get_mut(key) {
1300                    merge_toml_values(base_value, value);
1301                } else {
1302                    base_table.insert(key.clone(), value.clone());
1303                }
1304            }
1305        }
1306        (base, overlay) => {
1307            *base = overlay.clone();
1308        }
1309    }
1310}
1311
1312impl ConfigManager {
1313    /// Persist configuration to the manager's associated path or workspace
1314    pub fn save_config(&mut self, config: &VTCodeConfig) -> Result<()> {
1315        if let Some(path) = &self.config_path {
1316            Self::save_config_to_path(path, config)?;
1317        } else if let Some(workspace_root) = &self.workspace_root {
1318            let path = workspace_root.join(&self.config_file_name);
1319            Self::save_config_to_path(path, config)?;
1320        } else {
1321            let cwd = std::env::current_dir().context("Failed to resolve current directory")?;
1322            let path = cwd.join(&self.config_file_name);
1323            Self::save_config_to_path(path, config)?;
1324        }
1325
1326        self.sync_from_config(config)
1327    }
1328
1329    /// Sync internal config from a saved config
1330    /// Call this after save_config to keep internal state in sync
1331    pub fn sync_from_config(&mut self, config: &VTCodeConfig) -> Result<()> {
1332        self.config = config.clone();
1333        Ok(())
1334    }
1335}
1336
1337#[cfg(test)]
1338mod tests {
1339    use super::*;
1340    use crate::defaults::WorkspacePathsDefaults;
1341    use std::io::Write;
1342    use std::sync::Arc;
1343    use tempfile::NamedTempFile;
1344    use vtcode_commons::reference::StaticWorkspacePaths;
1345
1346    #[test]
1347    fn test_layered_config_loading() {
1348        let workspace = assert_fs::TempDir::new().expect("failed to create workspace");
1349        let workspace_root = workspace.path();
1350
1351        // 1. User config
1352        let home_dir = workspace_root.join("home");
1353        fs::create_dir_all(&home_dir).expect("failed to create home dir");
1354        let user_config_path = home_dir.join("vtcode.toml");
1355        fs::write(&user_config_path, "agent.provider = \"anthropic\"")
1356            .expect("failed to write user config");
1357
1358        // 2. Workspace config
1359        let workspace_config_path = workspace_root.join("vtcode.toml");
1360        fs::write(
1361            &workspace_config_path,
1362            "agent.default_model = \"claude-3-sonnet\"",
1363        )
1364        .expect("failed to write workspace config");
1365
1366        let static_paths =
1367            StaticWorkspacePaths::new(workspace_root, workspace_root.join(".vtcode"));
1368        let provider = WorkspacePathsDefaults::new(Arc::new(static_paths))
1369            .with_home_paths(vec![user_config_path.clone()]);
1370
1371        defaults::provider::with_config_defaults_provider_for_test(Arc::new(provider), || {
1372            let manager =
1373                ConfigManager::load_from_workspace(workspace_root).expect("failed to load config");
1374
1375            assert_eq!(manager.config().agent.provider, "anthropic");
1376            assert_eq!(manager.config().agent.default_model, "claude-3-sonnet");
1377
1378            let layers = manager.layer_stack().layers();
1379            // User + Workspace
1380            assert_eq!(layers.len(), 2);
1381            assert!(matches!(layers[0].source, ConfigLayerSource::User { .. }));
1382            assert!(matches!(
1383                layers[1].source,
1384                ConfigLayerSource::Workspace { .. }
1385            ));
1386        });
1387    }
1388
1389    #[test]
1390    fn test_config_builder_overrides() {
1391        let workspace = assert_fs::TempDir::new().expect("failed to create workspace");
1392        let workspace_root = workspace.path();
1393
1394        let workspace_config_path = workspace_root.join("vtcode.toml");
1395        fs::write(&workspace_config_path, "agent.provider = \"openai\"")
1396            .expect("failed to write workspace config");
1397
1398        let static_paths =
1399            StaticWorkspacePaths::new(workspace_root, workspace_root.join(".vtcode"));
1400        let provider = WorkspacePathsDefaults::new(Arc::new(static_paths)).with_home_paths(vec![]);
1401
1402        defaults::provider::with_config_defaults_provider_for_test(Arc::new(provider), || {
1403            let manager = ConfigBuilder::new()
1404                .workspace(workspace_root.to_path_buf())
1405                .cli_override(
1406                    "agent.provider".to_string(),
1407                    toml::Value::String("gemini".to_string()),
1408                )
1409                .cli_override(
1410                    "agent.default_model".to_string(),
1411                    toml::Value::String("gemini-1.5-pro".to_string()),
1412                )
1413                .build()
1414                .expect("failed to build config");
1415
1416            assert_eq!(manager.config().agent.provider, "gemini");
1417            assert_eq!(manager.config().agent.default_model, "gemini-1.5-pro");
1418
1419            let layers = manager.layer_stack().layers();
1420            // Workspace + Runtime
1421            assert_eq!(layers.len(), 2);
1422            assert!(matches!(
1423                layers[0].source,
1424                ConfigLayerSource::Workspace { .. }
1425            ));
1426            assert!(matches!(layers[1].source, ConfigLayerSource::Runtime));
1427        });
1428    }
1429
1430    #[test]
1431    fn test_insert_dotted_key() {
1432        let mut table = toml::Table::new();
1433        ConfigBuilder::insert_dotted_key(
1434            &mut table,
1435            "a.b.c",
1436            toml::Value::String("value".to_string()),
1437        );
1438
1439        let a = table.get("a").unwrap().as_table().unwrap();
1440        let b = a.get("b").unwrap().as_table().unwrap();
1441        let c = b.get("c").unwrap().as_str().unwrap();
1442        assert_eq!(c, "value");
1443    }
1444
1445    #[test]
1446    fn test_merge_toml_values() {
1447        let mut base = toml::from_str::<toml::Value>(
1448            r#"
1449            [agent]
1450            provider = "openai"
1451            [tools]
1452            default_policy = "prompt"
1453        "#,
1454        )
1455        .unwrap();
1456
1457        let overlay = toml::from_str::<toml::Value>(
1458            r#"
1459            [agent]
1460            provider = "anthropic"
1461            default_model = "claude-3"
1462        "#,
1463        )
1464        .unwrap();
1465
1466        merge_toml_values(&mut base, &overlay);
1467
1468        let agent = base.get("agent").unwrap().as_table().unwrap();
1469        assert_eq!(
1470            agent.get("provider").unwrap().as_str().unwrap(),
1471            "anthropic"
1472        );
1473        assert_eq!(
1474            agent.get("default_model").unwrap().as_str().unwrap(),
1475            "claude-3"
1476        );
1477
1478        let tools = base.get("tools").unwrap().as_table().unwrap();
1479        assert_eq!(
1480            tools.get("default_policy").unwrap().as_str().unwrap(),
1481            "prompt"
1482        );
1483    }
1484
1485    #[test]
1486    fn syntax_highlighting_defaults_are_valid() {
1487        let config = SyntaxHighlightingConfig::default();
1488        config
1489            .validate()
1490            .expect("default syntax highlighting config should be valid");
1491    }
1492
1493    #[test]
1494    fn vtcode_config_validation_fails_for_invalid_highlight_timeout() {
1495        let mut config = VTCodeConfig::default();
1496        config.syntax_highlighting.highlight_timeout_ms = 0;
1497        let error = config
1498            .validate()
1499            .expect_err("validation should fail for zero highlight timeout");
1500        assert!(
1501            format!("{:#}", error).contains("highlight"),
1502            "expected error to mention highlight, got: {:#}",
1503            error
1504        );
1505    }
1506
1507    #[test]
1508    fn load_from_file_rejects_invalid_syntax_highlighting() {
1509        let mut temp_file = NamedTempFile::new().expect("failed to create temp file");
1510        writeln!(
1511            temp_file,
1512            "[syntax_highlighting]\nhighlight_timeout_ms = 0\n"
1513        )
1514        .expect("failed to write temp config");
1515
1516        let result = ConfigManager::load_from_file(temp_file.path());
1517        assert!(result.is_err(), "expected validation error");
1518        let error = format!("{:?}", result.err().unwrap());
1519        assert!(
1520            error.contains("validate"),
1521            "expected validation context in error, got: {}",
1522            error
1523        );
1524    }
1525
1526    #[test]
1527    fn loader_loads_prompt_cache_retention_from_toml() {
1528        use std::fs::File;
1529        use std::io::Write;
1530
1531        let temp = tempfile::tempdir().unwrap();
1532        let path = temp.path().join("vtcode.toml");
1533        let mut file = File::create(&path).unwrap();
1534        let contents = r#"
1535[prompt_cache]
1536enabled = true
1537[prompt_cache.providers.openai]
1538prompt_cache_retention = "24h"
1539"#;
1540        file.write_all(contents.as_bytes()).unwrap();
1541
1542        let manager = ConfigManager::load_from_file(&path).unwrap();
1543        let config = manager.config();
1544        assert_eq!(
1545            config.prompt_cache.providers.openai.prompt_cache_retention,
1546            Some("24h".to_string())
1547        );
1548    }
1549
1550    #[test]
1551    fn save_config_preserves_comments() {
1552        use std::io::Write;
1553
1554        let mut temp_file = NamedTempFile::new().expect("failed to create temp file");
1555        let config_with_comments = r#"# This is a test comment
1556[agent]
1557# Provider comment
1558provider = "openai"
1559default_model = "gpt-5-nano"
1560
1561# Tools section comment
1562[tools]
1563default_policy = "prompt"
1564"#;
1565
1566        write!(temp_file, "{}", config_with_comments).expect("failed to write temp config");
1567        temp_file.flush().expect("failed to flush");
1568
1569        // Load config
1570        let manager =
1571            ConfigManager::load_from_file(temp_file.path()).expect("failed to load config");
1572
1573        // Modify and save
1574        let mut modified_config = manager.config().clone();
1575        modified_config.agent.default_model = "gpt-5".to_string();
1576
1577        ConfigManager::save_config_to_path(temp_file.path(), &modified_config)
1578            .expect("failed to save config");
1579
1580        // Read back and verify comments are preserved
1581        let saved_content =
1582            fs::read_to_string(temp_file.path()).expect("failed to read saved config");
1583
1584        assert!(
1585            saved_content.contains("# This is a test comment"),
1586            "top-level comment should be preserved"
1587        );
1588        assert!(
1589            saved_content.contains("# Provider comment"),
1590            "inline comment should be preserved"
1591        );
1592        assert!(
1593            saved_content.contains("# Tools section comment"),
1594            "section comment should be preserved"
1595        );
1596        assert!(
1597            saved_content.contains("gpt-5"),
1598            "modified value should be present"
1599        );
1600    }
1601
1602    #[test]
1603    fn config_defaults_provider_overrides_paths_and_theme() {
1604        let workspace = assert_fs::TempDir::new().expect("failed to create workspace");
1605        let workspace_root = workspace.path();
1606        let config_dir = workspace_root.join("config-root");
1607        fs::create_dir_all(&config_dir).expect("failed to create config directory");
1608
1609        let config_file_name = "custom-config.toml";
1610        let config_path = config_dir.join(config_file_name);
1611        let serialized =
1612            toml::to_string(&VTCodeConfig::default()).expect("failed to serialize default config");
1613        fs::write(&config_path, serialized).expect("failed to write config file");
1614
1615        let static_paths = StaticWorkspacePaths::new(workspace_root, &config_dir);
1616        let provider = WorkspacePathsDefaults::new(Arc::new(static_paths))
1617            .with_config_file_name(config_file_name)
1618            .with_home_paths(Vec::new())
1619            .with_syntax_theme("custom-theme")
1620            .with_syntax_languages(vec!["zig".to_string()]);
1621
1622        defaults::provider::with_config_defaults_provider_for_test(Arc::new(provider), || {
1623            let manager = ConfigManager::load_from_workspace(workspace_root)
1624                .expect("failed to load workspace config");
1625
1626            let resolved_path = manager
1627                .config_path()
1628                .expect("config path should be resolved");
1629            assert_eq!(resolved_path, config_path);
1630
1631            assert_eq!(SyntaxHighlightingDefaults::theme(), "custom-theme");
1632            assert_eq!(
1633                SyntaxHighlightingDefaults::enabled_languages(),
1634                vec!["zig".to_string()]
1635            );
1636        });
1637    }
1638
1639    #[test]
1640    fn save_config_updates_disk_file() {
1641        let temp_dir = tempfile::tempdir().unwrap();
1642        let workspace = temp_dir.path();
1643        let config_path = workspace.join("vtcode.toml");
1644
1645        // Write initial config
1646        let initial_config = r#"
1647[ui]
1648display_mode = "full"
1649show_sidebar = true
1650"#;
1651        fs::write(&config_path, initial_config).expect("failed to write initial config");
1652
1653        // Load config
1654        let mut manager =
1655            ConfigManager::load_from_workspace(workspace).expect("failed to load config");
1656        assert_eq!(manager.config().ui.display_mode, crate::UiDisplayMode::Full);
1657
1658        // Modify config (simulating /config palette changes)
1659        let mut modified_config = manager.config().clone();
1660        modified_config.ui.display_mode = crate::UiDisplayMode::Minimal;
1661        modified_config.ui.show_sidebar = false;
1662
1663        // Save config
1664        manager
1665            .save_config(&modified_config)
1666            .expect("failed to save config");
1667
1668        // Verify disk file was updated
1669        let saved_content = fs::read_to_string(&config_path).expect("failed to read saved config");
1670        assert!(
1671            saved_content.contains("display_mode = \"minimal\""),
1672            "saved config should contain minimal display_mode. Got:\n{}",
1673            saved_content
1674        );
1675        assert!(
1676            saved_content.contains("show_sidebar = false"),
1677            "saved config should contain show_sidebar = false. Got:\n{}",
1678            saved_content
1679        );
1680
1681        // Create a NEW manager to simulate reopening /config palette
1682        let new_manager =
1683            ConfigManager::load_from_workspace(workspace).expect("failed to reload config");
1684        assert_eq!(
1685            new_manager.config().ui.display_mode,
1686            crate::UiDisplayMode::Minimal,
1687            "reloaded config should have minimal display_mode"
1688        );
1689
1690        // Force disk read by loading from file directly
1691        let new_manager2 =
1692            ConfigManager::load_from_file(&config_path).expect("failed to reload from file");
1693        assert!(
1694            !new_manager2.config().ui.show_sidebar,
1695            "reloaded config should have show_sidebar = false, got: {}",
1696            new_manager2.config().ui.show_sidebar
1697        );
1698    }
1699}