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