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