1use 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: ¤t,
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: ¤t,
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 #[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 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)] 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 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 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}