Skip to main content

commit_wizard/engine/capabilities/config/
init.rs

1use 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
54/// Output returned from init_config, carrying back info the usecase needs (e.g. dry-run content).
55pub struct InitOutput {
56    /// The primary file path that was written (or would be written on dry-run).
57    pub path: PathBuf,
58    /// TOML content that would be written; only populated when dry_run=true.
59    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            // output_path is already the target file path (.cwizard.toml or cwizard.toml)
75            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        &registry_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(&section_dir)?;
242            }
243
244            save_standard(
245                &section_dir.join(CONFIG_FILE_NAME),
246                &config,
247                context.force,
248                context.dry_run,
249            )?;
250
251            save_rules(
252                &section_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}