Skip to main content

cli/
state.rs

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}