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::collections::BTreeSet;
5use std::future::Future;
6use std::path::{Path, PathBuf};
7use std::pin::Pin;
8
9const UPDATE_CACHE_FILE: &str = "update-check.json";
10
11pub async fn handle_migrate(config: &Config, dry_run: bool) -> Result<()> {
12    let schema_version = config.schema_version;
13    if schema_version > CURRENT_RUNTIME_SCHEMA_VERSION {
14        bail!(
15            "runtime schema {} is newer than this shine supports ({})",
16            schema_version,
17            CURRENT_RUNTIME_SCHEMA_VERSION
18        );
19    }
20
21    let steps = pending_steps(schema_version);
22    if steps.is_empty() {
23        let mut migrated_local_state = false;
24        for path in config_migration_paths(config) {
25            if config_needs_migration(&path).await? {
26                if dry_run {
27                    println!(
28                        "[dry-run] migrate GPG recipient configuration in {}",
29                        path.display()
30                    );
31                } else {
32                    migrate_config_gpg_recipient(&path).await?;
33                    println!("Migrated GPG recipient configuration in {}", path.display());
34                }
35                migrated_local_state = true;
36            }
37        }
38        if let Some(path) = find_workspace_from_current_dir()
39            && workspace_needs_migration(&path).await?
40        {
41            if dry_run {
42                println!("[dry-run] migrate workspace format in {}", path.display());
43            } else {
44                migrate_workspace_gpg_recipient(&path).await?;
45                println!("Migrated workspace format in {}", path.display());
46            }
47            migrated_local_state = true;
48        }
49        if migrated_local_state {
50            return Ok(());
51        }
52        if !dry_run && config.last_cleared_schema_version != Some(CURRENT_RUNTIME_SCHEMA_VERSION) {
53            let mut updated = config.clone();
54            updated.last_cleared_schema_version = Some(CURRENT_RUNTIME_SCHEMA_VERSION);
55            updated.save().await?;
56        }
57        println!(
58            "{}",
59            colors::green(&format!(
60                "Runtime config schema is already current ({CURRENT_RUNTIME_SCHEMA_VERSION})."
61            ))
62        );
63        return Ok(());
64    }
65
66    println!("{}", colors::bold("Migrating old runtime state"));
67    crate::config::print_presets_note(config);
68
69    for step in &steps {
70        println!("{}", colors::dim(&format!("schema {}", step.to_version)));
71        for action in actions_for_step(config, step) {
72            if dry_run {
73                println!("  [dry-run] {}", action.description);
74            } else {
75                println!("  {}", action.description);
76                (action.apply).await?;
77            }
78        }
79    }
80
81    if dry_run {
82        println!();
83        println!(
84            "{}",
85            colors::dim("Dry run only. Run `shine state migrate` to apply these changes.")
86        );
87        return Ok(());
88    }
89
90    let mut updated = config.clone();
91    if let Some(recipients) = gpg_recipients_from_config(config.config_path()).await? {
92        updated.gpg_recipients = recipients;
93    }
94    updated.legacy_gpg_key_id = None;
95    updated.schema_version = CURRENT_RUNTIME_SCHEMA_VERSION;
96    updated.last_cleared_schema_version = Some(CURRENT_RUNTIME_SCHEMA_VERSION);
97    updated.save().await?;
98
99    println!();
100    println!(
101        "{}",
102        colors::green(&format!(
103            "Runtime config schema is now {CURRENT_RUNTIME_SCHEMA_VERSION}."
104        ))
105    );
106    Ok(())
107}
108
109pub fn pending_schema_warning(schema_version: u32) -> Option<String> {
110    if schema_version >= CURRENT_RUNTIME_SCHEMA_VERSION {
111        return None;
112    }
113
114    Some(format!(
115        "Runtime config schema is behind: {schema_version} -> {CURRENT_RUNTIME_SCHEMA_VERSION}. Run `shine state migrate --dry-run` to inspect cleanup, then `shine state migrate`."
116    ))
117}
118
119struct CleanupStep {
120    to_version: u32,
121}
122
123struct CleanupAction<'a> {
124    description: String,
125    apply: Pin<Box<dyn Future<Output = Result<()>> + Send + 'a>>,
126}
127
128fn pending_steps(schema_version: u32) -> Vec<CleanupStep> {
129    ((schema_version + 1)..=CURRENT_RUNTIME_SCHEMA_VERSION)
130        .map(|to_version| CleanupStep { to_version })
131        .collect()
132}
133
134fn actions_for_step<'a>(config: &'a Config, step: &CleanupStep) -> Vec<CleanupAction<'a>> {
135    match step.to_version {
136        1 => {
137            let path = config.shine_dir().join(UPDATE_CACHE_FILE);
138            vec![CleanupAction {
139                description: format!("remove stale update cache {}", path.display()),
140                apply: Box::pin(async move {
141                    match tokio::fs::remove_file(&path).await {
142                        Ok(()) => Ok(()),
143                        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
144                        Err(e) => Err(e).with_context(|| format!("removing {}", path.display())),
145                    }
146                }),
147            }]
148        }
149        2 => migration_actions(config),
150        _ => Vec::new(),
151    }
152}
153
154fn migration_actions<'a>(config: &'a Config) -> Vec<CleanupAction<'a>> {
155    let mut actions = Vec::new();
156    for path in config_migration_paths(config) {
157        let description = format!("migrate GPG recipient configuration in {}", path.display());
158        actions.push(CleanupAction {
159            description,
160            apply: Box::pin(async move { migrate_config_gpg_recipient(&path).await }),
161        });
162    }
163    if let Some(path) = find_workspace_from_current_dir() {
164        let description = format!(
165            "migrate GPG workspace recipient configuration in {}",
166            path.display()
167        );
168        actions.push(CleanupAction {
169            description,
170            apply: Box::pin(
171                async move { migrate_workspace_gpg_recipient(&path).await.map(|_| ()) },
172            ),
173        });
174    }
175    actions
176}
177
178fn config_migration_paths(config: &Config) -> Vec<PathBuf> {
179    let mut paths = BTreeSet::from([config.config_path().to_path_buf()]);
180    if let Ok(current_dir) = std::env::current_dir()
181        && let Some(project) = crate::config::find_project_config(&current_dir)
182    {
183        paths.insert(project.path);
184    }
185    paths.into_iter().filter(|path| path.is_file()).collect()
186}
187
188fn find_workspace_from_current_dir() -> Option<PathBuf> {
189    let current_dir = std::env::current_dir().ok()?;
190    current_dir
191        .ancestors()
192        .map(|dir| dir.join("shine.workspace.toml"))
193        .find(|path| path.is_file())
194}
195
196async fn migrate_config_gpg_recipient(path: &Path) -> Result<()> {
197    migrate_recipient_key(path, |document| {
198        migrate_key(document, "gpg_key_id", "gpg_recipients")
199    })
200    .await
201    .map(|_| ())
202}
203
204async fn migrate_workspace_gpg_recipient(path: &Path) -> Result<bool> {
205    migrate_recipient_key(path, |document| {
206        let version = document
207            .as_table()
208            .get("version")
209            .and_then(toml_edit::Item::as_integer)
210            .unwrap_or(1);
211        if version > 2 {
212            bail!("workspace version {version} is newer than this shine supports (2)");
213        }
214        let mut changed = false;
215        let encryption = document["env"]["encryption"].as_table_mut();
216        if let Some(table) = encryption {
217            changed |= migrate_table_key(table, "recipient", "gpg_recipients")?;
218        }
219        if version < 2 {
220            document["version"] = toml_edit::value(2);
221            changed = true;
222        }
223        Ok(changed)
224    })
225    .await
226}
227
228async fn migrate_recipient_key(
229    path: &Path,
230    mutate: impl FnOnce(&mut toml_edit::DocumentMut) -> Result<bool>,
231) -> Result<bool> {
232    let contents = tokio::fs::read_to_string(path)
233        .await
234        .with_context(|| format!("reading {}", path.display()))?;
235    let mut document = contents
236        .parse::<toml_edit::DocumentMut>()
237        .with_context(|| format!("parsing {}", path.display()))?;
238    if mutate(&mut document)? {
239        crate::persist::atomic_write(path, document.to_string().as_bytes())
240            .await
241            .with_context(|| format!("writing {}", path.display()))?;
242        return Ok(true);
243    }
244    Ok(false)
245}
246
247async fn workspace_needs_migration(path: &Path) -> Result<bool> {
248    let contents = tokio::fs::read_to_string(path)
249        .await
250        .with_context(|| format!("reading {}", path.display()))?;
251    let document = contents
252        .parse::<toml_edit::DocumentMut>()
253        .with_context(|| format!("parsing {}", path.display()))?;
254    Ok(document
255        .as_table()
256        .get("version")
257        .and_then(toml_edit::Item::as_integer)
258        .unwrap_or(1)
259        < 2
260        || document["env"]["encryption"]["recipient"].is_value())
261}
262
263async fn config_needs_migration(path: &Path) -> Result<bool> {
264    let contents = tokio::fs::read_to_string(path)
265        .await
266        .with_context(|| format!("reading {}", path.display()))?;
267    let document = contents
268        .parse::<toml_edit::DocumentMut>()
269        .with_context(|| format!("parsing {}", path.display()))?;
270    Ok(document.as_table().contains_key("gpg_key_id"))
271}
272
273fn migrate_key(document: &mut toml_edit::DocumentMut, old: &str, new: &str) -> Result<bool> {
274    migrate_table_key(document.as_table_mut(), old, new)
275}
276
277fn migrate_table_key(table: &mut toml_edit::Table, old: &str, new: &str) -> Result<bool> {
278    let Some(old_item) = table.get(old) else {
279        return Ok(false);
280    };
281    if table.contains_key(new) {
282        bail!("configuration contains both {old} and {new}; resolve the conflict before migrating");
283    }
284    let recipient = old_item
285        .as_str()
286        .context("legacy GPG recipient must be a string")?
287        .to_owned();
288    let key_decor = table.key(old).map(|key| key.leaf_decor().clone());
289    let decor = old_item.as_value().map(|value| value.decor().clone());
290    table.remove(old);
291    let mut recipients = toml_edit::Array::new();
292    recipients.push(recipient);
293    table.insert(
294        new,
295        toml_edit::Item::Value(toml_edit::Value::Array(recipients)),
296    );
297    if let (Some(decor), Some(value)) = (
298        decor,
299        table.get_mut(new).and_then(toml_edit::Item::as_value_mut),
300    ) {
301        *value.decor_mut() = decor;
302    }
303    if let (Some(decor), Some(mut key)) = (key_decor, table.key_mut(new)) {
304        *key.leaf_decor_mut() = decor;
305    }
306    Ok(true)
307}
308
309async fn gpg_recipients_from_config(path: &Path) -> Result<Option<Vec<String>>> {
310    let contents = match tokio::fs::read_to_string(path).await {
311        Ok(contents) => contents,
312        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
313        Err(error) => return Err(error).with_context(|| format!("reading {}", path.display())),
314    };
315    let table: toml::Table =
316        toml::from_str(&contents).with_context(|| format!("parsing {}", path.display()))?;
317    let Some(value) = table.get("gpg_recipients") else {
318        return Ok(None);
319    };
320    let recipients = value
321        .as_array()
322        .context("gpg_recipients must be an array")?
323        .iter()
324        .map(|value| {
325            value
326                .as_str()
327                .context("gpg_recipients entries must be strings")
328        })
329        .collect::<Result<Vec<_>>>()?
330        .into_iter()
331        .map(str::to_owned)
332        .collect();
333    Ok(Some(recipients))
334}
335
336#[cfg(test)]
337mod tests {
338    use super::*;
339    use tokio::fs;
340
341    async fn make_temp_dir() -> std::path::PathBuf {
342        crate::test_support::make_temp_dir("shine-state-migrate").await
343    }
344
345    #[test]
346    fn pending_schema_warning_reports_old_schema() {
347        let warning = pending_schema_warning(0).unwrap();
348        assert!(warning.contains("0 -> 2"));
349        assert!(pending_schema_warning(CURRENT_RUNTIME_SCHEMA_VERSION).is_none());
350    }
351
352    #[tokio::test]
353    async fn migrate_removes_update_cache_and_records_schema() {
354        let dir = make_temp_dir().await;
355        let mut config = Config::new_for_test(&dir);
356        config.schema_version = 0;
357        fs::write(dir.join(UPDATE_CACHE_FILE), b"stale")
358            .await
359            .unwrap();
360
361        handle_migrate(&config, false).await.unwrap();
362
363        assert!(!dir.join(UPDATE_CACHE_FILE).exists());
364        let content = fs::read_to_string(dir.join("config.toml")).await.unwrap();
365        let parsed: toml::Table = toml::from_str(&content).unwrap();
366        assert_eq!(
367            parsed["schema_version"].as_integer(),
368            Some(CURRENT_RUNTIME_SCHEMA_VERSION.into())
369        );
370        assert_eq!(
371            parsed["last_cleared_schema_version"].as_integer(),
372            Some(CURRENT_RUNTIME_SCHEMA_VERSION.into())
373        );
374
375        fs::remove_dir_all(&dir).await.unwrap();
376    }
377
378    #[tokio::test]
379    async fn migrate_converts_legacy_gpg_recipient_to_a_list() {
380        let dir = make_temp_dir().await;
381        let mut config = Config::new_for_test(&dir);
382        config.schema_version = 1;
383        fs::write(
384            config.config_path(),
385            "# team encryption key\ngpg_key_id = \"alice@example.com\"\n",
386        )
387        .await
388        .unwrap();
389
390        handle_migrate(&config, false).await.unwrap();
391
392        let content = fs::read_to_string(config.config_path()).await.unwrap();
393        assert!(!content.contains("gpg_key_id"));
394        assert!(content.contains("# team encryption key"));
395        let parsed: toml::Table = toml::from_str(&content).unwrap();
396        assert_eq!(
397            parsed["gpg_recipients"].as_array().unwrap(),
398            &[toml::Value::String("alice@example.com".to_string())]
399        );
400        assert_eq!(
401            parsed["schema_version"].as_integer(),
402            Some(CURRENT_RUNTIME_SCHEMA_VERSION.into())
403        );
404
405        fs::remove_dir_all(&dir).await.unwrap();
406    }
407
408    #[tokio::test]
409    async fn workspace_migration_converts_legacy_recipient() {
410        let dir = make_temp_dir().await;
411        let workspace = dir.join("shine.workspace.toml");
412        fs::write(
413            &workspace,
414            "[env.encryption]\n# deployment key\nrecipient = \"alice@example.com\"\n",
415        )
416        .await
417        .unwrap();
418
419        migrate_workspace_gpg_recipient(&workspace).await.unwrap();
420
421        let content = fs::read_to_string(&workspace).await.unwrap();
422        assert!(!content.contains("recipient ="));
423        assert!(content.contains("# deployment key"));
424        assert!(content.contains("gpg_recipients = [\"alice@example.com\"]"));
425        assert!(content.contains("version = 2"));
426        fs::remove_dir_all(&dir).await.unwrap();
427    }
428
429    #[tokio::test]
430    async fn dry_run_does_not_remove_or_save() {
431        let dir = make_temp_dir().await;
432        let mut config = Config::new_for_test(&dir);
433        config.schema_version = 0;
434        fs::write(dir.join(UPDATE_CACHE_FILE), b"stale")
435            .await
436            .unwrap();
437
438        handle_migrate(&config, true).await.unwrap();
439
440        assert!(dir.join(UPDATE_CACHE_FILE).exists());
441        assert!(!dir.join("config.toml").exists());
442
443        fs::remove_dir_all(&dir).await.unwrap();
444    }
445
446    #[tokio::test]
447    async fn migrate_records_last_cleared_when_already_current() {
448        let dir = make_temp_dir().await;
449        let config = Config::new_for_test(&dir);
450
451        handle_migrate(&config, false).await.unwrap();
452
453        let content = fs::read_to_string(dir.join("config.toml")).await.unwrap();
454        let parsed: toml::Table = toml::from_str(&content).unwrap();
455        assert_eq!(
456            parsed["schema_version"].as_integer(),
457            Some(CURRENT_RUNTIME_SCHEMA_VERSION.into())
458        );
459        assert_eq!(
460            parsed["last_cleared_schema_version"].as_integer(),
461            Some(CURRENT_RUNTIME_SCHEMA_VERSION.into())
462        );
463
464        fs::remove_dir_all(&dir).await.unwrap();
465    }
466
467    #[tokio::test]
468    #[allow(clippy::await_holding_lock)]
469    async fn migrate_current_schema_converts_legacy_project_config() {
470        let _lock = crate::test_support::env_lock();
471        let original_dir = std::env::current_dir().unwrap();
472        let dir = make_temp_dir().await;
473        let project_dir = dir.join("project");
474        fs::create_dir_all(&project_dir).await.unwrap();
475        let project_config = project_dir.join("shine.config.toml");
476        fs::write(
477            &project_config,
478            "# project key\ngpg_key_id = \"project@example.com\"\n",
479        )
480        .await
481        .unwrap();
482        std::env::set_current_dir(&project_dir).unwrap();
483        let config = Config::new_for_test(&dir);
484
485        let result = handle_migrate(&config, false).await;
486        crate::test_support::restore_current_dir(&original_dir);
487        result.unwrap();
488
489        let content = fs::read_to_string(&project_config).await.unwrap();
490        assert!(!content.contains("gpg_key_id"));
491        assert!(content.contains("# project key"));
492        assert!(content.contains("gpg_recipients = [\"project@example.com\"]"));
493
494        fs::remove_dir_all(&dir).await.unwrap();
495    }
496
497    #[tokio::test]
498    async fn migrate_does_not_remove_other_runtime_files() {
499        let dir = make_temp_dir().await;
500        let mut config = Config::new_for_test(&dir);
501        config.schema_version = 0;
502        fs::create_dir_all(dir.join("rendered")).await.unwrap();
503        fs::create_dir_all(dir.join("bin")).await.unwrap();
504        fs::create_dir_all(dir.join("presets")).await.unwrap();
505        fs::write(dir.join("app-manifest.toml"), b"entries = []")
506            .await
507            .unwrap();
508
509        handle_migrate(&config, false).await.unwrap();
510
511        assert!(dir.join("rendered").exists());
512        assert!(dir.join("bin").exists());
513        assert!(dir.join("presets").exists());
514        assert!(dir.join("app-manifest.toml").exists());
515
516        fs::remove_dir_all(&dir).await.unwrap();
517    }
518}