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(&mut self, config: &VTCodeConfig) -> Result<()> {
1293        if let Some(path) = &self.config_path {
1294            Self::save_config_to_path(path, config)?;
1295        } else if let Some(workspace_root) = &self.workspace_root {
1296            let path = workspace_root.join(&self.config_file_name);
1297            Self::save_config_to_path(path, config)?;
1298        } else {
1299            let cwd = std::env::current_dir().context("Failed to resolve current directory")?;
1300            let path = cwd.join(&self.config_file_name);
1301            Self::save_config_to_path(path, config)?;
1302        }
1303
1304        self.sync_from_config(config)
1305    }
1306
1307    /// Sync internal config from a saved config
1308    /// Call this after save_config to keep internal state in sync
1309    pub fn sync_from_config(&mut self, config: &VTCodeConfig) -> Result<()> {
1310        self.config = config.clone();
1311        Ok(())
1312    }
1313}
1314
1315#[cfg(test)]
1316mod tests {
1317    use super::*;
1318    use crate::defaults::WorkspacePathsDefaults;
1319    use std::io::Write;
1320    use std::sync::Arc;
1321    use tempfile::NamedTempFile;
1322    use vtcode_commons::reference::StaticWorkspacePaths;
1323
1324    #[test]
1325    fn test_layered_config_loading() {
1326        let workspace = assert_fs::TempDir::new().expect("failed to create workspace");
1327        let workspace_root = workspace.path();
1328
1329        // 1. User config
1330        let home_dir = workspace_root.join("home");
1331        fs::create_dir_all(&home_dir).expect("failed to create home dir");
1332        let user_config_path = home_dir.join("vtcode.toml");
1333        fs::write(&user_config_path, "agent.provider = \"anthropic\"")
1334            .expect("failed to write user config");
1335
1336        // 2. Workspace config
1337        let workspace_config_path = workspace_root.join("vtcode.toml");
1338        fs::write(
1339            &workspace_config_path,
1340            "agent.default_model = \"claude-3-sonnet\"",
1341        )
1342        .expect("failed to write workspace config");
1343
1344        let static_paths =
1345            StaticWorkspacePaths::new(workspace_root, workspace_root.join(".vtcode"));
1346        let provider = WorkspacePathsDefaults::new(Arc::new(static_paths))
1347            .with_home_paths(vec![user_config_path.clone()]);
1348
1349        defaults::provider::with_config_defaults_provider_for_test(Arc::new(provider), || {
1350            let manager =
1351                ConfigManager::load_from_workspace(workspace_root).expect("failed to load config");
1352
1353            assert_eq!(manager.config().agent.provider, "anthropic");
1354            assert_eq!(manager.config().agent.default_model, "claude-3-sonnet");
1355
1356            let layers = manager.layer_stack().layers();
1357            // User + Workspace
1358            assert_eq!(layers.len(), 2);
1359            assert!(matches!(layers[0].source, ConfigLayerSource::User { .. }));
1360            assert!(matches!(
1361                layers[1].source,
1362                ConfigLayerSource::Workspace { .. }
1363            ));
1364        });
1365    }
1366
1367    #[test]
1368    fn test_config_builder_overrides() {
1369        let workspace = assert_fs::TempDir::new().expect("failed to create workspace");
1370        let workspace_root = workspace.path();
1371
1372        let workspace_config_path = workspace_root.join("vtcode.toml");
1373        fs::write(&workspace_config_path, "agent.provider = \"openai\"")
1374            .expect("failed to write workspace config");
1375
1376        let static_paths =
1377            StaticWorkspacePaths::new(workspace_root, workspace_root.join(".vtcode"));
1378        let provider = WorkspacePathsDefaults::new(Arc::new(static_paths)).with_home_paths(vec![]);
1379
1380        defaults::provider::with_config_defaults_provider_for_test(Arc::new(provider), || {
1381            let manager = ConfigBuilder::new()
1382                .workspace(workspace_root.to_path_buf())
1383                .cli_override(
1384                    "agent.provider".to_string(),
1385                    toml::Value::String("gemini".to_string()),
1386                )
1387                .cli_override(
1388                    "agent.default_model".to_string(),
1389                    toml::Value::String("gemini-1.5-pro".to_string()),
1390                )
1391                .build()
1392                .expect("failed to build config");
1393
1394            assert_eq!(manager.config().agent.provider, "gemini");
1395            assert_eq!(manager.config().agent.default_model, "gemini-1.5-pro");
1396
1397            let layers = manager.layer_stack().layers();
1398            // Workspace + Runtime
1399            assert_eq!(layers.len(), 2);
1400            assert!(matches!(
1401                layers[0].source,
1402                ConfigLayerSource::Workspace { .. }
1403            ));
1404            assert!(matches!(layers[1].source, ConfigLayerSource::Runtime));
1405        });
1406    }
1407
1408    #[test]
1409    fn test_insert_dotted_key() {
1410        let mut table = toml::Table::new();
1411        ConfigBuilder::insert_dotted_key(
1412            &mut table,
1413            "a.b.c",
1414            toml::Value::String("value".to_string()),
1415        );
1416
1417        let a = table.get("a").unwrap().as_table().unwrap();
1418        let b = a.get("b").unwrap().as_table().unwrap();
1419        let c = b.get("c").unwrap().as_str().unwrap();
1420        assert_eq!(c, "value");
1421    }
1422
1423    #[test]
1424    fn test_merge_toml_values() {
1425        let mut base = toml::from_str::<toml::Value>(
1426            r#"
1427            [agent]
1428            provider = "openai"
1429            [tools]
1430            default_policy = "prompt"
1431        "#,
1432        )
1433        .unwrap();
1434
1435        let overlay = toml::from_str::<toml::Value>(
1436            r#"
1437            [agent]
1438            provider = "anthropic"
1439            default_model = "claude-3"
1440        "#,
1441        )
1442        .unwrap();
1443
1444        merge_toml_values(&mut base, &overlay);
1445
1446        let agent = base.get("agent").unwrap().as_table().unwrap();
1447        assert_eq!(
1448            agent.get("provider").unwrap().as_str().unwrap(),
1449            "anthropic"
1450        );
1451        assert_eq!(
1452            agent.get("default_model").unwrap().as_str().unwrap(),
1453            "claude-3"
1454        );
1455
1456        let tools = base.get("tools").unwrap().as_table().unwrap();
1457        assert_eq!(
1458            tools.get("default_policy").unwrap().as_str().unwrap(),
1459            "prompt"
1460        );
1461    }
1462
1463    #[test]
1464    fn syntax_highlighting_defaults_are_valid() {
1465        let config = SyntaxHighlightingConfig::default();
1466        config
1467            .validate()
1468            .expect("default syntax highlighting config should be valid");
1469    }
1470
1471    #[test]
1472    fn vtcode_config_validation_fails_for_invalid_highlight_timeout() {
1473        let mut config = VTCodeConfig::default();
1474        config.syntax_highlighting.highlight_timeout_ms = 0;
1475        let error = config
1476            .validate()
1477            .expect_err("validation should fail for zero highlight timeout");
1478        assert!(
1479            format!("{:#}", error).contains("highlight"),
1480            "expected error to mention highlight, got: {:#}",
1481            error
1482        );
1483    }
1484
1485    #[test]
1486    fn load_from_file_rejects_invalid_syntax_highlighting() {
1487        let mut temp_file = NamedTempFile::new().expect("failed to create temp file");
1488        writeln!(
1489            temp_file,
1490            "[syntax_highlighting]\nhighlight_timeout_ms = 0\n"
1491        )
1492        .expect("failed to write temp config");
1493
1494        let result = ConfigManager::load_from_file(temp_file.path());
1495        assert!(result.is_err(), "expected validation error");
1496        let error = format!("{:?}", result.err().unwrap());
1497        assert!(
1498            error.contains("validate"),
1499            "expected validation context in error, got: {}",
1500            error
1501        );
1502    }
1503
1504    #[test]
1505    fn loader_loads_prompt_cache_retention_from_toml() {
1506        use std::fs::File;
1507        use std::io::Write;
1508
1509        let temp = tempfile::tempdir().unwrap();
1510        let path = temp.path().join("vtcode.toml");
1511        let mut file = File::create(&path).unwrap();
1512        let contents = r#"
1513[prompt_cache]
1514enabled = true
1515[prompt_cache.providers.openai]
1516prompt_cache_retention = "24h"
1517"#;
1518        file.write_all(contents.as_bytes()).unwrap();
1519
1520        let manager = ConfigManager::load_from_file(&path).unwrap();
1521        let config = manager.config();
1522        assert_eq!(
1523            config.prompt_cache.providers.openai.prompt_cache_retention,
1524            Some("24h".to_string())
1525        );
1526    }
1527
1528    #[test]
1529    fn save_config_preserves_comments() {
1530        use std::io::Write;
1531
1532        let mut temp_file = NamedTempFile::new().expect("failed to create temp file");
1533        let config_with_comments = r#"# This is a test comment
1534[agent]
1535# Provider comment
1536provider = "openai"
1537default_model = "gpt-5-nano"
1538
1539# Tools section comment
1540[tools]
1541default_policy = "prompt"
1542"#;
1543
1544        write!(temp_file, "{}", config_with_comments).expect("failed to write temp config");
1545        temp_file.flush().expect("failed to flush");
1546
1547        // Load config
1548        let manager =
1549            ConfigManager::load_from_file(temp_file.path()).expect("failed to load config");
1550
1551        // Modify and save
1552        let mut modified_config = manager.config().clone();
1553        modified_config.agent.default_model = "gpt-5".to_string();
1554
1555        ConfigManager::save_config_to_path(temp_file.path(), &modified_config)
1556            .expect("failed to save config");
1557
1558        // Read back and verify comments are preserved
1559        let saved_content =
1560            fs::read_to_string(temp_file.path()).expect("failed to read saved config");
1561
1562        assert!(
1563            saved_content.contains("# This is a test comment"),
1564            "top-level comment should be preserved"
1565        );
1566        assert!(
1567            saved_content.contains("# Provider comment"),
1568            "inline comment should be preserved"
1569        );
1570        assert!(
1571            saved_content.contains("# Tools section comment"),
1572            "section comment should be preserved"
1573        );
1574        assert!(
1575            saved_content.contains("gpt-5"),
1576            "modified value should be present"
1577        );
1578    }
1579
1580    #[test]
1581    fn config_defaults_provider_overrides_paths_and_theme() {
1582        let workspace = assert_fs::TempDir::new().expect("failed to create workspace");
1583        let workspace_root = workspace.path();
1584        let config_dir = workspace_root.join("config-root");
1585        fs::create_dir_all(&config_dir).expect("failed to create config directory");
1586
1587        let config_file_name = "custom-config.toml";
1588        let config_path = config_dir.join(config_file_name);
1589        let serialized =
1590            toml::to_string(&VTCodeConfig::default()).expect("failed to serialize default config");
1591        fs::write(&config_path, serialized).expect("failed to write config file");
1592
1593        let static_paths = StaticWorkspacePaths::new(workspace_root, &config_dir);
1594        let provider = WorkspacePathsDefaults::new(Arc::new(static_paths))
1595            .with_config_file_name(config_file_name)
1596            .with_home_paths(Vec::new())
1597            .with_syntax_theme("custom-theme")
1598            .with_syntax_languages(vec!["zig".to_string()]);
1599
1600        defaults::provider::with_config_defaults_provider_for_test(Arc::new(provider), || {
1601            let manager = ConfigManager::load_from_workspace(workspace_root)
1602                .expect("failed to load workspace config");
1603
1604            let resolved_path = manager
1605                .config_path()
1606                .expect("config path should be resolved");
1607            assert_eq!(resolved_path, config_path);
1608
1609            assert_eq!(SyntaxHighlightingDefaults::theme(), "custom-theme");
1610            assert_eq!(
1611                SyntaxHighlightingDefaults::enabled_languages(),
1612                vec!["zig".to_string()]
1613            );
1614        });
1615    }
1616
1617    #[test]
1618    fn save_config_updates_disk_file() {
1619        let temp_dir = tempfile::tempdir().unwrap();
1620        let workspace = temp_dir.path();
1621        let config_path = workspace.join("vtcode.toml");
1622
1623        // Write initial config
1624        let initial_config = r#"
1625[ui]
1626display_mode = "full"
1627show_sidebar = true
1628"#;
1629        fs::write(&config_path, initial_config).expect("failed to write initial config");
1630
1631        // Load config
1632        let mut manager =
1633            ConfigManager::load_from_workspace(workspace).expect("failed to load config");
1634        assert_eq!(manager.config().ui.display_mode, crate::UiDisplayMode::Full);
1635
1636        // Modify config (simulating /config palette changes)
1637        let mut modified_config = manager.config().clone();
1638        modified_config.ui.display_mode = crate::UiDisplayMode::Minimal;
1639        modified_config.ui.show_sidebar = false;
1640
1641        // Save config
1642        manager
1643            .save_config(&modified_config)
1644            .expect("failed to save config");
1645
1646        // Verify disk file was updated
1647        let saved_content = fs::read_to_string(&config_path).expect("failed to read saved config");
1648        assert!(
1649            saved_content.contains("display_mode = \"minimal\""),
1650            "saved config should contain minimal display_mode. Got:\n{}",
1651            saved_content
1652        );
1653        assert!(
1654            saved_content.contains("show_sidebar = false"),
1655            "saved config should contain show_sidebar = false. Got:\n{}",
1656            saved_content
1657        );
1658
1659        // Create a NEW manager to simulate reopening /config palette
1660        let new_manager =
1661            ConfigManager::load_from_workspace(workspace).expect("failed to reload config");
1662        assert_eq!(
1663            new_manager.config().ui.display_mode,
1664            crate::UiDisplayMode::Minimal,
1665            "reloaded config should have minimal display_mode"
1666        );
1667
1668        // Force disk read by loading from file directly
1669        let new_manager2 =
1670            ConfigManager::load_from_file(&config_path).expect("failed to reload from file");
1671        assert!(
1672            !new_manager2.config().ui.show_sidebar,
1673            "reloaded config should have show_sidebar = false, got: {}",
1674            new_manager2.config().ui.show_sidebar
1675        );
1676    }
1677}