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