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