1use crate::colors;
2use crate::config::{CURRENT_RUNTIME_SCHEMA_VERSION, Config};
3use anyhow::{Context, Result, bail};
4use std::future::Future;
5use std::pin::Pin;
6
7const UPDATE_CACHE_FILE: &str = "update-check.json";
8
9pub async fn handle_migrate(config: &Config, dry_run: bool) -> Result<()> {
10 let schema_version = config.schema_version;
11 if schema_version > CURRENT_RUNTIME_SCHEMA_VERSION {
12 bail!(
13 "runtime schema {} is newer than this shine supports ({})",
14 schema_version,
15 CURRENT_RUNTIME_SCHEMA_VERSION
16 );
17 }
18
19 let steps = pending_steps(schema_version);
20 if steps.is_empty() {
21 if !dry_run && config.last_cleared_schema_version != Some(CURRENT_RUNTIME_SCHEMA_VERSION) {
22 let mut updated = config.clone();
23 updated.last_cleared_schema_version = Some(CURRENT_RUNTIME_SCHEMA_VERSION);
24 updated.save().await?;
25 }
26 println!(
27 "{}",
28 colors::green(&format!(
29 "Runtime config schema is already current ({CURRENT_RUNTIME_SCHEMA_VERSION})."
30 ))
31 );
32 return Ok(());
33 }
34
35 println!("{}", colors::bold("Migrating old runtime state"));
36 crate::config::print_presets_note(config);
37
38 for step in &steps {
39 println!("{}", colors::dim(&format!("schema {}", step.to_version)));
40 for action in actions_for_step(config, step) {
41 if dry_run {
42 println!(" [dry-run] {}", action.description);
43 } else {
44 println!(" {}", action.description);
45 (action.apply).await?;
46 }
47 }
48 }
49
50 if dry_run {
51 println!();
52 println!(
53 "{}",
54 colors::dim("Dry run only. Run `shine state migrate` to apply these changes.")
55 );
56 return Ok(());
57 }
58
59 let mut updated = config.clone();
60 updated.schema_version = CURRENT_RUNTIME_SCHEMA_VERSION;
61 updated.last_cleared_schema_version = Some(CURRENT_RUNTIME_SCHEMA_VERSION);
62 updated.save().await?;
63
64 println!();
65 println!(
66 "{}",
67 colors::green(&format!(
68 "Runtime config schema is now {CURRENT_RUNTIME_SCHEMA_VERSION}."
69 ))
70 );
71 Ok(())
72}
73
74pub fn pending_schema_warning(schema_version: u32) -> Option<String> {
75 if schema_version >= CURRENT_RUNTIME_SCHEMA_VERSION {
76 return None;
77 }
78
79 Some(format!(
80 "Runtime config schema is behind: {schema_version} -> {CURRENT_RUNTIME_SCHEMA_VERSION}. Run `shine state migrate --dry-run` to inspect cleanup, then `shine state migrate`."
81 ))
82}
83
84struct CleanupStep {
85 to_version: u32,
86}
87
88struct CleanupAction<'a> {
89 description: String,
90 apply: Pin<Box<dyn Future<Output = Result<()>> + Send + 'a>>,
91}
92
93fn pending_steps(schema_version: u32) -> Vec<CleanupStep> {
94 ((schema_version + 1)..=CURRENT_RUNTIME_SCHEMA_VERSION)
95 .map(|to_version| CleanupStep { to_version })
96 .collect()
97}
98
99fn actions_for_step<'a>(config: &'a Config, step: &CleanupStep) -> Vec<CleanupAction<'a>> {
100 match step.to_version {
101 1 => {
102 let path = config.shine_dir().join(UPDATE_CACHE_FILE);
103 vec![CleanupAction {
104 description: format!("remove stale update cache {}", path.display()),
105 apply: Box::pin(async move {
106 match tokio::fs::remove_file(&path).await {
107 Ok(()) => Ok(()),
108 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
109 Err(e) => Err(e).with_context(|| format!("removing {}", path.display())),
110 }
111 }),
112 }]
113 }
114 _ => Vec::new(),
115 }
116}
117
118#[cfg(test)]
119mod tests {
120 use super::*;
121 use tokio::fs;
122
123 async fn make_temp_dir() -> std::path::PathBuf {
124 crate::test_support::make_temp_dir("shine-state-migrate").await
125 }
126
127 #[test]
128 fn pending_schema_warning_reports_old_schema() {
129 let warning = pending_schema_warning(0).unwrap();
130 assert!(warning.contains("0 -> 1"));
131 assert!(pending_schema_warning(CURRENT_RUNTIME_SCHEMA_VERSION).is_none());
132 }
133
134 #[tokio::test]
135 async fn migrate_removes_update_cache_and_records_schema() {
136 let dir = make_temp_dir().await;
137 let mut config = Config::new_for_test(&dir);
138 config.schema_version = 0;
139 fs::write(dir.join(UPDATE_CACHE_FILE), b"stale")
140 .await
141 .unwrap();
142
143 handle_migrate(&config, false).await.unwrap();
144
145 assert!(!dir.join(UPDATE_CACHE_FILE).exists());
146 let content = fs::read_to_string(dir.join("config.toml")).await.unwrap();
147 let parsed: toml::Table = toml::from_str(&content).unwrap();
148 assert_eq!(
149 parsed["schema_version"].as_integer(),
150 Some(CURRENT_RUNTIME_SCHEMA_VERSION.into())
151 );
152 assert_eq!(
153 parsed["last_cleared_schema_version"].as_integer(),
154 Some(CURRENT_RUNTIME_SCHEMA_VERSION.into())
155 );
156
157 fs::remove_dir_all(&dir).await.unwrap();
158 }
159
160 #[tokio::test]
161 async fn dry_run_does_not_remove_or_save() {
162 let dir = make_temp_dir().await;
163 let mut config = Config::new_for_test(&dir);
164 config.schema_version = 0;
165 fs::write(dir.join(UPDATE_CACHE_FILE), b"stale")
166 .await
167 .unwrap();
168
169 handle_migrate(&config, true).await.unwrap();
170
171 assert!(dir.join(UPDATE_CACHE_FILE).exists());
172 assert!(!dir.join("config.toml").exists());
173
174 fs::remove_dir_all(&dir).await.unwrap();
175 }
176
177 #[tokio::test]
178 async fn migrate_records_last_cleared_when_already_current() {
179 let dir = make_temp_dir().await;
180 let config = Config::new_for_test(&dir);
181
182 handle_migrate(&config, false).await.unwrap();
183
184 let content = fs::read_to_string(dir.join("config.toml")).await.unwrap();
185 let parsed: toml::Table = toml::from_str(&content).unwrap();
186 assert_eq!(
187 parsed["schema_version"].as_integer(),
188 Some(CURRENT_RUNTIME_SCHEMA_VERSION.into())
189 );
190 assert_eq!(
191 parsed["last_cleared_schema_version"].as_integer(),
192 Some(CURRENT_RUNTIME_SCHEMA_VERSION.into())
193 );
194
195 fs::remove_dir_all(&dir).await.unwrap();
196 }
197
198 #[tokio::test]
199 async fn migrate_does_not_remove_other_runtime_files() {
200 let dir = make_temp_dir().await;
201 let mut config = Config::new_for_test(&dir);
202 config.schema_version = 0;
203 fs::create_dir_all(dir.join("rendered")).await.unwrap();
204 fs::create_dir_all(dir.join("bin")).await.unwrap();
205 fs::create_dir_all(dir.join("presets")).await.unwrap();
206 fs::write(dir.join("app-manifest.toml"), b"entries = []")
207 .await
208 .unwrap();
209
210 handle_migrate(&config, false).await.unwrap();
211
212 assert!(dir.join("rendered").exists());
213 assert!(dir.join("bin").exists());
214 assert!(dir.join("presets").exists());
215 assert!(dir.join("app-manifest.toml").exists());
216
217 fs::remove_dir_all(&dir).await.unwrap();
218 }
219}