Skip to main content

vtcode_config/loader/
bootstrap.rs

1use std::fs;
2use std::path::{Path, PathBuf};
3use vtcode_commons::VtCodePaths;
4
5use anyhow::{Context, Result};
6
7use super::config::VTCodeConfig;
8use crate::defaults::{self, ConfigDefaultsProvider};
9
10const DEFAULT_GITIGNORE_FILE_NAME: &str = ".vtcodegitignore";
11
12/// Determine where configuration and gitignore files should be created when
13/// bootstrapping a workspace.
14fn determine_bootstrap_targets(
15    workspace: &Path,
16    use_home_dir: bool,
17    config_file_name: &str,
18    defaults_provider: &dyn ConfigDefaultsProvider,
19) -> Result<(PathBuf, PathBuf)> {
20    if use_home_dir {
21        if let Some(home_config_path) = select_home_config_path(defaults_provider, config_file_name)? {
22            let gitignore_path = gitignore_path_for(&home_config_path);
23            return Ok((home_config_path, gitignore_path));
24        }
25    }
26
27    let config_path = workspace.join(config_file_name);
28    let gitignore_path = workspace.join(DEFAULT_GITIGNORE_FILE_NAME);
29    Ok((config_path, gitignore_path))
30}
31
32/// Returns the preferred gitignore path for a given configuration file.
33fn gitignore_path_for(config_path: &Path) -> PathBuf {
34    config_path
35        .parent()
36        .map(|parent| parent.join(DEFAULT_GITIGNORE_FILE_NAME))
37        .unwrap_or_else(|| PathBuf::from(DEFAULT_GITIGNORE_FILE_NAME))
38}
39
40/// Ensures the parent directory for the provided path exists, creating it if
41/// necessary.
42fn ensure_parent_dir(path: &Path) -> Result<()> {
43    if let Some(parent) = path.parent() {
44        fs::create_dir_all(parent).with_context(|| format!("Failed to create directory: {}", parent.display()))?;
45    }
46
47    Ok(())
48}
49
50/// Ensures a canonical user-level configuration parent exists privately.
51fn ensure_private_parent_dir(path: &Path) -> Result<()> {
52    if let Some(parent) = path.parent() {
53        let _ = VtCodePaths::ensure_user_dir(parent)
54            .with_context(|| format!("Failed to create private directory: {}", parent.display()))?;
55    }
56
57    Ok(())
58}
59
60/// Selects the canonical user configuration path from the defaults provider.
61fn select_home_config_path(
62    defaults_provider: &dyn ConfigDefaultsProvider,
63    config_file_name: &str,
64) -> Result<Option<PathBuf>> {
65    defaults_provider
66        .canonical_user_config_path(config_file_name)
67        .context("Failed to resolve canonical user configuration path")
68}
69
70impl VTCodeConfig {
71    /// Bootstrap project with config + gitignore
72    pub fn bootstrap_project<P: AsRef<Path>>(workspace: P, force: bool) -> Result<Vec<String>> {
73        Self::bootstrap_project_with_options(workspace, force, false)
74    }
75
76    /// Bootstrap project with config + gitignore, with option to create in home directory
77    pub fn bootstrap_project_with_options<P: AsRef<Path>>(
78        workspace: P,
79        force: bool,
80        use_home_dir: bool,
81    ) -> Result<Vec<String>> {
82        let workspace = workspace.as_ref().to_path_buf();
83        defaults::with_config_defaults(|provider| {
84            Self::bootstrap_project_with_provider(&workspace, force, use_home_dir, provider)
85        })
86    }
87
88    /// Bootstrap project files using the supplied [`ConfigDefaultsProvider`].
89    fn bootstrap_project_with_provider<P: AsRef<Path>>(
90        workspace: P,
91        force: bool,
92        use_home_dir: bool,
93        defaults_provider: &dyn ConfigDefaultsProvider,
94    ) -> Result<Vec<String>> {
95        let workspace = workspace.as_ref();
96        let config_file_name = defaults_provider.config_file_name().to_string();
97        let (config_path, gitignore_path) =
98            determine_bootstrap_targets(workspace, use_home_dir, &config_file_name, defaults_provider)?;
99        let vtcode_readme_path = workspace.join(".vtcode").join("README.md");
100        let ast_grep_config_path = workspace.join("sgconfig.yml");
101        let ast_grep_rule_path = workspace.join("rules").join("examples").join("no-console-log.yml");
102        let ast_grep_test_path = workspace.join("rule-tests").join("examples").join("no-console-log-test.yml");
103        let ast_grep_snapshot_path = workspace
104            .join("rule-tests")
105            .join("__snapshots__")
106            .join("no-console-log-snapshot.yml");
107
108        let home_targets = use_home_dir && config_path != workspace.join(&config_file_name);
109        if home_targets {
110            ensure_private_parent_dir(&config_path)?;
111            ensure_private_parent_dir(&gitignore_path)?;
112        } else {
113            ensure_parent_dir(&config_path)?;
114            ensure_parent_dir(&gitignore_path)?;
115        }
116        ensure_parent_dir(&vtcode_readme_path)?;
117        ensure_parent_dir(&ast_grep_config_path)?;
118        ensure_parent_dir(&ast_grep_rule_path)?;
119        ensure_parent_dir(&ast_grep_test_path)?;
120        ensure_parent_dir(&ast_grep_snapshot_path)?;
121
122        let mut created_files = Vec::new();
123
124        if !config_path.exists() || force {
125            let config_content = Self::default_vtcode_toml_template();
126
127            fs::write(&config_path, config_content)
128                .with_context(|| format!("Failed to write config file: {}", config_path.display()))?;
129
130            if let Some(file_name) = config_path.file_name().and_then(|name| name.to_str()) {
131                created_files.push(file_name.to_string());
132            }
133        }
134
135        if !gitignore_path.exists() || force {
136            let gitignore_content = Self::default_vtcode_gitignore();
137            fs::write(&gitignore_path, gitignore_content)
138                .with_context(|| format!("Failed to write gitignore file: {}", gitignore_path.display()))?;
139
140            if let Some(file_name) = gitignore_path.file_name().and_then(|name| name.to_str()) {
141                created_files.push(file_name.to_string());
142            }
143        }
144
145        if !vtcode_readme_path.exists() || force {
146            let vtcode_readme = Self::default_vtcode_readme_template();
147            fs::write(&vtcode_readme_path, vtcode_readme)
148                .with_context(|| format!("Failed to write VT Code README: {}", vtcode_readme_path.display()))?;
149            created_files.push(".vtcode/README.md".to_string());
150        }
151
152        let ast_grep_files = [
153            (&ast_grep_config_path, Self::default_ast_grep_config_template(), "sgconfig.yml"),
154            (
155                &ast_grep_rule_path,
156                Self::default_ast_grep_example_rule_template(),
157                "rules/examples/no-console-log.yml",
158            ),
159            (
160                &ast_grep_test_path,
161                Self::default_ast_grep_example_test_template(),
162                "rule-tests/examples/no-console-log-test.yml",
163            ),
164            (
165                &ast_grep_snapshot_path,
166                Self::default_ast_grep_example_snapshot_template(),
167                "rule-tests/__snapshots__/no-console-log-snapshot.yml",
168            ),
169        ];
170
171        for (path, contents, label) in ast_grep_files {
172            if !path.exists() || force {
173                fs::write(path, contents)
174                    .with_context(|| format!("Failed to write ast-grep scaffold file: {}", path.display()))?;
175                created_files.push(label.to_string());
176            }
177        }
178
179        Ok(created_files)
180    }
181
182    /// Generate the default `vtcode.toml` template used by bootstrap helpers.
183    fn default_vtcode_toml_template() -> String {
184        include_str!("../../data/default_config.toml").to_owned()
185    }
186
187    fn default_vtcode_gitignore() -> String {
188        r#"# Security-focused exclusions
189.env, .env.local, secrets/, .aws/, .ssh/
190
191# Development artifacts
192target/, build/, dist/, node_modules/, vendor/
193
194# Database files
195*.db, *.sqlite, *.sqlite3
196
197# Binary files
198*.exe, *.dll, *.so, *.dylib, *.bin
199
200# IDE files (comprehensive)
201.vscode/, .idea/, *.swp, *.swo
202"#
203        .to_string()
204    }
205
206    fn default_vtcode_readme_template() -> &'static str {
207        "# VT Code Workspace Files\n\n- Put always-on repository guidance in `AGENTS.md`.\n- Put path-scoped prompt rules in `.vtcode/rules/*.md` using YAML frontmatter.\n- Keep authoring notes and other workspace docs outside `.vtcode/rules/` so they are not loaded into prompt memory.\n"
208    }
209
210    fn default_ast_grep_config_template() -> &'static str {
211        "ruleDirs:\n  - rules\nutilDirs:\n  - utils\ntestConfigs:\n  - testDir: rule-tests\n    snapshotDir: __snapshots__\n"
212    }
213
214    fn default_ast_grep_example_rule_template() -> &'static str {
215        "id: no-console-log\nlanguage: JavaScript\nseverity: error\nmessage: Avoid `console.log` in checked JavaScript files.\nnote: |\n  Avoid `console.log` in checked JavaScript files.\nrule:\n  pattern: console.log($$$ARGS)\nfiles:\n  - '**/*.js'\n"
216    }
217
218    fn default_ast_grep_example_test_template() -> &'static str {
219        "id: no-console-log\nvalid:\n  - |\n    const logger = {\n      info(message) {\n        return message;\n      },\n    };\ninvalid:\n  - |\n    function greet(name) {\n      console.log(name);\n    }\n"
220    }
221
222    fn default_ast_grep_example_snapshot_template() -> &'static str {
223        "id: no-console-log\nsnapshots:\n  ? |\n    function greet(name) {\n      console.log(name);\n    }\n  : labels:\n    - source: console.log(name)\n      style: primary\n      start: 25\n      end: 42\n"
224    }
225
226    /// Create sample configuration file
227    pub fn create_sample_config<P: AsRef<Path>>(output: P) -> Result<()> {
228        let output = output.as_ref();
229        let config_content = Self::default_vtcode_toml_template();
230
231        fs::write(output, config_content)
232            .with_context(|| format!("Failed to write config file: {}", output.display()))?;
233
234        Ok(())
235    }
236}
237
238#[cfg(test)]
239mod tests;