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