1use 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(MigrateArgs),
27
28 Validate(ValidateArgs),
30
31 Print(PrintArgs),
33}
34
35#[derive(Args, Debug)]
36pub struct MigrateArgs {
37 #[arg(short = 'n', long = "dry-run")]
39 pub dry_run: bool,
40
41 #[arg(short = 'f', long = "force")]
43 pub force: bool,
44
45 #[arg(long = "skip-backup")]
47 pub skip_backup: bool,
48}
49
50#[derive(Args, Debug)]
51pub struct ValidateArgs {
52 #[arg(long = "allow-deprecated", default_value_t = true)]
54 pub allow_deprecated: bool,
55}
56
57#[derive(Args, Debug)]
58pub struct PrintArgs {}
59
60fn 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
97pub 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 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 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 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 let result = plan
182 .write(Some(MigrationOptions {
183 backup_suffix: ".bak".to_string(),
184 skip_backup,
185 }))
186 .context("migration failed")?;
187
188 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
205pub 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 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 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
276pub 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
304pub 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}