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    // Validate every file in the migration scope before applying any cleanup
22    // or recipient rewrite so one malformed legacy recipient cannot leave a
23    // partially migrated global/project/workspace set.
24    validate_recipient_migration_inputs(config).await?;
25
26    let steps = pending_steps(schema_version);
27    if steps.is_empty() {
28        let mut migrated_local_state = false;
29        for path in config_migration_paths(config) {
30            if config_needs_migration(&path).await? {
31                let age_count = config_age_migration_count(&path).await?;
32                if dry_run {
33                    println!(
34                        "[dry-run] migrate recipient configuration in {} ({age_count} age1se recipient(s) to age1tag)",
35                        path.display(),
36                    );
37                } else {
38                    migrate_config_gpg_recipient(&path).await?;
39                    println!(
40                        "Migrated recipient configuration in {} ({age_count} age1se recipient(s) to age1tag)",
41                        path.display()
42                    );
43                }
44                if age_count > 0 {
45                    println!(
46                        "  Note: native tagged recipients let someone who knows the recipient test whether a ciphertext targets it."
47                    );
48                }
49                migrated_local_state = true;
50            }
51        }
52        if let Some(path) = find_workspace_from_current_dir()
53            && workspace_needs_migration(&path).await?
54        {
55            let age_count = workspace_age_migration_count(&path).await?;
56            if dry_run {
57                println!(
58                    "[dry-run] migrate workspace format and recipients in {} ({age_count} age1se recipient(s) to age1tag)",
59                    path.display()
60                );
61            } else {
62                migrate_workspace_gpg_recipient(&path).await?;
63                println!(
64                    "Migrated workspace format and recipients in {} ({age_count} age1se recipient(s) to age1tag)",
65                    path.display()
66                );
67            }
68            if age_count > 0 {
69                println!(
70                    "  Note: native tagged recipients let someone who knows the recipient test whether a ciphertext targets it."
71                );
72            }
73            migrated_local_state = true;
74        }
75        if migrated_local_state {
76            return Ok(());
77        }
78        if !dry_run && config.last_cleared_schema_version != Some(CURRENT_RUNTIME_SCHEMA_VERSION) {
79            let mut updated = config.clone();
80            updated.last_cleared_schema_version = Some(CURRENT_RUNTIME_SCHEMA_VERSION);
81            updated.save().await?;
82        }
83        println!(
84            "{}",
85            colors::green(&format!(
86                "Runtime config schema is already current ({CURRENT_RUNTIME_SCHEMA_VERSION})."
87            ))
88        );
89        return Ok(());
90    }
91
92    println!("{}", colors::bold("Migrating old runtime state"));
93    crate::config::print_presets_note(config);
94
95    for step in &steps {
96        println!("{}", colors::dim(&format!("schema {}", step.to_version)));
97        for action in actions_for_step(config, step).await? {
98            if dry_run {
99                println!("  [dry-run] {}", action.description);
100            } else {
101                println!("  {}", action.description);
102                (action.apply).await?;
103            }
104        }
105    }
106
107    if dry_run {
108        println!();
109        println!(
110            "{}",
111            colors::dim("Dry run only. Run `shine state migrate` to apply these changes.")
112        );
113        return Ok(());
114    }
115
116    let mut updated = config.clone();
117    if let Some(recipients) = gpg_recipients_from_config(config.config_path()).await? {
118        updated.gpg_recipients = recipients;
119    }
120    if let Some(recipients) = age_recipients_from_config(config.config_path()).await? {
121        updated.age_recipients = recipients;
122    }
123    updated.legacy_gpg_key_id = None;
124    updated.schema_version = CURRENT_RUNTIME_SCHEMA_VERSION;
125    updated.last_cleared_schema_version = Some(CURRENT_RUNTIME_SCHEMA_VERSION);
126    updated.save().await?;
127
128    println!();
129    println!(
130        "{}",
131        colors::green(&format!(
132            "Runtime config schema is now {CURRENT_RUNTIME_SCHEMA_VERSION}."
133        ))
134    );
135    Ok(())
136}
137
138pub fn pending_schema_warning(schema_version: u32) -> Option<String> {
139    if schema_version >= CURRENT_RUNTIME_SCHEMA_VERSION {
140        return None;
141    }
142
143    Some(format!(
144        "Runtime config schema is behind: {schema_version} -> {CURRENT_RUNTIME_SCHEMA_VERSION}. Run `shine state migrate --dry-run` to inspect cleanup, then `shine state migrate`."
145    ))
146}
147
148struct CleanupStep {
149    to_version: u32,
150}
151
152struct CleanupAction<'a> {
153    description: String,
154    apply: Pin<Box<dyn Future<Output = Result<()>> + Send + 'a>>,
155}
156
157fn pending_steps(schema_version: u32) -> Vec<CleanupStep> {
158    ((schema_version + 1)..=CURRENT_RUNTIME_SCHEMA_VERSION)
159        .map(|to_version| CleanupStep { to_version })
160        .collect()
161}
162
163async fn actions_for_step<'a>(
164    config: &'a Config,
165    step: &CleanupStep,
166) -> Result<Vec<CleanupAction<'a>>> {
167    Ok(match step.to_version {
168        1 => {
169            let path = config.shine_dir().join(UPDATE_CACHE_FILE);
170            vec![CleanupAction {
171                description: format!("remove stale update cache {}", path.display()),
172                apply: Box::pin(async move {
173                    match tokio::fs::remove_file(&path).await {
174                        Ok(()) => Ok(()),
175                        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
176                        Err(e) => Err(e).with_context(|| format!("removing {}", path.display())),
177                    }
178                }),
179            }]
180        }
181        2 => migration_actions(config).await?,
182        _ => Vec::new(),
183    })
184}
185
186async fn migration_actions<'a>(config: &'a Config) -> Result<Vec<CleanupAction<'a>>> {
187    let mut actions = Vec::new();
188    for path in config_migration_paths(config) {
189        let age_count = config_age_migration_count(&path).await?;
190        let description = migration_description("recipient configuration", &path, age_count);
191        actions.push(CleanupAction {
192            description,
193            apply: Box::pin(async move { migrate_config_gpg_recipient(&path).await }),
194        });
195    }
196    if let Some(path) = find_workspace_from_current_dir() {
197        let age_count = workspace_age_migration_count(&path).await?;
198        let description =
199            migration_description("workspace recipient configuration", &path, age_count);
200        actions.push(CleanupAction {
201            description,
202            apply: Box::pin(
203                async move { migrate_workspace_gpg_recipient(&path).await.map(|_| ()) },
204            ),
205        });
206    }
207    Ok(actions)
208}
209
210fn migration_description(kind: &str, path: &Path, age_count: usize) -> String {
211    let mut description = format!(
212        "migrate {kind} in {} ({age_count} age1se recipient(s) to age1tag)",
213        path.display()
214    );
215    if age_count > 0 {
216        description.push_str(
217            "; tagged recipients let someone who knows the recipient test whether a ciphertext targets it",
218        );
219    }
220    description
221}
222
223fn config_migration_paths(config: &Config) -> Vec<PathBuf> {
224    let mut paths = BTreeSet::from([config.config_path().to_path_buf()]);
225    if let Ok(current_dir) = std::env::current_dir()
226        && let Some(project) = crate::config::find_project_config(&current_dir)
227    {
228        paths.insert(project.path);
229    }
230    paths.into_iter().filter(|path| path.is_file()).collect()
231}
232
233fn find_workspace_from_current_dir() -> Option<PathBuf> {
234    let current_dir = std::env::current_dir().ok()?;
235    current_dir
236        .ancestors()
237        .map(|dir| dir.join("shine.workspace.toml"))
238        .find(|path| path.is_file())
239}
240
241async fn validate_recipient_migration_inputs(config: &Config) -> Result<()> {
242    for path in config_migration_paths(config) {
243        config_age_migration_count(&path)
244            .await
245            .with_context(|| format!("validating recipient migration in {}", path.display()))?;
246    }
247    if let Some(path) = find_workspace_from_current_dir() {
248        workspace_age_migration_count(&path)
249            .await
250            .with_context(|| format!("validating recipient migration in {}", path.display()))?;
251    }
252    Ok(())
253}
254
255async fn migrate_config_gpg_recipient(path: &Path) -> Result<()> {
256    migrate_recipient_key(path, |document| {
257        let mut changed = migrate_key(document, "gpg_key_id", "gpg_recipients")?;
258        changed |= migrate_age_recipients(document.as_table_mut())? > 0;
259        Ok(changed)
260    })
261    .await
262    .map(|_| ())
263}
264
265async fn migrate_workspace_gpg_recipient(path: &Path) -> Result<bool> {
266    migrate_recipient_key(path, |document| {
267        let version = document
268            .as_table()
269            .get("version")
270            .and_then(toml_edit::Item::as_integer)
271            .unwrap_or(1);
272        if version > 2 {
273            bail!("workspace version {version} is newer than this shine supports (2)");
274        }
275        let mut changed = false;
276        let encryption = document["env"]["encryption"].as_table_mut();
277        if let Some(table) = encryption {
278            changed |= migrate_table_key(table, "recipient", "gpg_recipients")?;
279            changed |= migrate_age_recipients(table)? > 0;
280        }
281        if version < 2 {
282            document["version"] = toml_edit::value(2);
283            changed = true;
284        }
285        Ok(changed)
286    })
287    .await
288}
289
290async fn migrate_recipient_key(
291    path: &Path,
292    mutate: impl FnOnce(&mut toml_edit::DocumentMut) -> Result<bool>,
293) -> Result<bool> {
294    let contents = tokio::fs::read_to_string(path)
295        .await
296        .with_context(|| format!("reading {}", path.display()))?;
297    let mut document = contents
298        .parse::<toml_edit::DocumentMut>()
299        .with_context(|| format!("parsing {}", path.display()))?;
300    if mutate(&mut document)? {
301        crate::persist::atomic_write(path, document.to_string().as_bytes())
302            .await
303            .with_context(|| format!("writing {}", path.display()))?;
304        return Ok(true);
305    }
306    Ok(false)
307}
308
309async fn workspace_needs_migration(path: &Path) -> Result<bool> {
310    let contents = tokio::fs::read_to_string(path)
311        .await
312        .with_context(|| format!("reading {}", path.display()))?;
313    let document = contents
314        .parse::<toml_edit::DocumentMut>()
315        .with_context(|| format!("parsing {}", path.display()))?;
316    Ok(document
317        .as_table()
318        .get("version")
319        .and_then(toml_edit::Item::as_integer)
320        .unwrap_or(1)
321        < 2
322        || document["env"]["encryption"]["recipient"].is_value()
323        || age_migration_count(
324            document["env"]["encryption"]
325                .as_table()
326                .and_then(|table| table.get("age_recipients")),
327        )? > 0)
328}
329
330async fn config_needs_migration(path: &Path) -> Result<bool> {
331    let contents = tokio::fs::read_to_string(path)
332        .await
333        .with_context(|| format!("reading {}", path.display()))?;
334    let document = contents
335        .parse::<toml_edit::DocumentMut>()
336        .with_context(|| format!("parsing {}", path.display()))?;
337    Ok(document.as_table().contains_key("gpg_key_id")
338        || age_migration_count(document.as_table().get("age_recipients"))? > 0)
339}
340
341async fn config_age_migration_count(path: &Path) -> Result<usize> {
342    let contents = tokio::fs::read_to_string(path)
343        .await
344        .with_context(|| format!("reading {}", path.display()))?;
345    let document = contents
346        .parse::<toml_edit::DocumentMut>()
347        .with_context(|| format!("parsing {}", path.display()))?;
348    age_migration_count(document.as_table().get("age_recipients"))
349}
350
351async fn workspace_age_migration_count(path: &Path) -> Result<usize> {
352    let contents = tokio::fs::read_to_string(path)
353        .await
354        .with_context(|| format!("reading {}", path.display()))?;
355    let document = contents
356        .parse::<toml_edit::DocumentMut>()
357        .with_context(|| format!("parsing {}", path.display()))?;
358    age_migration_count(
359        document["env"]["encryption"]
360            .as_table()
361            .and_then(|table| table.get("age_recipients")),
362    )
363}
364
365fn age_migration_count(item: Option<&toml_edit::Item>) -> Result<usize> {
366    let Some(item) = item else {
367        return Ok(0);
368    };
369    let recipients = item.as_array().context("age_recipients must be an array")?;
370    let mut count = 0;
371    for value in recipients {
372        let recipient = value
373            .as_str()
374            .context("age_recipients entries must be strings")?;
375        if recipient.starts_with("age1se1") {
376            crate::secret::secure_enclave_recipient_to_tag(recipient)?;
377            count += 1;
378        }
379    }
380    Ok(count)
381}
382
383fn migrate_age_recipients(table: &mut toml_edit::Table) -> Result<usize> {
384    let Some(item) = table.get_mut("age_recipients") else {
385        return Ok(0);
386    };
387    let recipients = item
388        .as_array_mut()
389        .context("age_recipients must be an array")?;
390    let mut count = 0;
391    for value in recipients.iter_mut() {
392        let recipient = value
393            .as_str()
394            .context("age_recipients entries must be strings")?;
395        if recipient.starts_with("age1se1") {
396            let tagged = crate::secret::secure_enclave_recipient_to_tag(recipient)?;
397            let decor = value.decor().clone();
398            *value = toml_edit::Value::from(tagged);
399            *value.decor_mut() = decor;
400            count += 1;
401        }
402    }
403    Ok(count)
404}
405
406fn migrate_key(document: &mut toml_edit::DocumentMut, old: &str, new: &str) -> Result<bool> {
407    migrate_table_key(document.as_table_mut(), old, new)
408}
409
410fn migrate_table_key(table: &mut toml_edit::Table, old: &str, new: &str) -> Result<bool> {
411    let Some(old_item) = table.get(old) else {
412        return Ok(false);
413    };
414    if table.contains_key(new) {
415        bail!("configuration contains both {old} and {new}; resolve the conflict before migrating");
416    }
417    let recipient = old_item
418        .as_str()
419        .context("legacy GPG recipient must be a string")?
420        .to_owned();
421    let key_decor = table.key(old).map(|key| key.leaf_decor().clone());
422    let decor = old_item.as_value().map(|value| value.decor().clone());
423    table.remove(old);
424    let mut recipients = toml_edit::Array::new();
425    recipients.push(recipient);
426    table.insert(
427        new,
428        toml_edit::Item::Value(toml_edit::Value::Array(recipients)),
429    );
430    if let (Some(decor), Some(value)) = (
431        decor,
432        table.get_mut(new).and_then(toml_edit::Item::as_value_mut),
433    ) {
434        *value.decor_mut() = decor;
435    }
436    if let (Some(decor), Some(mut key)) = (key_decor, table.key_mut(new)) {
437        *key.leaf_decor_mut() = decor;
438    }
439    Ok(true)
440}
441
442async fn gpg_recipients_from_config(path: &Path) -> Result<Option<Vec<String>>> {
443    recipients_from_config(path, "gpg_recipients").await
444}
445
446async fn age_recipients_from_config(path: &Path) -> Result<Option<Vec<String>>> {
447    recipients_from_config(path, "age_recipients").await
448}
449
450async fn recipients_from_config(path: &Path, key: &str) -> Result<Option<Vec<String>>> {
451    let contents = match tokio::fs::read_to_string(path).await {
452        Ok(contents) => contents,
453        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
454        Err(error) => return Err(error).with_context(|| format!("reading {}", path.display())),
455    };
456    let table: toml::Table =
457        toml::from_str(&contents).with_context(|| format!("parsing {}", path.display()))?;
458    let Some(value) = table.get(key) else {
459        return Ok(None);
460    };
461    let recipients = value
462        .as_array()
463        .with_context(|| format!("{key} must be an array"))?
464        .iter()
465        .map(|value| {
466            value
467                .as_str()
468                .with_context(|| format!("{key} entries must be strings"))
469        })
470        .collect::<Result<Vec<_>>>()?
471        .into_iter()
472        .map(str::to_owned)
473        .collect();
474    Ok(Some(recipients))
475}
476
477#[cfg(test)]
478mod tests {
479    use super::*;
480    use tokio::fs;
481
482    async fn make_temp_dir() -> std::path::PathBuf {
483        crate::test_support::make_temp_dir("shine-state-migrate").await
484    }
485
486    #[test]
487    fn pending_schema_warning_reports_old_schema() {
488        let warning = pending_schema_warning(0).unwrap();
489        assert!(warning.contains("0 -> 2"));
490        assert!(pending_schema_warning(CURRENT_RUNTIME_SCHEMA_VERSION).is_none());
491    }
492
493    #[tokio::test]
494    async fn migrate_removes_update_cache_and_records_schema() {
495        let dir = make_temp_dir().await;
496        let mut config = Config::new_for_test(&dir);
497        config.schema_version = 0;
498        fs::write(dir.join(UPDATE_CACHE_FILE), b"stale")
499            .await
500            .unwrap();
501
502        handle_migrate(&config, false).await.unwrap();
503
504        assert!(!dir.join(UPDATE_CACHE_FILE).exists());
505        let content = fs::read_to_string(dir.join("config.toml")).await.unwrap();
506        let parsed: toml::Table = toml::from_str(&content).unwrap();
507        assert_eq!(
508            parsed["schema_version"].as_integer(),
509            Some(CURRENT_RUNTIME_SCHEMA_VERSION.into())
510        );
511        assert_eq!(
512            parsed["last_cleared_schema_version"].as_integer(),
513            Some(CURRENT_RUNTIME_SCHEMA_VERSION.into())
514        );
515
516        fs::remove_dir_all(&dir).await.unwrap();
517    }
518
519    #[tokio::test]
520    async fn migrate_converts_legacy_gpg_recipient_to_a_list() {
521        let dir = make_temp_dir().await;
522        let mut config = Config::new_for_test(&dir);
523        config.schema_version = 1;
524        fs::write(
525            config.config_path(),
526            "# team encryption key\ngpg_key_id = \"alice@example.com\"\n",
527        )
528        .await
529        .unwrap();
530
531        handle_migrate(&config, false).await.unwrap();
532
533        let content = fs::read_to_string(config.config_path()).await.unwrap();
534        assert!(!content.contains("gpg_key_id"));
535        assert!(content.contains("# team encryption key"));
536        let parsed: toml::Table = toml::from_str(&content).unwrap();
537        assert_eq!(
538            parsed["gpg_recipients"].as_array().unwrap(),
539            &[toml::Value::String("alice@example.com".to_string())]
540        );
541        assert_eq!(
542            parsed["schema_version"].as_integer(),
543            Some(CURRENT_RUNTIME_SCHEMA_VERSION.into())
544        );
545
546        fs::remove_dir_all(&dir).await.unwrap();
547    }
548
549    #[tokio::test]
550    async fn workspace_migration_converts_legacy_recipient() {
551        let dir = make_temp_dir().await;
552        let workspace = dir.join("shine.workspace.toml");
553        let legacy = "age1se1qgg72x2qfk9wg3wh0qg9u0v7l5dkq4jx69fv80p6wdus3ftg6flwg5dz2dp";
554        let tagged = "age1tag1qgg72x2qfk9wg3wh0qg9u0v7l5dkq4jx69fv80p6wdus3ftg6flwgc25f05";
555        fs::write(
556            &workspace,
557            format!(
558                "version = 2\n[env.encryption]\n# deployment key\nrecipient = \"alice@example.com\"\nage_recipients = [\"{legacy}\"]\n"
559            ),
560        )
561        .await
562        .unwrap();
563
564        migrate_workspace_gpg_recipient(&workspace).await.unwrap();
565
566        let content = fs::read_to_string(&workspace).await.unwrap();
567        assert!(!content.contains("recipient ="));
568        assert!(content.contains("# deployment key"));
569        assert!(content.contains("gpg_recipients = [\"alice@example.com\"]"));
570        assert!(content.contains(tagged));
571        assert!(content.contains("version = 2"));
572        fs::remove_dir_all(&dir).await.unwrap();
573    }
574
575    #[tokio::test]
576    async fn migration_converts_secure_enclave_recipients_in_place() {
577        let dir = make_temp_dir().await;
578        let config_path = dir.join("config.toml");
579        let legacy = "age1se1qgg72x2qfk9wg3wh0qg9u0v7l5dkq4jx69fv80p6wdus3ftg6flwg5dz2dp";
580        let tagged = "age1tag1qgg72x2qfk9wg3wh0qg9u0v7l5dkq4jx69fv80p6wdus3ftg6flwgc25f05";
581        fs::write(
582            &config_path,
583            format!("# team recipients\nage_recipients = [\n  \"{legacy}\", # Touch ID\n  \"age1phone1example\",\n]\n"),
584        )
585        .await
586        .unwrap();
587
588        migrate_config_gpg_recipient(&config_path).await.unwrap();
589
590        let content = fs::read_to_string(&config_path).await.unwrap();
591        assert!(content.contains(tagged));
592        assert!(content.contains("# Touch ID"));
593        assert!(content.contains("age1phone1example"));
594        assert!(!content.contains(legacy));
595        fs::remove_dir_all(&dir).await.unwrap();
596    }
597
598    #[tokio::test]
599    async fn invalid_secure_enclave_recipient_leaves_file_unchanged() {
600        let dir = make_temp_dir().await;
601        let config_path = dir.join("config.toml");
602        let original = "age_recipients = [\"age1se1qgg72x2qfk9wg3wh0qg9u0v7l5dkq4jx69fv80p6wdus3ftg6flwg5dz2dp\", \"age1se1invalid\"]\n";
603        fs::write(&config_path, original).await.unwrap();
604
605        assert!(migrate_config_gpg_recipient(&config_path).await.is_err());
606        assert_eq!(fs::read_to_string(&config_path).await.unwrap(), original);
607        fs::remove_dir_all(&dir).await.unwrap();
608    }
609
610    #[tokio::test]
611    #[allow(clippy::await_holding_lock)]
612    async fn invalid_project_recipient_prevents_any_config_write() {
613        let _lock = crate::test_support::env_lock();
614        let original_dir = std::env::current_dir().unwrap();
615        let dir = make_temp_dir().await;
616        let project_dir = dir.join("project");
617        fs::create_dir_all(&project_dir).await.unwrap();
618        let config = Config::new_for_test(&dir);
619        let legacy = "age1se1qgg72x2qfk9wg3wh0qg9u0v7l5dkq4jx69fv80p6wdus3ftg6flwg5dz2dp";
620        let global_original = format!("schema_version = 2\nage_recipients = [\"{legacy}\"]\n");
621        fs::write(config.config_path(), &global_original)
622            .await
623            .unwrap();
624        fs::write(
625            project_dir.join("shine.config.toml"),
626            "age_recipients = [\"age1se1invalid\"]\n",
627        )
628        .await
629        .unwrap();
630        std::env::set_current_dir(&project_dir).unwrap();
631
632        let result = handle_migrate(&config, false).await;
633        crate::test_support::restore_current_dir(&original_dir);
634
635        assert!(result.is_err());
636        assert_eq!(
637            fs::read_to_string(config.config_path()).await.unwrap(),
638            global_original
639        );
640        fs::remove_dir_all(&dir).await.unwrap();
641    }
642
643    #[tokio::test]
644    async fn dry_run_does_not_convert_secure_enclave_recipients() {
645        let dir = make_temp_dir().await;
646        let config = Config::new_for_test(&dir);
647        let legacy = "age1se1qgg72x2qfk9wg3wh0qg9u0v7l5dkq4jx69fv80p6wdus3ftg6flwg5dz2dp";
648        let original = format!("schema_version = 2\nage_recipients = [\"{legacy}\"]\n");
649        fs::write(config.config_path(), &original).await.unwrap();
650
651        handle_migrate(&config, true).await.unwrap();
652
653        assert_eq!(
654            fs::read_to_string(config.config_path()).await.unwrap(),
655            original
656        );
657        fs::remove_dir_all(&dir).await.unwrap();
658    }
659
660    #[tokio::test]
661    async fn dry_run_does_not_remove_or_save() {
662        let dir = make_temp_dir().await;
663        let mut config = Config::new_for_test(&dir);
664        config.schema_version = 0;
665        fs::write(dir.join(UPDATE_CACHE_FILE), b"stale")
666            .await
667            .unwrap();
668
669        handle_migrate(&config, true).await.unwrap();
670
671        assert!(dir.join(UPDATE_CACHE_FILE).exists());
672        assert!(!dir.join("config.toml").exists());
673
674        fs::remove_dir_all(&dir).await.unwrap();
675    }
676
677    #[tokio::test]
678    async fn migrate_records_last_cleared_when_already_current() {
679        let dir = make_temp_dir().await;
680        let config = Config::new_for_test(&dir);
681
682        handle_migrate(&config, false).await.unwrap();
683
684        let content = fs::read_to_string(dir.join("config.toml")).await.unwrap();
685        let parsed: toml::Table = toml::from_str(&content).unwrap();
686        assert_eq!(
687            parsed["schema_version"].as_integer(),
688            Some(CURRENT_RUNTIME_SCHEMA_VERSION.into())
689        );
690        assert_eq!(
691            parsed["last_cleared_schema_version"].as_integer(),
692            Some(CURRENT_RUNTIME_SCHEMA_VERSION.into())
693        );
694
695        fs::remove_dir_all(&dir).await.unwrap();
696    }
697
698    #[tokio::test]
699    #[allow(clippy::await_holding_lock)]
700    async fn migrate_current_schema_converts_legacy_project_config() {
701        let _lock = crate::test_support::env_lock();
702        let original_dir = std::env::current_dir().unwrap();
703        let dir = make_temp_dir().await;
704        let project_dir = dir.join("project");
705        fs::create_dir_all(&project_dir).await.unwrap();
706        let project_config = project_dir.join("shine.config.toml");
707        let legacy = "age1se1qgg72x2qfk9wg3wh0qg9u0v7l5dkq4jx69fv80p6wdus3ftg6flwg5dz2dp";
708        let tagged = "age1tag1qgg72x2qfk9wg3wh0qg9u0v7l5dkq4jx69fv80p6wdus3ftg6flwgc25f05";
709        fs::write(
710            &project_config,
711            format!(
712                "# project key\ngpg_key_id = \"project@example.com\"\nage_recipients = [\"{legacy}\"]\n"
713            ),
714        )
715        .await
716        .unwrap();
717        std::env::set_current_dir(&project_dir).unwrap();
718        let config = Config::new_for_test(&dir);
719
720        let result = handle_migrate(&config, false).await;
721        crate::test_support::restore_current_dir(&original_dir);
722        result.unwrap();
723
724        let content = fs::read_to_string(&project_config).await.unwrap();
725        assert!(!content.contains("gpg_key_id"));
726        assert!(content.contains("# project key"));
727        assert!(content.contains("gpg_recipients = [\"project@example.com\"]"));
728        assert!(content.contains(tagged));
729
730        fs::remove_dir_all(&dir).await.unwrap();
731    }
732
733    #[tokio::test]
734    async fn migrate_does_not_remove_other_runtime_files() {
735        let dir = make_temp_dir().await;
736        let mut config = Config::new_for_test(&dir);
737        config.schema_version = 0;
738        fs::create_dir_all(dir.join("rendered")).await.unwrap();
739        fs::create_dir_all(dir.join("bin")).await.unwrap();
740        fs::create_dir_all(dir.join("presets")).await.unwrap();
741        fs::write(dir.join("app-manifest.toml"), b"entries = []")
742            .await
743            .unwrap();
744
745        handle_migrate(&config, false).await.unwrap();
746
747        assert!(dir.join("rendered").exists());
748        assert!(dir.join("bin").exists());
749        assert!(dir.join("presets").exists());
750        assert!(dir.join("app-manifest.toml").exists());
751
752        fs::remove_dir_all(&dir).await.unwrap();
753    }
754}