Skip to main content

cli/
preset_migration.rs

1//! Reviewed CLI adapter for Preset source migration.
2
3use crate::commands::PresetReportFormat;
4use crate::config::discover_runtime_paths_read_only;
5use crate::{core_runtime, persist};
6use anyhow::{Context, Result, bail};
7use serde::Serialize;
8use shine_core::runtime::{
9    InMemoryHost, PresetDiagnosticSeverity, PresetMigrationBaseline, PresetMigrationDiagnosticV1,
10    PresetMigrationEdit, PresetMigrationPlan, PresetMigrationSeverityV1, PresetMigrationStatusV1,
11    PresetSnapshot, PresetSnapshotRequest, PresetSnapshotSource, PresetSourceKind, RealHost,
12    RuntimePlatform, capture_embedded_preset_snapshot, capture_preset_snapshot,
13    plan_preset_migration, sha256, validate_preset_path,
14};
15use std::collections::{BTreeMap, BTreeSet};
16use std::fmt::Write as _;
17use std::io::IsTerminal;
18use std::path::{Path, PathBuf};
19
20pub async fn handle_migrate(
21    path: Option<&Path>,
22    dry_run: bool,
23    yes: bool,
24    format: PresetReportFormat,
25) -> Result<bool> {
26    if format == PresetReportFormat::Json && !dry_run && !yes {
27        bail!("`shine preset migrate --format json` requires --dry-run or --yes");
28    }
29
30    let (snapshot, scope, selected, shine_dir, managed_overlay) = migration_inputs(path).await?;
31    let current = capture_embedded_preset_snapshot(core_runtime::embedded_preset_files());
32    let legacy = legacy_metadata_hashes();
33    let mut plan = plan_preset_migration(
34        &snapshot,
35        scope,
36        selected.as_ref(),
37        Some(PresetMigrationBaseline {
38            current: &current,
39            legacy_metadata_sha256: &legacy,
40        }),
41    );
42    validate_candidate(&snapshot, &mut plan).await;
43    let display_edits = plan.edits.clone();
44    if let Some(root) = managed_overlay.as_deref() {
45        mark_managed_overlay_read_only(&mut plan, root);
46    }
47
48    if format == PresetReportFormat::Text {
49        print_text(&plan, &snapshot, managed_overlay.as_deref(), path.is_none());
50        print_diffs(&display_edits);
51    }
52    if dry_run {
53        if format == PresetReportFormat::Json {
54            println!("{}", serde_json::to_string_pretty(&plan.report)?);
55        }
56        return Ok(plan.report.summary.blockers == 0);
57    }
58
59    if plan.edits.is_empty() {
60        if format == PresetReportFormat::Json {
61            println!("{}", serde_json::to_string_pretty(&plan.report)?);
62        }
63        return Ok(plan.report.summary.blockers == 0);
64    }
65    if !yes {
66        if !(std::io::stdin().is_terminal() && std::io::stdout().is_terminal()) {
67            bail!("Preset migration approval requires an interactive terminal or explicit --yes");
68        }
69        let approved = dialoguer::Confirm::new()
70            .with_prompt("Apply this Preset migration?")
71            .default(false)
72            .interact()?;
73        if !approved {
74            bail!("Preset migration was not approved; no changes were made");
75        }
76    }
77
78    let sources = migration_source_observations(&snapshot, &plan.edits);
79    let backup = create_backup_set(&shine_dir, &plan.edits, &sources).await?;
80    plan.report.backup_set = backup
81        .file_name()
82        .and_then(|name| name.to_str())
83        .map(|name| format!("preset-migration-backups/{name}"));
84    if let Err(error) = apply_edits(&plan.edits, &sources).await {
85        bail!(
86            "Preset migration stopped; backup retained at {}: {error:#}",
87            backup.display()
88        );
89    }
90    plan.report.status = if plan.report.summary.blockers > 0 {
91        PresetMigrationStatusV1::PartiallyApplied
92    } else {
93        PresetMigrationStatusV1::Applied
94    };
95    if format == PresetReportFormat::Json {
96        println!("{}", serde_json::to_string_pretty(&plan.report)?);
97    } else {
98        println!();
99        println!("Migrated {} Preset metadata file(s).", plan.edits.len());
100        println!("Backup: {}", backup.display());
101        if plan.report.summary.blockers > 0 {
102            println!(
103                "Manual migration is still required for {} blocker(s).",
104                plan.report.summary.blockers
105            );
106        }
107    }
108    Ok(plan.report.summary.blockers == 0)
109}
110
111pub async fn active_compatibility_plan(target: Option<&str>) -> Result<PresetMigrationPlan> {
112    let (snapshot, scope, mut selected, _, managed_overlay) = migration_inputs(None).await?;
113    if let Some(target) = target {
114        let canonical = if let Some(item) = target.strip_prefix("sys/") {
115            let mut categories = sys_categories_for_item(&snapshot, item);
116            if categories.is_empty() {
117                let os_id = crate::sys::detect_os_id().await?;
118                let active = format!("sys/{os_id}");
119                if snapshot.get(&format!("{active}/shine.toml")).is_some() {
120                    categories.insert(active);
121                }
122            }
123            categories
124        } else if target.starts_with("app/") || target.starts_with("shell/") {
125            BTreeSet::from([target.split('/').take(2).collect::<Vec<_>>().join("/")])
126        } else {
127            let categories = snapshot
128                .files()
129                .keys()
130                .filter_map(|path| {
131                    let mut parts = path.split('/');
132                    let kind = parts.next()?;
133                    let name = parts.next()?;
134                    (name == target).then(|| format!("{kind}/{name}"))
135                })
136                .collect::<BTreeSet<_>>();
137            if categories.len() == 1 {
138                categories
139            } else {
140                let sys_categories = sys_categories_for_item(&snapshot, target);
141                if sys_categories.is_empty() {
142                    BTreeSet::from([target.to_string()])
143                } else {
144                    sys_categories
145                }
146            }
147        };
148        selected = Some(canonical);
149    }
150    let current = capture_embedded_preset_snapshot(core_runtime::embedded_preset_files());
151    let legacy = legacy_metadata_hashes();
152    let mut plan = plan_preset_migration(
153        &snapshot,
154        scope,
155        selected.as_ref(),
156        Some(PresetMigrationBaseline {
157            current: &current,
158            legacy_metadata_sha256: &legacy,
159        }),
160    );
161    validate_candidate(&snapshot, &mut plan).await;
162    if let Some(root) = managed_overlay.as_deref() {
163        mark_managed_overlay_read_only(&mut plan, root);
164    }
165    Ok(plan)
166}
167
168fn sys_categories_for_item(snapshot: &PresetSnapshot, item: &str) -> BTreeSet<String> {
169    snapshot
170        .files()
171        .iter()
172        .filter_map(|(logical, bytes)| {
173            let category = logical.strip_prefix("sys/")?.strip_suffix("/shine.toml")?;
174            if category == item {
175                return Some(format!("sys/{category}"));
176            }
177            let value = toml::from_slice::<toml::Value>(bytes).ok()?;
178            value
179                .get("items")
180                .and_then(toml::Value::as_array)
181                .is_some_and(|items| {
182                    items
183                        .iter()
184                        .any(|entry| entry.get("id").and_then(toml::Value::as_str) == Some(item))
185                })
186                .then(|| format!("sys/{category}"))
187        })
188        .collect()
189}
190
191pub fn print_compatibility(plan: &PresetMigrationPlan) {
192    print!("{}", compatibility_text(plan));
193}
194
195fn compatibility_text(plan: &PresetMigrationPlan) -> String {
196    if plan.edits.is_empty() && plan.report.diagnostics.is_empty() {
197        return String::new();
198    }
199    let mut output = format!("{}\n", crate::colors::bold("Preset compatibility"));
200    for file in &plan.report.files {
201        let _ = writeln!(output);
202        let _ = writeln!(
203            output,
204            "  {} migrate {} ({})",
205            crate::colors::symbol("~"),
206            file.target,
207            file.source_layer
208        );
209    }
210    for diagnostic in &plan.report.diagnostics {
211        let marker = if diagnostic.severity == PresetMigrationSeverityV1::Blocker {
212            crate::colors::symbol("✗")
213        } else {
214            crate::colors::yellow("!")
215        };
216        let _ = writeln!(output);
217        let _ = writeln!(
218            output,
219            "  {marker} {}{}",
220            diagnostic.target,
221            diagnostic
222                .source_layer
223                .as_deref()
224                .map(|layer| format!(" ({layer})"))
225                .unwrap_or_default()
226        );
227        crate::preset_report::write_wrapped(&mut output, "    ", &diagnostic.message);
228        let _ = writeln!(
229            output,
230            "    {} {}",
231            crate::colors::dim("code:"),
232            diagnostic.code
233        );
234    }
235    let _ = writeln!(output);
236    output
237}
238
239pub fn compatibility_required(plan: &PresetMigrationPlan) -> bool {
240    !plan.edits.is_empty() || plan.report.summary.blockers > 0
241}
242
243pub fn compatibility_failure_message(plan: &PresetMigrationPlan) -> String {
244    let blockers = plan.report.summary.blockers;
245    let changes = plan.edits.len();
246    let reason = match (blockers, changes) {
247        (0, changes) => count_phrase(changes, "automatic change", "automatic changes"),
248        (blockers, 0) => count_phrase(blockers, "blocker", "blockers"),
249        (blockers, changes) => format!(
250            "{} and {}",
251            count_phrase(blockers, "blocker", "blockers"),
252            count_phrase(changes, "automatic change", "automatic changes")
253        ),
254    };
255    format!(
256        "Preset compatibility requires attention ({reason})\n\nNext:\n  shine preset migrate --dry-run"
257    )
258}
259
260async fn migration_inputs(
261    path: Option<&Path>,
262) -> Result<(
263    PresetSnapshot,
264    String,
265    Option<BTreeSet<String>>,
266    PathBuf,
267    Option<PathBuf>,
268)> {
269    let runtime = discover_runtime_paths_read_only().context("resolving active Preset paths")?;
270    if let Some(path) = path {
271        let canonical = tokio::fs::canonicalize(path)
272            .await
273            .with_context(|| format!("resolving Preset path {}", path.display()))?;
274        let (root, selected) = explicit_scope(&canonical)?;
275        let snapshot = capture_preset_snapshot(
276            &RealHost,
277            PresetSnapshotRequest {
278                source: PresetSnapshotSource::External(root.clone()),
279                overlay_root: None,
280            },
281        )
282        .await?;
283        let managed = runtime
284            .managed_overlay
285            .then_some(runtime.presets_overlay_dir)
286            .flatten()
287            .filter(|overlay| canonical.starts_with(overlay));
288        let scope = selected
289            .as_ref()
290            .and_then(|targets| targets.iter().next())
291            .cloned()
292            .unwrap_or_else(|| "explicit-repository".to_string());
293        return Ok((snapshot, scope, selected, runtime.shine_dir, managed));
294    }
295
296    let source = if runtime.is_external_presets {
297        PresetSnapshotSource::External(runtime.presets_dir.clone())
298    } else {
299        PresetSnapshotSource::Embedded(core_runtime::embedded_preset_files())
300    };
301    let snapshot = capture_preset_snapshot(
302        &RealHost,
303        PresetSnapshotRequest {
304            source,
305            overlay_root: runtime.presets_overlay_dir.clone(),
306        },
307    )
308    .await?;
309    let managed = runtime
310        .managed_overlay
311        .then_some(runtime.presets_overlay_dir)
312        .flatten();
313    Ok((
314        snapshot,
315        "active".to_string(),
316        None,
317        runtime.shine_dir,
318        managed,
319    ))
320}
321
322fn explicit_scope(path: &Path) -> Result<(PathBuf, Option<BTreeSet<String>>)> {
323    let category = if path.is_file() {
324        if path.file_name().and_then(|name| name.to_str()) != Some("shine.toml") {
325            bail!("Preset migration file input must be shine.toml");
326        }
327        path.parent()
328            .context("shine.toml has no category directory")?
329    } else {
330        path
331    };
332    if let (Some(name), Some(kind_dir)) = (
333        category.file_name().and_then(|name| name.to_str()),
334        category.parent(),
335    ) && let Some(kind) = kind_dir.file_name().and_then(|name| name.to_str())
336        && matches!(kind, "app" | "shell" | "sys")
337    {
338        let root = kind_dir
339            .parent()
340            .context("Preset category has no repository root")?;
341        return Ok((
342            root.to_path_buf(),
343            Some(BTreeSet::from([format!("{kind}/{name}")])),
344        ));
345    }
346    if path.is_file() {
347        bail!("shine.toml must be under app/<name>, shell/<name>, or sys/<name>");
348    }
349    Ok((path.to_path_buf(), None))
350}
351
352fn mark_managed_overlay_read_only(plan: &mut PresetMigrationPlan, root: &Path) {
353    let blocked = plan
354        .edits
355        .iter()
356        .filter(|edit| edit.physical_path.starts_with(root))
357        .map(|edit| edit.logical_path.clone())
358        .collect::<BTreeSet<_>>();
359    if blocked.is_empty() {
360        return;
361    }
362    plan.edits
363        .retain(|edit| !blocked.contains(&edit.logical_path));
364    for target in blocked {
365        plan.report.diagnostics.push(PresetMigrationDiagnosticV1 {
366            severity: PresetMigrationSeverityV1::Blocker,
367            code: "managed_overlay_read_only".to_string(),
368            target,
369            source_layer: Some("overlay".to_string()),
370            message: "the active Git-managed overlay is force-mirrored; migrate its upstream checkout instead".to_string(),
371        });
372    }
373    sync_report_with_edits(plan);
374}
375
376async fn validate_candidate(snapshot: &PresetSnapshot, plan: &mut PresetMigrationPlan) {
377    // `validate_preset_path` derives a synthetic home beneath this root. Keep the
378    // root absolute according to the native path semantics so its all-platform
379    // metadata validation can run on Windows too.
380    #[cfg(windows)]
381    let root = Path::new(r"C:\shine-preset-migration");
382    #[cfg(not(windows))]
383    let root = Path::new("/shine-preset-migration");
384    let host = InMemoryHost::new();
385    let candidate_targets = plan
386        .edits
387        .iter()
388        .map(|edit| {
389            edit.logical_path
390                .split('/')
391                .take(2)
392                .collect::<Vec<_>>()
393                .join("/")
394        })
395        .collect::<BTreeSet<_>>();
396    if candidate_targets.is_empty() {
397        return;
398    }
399    let mut files = snapshot.files().clone();
400    for edit in &plan.edits {
401        match &edit.candidate {
402            Some(candidate) => {
403                files.insert(edit.logical_path.clone(), candidate.clone());
404            }
405            None => {
406                if let Some(base) = snapshot.base_bytes(&edit.logical_path) {
407                    files.insert(edit.logical_path.clone(), base.to_vec());
408                } else {
409                    files.remove(&edit.logical_path);
410                }
411            }
412        }
413    }
414    for (logical, bytes) in files {
415        let target = logical.split('/').take(2).collect::<Vec<_>>().join("/");
416        if !candidate_targets.contains(&target) {
417            continue;
418        }
419        host.put_file(root.join(logical), bytes);
420    }
421    let validation = validate_preset_path(&host, root, root).await;
422    let invalid_targets = validation
423        .categories
424        .iter()
425        .filter(|category| !category.valid)
426        .map(|category| format!("{}/{}", category.kind, category.name))
427        .collect::<BTreeSet<_>>();
428    for category in validation
429        .categories
430        .iter()
431        .filter(|category| !category.valid)
432    {
433        let target = format!("{}/{}", category.kind, category.name);
434        for item in category
435            .diagnostics
436            .iter()
437            .filter(|item| item.severity == PresetDiagnosticSeverity::Error)
438        {
439            plan.report.diagnostics.push(PresetMigrationDiagnosticV1 {
440                severity: PresetMigrationSeverityV1::Blocker,
441                code: format!("candidate_{}", item.code),
442                target: target.clone(),
443                source_layer: report_source_layer(plan, &target),
444                message: item.message.clone(),
445            });
446        }
447    }
448    for item in validation
449        .diagnostics
450        .iter()
451        .filter(|item| item.severity == PresetDiagnosticSeverity::Error)
452    {
453        plan.report.diagnostics.push(PresetMigrationDiagnosticV1 {
454            severity: PresetMigrationSeverityV1::Blocker,
455            code: format!("candidate_{}", item.code),
456            target: plan.report.scope.clone(),
457            source_layer: None,
458            message: item.message.clone(),
459        });
460    }
461    if !invalid_targets.is_empty() {
462        plan.edits.retain(|edit| {
463            let target = edit
464                .logical_path
465                .split('/')
466                .take(2)
467                .collect::<Vec<_>>()
468                .join("/");
469            !invalid_targets.contains(&target)
470        });
471    }
472    sync_report_with_edits(plan);
473}
474
475fn sync_report_with_edits(plan: &mut PresetMigrationPlan) {
476    let edited = plan
477        .edits
478        .iter()
479        .map(|edit| edit.logical_path.as_str())
480        .collect::<BTreeSet<_>>();
481    plan.report
482        .files
483        .retain(|file| edited.contains(file.target.as_str()));
484    plan.report.summary.changes = plan.edits.len();
485    plan.report.summary.blockers = plan
486        .report
487        .diagnostics
488        .iter()
489        .filter(|item| item.severity == PresetMigrationSeverityV1::Blocker)
490        .count();
491    plan.report.status = if plan.report.summary.blockers > 0 {
492        PresetMigrationStatusV1::Blocked
493    } else if plan.edits.is_empty() {
494        PresetMigrationStatusV1::Current
495    } else {
496        PresetMigrationStatusV1::Pending
497    };
498}
499
500fn report_source_layer(plan: &PresetMigrationPlan, target: &str) -> Option<String> {
501    plan.report
502        .files
503        .iter()
504        .find(|file| file.target.starts_with(target))
505        .map(|file| file.source_layer.clone())
506}
507
508fn print_text(
509    plan: &PresetMigrationPlan,
510    snapshot: &PresetSnapshot,
511    managed_overlay: Option<&Path>,
512    active_source: bool,
513) {
514    print!(
515        "{}",
516        migration_text(plan, snapshot, managed_overlay, active_source)
517    );
518}
519
520fn migration_text(
521    plan: &PresetMigrationPlan,
522    snapshot: &PresetSnapshot,
523    managed_overlay: Option<&Path>,
524    active_source: bool,
525) -> String {
526    let mut output = String::new();
527    let status = match plan.report.status {
528        PresetMigrationStatusV1::Current => "current",
529        PresetMigrationStatusV1::Pending => "changes pending",
530        PresetMigrationStatusV1::Blocked => "manual action required",
531        PresetMigrationStatusV1::Applied => "applied",
532        PresetMigrationStatusV1::PartiallyApplied => "partially applied",
533    };
534    let styled_status = match plan.report.status {
535        PresetMigrationStatusV1::Current | PresetMigrationStatusV1::Applied => {
536            crate::colors::green(status)
537        }
538        PresetMigrationStatusV1::Pending | PresetMigrationStatusV1::PartiallyApplied => {
539            crate::colors::yellow(status)
540        }
541        PresetMigrationStatusV1::Blocked => crate::colors::red(status),
542    };
543    let _ = writeln!(
544        output,
545        "{} {styled_status}",
546        crate::colors::bold("Preset migration:")
547    );
548
549    let mut groups = BTreeMap::<String, Vec<&PresetMigrationDiagnosticV1>>::new();
550    for diagnostic in &plan.report.diagnostics {
551        let key = metadata_logical_path(snapshot, &diagnostic.target)
552            .unwrap_or_else(|| diagnostic_category(&diagnostic.target));
553        groups.entry(key).or_default().push(diagnostic);
554    }
555    let mut needs_preset_pull = false;
556    for (logical, diagnostics) in groups {
557        let layer = diagnostics
558            .iter()
559            .find_map(|diagnostic| diagnostic.source_layer.as_deref())
560            .map(|value| format!(" ({value})"))
561            .unwrap_or_default();
562        let _ = writeln!(output);
563        let _ = writeln!(
564            output,
565            "  {}{layer}",
566            logical.trim_end_matches("/shine.toml")
567        );
568        for diagnostic in &diagnostics {
569            let symbol = if diagnostic.severity == PresetMigrationSeverityV1::Blocker {
570                crate::colors::symbol("✗")
571            } else {
572                crate::colors::symbol("!")
573            };
574            let _ = writeln!(output, "    {symbol} {}", diagnostic.target);
575            crate::preset_report::write_wrapped(&mut output, "      ", &diagnostic.message);
576            let _ = writeln!(
577                output,
578                "      {} {}",
579                crate::colors::dim("code:"),
580                diagnostic.code
581            );
582        }
583        needs_preset_pull |= render_remediation(
584            &mut output,
585            snapshot,
586            &logical,
587            &diagnostics,
588            managed_overlay,
589            active_source,
590        );
591    }
592
593    if needs_preset_pull {
594        let _ = writeln!(output);
595        let _ = writeln!(output, "  {}", crate::colors::cyan("Next:"));
596        let _ = writeln!(output, "    Commit the upstream changes, then run:");
597        let _ = writeln!(output, "      shine preset pull");
598    }
599
600    if !plan.report.diagnostics.is_empty() {
601        let _ = writeln!(output);
602    }
603    let changes = count_phrase(
604        plan.report.summary.changes,
605        "automatic change",
606        "automatic changes",
607    );
608    let blockers = count_phrase(plan.report.summary.blockers, "blocker", "blockers");
609    let advisories = count_phrase(plan.report.summary.advisories, "advisory", "advisories");
610    let _ = writeln!(
611        output,
612        "{} {} · {} · {}",
613        crate::colors::bold("Summary:"),
614        if plan.report.summary.blockers > 0 {
615            crate::colors::red(&blockers)
616        } else {
617            crate::colors::dim(&blockers)
618        },
619        if plan.report.summary.changes > 0 {
620            crate::colors::green(&changes)
621        } else {
622            crate::colors::dim(&changes)
623        },
624        if plan.report.summary.advisories > 0 {
625            crate::colors::yellow(&advisories)
626        } else {
627            crate::colors::dim(&advisories)
628        }
629    );
630    output
631}
632
633fn render_remediation(
634    output: &mut String,
635    snapshot: &PresetSnapshot,
636    logical: &str,
637    diagnostics: &[&PresetMigrationDiagnosticV1],
638    managed_overlay: Option<&Path>,
639    active_source: bool,
640) -> bool {
641    let manual_permissions = diagnostics
642        .iter()
643        .any(|diagnostic| diagnostic.code == "manual_permission_review_required");
644    let managed_read_only = diagnostics
645        .iter()
646        .any(|diagnostic| diagnostic.code == "managed_overlay_read_only");
647    let manifest = snapshot
648        .origin(logical)
649        .and_then(|origin| origin.physical_path.as_deref());
650    let manifest_is_managed =
651        manifest.is_some_and(|path| managed_overlay.is_some_and(|root| path.starts_with(root)));
652
653    if managed_read_only && !manual_permissions {
654        let _ = writeln!(output);
655        let _ = writeln!(output, "    {}", crate::colors::yellow("Fix:"));
656        let _ = writeln!(
657            output,
658            "      Review and update {logical} in the upstream checkout."
659        );
660        let _ = writeln!(output, "      The managed overlay mirror is read-only.");
661    }
662
663    if manual_permissions {
664        if managed_read_only || manifest_is_managed {
665            let _ = writeln!(output);
666            let _ = writeln!(output, "    {}", crate::colors::yellow("Fix:"));
667            let _ = writeln!(
668                output,
669                "      Review and update {logical} in the upstream checkout."
670            );
671            let _ = writeln!(output, "      The managed overlay mirror is read-only.");
672        } else if let Some(manifest) = manifest {
673            let quoted = quote_command_arg(manifest, RuntimePlatform::current());
674            let _ = writeln!(output);
675            let _ = writeln!(output, "    {}", crate::colors::yellow("Fix:"));
676            let _ = writeln!(output, "      Edit: {}", manifest.display());
677            let _ = writeln!(output, "      Verify:");
678            let _ = writeln!(output, "        shine preset validate {quoted}");
679            let _ = writeln!(
680                output,
681                "        shine preset plan {quoted} --platform {}",
682                RuntimePlatform::current().as_str()
683            );
684        } else {
685            let _ = writeln!(output);
686            let _ = writeln!(output, "    {}", crate::colors::yellow("Fix:"));
687            let _ = writeln!(
688                output,
689                "      Add the target-local permission declaration in {logical}, then validate and plan that manifest."
690            );
691        }
692    }
693
694    for diagnostic in diagnostics {
695        if diagnostic.code == "recursive_artifact_hook_removed"
696            && let Some(category) = diagnostic.target.strip_prefix("app/")
697        {
698            let _ = writeln!(
699                output,
700                "    Next: run `shine app artifact apply {category}` after relevant changes."
701            );
702        }
703    }
704
705    for target in trust_review_targets(snapshot, logical, diagnostics) {
706        let timing = if active_source {
707            "After validation"
708        } else {
709            "After this source becomes active"
710        };
711        let _ = writeln!(output, "    {timing}, review the external executable code:");
712        let _ = writeln!(output, "      `shine trust inspect {target}`");
713        let _ = writeln!(
714            output,
715            "      If the inspection reports a requirement and you accept its scope, run `shine trust grant {target}`."
716        );
717    }
718
719    managed_read_only || (manual_permissions && manifest_is_managed)
720}
721
722fn metadata_logical_path(snapshot: &PresetSnapshot, target: &str) -> Option<String> {
723    let mut parts = target.split('/');
724    let kind = parts.next()?;
725    let name = parts.next()?;
726    let direct = format!("{kind}/{name}/shine.toml");
727    if snapshot.get(&direct).is_some() {
728        return Some(direct);
729    }
730    if kind != "sys" {
731        return None;
732    }
733    snapshot.files().iter().find_map(|(logical, bytes)| {
734        if !logical.starts_with("sys/") || !logical.ends_with("/shine.toml") {
735            return None;
736        }
737        let value = toml::from_slice::<toml::Value>(bytes).ok()?;
738        value
739            .get("items")
740            .and_then(toml::Value::as_array)
741            .is_some_and(|items| {
742                items
743                    .iter()
744                    .any(|item| item.get("id").and_then(toml::Value::as_str) == Some(name))
745            })
746            .then(|| logical.clone())
747    })
748}
749
750fn diagnostic_category(target: &str) -> String {
751    target.split('/').take(2).collect::<Vec<_>>().join("/")
752}
753
754fn trust_review_targets(
755    snapshot: &PresetSnapshot,
756    logical: &str,
757    diagnostics: &[&PresetMigrationDiagnosticV1],
758) -> BTreeSet<String> {
759    diagnostics
760        .iter()
761        .filter_map(|diagnostic| {
762            if diagnostic.source_layer.as_deref() == Some("embedded") {
763                return None;
764            }
765            match diagnostic.target.split_once('/') {
766                Some(("app", _))
767                    if matches!(
768                        diagnostic.code.as_str(),
769                        "manual_permission_review_required" | "external_code_trust_review_required"
770                    ) =>
771                {
772                    Some(diagnostic.target.clone())
773                }
774                Some(("sys", item))
775                    if diagnostic.code == "manual_permission_review_required"
776                        && sys_item_has_executable_code(snapshot, logical, item) =>
777                {
778                    Some(diagnostic.target.clone())
779                }
780                _ => None,
781            }
782        })
783        .collect()
784}
785
786fn sys_item_has_executable_code(snapshot: &PresetSnapshot, logical: &str, item_id: &str) -> bool {
787    let Some(value) = snapshot
788        .get(logical)
789        .and_then(|bytes| toml::from_slice::<toml::Value>(bytes).ok())
790    else {
791        return false;
792    };
793    let item_code = value
794        .get("items")
795        .and_then(toml::Value::as_array)
796        .and_then(|items| {
797            items
798                .iter()
799                .find(|item| item.get("id").and_then(toml::Value::as_str) == Some(item_id))
800        })
801        .is_some_and(|item| {
802            let script = item
803                .get("install")
804                .and_then(|install| install.get("kind"))
805                .and_then(toml::Value::as_str)
806                == Some("script");
807            let shell_code = item
808                .get("shell")
809                .and_then(toml::Value::as_array)
810                .into_iter()
811                .flatten()
812                .any(|integration| {
813                    ["eval", "source", "fragment"]
814                        .iter()
815                        .any(|key| integration.get(*key).is_some())
816                });
817            script || shell_code
818        });
819    if item_code {
820        return true;
821    }
822    let category_prefix = logical.trim_end_matches("shine.toml");
823    snapshot.files().keys().any(|path| {
824        path.starts_with(category_prefix)
825            && path.contains("/profile/base.")
826            && snapshot
827                .origin(path)
828                .is_some_and(|origin| origin.source_kind != PresetSourceKind::Embedded)
829    })
830}
831
832fn quote_command_arg(path: &Path, platform: RuntimePlatform) -> String {
833    let value = path.display().to_string();
834    if platform == RuntimePlatform::Windows {
835        format!("'{}'", value.replace('\'', "''"))
836    } else {
837        crate::shell_quote::quote_if_needed(&value)
838    }
839}
840
841fn count_phrase(count: usize, singular: &str, plural: &str) -> String {
842    format!("{count} {}", if count == 1 { singular } else { plural })
843}
844
845fn print_diffs(edits: &[PresetMigrationEdit]) {
846    for edit in edits {
847        let old = String::from_utf8_lossy(&edit.original);
848        let new = edit
849            .candidate
850            .as_deref()
851            .map(String::from_utf8_lossy)
852            .unwrap_or_default();
853        println!();
854        println!(
855            "{}",
856            similar::TextDiff::from_lines(&old, &new)
857                .unified_diff()
858                .header(
859                    &format!("a/{}", edit.logical_path),
860                    &format!("b/{}", edit.logical_path)
861                )
862        );
863    }
864}
865
866#[derive(Serialize)]
867struct BackupManifest<'a> {
868    schema_version: u32,
869    files: Vec<BackupEntry<'a>>,
870}
871
872#[derive(Serialize)]
873struct BackupEntry<'a> {
874    logical_path: &'a str,
875    source_layer: &'a str,
876    original_sha256: String,
877    mode: Option<u32>,
878}
879
880fn migration_source_observations(
881    snapshot: &PresetSnapshot,
882    edits: &[PresetMigrationEdit],
883) -> BTreeMap<PathBuf, Vec<u8>> {
884    let targets = edits
885        .iter()
886        .map(|edit| {
887            edit.logical_path
888                .split('/')
889                .take(2)
890                .collect::<Vec<_>>()
891                .join("/")
892        })
893        .collect::<BTreeSet<_>>();
894    snapshot
895        .source_files()
896        .filter(|(logical, _)| {
897            let target = logical.split('/').take(2).collect::<Vec<_>>().join("/");
898            targets.contains(&target)
899        })
900        .filter_map(|(_, file)| {
901            file.origin
902                .physical_path
903                .as_ref()
904                .map(|path| (path.clone(), file.bytes.clone()))
905        })
906        .collect()
907}
908
909async fn ensure_sources_unchanged(sources: &BTreeMap<PathBuf, Vec<u8>>) -> Result<()> {
910    for (path, original) in sources {
911        let current = tokio::fs::read(path)
912            .await
913            .with_context(|| format!("reading {} after review", path.display()))?;
914        if current != *original {
915            bail!("Preset source changed after review");
916        }
917    }
918    Ok(())
919}
920
921async fn create_backup_set(
922    shine_dir: &Path,
923    edits: &[PresetMigrationEdit],
924    sources: &BTreeMap<PathBuf, Vec<u8>>,
925) -> Result<PathBuf> {
926    ensure_sources_unchanged(sources).await?;
927    let root = shine_dir
928        .join("preset-migration-backups")
929        .join(uuid::Uuid::new_v4().to_string());
930    tokio::fs::create_dir_all(&root).await?;
931    #[cfg(unix)]
932    tokio::fs::set_permissions(&root, std::os::unix::fs::PermissionsExt::from_mode(0o700)).await?;
933    let mut entries = Vec::new();
934    for edit in edits {
935        let backup = root.join(&edit.logical_path);
936        persist::atomic_write_private(&backup, &edit.original).await?;
937        entries.push(BackupEntry {
938            logical_path: &edit.logical_path,
939            source_layer: &edit.source_layer,
940            original_sha256: sha256(&edit.original),
941            mode: file_mode(&edit.physical_path).await?,
942        });
943    }
944    let manifest = toml::to_string_pretty(&BackupManifest {
945        schema_version: 1,
946        files: entries,
947    })?;
948    persist::atomic_write_private(&root.join("manifest.toml"), manifest.as_bytes()).await?;
949    Ok(root)
950}
951
952async fn apply_edits(
953    edits: &[PresetMigrationEdit],
954    sources: &BTreeMap<PathBuf, Vec<u8>>,
955) -> Result<()> {
956    ensure_sources_unchanged(sources).await?;
957    for edit in edits {
958        let current = tokio::fs::read(&edit.physical_path)
959            .await
960            .with_context(|| {
961                format!("reading {} before migration", edit.physical_path.display())
962            })?;
963        if current != edit.original {
964            bail!("Preset source changed after review: {}", edit.logical_path);
965        }
966        let permissions = tokio::fs::metadata(&edit.physical_path)
967            .await?
968            .permissions();
969        match &edit.candidate {
970            Some(candidate) => {
971                persist::atomic_write(&edit.physical_path, candidate).await?;
972                tokio::fs::set_permissions(&edit.physical_path, permissions).await?;
973            }
974            None => tokio::fs::remove_file(&edit.physical_path).await?,
975        }
976    }
977    Ok(())
978}
979
980#[cfg(unix)]
981async fn file_mode(path: &Path) -> Result<Option<u32>> {
982    use std::os::unix::fs::PermissionsExt;
983    Ok(Some(tokio::fs::metadata(path).await?.permissions().mode()))
984}
985
986#[cfg(not(unix))]
987async fn file_mode(_path: &Path) -> Result<Option<u32>> {
988    Ok(None)
989}
990
991fn legacy_metadata_hashes() -> BTreeMap<String, BTreeSet<String>> {
992    // Unique shine.toml SHA-256 values shipped by every v1.0.0 through v1.8.0
993    // release tag (including patch releases). Sys fingerprints remain evidence
994    // only: Core deliberately refuses to turn a v1 dispatcher into v2 items.
995    const ENTRIES: &[(&str, &str)] = &[
996        (
997            "app/JetBrains/shine.toml",
998            "0d6545cdfa392b6d4742bcddc288b4faf7382df3a23395a69300508e7c09e8f1",
999        ),
1000        (
1001            "app/archey4/shine.toml",
1002            "97355899e41859f3c63323c5f4419a8e2c5f6723673765b7bf7935d3f8e2b6c2",
1003        ),
1004        (
1005            "app/clash-verge/shine.toml",
1006            "1a90f41ca438622b212de8fd5732a89c302f19216b53ac9c633bf8bef43ce97f",
1007        ),
1008        (
1009            "app/clash-verge/shine.toml",
1010            "842c227f8e53e3ddd6113402b53f293b495d4833d2530fa79ef0f8337a04f5bf",
1011        ),
1012        (
1013            "app/docker-desktop/shine.toml",
1014            "9ed5a6b310a152639bbacc3cc157d2e408fe2c4187d9d8f92b346941f6b6314b",
1015        ),
1016        (
1017            "app/docker-engine/shine.toml",
1018            "658acb03b9dc488f214daa0b2b13dd2516028047de8952e30557de26cbbc63a0",
1019        ),
1020        (
1021            "app/fastfetch/shine.toml",
1022            "2dd7d716ddaaf13f07649a24f3ce580d23275649a8744d7359f66edf3ada3731",
1023        ),
1024        (
1025            "app/ghostty/shine.toml",
1026            "7c8201f5059a7bb3e81382cf5436a1da14906656a62aaa243cdc970b149f9ef4",
1027        ),
1028        (
1029            "app/surge/shine.toml",
1030            "5df30183647d35bb9359c9a09ad7efe94fe8b5c212b0d486ded7c6288b349034",
1031        ),
1032        (
1033            "app/surge/shine.toml",
1034            "ac5db93291294515aba2c8f457af1790d1520732e5a7c337fcbbd71a60144a23",
1035        ),
1036        (
1037            "app/vim/shine.toml",
1038            "75d27a891409dc484bb833ca8b1c192461ee993ff7ec49ea71e0d5c05f5735a1",
1039        ),
1040        (
1041            "shell/agent/shine.toml",
1042            "e8eb84b91e3dfd958a81cc36d2425a0847257f9e940932643edfff5e80d53fd9",
1043        ),
1044        (
1045            "shell/image-tools/shine.toml",
1046            "5d18cec5a585d58f897ad8aa74073c805f95c2af84db714ca09c54d02ce92a28",
1047        ),
1048        (
1049            "shell/proxy/shine.toml",
1050            "9e1dbfca07fab117c067ea8a8244cc7a8d64684e204fb5ecf8690f17fe743d6e",
1051        ),
1052        (
1053            "shell/utils/shine.toml",
1054            "b670ab168bc4eaf4cc75b20b7637a6c109bd0ea6db309464d6b0cde81497997d",
1055        ),
1056        (
1057            "sys/macos/shine.toml",
1058            "18cf178c1f3e8b6d456731356c62c6db3af005a388b647f10e38513c5d25d49d",
1059        ),
1060        (
1061            "sys/macos/shine.toml",
1062            "31d2809917dfb40d6c5642acbca4d4fb8a5d0514eb3b4208bc840becdedf735e",
1063        ),
1064        (
1065            "sys/ubuntu/shine.toml",
1066            "72788f06f29e7e554be80f25bfac1239b8759f89e504285f3045db12b6cc9a96",
1067        ),
1068        (
1069            "sys/ubuntu/shine.toml",
1070            "957133cacf805a4041e91bf08f86ff727415fd5165ca52a34c4da8c2044e9d57",
1071        ),
1072        (
1073            "sys/ubuntu/shine.toml",
1074            "f52d5ae3be3506269b1e8b0d34c5daab0fd5ea3dc87fa22b0500c05bbc4fd4b5",
1075        ),
1076        (
1077            "sys/ubuntu/shine.toml",
1078            "fb0ec4efa47a16618d63eab975066eb19a0157a682399505d0bae9f67e559769",
1079        ),
1080        (
1081            "sys/windows/shine.toml",
1082            "707ad4c963722a983c853705d1bd7ee4d0c72f00c3e8cb5f3bf0490e7d346708",
1083        ),
1084        (
1085            "sys/windows/shine.toml",
1086            "916f8f87ac7f36c37d2970417176e3a49ac07b4fa8dee40a3ed409e94feefce2",
1087        ),
1088        (
1089            "sys/windows/shine.toml",
1090            "edb1ec46dd84cb5ca4c799164b6f4e21591a1e988876bf1d3fdbc8fc20870ad8",
1091        ),
1092    ];
1093    let mut map = BTreeMap::<String, BTreeSet<String>>::new();
1094    for (path, hash) in ENTRIES {
1095        map.entry((*path).to_string())
1096            .or_default()
1097            .insert((*hash).to_string());
1098    }
1099    map
1100}
1101
1102#[cfg(test)]
1103mod tests {
1104    use super::*;
1105    use crate::commands::{Cli, Commands, PresetCommands};
1106    use crate::test_support::{env_lock, make_temp_dir};
1107    use clap::Parser;
1108
1109    #[test]
1110    fn migrate_cli_parses_review_flags_and_rejects_yes_with_dry_run() {
1111        let cli = Cli::try_parse_from([
1112            "shine",
1113            "preset",
1114            "migrate",
1115            "presets/app/demo",
1116            "--dry-run",
1117            "--format",
1118            "json",
1119        ])
1120        .unwrap();
1121        assert!(matches!(
1122            cli.command,
1123            Commands::Preset {
1124                command: PresetCommands::Migrate {
1125                    path: Some(_),
1126                    dry_run: true,
1127                    yes: false,
1128                    format: PresetReportFormat::Json,
1129                }
1130            }
1131        ));
1132        assert!(Cli::try_parse_from(["shine", "preset", "migrate", "--dry-run", "--yes"]).is_err());
1133    }
1134
1135    #[test]
1136    fn managed_overlay_candidates_are_diagnostic_only() {
1137        let root = Path::new("/managed-overlay");
1138        let snapshot = PresetSnapshot::builder(shine_core::runtime::PresetSourceKind::Embedded)
1139            .file(
1140                "app/demo/shine.toml",
1141                b"metadata_schema_version = 2\ndest = '~/.demo'\n[permissions]\nschema_version = 1\n"
1142                    .to_vec(),
1143            )
1144            .overlay_root(root)
1145            .overlay_file(
1146                "app/demo/shine.toml",
1147                b"dest = '~/.demo'\n[[files]]\nsource = 'config.toml'\n".to_vec(),
1148            )
1149            .overlay_file("app/demo/config.toml", Vec::new())
1150            .build();
1151        let mut plan = plan_preset_migration(&snapshot, "active", None, None);
1152        assert_eq!(plan.edits.len(), 1);
1153
1154        mark_managed_overlay_read_only(&mut plan, root);
1155
1156        assert!(plan.edits.is_empty());
1157        assert!(plan.report.diagnostics.iter().any(|item| {
1158            item.code == "managed_overlay_read_only"
1159                && item.source_layer.as_deref() == Some("overlay")
1160        }));
1161        let output = migration_text(&plan, &snapshot, Some(root), true);
1162        assert!(output.contains("upstream checkout"));
1163        assert!(output.contains("shine preset pull"));
1164        assert!(!output.contains("Edit: /managed-overlay"));
1165    }
1166
1167    #[test]
1168    fn targeted_sys_compatibility_selects_only_the_item_category() {
1169        let snapshot = PresetSnapshot::builder(shine_core::runtime::PresetSourceKind::External)
1170            .file(
1171                "sys/macos/shine.toml",
1172                b"version = 2\n[[items]]\nid = 'one'\n".to_vec(),
1173            )
1174            .file(
1175                "sys/ubuntu/shine.toml",
1176                b"version = 2\n[[items]]\nid = 'two'\n".to_vec(),
1177            )
1178            .build();
1179
1180        assert_eq!(
1181            sys_categories_for_item(&snapshot, "two"),
1182            BTreeSet::from(["sys/ubuntu".to_string()])
1183        );
1184    }
1185
1186    #[test]
1187    fn compatibility_summary_defers_the_single_next_command_to_the_failure() {
1188        let snapshot = PresetSnapshot::builder(shine_core::runtime::PresetSourceKind::External)
1189            .base_root("/presets")
1190            .file(
1191                "shell/chrome/shine.toml",
1192                b"[[files]]\nsource = 'open.sh'\ntarget = 'open-chrome'\n".to_vec(),
1193            )
1194            .file("shell/chrome/open.sh", Vec::new())
1195            .build();
1196        let plan = plan_preset_migration(&snapshot, "active", None, None);
1197
1198        let summary = compatibility_text(&plan);
1199        let failure = compatibility_failure_message(&plan);
1200        let detailed = migration_text(&plan, &snapshot, None, true);
1201
1202        assert!(summary.contains("shell/chrome/open-chrome (external)"));
1203        assert!(summary.contains("code: manual_permission_review_required"));
1204        assert!(!summary.contains("[manual_permission_review_required]"));
1205        assert!(!summary.contains("preset migrate --dry-run"));
1206        assert_eq!(failure.matches("preset migrate --dry-run").count(), 1);
1207        assert!(failure.contains("1 blocker"));
1208        assert!(failure.contains("\n\nNext:\n  shine preset migrate --dry-run"));
1209        assert!(detailed.contains("1 blocker · 0 automatic changes · 0 advisories"));
1210        assert!(!detailed.contains("1 blockers"));
1211    }
1212
1213    #[test]
1214    fn detailed_shell_remediation_groups_commands_and_never_suggests_trust() {
1215        let snapshot = PresetSnapshot::builder(shine_core::runtime::PresetSourceKind::External)
1216            .base_root("/preset root")
1217            .file(
1218                "shell/chrome/shine.toml",
1219                b"[[files]]\nsource = 'open.sh'\ntarget = 'open-chrome'\n\n[[files]]\nsource = 'close.sh'\ntarget = 'close-chrome'\n".to_vec(),
1220            )
1221            .file("shell/chrome/open.sh", Vec::new())
1222            .file("shell/chrome/close.sh", Vec::new())
1223            .build();
1224        let plan = plan_preset_migration(&snapshot, "active", None, None);
1225
1226        let output = migration_text(&plan, &snapshot, None, true);
1227
1228        assert_eq!(output.matches("  shell/chrome (external)").count(), 1);
1229        assert_eq!(output.matches("shine preset validate").count(), 1);
1230        assert_eq!(output.matches("shine preset plan").count(), 1);
1231        let manifest = snapshot
1232            .origin("shell/chrome/shine.toml")
1233            .and_then(|origin| origin.physical_path.as_deref())
1234            .expect("external snapshot manifest has a physical path");
1235        let quoted = quote_command_arg(manifest, RuntimePlatform::current());
1236        assert!(output.contains(&format!("shine preset validate {quoted}")));
1237        assert!(output.contains(&format!(
1238            "shine preset plan {quoted} --platform {}",
1239            RuntimePlatform::current().as_str()
1240        )));
1241        assert!(output.contains("2 blockers · 0 automatic changes · 0 advisories"));
1242        assert!(!output.contains("shine trust"));
1243    }
1244
1245    #[test]
1246    fn detailed_app_and_sys_remediation_suggests_trust_only_for_executable_code() {
1247        let snapshot = PresetSnapshot::builder(shine_core::runtime::PresetSourceKind::External)
1248            .base_root("/presets")
1249            .file(
1250                "app/demo/shine.toml",
1251                b"dest = '~/.demo'\n[artifact]\nscript = 'build.ts'\n".to_vec(),
1252            )
1253            .file("app/demo/build.ts", Vec::new())
1254            .file(
1255                "sys/ubuntu/shine.toml",
1256                b"version = 2\n[[items]]\nid = 'scripted'\ninstall = { kind = 'script', path = 'install.sh' }\n\n[[items]]\nid = 'package-only'\ninstall = { kind = 'package', provider = 'apt', package = 'demo' }\n".to_vec(),
1257            )
1258            .file("sys/ubuntu/install.sh", Vec::new())
1259            .build();
1260        let plan = plan_preset_migration(&snapshot, "active", None, None);
1261
1262        let output = migration_text(&plan, &snapshot, None, true);
1263
1264        assert!(output.contains("shine trust inspect app/demo"));
1265        assert!(output.contains("shine trust grant app/demo"));
1266        assert!(output.contains("shine trust inspect sys/scripted"));
1267        assert!(output.contains("shine trust grant sys/scripted"));
1268        assert!(!output.contains("shine trust inspect sys/package-only"));
1269        assert!(!output.contains("shine trust grant sys/package-only"));
1270    }
1271
1272    #[test]
1273    fn managed_overlay_remediation_never_suggests_editing_the_mirror() {
1274        let root = Path::new("/managed overlay");
1275        let snapshot = PresetSnapshot::builder(shine_core::runtime::PresetSourceKind::Embedded)
1276            .overlay_root(root)
1277            .overlay_file(
1278                "shell/chrome/shine.toml",
1279                b"[[files]]\nsource = 'open.sh'\ntarget = 'open-chrome'\n".to_vec(),
1280            )
1281            .overlay_file("shell/chrome/open.sh", Vec::new())
1282            .overlay_file(
1283                "shell/test/shine.toml",
1284                b"[[files]]\nsource = 'test.sh'\ntarget = 'mytool'\n".to_vec(),
1285            )
1286            .overlay_file("shell/test/test.sh", Vec::new())
1287            .build();
1288        let plan = plan_preset_migration(&snapshot, "active", None, None);
1289
1290        let output = migration_text(&plan, &snapshot, Some(root), true);
1291
1292        assert!(output.contains("upstream checkout"));
1293        assert_eq!(output.matches("shine preset pull").count(), 1);
1294        assert!(output.contains("    ✗ shell/chrome/open-chrome\n      Shell command"));
1295        assert!(output.contains("      code: manual_permission_review_required"));
1296        assert_eq!(output.matches("    Fix:\n").count(), 2);
1297        assert!(output.contains("  Next:\n    Commit the upstream changes, then run:"));
1298        assert!(!output.contains("Edit: /managed overlay"));
1299        assert!(!output.contains("shine preset validate"));
1300    }
1301
1302    #[test]
1303    fn remediation_command_paths_are_platform_quoted() {
1304        let path = Path::new("/preset root/it's/shine.toml");
1305
1306        assert_eq!(
1307            quote_command_arg(path, RuntimePlatform::Linux),
1308            "'/preset root/it'\\''s/shine.toml'"
1309        );
1310        assert_eq!(
1311            quote_command_arg(path, RuntimePlatform::Windows),
1312            "'/preset root/it''s/shine.toml'"
1313        );
1314    }
1315
1316    #[tokio::test]
1317    #[allow(clippy::await_holding_lock)] // env_lock serializes process-global discovery state.
1318    async fn dry_run_is_read_only_and_apply_creates_private_backup_state() {
1319        let _guard = env_lock();
1320        let root = make_temp_dir("shine-preset-migrate").await;
1321        let state = root.join("state");
1322        let presets = root.join("source");
1323        let category = presets.join("app/demo");
1324        tokio::fs::create_dir_all(&category).await.unwrap();
1325        let metadata = category.join("shine.toml");
1326        let original = b"dest = '~/.demo'\n[[files]]\nsource = 'config.toml'\n";
1327        tokio::fs::write(&metadata, original).await.unwrap();
1328        tokio::fs::write(category.join("config.toml"), b"payload")
1329            .await
1330            .unwrap();
1331
1332        let previous_config = std::env::var_os("SHINE_CONFIG_DIR");
1333        let previous_presets = std::env::var_os("SHINE_PRESETS");
1334        // SAFETY: env_lock serializes process-global environment mutation.
1335        unsafe {
1336            std::env::set_var("SHINE_CONFIG_DIR", &state);
1337            std::env::remove_var("SHINE_PRESETS");
1338        }
1339
1340        let dry = handle_migrate(Some(&presets), true, false, PresetReportFormat::Text)
1341            .await
1342            .unwrap();
1343        assert!(dry);
1344        assert_eq!(tokio::fs::read(&metadata).await.unwrap(), original);
1345        assert!(!state.exists());
1346
1347        let json_without_yes =
1348            handle_migrate(Some(&presets), false, false, PresetReportFormat::Json)
1349                .await
1350                .unwrap_err();
1351        assert!(json_without_yes.to_string().contains("--dry-run or --yes"));
1352        assert!(!state.exists());
1353
1354        let non_interactive =
1355            handle_migrate(Some(&presets), false, false, PresetReportFormat::Text)
1356                .await
1357                .unwrap_err();
1358        assert!(non_interactive.to_string().contains("explicit --yes"));
1359        assert_eq!(tokio::fs::read(&metadata).await.unwrap(), original);
1360        assert!(!state.exists());
1361
1362        let applied = handle_migrate(Some(&presets), false, true, PresetReportFormat::Text)
1363            .await
1364            .unwrap();
1365        assert!(applied);
1366        let migrated = tokio::fs::read_to_string(&metadata).await.unwrap();
1367        assert!(migrated.contains("metadata_schema_version = 2"));
1368        assert!(migrated.contains("[permissions]"));
1369        let backups = state.join("preset-migration-backups");
1370        assert!(backups.is_dir());
1371        let mut sets = tokio::fs::read_dir(&backups).await.unwrap();
1372        let backup = sets.next_entry().await.unwrap().unwrap().path();
1373        let manifest = tokio::fs::read_to_string(backup.join("manifest.toml"))
1374            .await
1375            .unwrap();
1376        assert!(manifest.contains("source_layer = \"external\""));
1377        assert!(!manifest.contains("source_path"));
1378        #[cfg(unix)]
1379        {
1380            use std::os::unix::fs::PermissionsExt;
1381            assert_eq!(
1382                tokio::fs::metadata(&backup)
1383                    .await
1384                    .unwrap()
1385                    .permissions()
1386                    .mode()
1387                    & 0o777,
1388                0o700
1389            );
1390        }
1391
1392        // SAFETY: env_lock remains held while the previous environment is restored.
1393        unsafe {
1394            match previous_config {
1395                Some(value) => std::env::set_var("SHINE_CONFIG_DIR", value),
1396                None => std::env::remove_var("SHINE_CONFIG_DIR"),
1397            }
1398            match previous_presets {
1399                Some(value) => std::env::set_var("SHINE_PRESETS", value),
1400                None => std::env::remove_var("SHINE_PRESETS"),
1401            }
1402        }
1403        tokio::fs::remove_dir_all(root).await.unwrap();
1404    }
1405
1406    #[tokio::test]
1407    async fn backup_refuses_a_source_changed_after_review() {
1408        let root = make_temp_dir("shine-preset-source-change").await;
1409        let source = root.join("shine.toml");
1410        tokio::fs::write(&source, b"original").await.unwrap();
1411        let edit = PresetMigrationEdit {
1412            logical_path: "app/demo/shine.toml".to_string(),
1413            physical_path: source.clone(),
1414            source_layer: "external".to_string(),
1415            operations: vec!["test".to_string()],
1416            original: b"original".to_vec(),
1417            candidate: Some(b"candidate".to_vec()),
1418        };
1419        tokio::fs::write(&source, b"changed").await.unwrap();
1420
1421        let sources = BTreeMap::from([(source.clone(), b"original".to_vec())]);
1422        let error = create_backup_set(&root.join("state"), &[edit], &sources)
1423            .await
1424            .unwrap_err();
1425        assert!(error.to_string().contains("changed after review"));
1426        assert!(!root.join("state").exists());
1427
1428        tokio::fs::remove_dir_all(root).await.unwrap();
1429    }
1430
1431    #[test]
1432    fn migration_observations_include_shadowed_base_files() {
1433        let snapshot = PresetSnapshot::builder(shine_core::runtime::PresetSourceKind::External)
1434            .base_root("/base")
1435            .file("app/demo/shine.toml", b"base metadata".to_vec())
1436            .file("app/demo/config.toml", b"base payload".to_vec())
1437            .overlay_root("/overlay")
1438            .overlay_file("app/demo/shine.toml", b"overlay metadata".to_vec())
1439            .build();
1440        let edits = vec![PresetMigrationEdit {
1441            logical_path: "app/demo/shine.toml".to_string(),
1442            physical_path: PathBuf::from("/overlay/app/demo/shine.toml"),
1443            source_layer: "overlay".to_string(),
1444            operations: Vec::new(),
1445            original: b"overlay metadata".to_vec(),
1446            candidate: None,
1447        }];
1448
1449        let observations = migration_source_observations(&snapshot, &edits);
1450
1451        assert_eq!(observations.len(), 3);
1452        assert_eq!(
1453            observations.get(&PathBuf::from("/base/app/demo/shine.toml")),
1454            Some(&b"base metadata".to_vec())
1455        );
1456        assert_eq!(
1457            observations.get(&PathBuf::from("/overlay/app/demo/shine.toml")),
1458            Some(&b"overlay metadata".to_vec())
1459        );
1460    }
1461
1462    #[test]
1463    fn report_drops_rejected_edits() {
1464        let snapshot = PresetSnapshot::builder(shine_core::runtime::PresetSourceKind::External)
1465            .base_root("/presets")
1466            .file(
1467                "app/demo/shine.toml",
1468                b"dest = '~/.demo'\n[[files]]\nsource = 'config.toml'\n".to_vec(),
1469            )
1470            .file("app/demo/config.toml", Vec::new())
1471            .build();
1472        let mut plan = plan_preset_migration(&snapshot, "test", None, None);
1473
1474        plan.edits.clear();
1475        sync_report_with_edits(&mut plan);
1476
1477        assert!(plan.report.files.is_empty());
1478        assert_eq!(plan.report.summary.changes, 0);
1479        assert_eq!(plan.report.status, PresetMigrationStatusV1::Current);
1480    }
1481}