vtcode_config/loader/
mod.rs

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