commit_wizard/engine/capabilities/config/
init.rs1use std::path::{Path, PathBuf};
2
3use crate::engine::{
4 config::{ProjectConfig, RulesConfig, StandardConfig},
5 constants::{CONFIG_FILE_NAME, RULES_FILE_NAME, app_config_dir},
6 error::{ErrorCode, Result},
7};
8
9pub enum ConfigOption {
10 Project,
11 Global,
12 Registry,
13}
14
15pub enum ConfigSpec {
16 Minimal,
17 Standard,
18 Full,
19}
20
21pub struct RegistryOptions {
22 pub name: String,
23 pub git_init: bool,
24 pub sections: bool,
25}
26
27impl Default for RegistryOptions {
28 fn default() -> Self {
29 Self {
30 name: "registry".to_string(),
31 git_init: false,
32 sections: false,
33 }
34 }
35}
36
37pub struct ConfigContext {
38 pub option: ConfigOption,
39 pub spec: ConfigSpec,
40 pub output_path: PathBuf,
41 pub force: bool,
42 pub auto_yes: bool,
43 pub dry_run: bool,
44 pub hidden: bool,
45 pub with_rules: bool,
46 pub registry_options: RegistryOptions,
47}
48
49pub struct ConfigPayload {
50 pub config_type: ConfigOption,
51 pub with_rules: bool,
52}
53
54pub struct InitOutput {
56 pub path: PathBuf,
58 pub dry_run_content: Option<String>,
60}
61
62pub fn init_config(context: &ConfigContext) -> Result<InitOutput> {
63 let rules = RulesConfig::default();
64 match context.option {
65 ConfigOption::Project => {
66 let mut input = match context.spec {
67 ConfigSpec::Minimal => ProjectConfig::minimal(),
68 ConfigSpec::Standard => ProjectConfig::standard(),
69 ConfigSpec::Full => ProjectConfig::full(),
70 };
71 if context.with_rules {
72 input.inner.rules = Some(rules);
73 }
74 let config_path = &context.output_path;
76
77 if context.dry_run {
78 let content = toml::to_string_pretty(&input).map_err(|err| {
79 ErrorCode::SerializationFailure
80 .error()
81 .with_context("path", config_path.display().to_string())
82 .with_context("error", err.to_string())
83 })?;
84 return Ok(InitOutput {
85 path: config_path.clone(),
86 dry_run_content: Some(content),
87 });
88 }
89
90 save_project(config_path, &input, context.force, context.dry_run)?;
91 Ok(InitOutput {
92 path: config_path.clone(),
93 dry_run_content: None,
94 })
95 }
96 ConfigOption::Global => {
97 let input = match context.spec {
98 ConfigSpec::Minimal => StandardConfig::minimal(),
99 ConfigSpec::Standard => StandardConfig::standard(),
100 ConfigSpec::Full => StandardConfig::full(),
101 };
102
103 let root = app_config_dir()?;
104 let config_path = root.join(CONFIG_FILE_NAME);
105
106 let dry_run_content = if context.dry_run {
107 let content = toml::to_string_pretty(&input).map_err(|err| {
108 ErrorCode::SerializationFailure
109 .error()
110 .with_context("path", config_path.display().to_string())
111 .with_context("error", err.to_string())
112 })?;
113 Some(content)
114 } else {
115 save_standard(&config_path, &input, context.force, context.dry_run)?;
116 None
117 };
118
119 if context.with_rules {
120 let rules_path = root.join(RULES_FILE_NAME);
121 if !context.dry_run {
122 save_rules(&rules_path, &rules, context.force, context.dry_run)?;
123 }
124 }
125
126 Ok(InitOutput {
127 path: config_path,
128 dry_run_content,
129 })
130 }
131 ConfigOption::Registry => init_registry(context),
132 }
133}
134
135pub fn save_project(path: &Path, input: &ProjectConfig, force: bool, dry_run: bool) -> Result<()> {
136 save_toml(path, input, force, dry_run)
137}
138
139pub fn save_standard(
140 path: &Path,
141 input: &StandardConfig,
142 force: bool,
143 dry_run: bool,
144) -> Result<()> {
145 save_toml(path, input, force, dry_run)
146}
147
148pub fn save_rules(path: &Path, input: &RulesConfig, force: bool, dry_run: bool) -> Result<()> {
149 save_toml(path, input, force, dry_run)
150}
151
152fn save_toml<T: serde::Serialize>(
153 path: &Path,
154 input: &T,
155 force: bool,
156 dry_run: bool,
157) -> Result<()> {
158 if path.exists() && !force {
159 return Err(ErrorCode::ConfigInvalid
160 .error()
161 .with_context("path", path.display().to_string())
162 .with_context("reason", "file already exists; use --force to overwrite"));
163 }
164
165 let content = toml::to_string_pretty(input).map_err(|err| {
166 ErrorCode::SerializationFailure
167 .error()
168 .with_context("path", path.display().to_string())
169 .with_context("error", err.to_string())
170 })?;
171
172 if dry_run {
173 return Ok(());
174 }
175
176 if let Some(parent) = path.parent()
177 && !parent.as_os_str().is_empty()
178 {
179 std::fs::create_dir_all(parent)?;
180 }
181
182 std::fs::write(path, &content)?;
183 Ok(())
184}
185
186fn save_text(path: &Path, content: &str, force: bool, dry_run: bool) -> Result<()> {
187 if path.exists() && !force {
188 return Err(ErrorCode::ConfigInvalid
189 .error()
190 .with_context("path", path.display().to_string())
191 .with_context("reason", "file already exists; use --force to overwrite"));
192 }
193
194 if dry_run {
195 return Ok(());
196 }
197
198 if let Some(parent) = path.parent()
199 && !parent.as_os_str().is_empty()
200 {
201 std::fs::create_dir_all(parent)?;
202 }
203
204 std::fs::write(path, content)?;
205 Ok(())
206}
207
208fn init_registry(context: &ConfigContext) -> Result<InitOutput> {
209 let root = &context.output_path;
210
211 if !context.dry_run {
212 std::fs::create_dir_all(root)?;
213 }
214
215 let config = StandardConfig::minimal();
216
217 let config_path = root.join(CONFIG_FILE_NAME);
218 save_standard(&config_path, &config, context.force, context.dry_run)?;
219
220 let rules_path = root.join(RULES_FILE_NAME);
221 save_rules(
222 &rules_path,
223 &RulesConfig::default(),
224 context.force,
225 context.dry_run,
226 )?;
227
228 let readme_path = root.join("README.md");
229 save_text(
230 &readme_path,
231 ®istry_readme(context),
232 context.force,
233 context.dry_run,
234 )?;
235
236 if context.registry_options.sections {
237 for section in ["standard", "team1"] {
238 let section_dir = root.join(section);
239
240 if !context.dry_run {
241 std::fs::create_dir_all(§ion_dir)?;
242 }
243
244 save_standard(
245 §ion_dir.join(CONFIG_FILE_NAME),
246 &config,
247 context.force,
248 context.dry_run,
249 )?;
250
251 save_rules(
252 §ion_dir.join(RULES_FILE_NAME),
253 &RulesConfig::default(),
254 context.force,
255 context.dry_run,
256 )?;
257 }
258 }
259
260 Ok(InitOutput {
261 path: root.join(CONFIG_FILE_NAME),
262 dry_run_content: None,
263 })
264}
265fn registry_readme(context: &ConfigContext) -> String {
266 let mut s = String::new();
267
268 s.push_str("# commit-wizard registry\n\n");
269 s.push_str("This repository is a commit-wizard registry.\n\n");
270 s.push_str("A registry is a Git repository that provides shared `config.toml` and `rules.toml` files for commit-wizard consumers.\n\n");
271
272 s.push_str("## Supported layouts\n\n");
273 s.push_str("### Single-source registry\n\n");
274 s.push_str("```text\n");
275 s.push_str("config.toml\n");
276 s.push_str("rules.toml\n");
277 s.push_str("```\n\n");
278
279 s.push_str("### Sectioned registry\n\n");
280 s.push_str("```text\n");
281 s.push_str("standard/config.toml\n");
282 s.push_str("standard/rules.toml\n\n");
283 s.push_str("team1/config.toml\n");
284 s.push_str("team1/rules.toml\n");
285 s.push_str("```\n\n");
286
287 s.push_str("## Consumer configuration\n\n");
288 s.push_str("```toml\n");
289 s.push_str("version = 1\n\n");
290 s.push_str("[registry]\n");
291 s.push_str("use = \"my-org\"\n\n");
292 s.push_str("[registries.my-org]\n");
293 s.push_str("url = \"https://github.com/org/registry.git\"\n");
294 s.push_str("ref = \"main\"\n");
295 s.push_str("section = \"standard\"\n");
296 s.push_str("```\n\n");
297
298 s.push_str("## Resolution rules\n\n");
299 s.push_str("- Registry selection precedence: CLI, then ENV, then config.\n");
300 s.push_str("- `ref` may be a branch, tag, or commit SHA.\n");
301 s.push_str("- If `section` is set, commit-wizard loads `<section>/config.toml` and `<section>/rules.toml`.\n");
302 s.push_str("- If `section` is not set, commit-wizard loads `config.toml` and `rules.toml` from the repository root.\n");
303 s.push_str("- Missing files are fatal.\n");
304 s.push_str("- Invalid TOML or schema violations are fatal.\n");
305 s.push_str("- No silent fallback is allowed.\n\n");
306
307 s.push_str("## Update workflow\n\n");
308 s.push_str("1. Edit the shared configuration files in this repository.\n");
309 s.push_str("2. Commit and push the changes.\n");
310 s.push_str("3. Update consuming projects to the desired `ref` when needed.\n");
311 s.push_str("4. Consumers will re-fetch and re-validate the registry on each run.\n\n");
312
313 s.push_str("## Notes\n\n");
314 s.push_str("- The local registry cache is disposable and may be deleted safely.\n");
315 s.push_str("- State is advisory only and not authoritative.\n");
316 s.push_str("- Registries are trusted input; commit-wizard reads files only and must not execute code from the registry.\n");
317
318 if context.registry_options.sections {
319 s.push_str("\nThis scaffold includes example sections: `standard/` and `team1/`.\n");
320 }
321
322 s
323}