Skip to main content

xianyun_cli/commands/
config.rs

1//! `caproni config`: configuration management commands.
2//!
3//! Port of Go's `cmd/config` package (`migrate`, `validate`, `print`).
4
5use std::io::Write;
6
7use anyhow::Context as _;
8use clap::{Args, Subcommand};
9use xianyun_core::config::caproni::{load_caproni_config, load_from_context};
10use xianyun_core::config::errors::ConfigError;
11use xianyun_core::config::global::{Context, from_context};
12use xianyun_core::config::migration::{MigrationOptions, plan_migration};
13use xianyun_core::config::registry::LATEST_VERSION;
14use xianyun_core::config::version::{ConfigVersion, detect_version_from_file};
15use xianyun_core::orderedmap::OrderedMap;
16
17#[derive(Args, Debug)]
18pub struct ConfigArgs {
19    #[command(subcommand)]
20    pub command: ConfigCommands,
21}
22
23#[derive(Subcommand, Debug)]
24pub enum ConfigCommands {
25    /// Migrate configuration to the latest format.
26    Migrate(MigrateArgs),
27
28    /// Validate configuration file.
29    Validate(ValidateArgs),
30
31    /// Print the resolved configuration.
32    Print(PrintArgs),
33}
34
35#[derive(Args, Debug)]
36pub struct MigrateArgs {
37    /// Preview migration without modifying files.
38    #[arg(short = 'n', long = "dry-run")]
39    pub dry_run: bool,
40
41    /// Skip confirmation prompt.
42    #[arg(short = 'f', long = "force")]
43    pub force: bool,
44
45    /// Skip creating backup file (use with caution).
46    #[arg(long = "skip-backup")]
47    pub skip_backup: bool,
48}
49
50#[derive(Args, Debug)]
51pub struct ValidateArgs {
52    /// Allow deprecated (older, non-latest) configuration versions.
53    #[arg(long = "allow-deprecated", default_value_t = true)]
54    pub allow_deprecated: bool,
55}
56
57#[derive(Args, Debug)]
58pub struct PrintArgs {}
59
60/// Reads a single confirmation line from stdin, echoing the prompt to
61/// stdout first. Mirrors `pterm.DefaultInteractiveConfirm`'s default (an
62/// empty line, i.e. just pressing Enter) resolving to "no": only an
63/// explicit "y"/"yes" (case-insensitive) counts as confirmed.
64fn stdin_confirm(prompt: &str) -> anyhow::Result<bool> {
65    print!("{prompt} [y/N] ");
66    std::io::stdout().flush().context("flushing prompt")?;
67
68    let mut line = String::new();
69    std::io::stdin()
70        .read_line(&mut line)
71        .context("reading confirmation")?;
72
73    let answer = line.trim().to_ascii_lowercase();
74    Ok(answer == "y" || answer == "yes")
75}
76
77pub fn run(args: &ConfigArgs, ctx: &Context) -> anyhow::Result<()> {
78    match &args.command {
79        ConfigCommands::Migrate(a) => run_migrate(
80            ctx,
81            a.dry_run,
82            a.force,
83            a.skip_backup,
84            &mut std::io::stdout(),
85            &mut stdin_confirm,
86        ),
87        ConfigCommands::Validate(a) => run_validate(
88            ctx,
89            &mut std::io::stdout(),
90            &mut std::io::stderr(),
91            a.allow_deprecated,
92        ),
93        ConfigCommands::Print(_) => run_print(ctx, &mut std::io::stdout()),
94    }
95}
96
97/// Port of `config.runMigrate`: computes the migration plan once, then
98/// either previews it (`--dry-run`), confirms with the user (unless
99/// `--force`), or writes it to disk with a `.bak` backup (unless
100/// `--skip-backup`).
101///
102/// `confirm` mirrors Go's `pterm.DefaultInteractiveConfirm.Show()` as an
103/// injected closure (taking the prompt text, returning the user's yes/no
104/// answer) rather than a hard-wired terminal read, so this is directly
105/// testable without a real TTY -- the same injected-behavior pattern
106/// `up`/`ca`/`deploy`/`run` use for their `Connector`/`KubectlFactory`
107/// parameters (see `PORT_NOTES.md`).
108pub fn run_migrate(
109    ctx: &Context,
110    dry_run: bool,
111    force: bool,
112    skip_backup: bool,
113    stdout: &mut dyn Write,
114    confirm: &mut dyn FnMut(&str) -> anyhow::Result<bool>,
115) -> anyhow::Result<()> {
116    let global = from_context(ctx).ok_or_else(|| {
117        ConfigError::ConfigFileNotFound("no global config in context".to_string())
118    })?;
119    let config_file = global.config_file.clone();
120
121    tracing::info!(config_file = %config_file, "analyzing configuration");
122
123    // 1. Compute the upgrade once. This also detects the "already latest"
124    //    case. The same plan is reused for the write below, so the file is
125    //    read and the upgrade chain is run only once.
126    let plan = match plan_migration(&config_file) {
127        Ok(plan) => plan,
128        Err(ConfigError::AlreadyMigrated) => {
129            writeln!(
130                stdout,
131                "Configuration is already at the latest version. Nothing to migrate."
132            )?;
133            return Ok(());
134        }
135        Err(err) => return Err(err).context("planning migration"),
136    };
137
138    // 2. Dry-run: show the preview and stop.
139    if dry_run {
140        writeln!(stdout)?;
141        writeln!(
142            stdout,
143            "\u{26a0}\u{fe0f}  Dry-run mode: No files will be modified"
144        )?;
145        writeln!(stdout)?;
146        writeln!(stdout, "Preview of migrated configuration:")?;
147        writeln!(stdout, "---")?;
148        writeln!(stdout, "{}", String::from_utf8_lossy(&plan.content))?;
149        writeln!(stdout, "---")?;
150        writeln!(stdout)?;
151        writeln!(stdout, "To apply this migration, run without --dry-run:")?;
152        writeln!(stdout, "  caproni config migrate")?;
153        writeln!(stdout)?;
154        return Ok(());
155    }
156
157    // 3. Confirm with the user unless --force.
158    writeln!(stdout)?;
159    writeln!(
160        stdout,
161        "\u{26a0}\u{fe0f}  This will rewrite your configuration in the latest format."
162    )?;
163
164    if !skip_backup {
165        writeln!(stdout, "   A backup will be saved to {config_file}.bak")?;
166    }
167
168    writeln!(stdout)?;
169
170    if !force {
171        let confirmed = confirm(&format!("Write migrated config to {config_file}?"))
172            .context("reading user input")?;
173
174        if !confirmed {
175            writeln!(stdout, "Migration cancelled.")?;
176            return Ok(());
177        }
178    }
179
180    // 4. Perform the migration, reusing the plan computed above.
181    let result = plan
182        .write(Some(MigrationOptions {
183            backup_suffix: ".bak".to_string(),
184            skip_backup,
185        }))
186        .context("migration failed")?;
187
188    // 5. Display the results.
189    writeln!(stdout)?;
190
191    if !skip_backup {
192        writeln!(stdout, "\u{2713} Backup saved to {}", result.backup_file)?;
193    }
194
195    writeln!(
196        stdout,
197        "\u{2713} Migrated config written to {} ({} \u{2192} {})",
198        result.original_file, result.from_version, result.to_version
199    )?;
200    writeln!(stdout)?;
201
202    Ok(())
203}
204
205/// Port of `config.runValidate`: loads and validates the configuration,
206/// writing failure diagnostics to `stderr` and success diagnostics to
207/// `stdout` so they survive shell redirection of the command's stdout.
208pub fn run_validate(
209    ctx: &Context,
210    stdout: &mut dyn Write,
211    stderr: &mut dyn Write,
212    allow_deprecated: bool,
213) -> anyhow::Result<()> {
214    let global = from_context(ctx).ok_or_else(|| {
215        ConfigError::ConfigFileNotFound("no global config in context".to_string())
216    })?;
217    let config_file = global.config_file.clone();
218
219    tracing::debug!(config_file = %config_file, "validating configuration");
220
221    let version = detect_version_from_file(&config_file).context("detecting config version")?;
222
223    let caproni_cfg = match load_caproni_config(&global) {
224        Ok(cfg) => cfg,
225        Err(err) => {
226            writeln!(stderr, "\u{2717} Configuration validation failed")?;
227            writeln!(stderr)?;
228
229            // **Adaptation** (see `PORT_NOTES.md`): Go's `runValidate` uses
230            // `errors.AsType[*config.ValidationError]` to render each
231            // semantic-validation failure as its own bullet. This port's
232            // `load_caproni_config` already flattens a
233            // `validate::ValidationError` (a `Vec<ConfigError>`) into a
234            // single `ConfigError::SemanticValidation(String)` via
235            // `.to_string()` before it ever reaches here, so the original
236            // list structure isn't available to re-render -- the flattened
237            // message (already numbered when there was more than one
238            // error; see `validate::render_validation_errors`) is printed
239            // as a single "Validation errors:" block instead.
240            if matches!(err, ConfigError::SemanticValidation(_)) {
241                writeln!(stderr, "Validation errors:")?;
242                writeln!(stderr, "  \u{2022} {err}")?;
243            } else {
244                writeln!(stderr, "Error: {err}")?;
245            }
246            writeln!(stderr)?;
247
248            return Err(err).context("config validation failed");
249        }
250    };
251
252    // The config is well-formed. Enforce the deprecation policy.
253    report_deprecation(stderr, version, allow_deprecated)?;
254
255    writeln!(stdout, "\u{2713} Configuration is valid ({version})")?;
256    writeln!(
257        stdout,
258        "  \u{2022} {} deployers configured",
259        caproni_cfg.deployers.as_ref().map_or(0, OrderedMap::len)
260    )?;
261    writeln!(
262        stdout,
263        "  \u{2022} {} repositories defined",
264        caproni_cfg.repositories.as_ref().map_or(0, OrderedMap::len)
265    )?;
266    writeln!(
267        stdout,
268        "  \u{2022} {} reloaders configured",
269        caproni_cfg.reloaders.as_ref().map_or(0, OrderedMap::len)
270    )?;
271    writeln!(stdout)?;
272
273    Ok(())
274}
275
276/// Port of `config.reportDeprecation`: enforces the deprecation policy for
277/// a well-formed config -- a supported but non-latest version is
278/// deprecated and rejected, with guidance written to `stderr`, unless
279/// `allow_deprecated` is set.
280pub fn report_deprecation(
281    stderr: &mut dyn Write,
282    version: ConfigVersion,
283    allow_deprecated: bool,
284) -> anyhow::Result<()> {
285    if allow_deprecated || version == LATEST_VERSION {
286        return Ok(());
287    }
288
289    writeln!(
290        stderr,
291        "\u{2717} Configuration uses a deprecated version ({version})"
292    )?;
293    writeln!(stderr)?;
294    writeln!(
295        stderr,
296        "Run 'caproni config migrate' to upgrade it to the latest format,"
297    )?;
298    writeln!(stderr, "or pass --allow-deprecated to accept it.")?;
299    writeln!(stderr)?;
300
301    Err(ConfigError::DeprecatedVersion).context("configuration version is deprecated")
302}
303
304/// Port of `config.runPrint`: prints the fully resolved configuration
305/// (after applying all overlays) as YAML.
306pub fn run_print(ctx: &Context, stdout: &mut dyn Write) -> anyhow::Result<()> {
307    let container = load_from_context(ctx).context("loading config")?;
308
309    let yaml = serde_yaml::to_string(&container.caproni).context("writing config")?;
310    write!(stdout, "{yaml}")?;
311
312    Ok(())
313}