1use anyhow::{Context, Result, bail};
2use std::io::IsTerminal;
3
4use crate::colors;
5use crate::config::Config;
6
7use super::detect::detect_os_id;
8use super::execution::{
9 print_item_outcome, print_run_header, print_sys_summary, status_text, sys_item_label_width,
10};
11use super::managed::managed_updates;
12use super::render::{driver_name, item_mode_name, print_available_item, print_dry_run};
13use super::run_manifest::SysRunEntry;
14use super::{
15 SysDetection, SysDetectionProbe, SysInstall, SysItemMode, SysItemOutcome, SysItemStatus,
16 SysPackageProvider,
17};
18
19pub async fn handle_list(config: &Config, all: bool) -> Result<()> {
20 crate::config::print_presets_note(config);
21 let current_os = if all {
22 detect_os_id().await.ok()
23 } else {
24 Some(detect_os_id().await?)
25 };
26 let runtime = crate::core_runtime::from_config(config).await?;
27 let mut presets = runtime.available_sys_manifests()?;
28 if !all {
29 presets.retain(|(os_id, _)| Some(os_id.as_str()) == current_os.as_deref());
30 }
31 if presets.is_empty() {
32 if all {
33 println!("{}", colors::dim("No system presets found."));
34 return Ok(());
35 }
36 let current_os = current_os.as_deref().unwrap_or("unknown");
37 bail!("No system preset found for `{current_os}`");
38 }
39
40 let run_manifest = runtime.inspect_sys_run_manifest().await?;
41 println!("{}\n", colors::bold("System Items"));
42 for (index, (os_id, manifest)) in presets.iter().enumerate() {
43 if index > 0 {
44 println!();
45 }
46 let current = if Some(os_id.as_str()) == current_os.as_deref() {
47 " (current)"
48 } else {
49 ""
50 };
51 println!(" {}{}", colors::bold(os_id), colors::dim(current));
52 if !manifest.description.is_empty() {
53 println!(" {}", colors::dim(&manifest.description));
54 }
55 if manifest.items.is_empty() {
56 println!(" {}", colors::dim("No items available."));
57 }
58 for item in &manifest.items {
59 let entry = run_manifest
60 .entries
61 .iter()
62 .find(|entry| entry.os_id == *os_id && entry.item_id == item.id);
63 print_available_item(item, entry);
64 }
65 }
66
67 println!();
68 println!(
69 "{}",
70 colors::dim("Use `shine sys info <ITEM>` for details.")
71 );
72 println!("{}", colors::dim("Bootstrap items: `shine sys bootstrap`."));
73 println!(
74 "{}",
75 colors::dim("Managed items: `shine sys apply <ITEM>`.")
76 );
77 if !all {
78 println!(
79 "{}",
80 colors::dim("Use `shine sys list --all` to show every OS.")
81 );
82 }
83 Ok(())
84}
85
86pub async fn handle_info(config: &Config, item_id: &str) -> Result<()> {
87 crate::config::print_presets_note(config);
88 let os_id = detect_os_id().await?;
89 let runtime = crate::core_runtime::from_config(config).await?;
90 let presets = runtime.available_sys_manifests()?;
91 let manifest = presets
92 .iter()
93 .find(|(candidate, _)| candidate == &os_id)
94 .map(|(_, manifest)| manifest)
95 .with_context(|| format!("No system preset found for `{os_id}`"))?;
96 let item = manifest
97 .items
98 .iter()
99 .find(|candidate| candidate.id == item_id)
100 .with_context(|| {
101 let available = manifest
102 .items
103 .iter()
104 .map(|candidate| candidate.id.as_str())
105 .collect::<Vec<_>>()
106 .join(", ");
107 format!("unknown sys item `{item_id}` for {os_id}. Available: {available}")
108 })?;
109 let run_manifest = runtime.inspect_sys_run_manifest().await?;
110 let entry = run_manifest
111 .entries
112 .iter()
113 .find(|entry| entry.os_id == os_id && entry.item_id == item.id);
114
115 println!("{}\n", colors::bold("System Item"));
116 println!(
117 " {} {}",
118 colors::bold(&item.label),
119 colors::dim(&format!("({})", item.id))
120 );
121 if !item.description.is_empty() {
122 println!(" {}", item.description);
123 }
124 println!();
125 println!(" {:<14} {}", "OS", os_id);
126 println!(" {:<14} {}", "Type", item_mode_name(item.mode));
127 if item.mode == SysItemMode::Managed {
128 println!(" {:<14} {}", "Driver", driver_name(item.driver));
129 } else {
130 println!(
131 " {:<14} {}",
132 "Detection",
133 describe_detection(item.detect.as_ref())
134 );
135 println!(
136 " {:<14} {}",
137 "Installer",
138 describe_install(item.install.as_ref())
139 );
140 println!(
141 " {:<14} {}",
142 "Integration",
143 if item.shell.is_empty() {
144 "none".to_string()
145 } else if entry.is_some_and(|entry| entry.profile_enabled) {
146 format!("enabled ({} declaration(s))", item.shell.len())
147 } else {
148 format!("disabled ({} declaration(s))", item.shell.len())
149 }
150 );
151 }
152 let admin_access = match item.install.as_ref() {
153 Some(SysInstall::Package {
154 provider: SysPackageProvider::Apt,
155 ..
156 }) => "required",
157 Some(SysInstall::Package {
158 provider: SysPackageProvider::Winget,
159 ..
160 }) => "package-dependent",
161 _ if item.requires_admin => "required",
162 _ => "not required",
163 };
164 println!(" {:<14} {}", "Admin access", admin_access);
165 println!(
166 " {:<14} {}",
167 "Status",
168 entry
169 .map(|entry| status_text(entry.status))
170 .unwrap_or("not recorded")
171 );
172 if let Some(entry) = entry
173 && !entry.detail.is_empty()
174 {
175 println!(" {:<14} {}", "Status detail", entry.detail);
176 }
177 println!(
178 " {:<14} {}",
179 "Required env",
180 if item.required_env.is_empty() {
181 "none".to_string()
182 } else {
183 item.required_env.join(", ")
184 }
185 );
186 if item.mode == SysItemMode::Managed
187 && entry.is_some()
188 && let Some(update) = managed_updates(config)
189 .await?
190 .into_iter()
191 .find(|update| update.item_id == item.id)
192 {
193 println!(" {:<14} update available", "Pending");
194 for detail in update.details {
195 println!(" {:<14} {}", "", detail);
196 }
197 }
198 println!();
199 match item.mode {
200 SysItemMode::Init => println!(" Next: run `shine sys bootstrap {}`.", item.id),
201 SysItemMode::Managed if entry.is_some() => {
202 println!(" Apply: `shine sys apply {}`", item.id);
203 println!(" Uninstall: `shine sys uninstall {}`", item.id);
204 }
205 SysItemMode::Managed => println!(" Next: run `shine sys apply {}`.", item.id),
206 }
207 Ok(())
208}
209
210fn describe_detection(detect: Option<&SysDetection>) -> String {
211 match detect {
212 Some(SysDetection::Command {
213 command,
214 version_args,
215 }) => std::iter::once(command.as_str())
216 .chain(version_args.iter().map(String::as_str))
217 .collect::<Vec<_>>()
218 .join(" "),
219 Some(SysDetection::Path { path }) => format!("path {path}"),
220 Some(SysDetection::Any { probes }) => format!(
221 "any of {}",
222 probes
223 .iter()
224 .map(|probe| match probe {
225 SysDetectionProbe::Command { command } => format!("command {command}"),
226 SysDetectionProbe::Path { path } => format!("path {path}"),
227 })
228 .collect::<Vec<_>>()
229 .join(", ")
230 ),
231 None => "legacy platform script".to_string(),
232 }
233}
234
235fn describe_install(install: Option<&SysInstall>) -> String {
236 match install {
237 Some(SysInstall::Package {
238 provider, package, ..
239 }) => format!("{} package {package}", package_provider_name(*provider)),
240 Some(SysInstall::Script { path, .. }) => format!("item script {path}"),
241 None => "legacy platform script".to_string(),
242 }
243}
244
245fn package_provider_name(provider: SysPackageProvider) -> &'static str {
246 match provider {
247 SysPackageProvider::Homebrew => "homebrew",
248 SysPackageProvider::HomebrewCask => "homebrew-cask",
249 SysPackageProvider::Apt => "apt",
250 SysPackageProvider::Winget => "winget",
251 }
252}
253
254pub async fn handle_status(config: &Config) -> Result<()> {
255 let os_id = detect_os_id().await?;
256 let manifest = crate::core_runtime::from_config(config)
257 .await?
258 .inspect_sys_run_manifest()
259 .await?;
260 let entries: Vec<&SysRunEntry> = manifest
261 .entries
262 .iter()
263 .filter(|entry| entry.os_id == os_id)
264 .collect();
265
266 if entries.is_empty() {
267 println!(
268 "{}",
269 colors::dim(&format!(
270 "No bootstrap items recorded for {os_id}. Run `shine sys bootstrap` to initialize the current system."
271 ))
272 );
273 return Ok(());
274 }
275
276 println!("{}\n", colors::bold("Recorded Bootstrap Results"));
277 println!(
278 "{}\n",
279 colors::dim(
280 "These are results recorded by the last bootstrap run, not live version checks."
281 )
282 );
283
284 let label_width = entries
285 .iter()
286 .map(|entry| entry.label.len())
287 .max()
288 .unwrap_or(14)
289 .max(14);
290
291 for entry in entries {
292 print_item_outcome(
293 &SysItemOutcome {
294 item_id: entry.item_id.clone(),
295 label: entry.label.clone(),
296 status: entry.status,
297 detail: entry.detail.clone(),
298 logs: Vec::new(),
299 },
300 label_width,
301 );
302 }
303
304 Ok(())
305}
306
307pub async fn handle_init(
308 config: &Config,
309 requested: &[String],
310 preset: Option<&str>,
311 dry_run: bool,
312 force_profile: bool,
313 proxy: bool,
314 yes: bool,
315) -> Result<()> {
316 let os_id = detect_os_id().await?;
317 handle_init_for_os(
318 config,
319 &os_id,
320 BootstrapCliOptions {
321 requested,
322 preset,
323 dry_run,
324 force_profile,
325 proxy,
326 yes,
327 },
328 )
329 .await
330}
331
332struct BootstrapCliOptions<'a> {
333 requested: &'a [String],
334 preset: Option<&'a str>,
335 dry_run: bool,
336 force_profile: bool,
337 proxy: bool,
338 yes: bool,
339}
340
341async fn handle_init_for_os(
342 config: &Config,
343 os_id: &str,
344 options: BootstrapCliOptions<'_>,
345) -> Result<()> {
346 let BootstrapCliOptions {
347 requested,
348 preset,
349 dry_run,
350 force_profile,
351 proxy,
352 yes,
353 } = options;
354 crate::config::print_presets_note(config);
355 let interactive = std::io::stdin().is_terminal() && std::io::stdout().is_terminal();
356 let sys_shell: &'static str = config.shell_type.into();
357 let proxy_env = if proxy {
358 super::execution::proxy_env_vars(config)
359 } else {
360 Vec::new()
361 };
362 let proxy_env_map = proxy_env
363 .iter()
364 .map(|(key, value)| ((*key).to_string(), value.clone()))
365 .collect::<std::collections::BTreeMap<_, _>>();
366
367 let mut runtime = crate::core_runtime::from_config(config).await?;
368 runtime.context_mut_for_cli().proxy_env = proxy_env_map.clone();
369 let mut interaction = crate::presentation::TerminalInteraction;
370 let mut observer = BatchBootstrapObserver::default();
371 if dry_run {
372 let report = runtime
373 .preview_sys_bootstrap(
374 shine_core::runtime::SysBootstrapBatchRequest {
375 os_id: os_id.to_string(),
376 requested: requested.to_vec(),
377 preset: preset.map(str::to_string),
378 interactive,
379 sys_shell: sys_shell.to_string(),
380 dry_run: true,
381 force_profile,
382 },
383 &mut interaction,
384 &mut observer,
385 )
386 .await?;
387 print_dry_run(
388 os_id,
389 &report.loaded,
390 &report.selection,
391 sys_shell,
392 &proxy_env,
393 &report.previews,
394 )
395 .await?;
396 return Ok(());
397 }
398
399 let selection = runtime
400 .resolve_sys_bootstrap_selection(
401 os_id,
402 requested,
403 preset,
404 interactive,
405 &mut interaction,
406 &mut observer,
407 )
408 .await?;
409 if selection.item_ids.is_empty() {
410 println!(
411 "{}",
412 colors::dim(&format!(
413 "No sys bootstrap items selected for {} ({}).",
414 os_id,
415 selection.source.describe()
416 ))
417 );
418 return Ok(());
419 }
420 let plan_request = shine_core::runtime::SysBootstrapPlanRequest {
421 os_id: os_id.to_string(),
422 item_ids: selection.item_ids,
423 sys_shell: sys_shell.to_string(),
424 force_profile,
425 input_versions: shine_core::runtime::PlanningInputVersions::default(),
426 };
427 let reviewed = crate::lifecycle_plan::review_plans(
428 config,
429 [crate::lifecycle_plan::LifecyclePlanRequest::sys_bootstrap(
430 plan_request.clone(),
431 config,
432 proxy_env_map,
433 )],
434 yes,
435 )
436 .await?
437 .into_iter()
438 .next()
439 .context("missing reviewed Sys bootstrap Plan")?;
440 let runtime = crate::lifecycle_plan::prepare_runtime(config, &reviewed).await?;
441 let report = match crate::lifecycle_plan::execute_reviewed(
442 config,
443 runtime,
444 reviewed,
445 shine_core::frontend::ExecutionOptions::default(),
446 &mut observer,
447 &mut interaction,
448 )
449 .await?
450 {
451 shine_core::frontend::OperationDetails::SysBootstrap(report) => *report,
452 _ => unreachable!("reviewed operation result type"),
453 };
454 println!();
455 print_sys_summary(&report.outcomes);
456 if report
457 .outcomes
458 .iter()
459 .any(|outcome| outcome.status == SysItemStatus::Failed)
460 {
461 bail!("sys bootstrap failed");
462 }
463
464 Ok(())
465}
466
467#[derive(Default)]
468struct BatchBootstrapObserver {
469 label_width: usize,
470}
471
472impl shine_core::runtime::RuntimeObserver for BatchBootstrapObserver {
473 fn emit(&mut self, event: shine_core::runtime::RuntimeEvent) {
474 match event {
475 shine_core::runtime::RuntimeEvent::Interaction {
476 code: "sys_bootstrap_selection",
477 target,
478 } => {
479 if !target.is_empty() {
480 println!("{}", colors::dim(&format!("Default profile: {target}")));
481 }
482 println!("{}", colors::dim("Use Space to toggle, Enter to confirm."));
483 println!();
484 }
485 shine_core::runtime::RuntimeEvent::SysBootstrapSelection {
486 os_id,
487 shell,
488 item_ids,
489 item_labels,
490 source,
491 } => {
492 let selection = super::ResolvedSelection { item_ids, source };
493 let labels = item_labels
494 .iter()
495 .map(|(id, label)| (id.as_str(), label.clone()))
496 .collect();
497 self.label_width = sys_item_label_width(&selection, &labels);
498 print_run_header(&os_id, &shell, &selection);
499 }
500 shine_core::runtime::RuntimeEvent::SysBootstrapItemStart {
501 item_id,
502 label,
503 requires_admin,
504 } => {
505 let admin = if requires_admin {
506 " (administrator access required)"
507 } else {
508 ""
509 };
510 println!(
511 " {} {}",
512 colors::symbol("•"),
513 colors::dim(&format!("sys/{item_id} ({label}) installing{admin}"))
514 );
515 }
516 shine_core::runtime::RuntimeEvent::SysBootstrapOutcome(outcome) => {
517 print_item_outcome(&outcome, self.label_width.max(14));
518 }
519 _ => {}
520 }
521 }
522}
523
524#[cfg(any())]
527mod legacy_dispatcher_tests {
528 use super::*;
529 use crate::config::Config;
530 use crate::shells::ShellType;
531 use crate::sys::execution::{
532 format_command_preview, parse_status_event, parse_sys_item_output, parse_sys_update_output,
533 parse_update_event,
534 };
535 use crate::sys::manifest::{parse_and_validate_manifest, sys_init_script_name};
536 use crate::sys::profile::{fallback_three_way_merge, install_sys_profile_files};
537 use crate::sys::profile_blocks::{update_sys_shell_profile_blocks, update_sys_shell_profiles};
538 use crate::sys::run_manifest::SYS_MANIFEST_FILE;
539 use crate::sys::selection::{format_interactive_item, format_item_ids};
540 use std::path::PathBuf;
541 use tokio::fs;
542
543 async fn make_temp_dir() -> PathBuf {
544 crate::test_support::make_temp_dir("shine-sys").await
545 }
546
547 fn sample_manifest() -> SysManifest {
548 parse_and_validate_manifest(
549 r#"
550description = "Test distro"
551default_profile = "recommended"
552
553[[items]]
554id = "neovim"
555label = "Neovim"
556description = "Install Neovim"
557
558[[items]]
559id = "atuin"
560label = "Atuin"
561description = "Install Atuin"
562default = true
563
564[profiles.recommended]
565items = ["neovim"]
566
567[profiles.full]
568items = ["neovim", "atuin"]
569"#,
570 )
571 .unwrap()
572 }
573
574 #[test]
577 fn parses_valid_manifest() {
578 let manifest = sample_manifest();
579 assert_eq!(manifest.description, "Test distro");
580 assert_eq!(manifest.default_profile.as_deref(), Some("recommended"));
581 assert_eq!(manifest.items.len(), 2);
582 }
583
584 #[test]
585 fn rejects_duplicate_item_ids() {
586 let err = parse_and_validate_manifest(
587 r#"
588[[items]]
589id = "dup"
590label = "One"
591
592[[items]]
593id = "dup"
594label = "Two"
595"#,
596 )
597 .unwrap_err();
598 assert!(err.to_string().contains("duplicate sys bootstrap item id"));
599 }
600
601 #[test]
602 fn rejects_unknown_profile_items() {
603 let err = parse_and_validate_manifest(
604 r#"
605[[items]]
606id = "neovim"
607label = "Neovim"
608
609[profiles.recommended]
610items = ["atuin"]
611"#,
612 )
613 .unwrap_err();
614 assert!(err.to_string().contains("unknown item `atuin`"));
615 }
616
617 #[test]
618 fn rejects_missing_default_profile() {
619 let err = parse_and_validate_manifest(
620 r#"
621default_profile = "recommended"
622
623[[items]]
624id = "neovim"
625label = "Neovim"
626"#,
627 )
628 .unwrap_err();
629 assert!(err.to_string().contains("default profile `recommended`"));
630 }
631
632 #[tokio::test]
633 async fn standard_only_external_sys_preset_does_not_require_legacy_script() {
634 let dir = make_temp_dir().await;
635 let os_dir = dir.join("presets/sys/fakeos");
636 fs::create_dir_all(&os_dir).await.unwrap();
637 fs::write(
638 os_dir.join("shine.toml"),
639 r#"
640[[items]]
641id = "tool"
642label = "Tool"
643
644[items.detect]
645kind = "command"
646command = "tool"
647
648[items.install]
649kind = "package"
650provider = "homebrew"
651package = "tool"
652"#,
653 )
654 .await
655 .unwrap();
656 let mut config = Config::new_for_test(&dir);
657 config.is_external_presets = true;
658
659 let loaded = load_sys_preset(&config, "fakeos").await.unwrap();
660 assert!(!loaded.script_path.exists());
661
662 fs::remove_dir_all(&dir).await.unwrap();
663 }
664
665 fn sample_sys_run_entry(os_id: &str, item_id: &str, label: &str) -> SysRunEntry {
668 SysRunEntry {
669 os_id: os_id.to_string(),
670 item_id: item_id.to_string(),
671 label: label.to_string(),
672 status: SysItemStatus::Installed,
673 detail: "ok".to_string(),
674 updated_at: "123".to_string(),
675 managed: false,
676 profile_enabled: true,
677 receipt: None,
678 }
679 }
680
681 #[tokio::test]
682 async fn sys_run_manifest_load_returns_empty_when_missing() {
683 let dir = make_temp_dir().await;
684 let manifest = SysRunManifest::load(&shine_core::runtime::RealHost, &dir)
685 .await
686 .unwrap();
687 assert!(manifest.entries.is_empty());
688 fs::remove_dir_all(&dir).await.unwrap();
689 }
690
691 #[test]
692 fn old_sys_manifest_without_receipt_remains_compatible() {
693 let manifest: SysRunManifest = toml::from_str(
694 r#"
695[[entries]]
696os_id = "macos"
697item_id = "legacy-managed"
698label = "Legacy"
699status = "installed"
700updated_at = "123"
701managed = true
702"#,
703 )
704 .unwrap();
705 assert_eq!(manifest.entries.len(), 1);
706 assert!(manifest.entries[0].managed);
707 assert!(manifest.entries[0].receipt.is_none());
708 }
709
710 #[tokio::test]
711 async fn sys_run_manifest_save_and_load_roundtrip() {
712 let dir = make_temp_dir().await;
713 let mut manifest = SysRunManifest::default();
714 manifest.upsert(sample_sys_run_entry("macos", "rust", "Rust"));
715 manifest
716 .save(&shine_core::runtime::RealHost, &dir)
717 .await
718 .unwrap();
719
720 let loaded = SysRunManifest::load(&shine_core::runtime::RealHost, &dir)
721 .await
722 .unwrap();
723 assert_eq!(loaded, manifest);
724 fs::remove_dir_all(&dir).await.unwrap();
725 }
726
727 #[test]
728 fn sys_run_manifest_upsert_replaces_by_os_and_item() {
729 let mut manifest = SysRunManifest::default();
730 manifest.upsert(sample_sys_run_entry("macos", "rust", "Rust"));
731 manifest.upsert(sample_sys_run_entry("ubuntu", "rust", "Rust"));
732
733 let mut replacement = sample_sys_run_entry("macos", "rust", "Rust");
734 replacement.status = SysItemStatus::AlreadyInstalled;
735 replacement.detail = "rustup 1.28.2".to_string();
736 replacement.updated_at = "456".to_string();
737 manifest.upsert(replacement);
738
739 assert_eq!(manifest.entries.len(), 2);
740 let macos = manifest
741 .entries
742 .iter()
743 .find(|entry| entry.os_id == "macos" && entry.item_id == "rust")
744 .unwrap();
745 assert_eq!(macos.status, SysItemStatus::AlreadyInstalled);
746 assert_eq!(macos.detail, "rustup 1.28.2");
747 assert_eq!(macos.updated_at, "456");
748 }
749
750 #[test]
753 fn resolve_selection_uses_explicit_profile() {
754 let selection = resolve_selection(&sample_manifest(), &[], Some("full"), false).unwrap();
755 assert_eq!(selection.item_ids, vec!["neovim", "atuin"]);
756 assert_eq!(
757 selection.source,
758 SelectionSource::Profile("full".to_string())
759 );
760 }
761
762 #[test]
763 fn resolve_selection_uses_default_profile_when_non_interactive() {
764 let selection = resolve_selection(&sample_manifest(), &[], None, false).unwrap();
765 assert_eq!(selection.item_ids, vec!["neovim"]);
766 assert_eq!(
767 selection.source,
768 SelectionSource::DefaultProfile("recommended".to_string())
769 );
770 }
771
772 #[test]
773 fn resolve_selection_preserves_explicit_order_and_deduplicates() {
774 let requested = vec![
775 "atuin".to_string(),
776 "neovim".to_string(),
777 "atuin".to_string(),
778 ];
779 let selection = resolve_selection(&sample_manifest(), &requested, None, false).unwrap();
780 assert_eq!(selection.item_ids, ["atuin", "neovim"]);
781 assert_eq!(selection.source, SelectionSource::Items);
782 }
783
784 #[test]
785 fn resolve_selection_rejects_managed_explicit_item() {
786 let manifest = parse_and_validate_manifest(
787 r#"
788[[items]]
789id = "dns"
790label = "DNS"
791mode = "managed"
792"#,
793 )
794 .unwrap();
795 let error = resolve_selection(&manifest, &["dns".to_string()], None, false).unwrap_err();
796 assert!(error.to_string().contains("shine sys apply dns"));
797 }
798
799 #[test]
800 fn parses_standard_bootstrap_and_shell_integration() {
801 let manifest = parse_and_validate_manifest(
802 r#"
803profile_composition = true
804
805[[items]]
806id = "mise"
807label = "mise"
808
809[items.detect]
810kind = "command"
811command = "mise"
812version_args = ["--version"]
813
814[items.install]
815kind = "package"
816provider = "homebrew"
817package = "mise"
818
819[[items.shell]]
820shells = ["bash", "zsh"]
821phase = "post"
822when_command = "mise"
823eval = ["mise", "activate", "{shell}"]
824"#,
825 )
826 .unwrap();
827 assert!(manifest.profile_composition);
828 assert!(manifest.items[0].detect.is_some());
829 assert!(manifest.items[0].install.is_some());
830 assert_eq!(manifest.items[0].shell.len(), 1);
831 }
832
833 #[test]
834 fn rejects_option_like_package_identifier() {
835 let error = parse_and_validate_manifest(
836 r#"
837[[items]]
838id = "unsafe"
839label = "Unsafe"
840
841[items.detect]
842kind = "command"
843command = "unsafe"
844
845[items.install]
846kind = "package"
847provider = "apt"
848package = "--reinstall"
849"#,
850 )
851 .unwrap_err();
852 assert!(error.to_string().contains("invalid package identifier"));
853 }
854
855 #[test]
856 fn resolve_selection_returns_empty_when_no_items_exist() {
857 let manifest = parse_and_validate_manifest(
858 r#"
859description = "Placeholder"
860"#,
861 )
862 .unwrap();
863 let selection = resolve_selection(&manifest, &[], None, false).unwrap();
864 assert!(selection.item_ids.is_empty());
865 assert_eq!(selection.source, SelectionSource::NoItems);
866 }
867
868 #[test]
869 fn managed_item_metadata_parses_and_old_items_default_to_init() {
870 let manifest = parse_and_validate_manifest(
871 r#"
872[[items]]
873id = "legacy"
874label = "Legacy"
875
876[[items]]
877id = "dns"
878label = "DNS"
879mode = "managed"
880requires_admin = true
881required_env = ["PRIVATE_DNS_DOMAIN", "PRIVATE_DNS_SERVERS"]
882"#,
883 )
884 .unwrap();
885 assert_eq!(manifest.items[0].mode, SysItemMode::Init);
886 assert!(!manifest.items[0].requires_admin);
887 assert_eq!(manifest.items[1].mode, SysItemMode::Managed);
888 assert_eq!(manifest.items[1].driver, SysDriverKind::Script);
889 assert!(manifest.items[1].requires_admin);
890 assert_eq!(manifest.items[1].required_env.len(), 2);
891 }
892
893 #[test]
894 fn managed_item_rejects_invalid_required_env_name() {
895 let error = parse_and_validate_manifest(
896 r#"
897[[items]]
898id = "dns"
899label = "DNS"
900mode = "managed"
901required_env = ["NOT-AN-ENV"]
902"#,
903 )
904 .unwrap_err();
905 assert!(error.to_string().contains("invalid required_env"));
906 }
907
908 #[test]
909 fn shell_type_into_static_str() {
910 assert_eq!(<&'static str>::from(ShellType::Bash), "bash");
911 assert_eq!(<&'static str>::from(ShellType::Zsh), "zsh");
912 assert_eq!(<&'static str>::from(ShellType::Fish), "fish");
913 assert_eq!(<&'static str>::from(ShellType::PowerShell), "powershell");
914 assert_eq!(<&'static str>::from(ShellType::Elvish), "elvish");
915 }
916
917 #[test]
918 fn format_interactive_item_includes_separator_and_description() {
919 let item = SysItem {
920 id: "neovim".to_string(),
921 label: "Neovim".to_string(),
922 description: "Install Neovim".to_string(),
923 default: false,
924 mode: SysItemMode::Init,
925 requires_admin: false,
926 required_env: Vec::new(),
927 driver: SysDriverKind::Script,
928 config: toml::Table::new(),
929 detect: None,
930 install: None,
931 shell: Vec::new(),
932 permissions: None,
933 };
934 let rendered = format_interactive_item(&item);
935 assert!(rendered.contains("Neovim"));
936 assert!(rendered.contains("·"));
937 assert!(rendered.contains("Install Neovim"));
938 }
939
940 #[test]
941 fn format_interactive_item_omits_separator_without_description() {
942 let item = SysItem {
943 id: "atuin".to_string(),
944 label: "Atuin".to_string(),
945 description: String::new(),
946 default: false,
947 mode: SysItemMode::Init,
948 requires_admin: false,
949 required_env: Vec::new(),
950 driver: SysDriverKind::Script,
951 config: toml::Table::new(),
952 detect: None,
953 install: None,
954 shell: Vec::new(),
955 permissions: None,
956 };
957 let rendered = format_interactive_item(&item);
958 assert_eq!(rendered, "Atuin");
959 }
960
961 #[test]
962 fn format_item_ids_handles_empty_selection() {
963 assert_eq!(format_item_ids(&[]), "(none)");
964 }
965
966 #[test]
967 fn parse_status_event_reads_machine_status() {
968 let parsed = parse_status_event("SHINE_SYS_STATUS\talready-installed\tatuin 18.16.0")
969 .expect("status event should parse");
970
971 assert_eq!(
972 parsed,
973 (SysItemStatus::AlreadyInstalled, "atuin 18.16.0".to_string())
974 );
975 }
976
977 #[test]
978 fn parse_status_event_trims_empty_version_suffix() {
979 let parsed = parse_status_event("SHINE_SYS_STATUS\talready-installed\tatuin 18.13.6 ()")
980 .expect("status event should parse");
981
982 assert_eq!(
983 parsed,
984 (SysItemStatus::AlreadyInstalled, "atuin 18.13.6".to_string())
985 );
986 }
987
988 #[test]
989 fn parse_status_event_ignores_regular_logs() {
990 assert!(parse_status_event("Installing Atuin...").is_none());
991 }
992
993 #[test]
994 fn parse_sys_item_output_uses_status_event_and_keeps_logs() {
995 let outcome = parse_sys_item_output(
996 "atuin",
997 "Atuin",
998 true,
999 "Installing Atuin...\nSHINE_SYS_STATUS\tinstalled\tatuin 18.16.0\n",
1000 "",
1001 );
1002
1003 assert_eq!(outcome.status, SysItemStatus::Installed);
1004 assert_eq!(outcome.detail, "atuin 18.16.0");
1005 assert_eq!(outcome.logs, vec!["Installing Atuin..."]);
1006 }
1007
1008 #[test]
1009 fn parse_sys_item_output_falls_back_for_legacy_success() {
1010 let outcome =
1011 parse_sys_item_output("legacy", "Legacy", true, "legacy script completed\n", "");
1012
1013 assert_eq!(outcome.status, SysItemStatus::Completed);
1014 assert_eq!(outcome.logs, vec!["legacy script completed"]);
1015 }
1016
1017 #[test]
1018 fn parse_sys_item_output_marks_failed_exit() {
1019 let outcome =
1020 parse_sys_item_output("legacy", "Legacy", false, "", "legacy script failed\n");
1021
1022 assert_eq!(outcome.status, SysItemStatus::Failed);
1023 assert_eq!(outcome.detail, "script exited with a non-zero status");
1024 assert_eq!(outcome.logs, vec!["legacy script failed"]);
1025 }
1026
1027 #[test]
1028 fn parse_update_event_reads_all_protocol_states() {
1029 for (wire, expected) in [
1030 ("available", SysUpdateState::Available),
1031 ("current", SysUpdateState::Current),
1032 ("manual", SysUpdateState::Manual),
1033 ("unsupported", SysUpdateState::Unsupported),
1034 ("failed", SysUpdateState::Failed),
1035 ] {
1036 let event = parse_update_event(&format!(
1037 "SHINE_SYS_UPDATE\t{wire}\tdetail\tupgrade command"
1038 ))
1039 .expect("update event should parse");
1040 assert_eq!(
1041 event,
1042 (
1043 expected,
1044 "detail".to_string(),
1045 "upgrade command".to_string()
1046 )
1047 );
1048 }
1049 assert!(parse_update_event("SHINE_SYS_UPDATE\tbogus\tdetail\tcmd").is_none());
1050 }
1051
1052 #[test]
1053 fn parse_update_output_rejects_missing_or_failed_check_events() {
1054 let missing = parse_sys_update_output("tool", "Tool", true, "ordinary log\n", "");
1055 assert_eq!(missing.state, SysUpdateState::Failed);
1056 assert!(missing.detail.contains("no valid update event"));
1057
1058 let failed = parse_sys_update_output(
1059 "tool",
1060 "Tool",
1061 false,
1062 "SHINE_SYS_UPDATE\tavailable\tshould not be trusted\tupgrade tool\n",
1063 "",
1064 );
1065 assert_eq!(failed.state, SysUpdateState::Failed);
1066 assert!(failed.upgrade_command.is_empty());
1067 }
1068
1069 #[test]
1070 fn embedded_sys_scripts_keep_update_checks_separate_from_installs() {
1071 for (os_id, script_name) in [
1072 ("macos", "init.sh"),
1073 ("ubuntu", "init.sh"),
1074 ("windows", "init.ps1"),
1075 ] {
1076 let path = format!("sys/{os_id}/{script_name}");
1077 let script = crate::presets::read_asset_bytes(&path)
1078 .and_then(|bytes| String::from_utf8(bytes).ok())
1079 .expect("missing embedded sys script");
1080 assert!(
1081 script.contains("SHINE_SYS_UPDATE"),
1082 "{path} lacks update protocol"
1083 );
1084 assert!(
1085 script.contains("check-update"),
1086 "{path} lacks update dispatch"
1087 );
1088 if os_id == "windows" {
1089 assert!(
1090 script.contains("$wingetArgs += @(\"--proxy\", $script:ProxyUri)"),
1091 "Windows update checks must pass WinGet's explicit proxy option"
1092 );
1093 assert!(
1094 script.contains("\"list\", \"--upgrade-available\"")
1095 && !script.contains("& winget upgrade"),
1096 "Windows update checks must use WinGet's read-only list command"
1097 );
1098 }
1099 }
1100 }
1101
1102 #[test]
1103 fn sys_init_command_uses_zsh_for_macos() {
1104 let command = sys_init_command("macos");
1105 assert_eq!(command.program, "zsh");
1106 assert!(command.fixed_args.is_empty());
1107 }
1108
1109 #[test]
1110 fn sys_init_command_uses_powershell_for_windows() {
1111 let command = sys_init_command("windows");
1112 assert_eq!(command.program, "powershell.exe");
1113 assert_eq!(
1114 command.fixed_args,
1115 vec!["-NoProfile", "-ExecutionPolicy", "Bypass", "-File"]
1116 );
1117 }
1118
1119 #[test]
1120 fn sys_init_command_uses_bash_for_other_systems() {
1121 let ubuntu = sys_init_command("ubuntu");
1122 let fakeos = sys_init_command("fakeos");
1123 assert_eq!(ubuntu.program, "bash");
1124 assert!(ubuntu.fixed_args.is_empty());
1125 assert_eq!(fakeos.program, "bash");
1126 assert!(fakeos.fixed_args.is_empty());
1127 }
1128
1129 #[test]
1130 fn sys_init_script_name_uses_ps1_for_windows() {
1131 assert_eq!(sys_init_script_name("windows"), "init.ps1");
1132 }
1133
1134 #[test]
1135 fn sys_init_script_name_uses_sh_for_other_systems() {
1136 assert_eq!(sys_init_script_name("macos"), "init.sh");
1137 assert_eq!(sys_init_script_name("ubuntu"), "init.sh");
1138 }
1139
1140 #[test]
1141 fn format_command_preview_includes_item_ids() {
1142 let script_path = Path::new("/tmp/init.sh");
1143 let items = vec!["neovim".to_string(), "atuin".to_string()];
1144 assert_eq!(
1145 format_command_preview(&sys_init_command("ubuntu"), script_path, &items),
1146 "bash /tmp/init.sh neovim atuin"
1147 );
1148 }
1149
1150 #[test]
1151 fn format_command_preview_includes_windows_fixed_args() {
1152 let script_path = Path::new("C:/tmp/init.ps1");
1153 let items = vec!["rust".to_string(), "yazi".to_string()];
1154 assert_eq!(
1155 format_command_preview(&sys_init_command("windows"), script_path, &items),
1156 "powershell.exe -NoProfile -ExecutionPolicy Bypass -File C:/tmp/init.ps1 rust yazi"
1157 );
1158 }
1159
1160 #[tokio::test]
1161 async fn install_sys_profile_files_creates_active_profile_and_base() {
1162 let dir = make_temp_dir().await;
1163 let script_dir = dir.join("presets/sys/ubuntu");
1164 fs::create_dir_all(&script_dir).await.unwrap();
1165 fs::write(script_dir.join("profile.pre.sh"), "echo pre template\n")
1166 .await
1167 .unwrap();
1168 fs::write(script_dir.join("profile.post.sh"), "echo post template\n")
1169 .await
1170 .unwrap();
1171 let config = Config::new_for_test(&dir);
1172
1173 let update = install_sys_profile_files(&config, "ubuntu", &script_dir, false)
1174 .await
1175 .unwrap();
1176
1177 assert!(update.updated);
1178 assert!(!update.needs_action);
1179 let profile_dir = dir.join(".shine/profile");
1180 assert_eq!(
1181 fs::read_to_string(profile_dir.join("ubuntu-sys.pre.sh"))
1182 .await
1183 .unwrap(),
1184 "echo pre template\n"
1185 );
1186 assert_eq!(
1187 fs::read_to_string(profile_dir.join("ubuntu-sys.pre.base.sh"))
1188 .await
1189 .unwrap(),
1190 "echo pre template\n"
1191 );
1192 assert_eq!(
1193 fs::read_to_string(profile_dir.join("ubuntu-sys.post.sh"))
1194 .await
1195 .unwrap(),
1196 "echo post template\n"
1197 );
1198 assert_eq!(
1199 fs::read_to_string(profile_dir.join("ubuntu-sys.post.base.sh"))
1200 .await
1201 .unwrap(),
1202 "echo post template\n"
1203 );
1204
1205 fs::remove_dir_all(&dir).await.unwrap();
1206 }
1207
1208 #[tokio::test]
1209 async fn install_sys_profile_files_falls_back_to_embedded_templates_for_stale_external_ubuntu()
1210 {
1211 let dir = make_temp_dir().await;
1212 let script_dir = dir.join("presets/sys/ubuntu");
1213 fs::create_dir_all(&script_dir).await.unwrap();
1214 let config = Config::new_for_test(&dir);
1215
1216 let update = install_sys_profile_files(&config, "ubuntu", &script_dir, false)
1217 .await
1218 .unwrap();
1219
1220 assert!(update.updated);
1221 assert!(!update.needs_action);
1222 let profile_dir = dir.join(".shine/profile");
1223 assert!(
1224 fs::read_to_string(profile_dir.join("ubuntu-sys.pre.sh"))
1225 .await
1226 .unwrap()
1227 .contains("Managed by `shine sys bootstrap` for Ubuntu")
1228 );
1229 assert!(
1230 fs::read_to_string(profile_dir.join("ubuntu-sys.post.sh"))
1231 .await
1232 .unwrap()
1233 .contains("mise activate")
1234 );
1235
1236 fs::remove_dir_all(&dir).await.unwrap();
1237 }
1238
1239 #[tokio::test]
1240 async fn install_sys_profile_files_without_base_reports_needs_action_for_legacy_edits() {
1241 let dir = make_temp_dir().await;
1242 let script_dir = dir.join("presets/sys/ubuntu");
1243 let profile_dir = dir.join(".shine/profile");
1244 fs::create_dir_all(&script_dir).await.unwrap();
1245 fs::create_dir_all(&profile_dir).await.unwrap();
1246 fs::write(script_dir.join("profile.pre.sh"), "echo new template\n")
1247 .await
1248 .unwrap();
1249 fs::write(script_dir.join("profile.post.sh"), "echo post template\n")
1250 .await
1251 .unwrap();
1252 fs::write(profile_dir.join("ubuntu-sys.pre.sh"), "echo user edit\n")
1253 .await
1254 .unwrap();
1255 let config = Config::new_for_test(&dir);
1256
1257 let update = install_sys_profile_files(&config, "ubuntu", &script_dir, false)
1258 .await
1259 .unwrap();
1260
1261 assert!(update.updated);
1262 assert!(update.needs_action);
1263 assert_eq!(
1264 fs::read_to_string(profile_dir.join("ubuntu-sys.pre.sh"))
1265 .await
1266 .unwrap(),
1267 "echo user edit\n"
1268 );
1269 assert!(
1270 fs::read_to_string(profile_dir.join("ubuntu-sys.pre.new.sh"))
1271 .await
1272 .unwrap()
1273 .contains("echo new template")
1274 );
1275
1276 fs::remove_dir_all(&dir).await.unwrap();
1277 }
1278
1279 #[tokio::test]
1280 async fn install_sys_profile_files_without_base_accepts_uncommented_template_lines() {
1281 let dir = make_temp_dir().await;
1282 let script_dir = dir.join("presets/sys/macos");
1283 let profile_dir = dir.join(".shine/profile");
1284 fs::create_dir_all(&script_dir).await.unwrap();
1285 fs::create_dir_all(&profile_dir).await.unwrap();
1286 let template = "# fastfetch\n# if [[ -z \"$ZELLIJ\" ]] && command -v fastfetch >/dev/null 2>&1; then\n# fastfetch\n# fi\n";
1287 let active = "# fastfetch\nif [[ -z \"$ZELLIJ\" ]] && command -v fastfetch >/dev/null 2>&1; then\n fastfetch\nfi\n";
1288 fs::write(script_dir.join("profile.pre.sh"), "echo pre template\n")
1289 .await
1290 .unwrap();
1291 fs::write(script_dir.join("profile.post.sh"), template)
1292 .await
1293 .unwrap();
1294 fs::write(profile_dir.join("macos-sys.post.sh"), active)
1295 .await
1296 .unwrap();
1297 let config = Config::new_for_test(&dir);
1298
1299 let update = install_sys_profile_files(&config, "macos", &script_dir, false)
1300 .await
1301 .unwrap();
1302
1303 assert!(update.updated);
1304 assert!(!update.needs_action);
1305 assert_eq!(
1306 fs::read_to_string(profile_dir.join("macos-sys.post.sh"))
1307 .await
1308 .unwrap(),
1309 active
1310 );
1311 assert_eq!(
1312 fs::read_to_string(profile_dir.join("macos-sys.post.base.sh"))
1313 .await
1314 .unwrap(),
1315 template
1316 );
1317 assert!(!profile_dir.join("macos-sys.post.new.sh").exists());
1318
1319 fs::remove_dir_all(&dir).await.unwrap();
1320 }
1321
1322 #[tokio::test]
1323 async fn install_sys_profile_files_force_profile_backs_up_and_replaces_active() {
1324 let dir = make_temp_dir().await;
1325 let script_dir = dir.join("presets/sys/ubuntu");
1326 let profile_dir = dir.join(".shine/profile");
1327 fs::create_dir_all(&script_dir).await.unwrap();
1328 fs::create_dir_all(&profile_dir).await.unwrap();
1329 fs::write(script_dir.join("profile.pre.sh"), "echo template\n")
1330 .await
1331 .unwrap();
1332 fs::write(script_dir.join("profile.post.sh"), "echo post template\n")
1333 .await
1334 .unwrap();
1335 fs::write(profile_dir.join("ubuntu-sys.pre.sh"), "echo user edit\n")
1336 .await
1337 .unwrap();
1338 let config = Config::new_for_test(&dir);
1339
1340 let update = install_sys_profile_files(&config, "ubuntu", &script_dir, true)
1341 .await
1342 .unwrap();
1343
1344 assert!(update.updated);
1345 assert!(!update.needs_action);
1346 assert_eq!(
1347 fs::read_to_string(profile_dir.join("ubuntu-sys.pre.sh"))
1348 .await
1349 .unwrap(),
1350 "echo template\n"
1351 );
1352 assert_eq!(
1353 fs::read_to_string(profile_dir.join("ubuntu-sys.pre.base.sh"))
1354 .await
1355 .unwrap(),
1356 "echo template\n"
1357 );
1358 let mut entries = fs::read_dir(&profile_dir).await.unwrap();
1359 let mut backup_found = false;
1360 while let Some(entry) = entries.next_entry().await.unwrap() {
1361 let name = entry.file_name();
1362 let name = name.to_string_lossy();
1363 if name.starts_with("ubuntu-sys.pre.sh.bak.") {
1364 backup_found = true;
1365 }
1366 }
1367 assert!(backup_found, "pre profile backup should be created");
1368
1369 fs::remove_dir_all(&dir).await.unwrap();
1370 }
1371
1372 #[test]
1373 fn fallback_three_way_merge_preserves_uncommented_line_position() {
1374 let base = b"before\n# eval \"$(starship init zsh)\"\nafter\n";
1375 let active = b"before\neval \"$(starship init zsh)\"\nafter\n";
1376 let template = b"before\n# eval \"$(starship init zsh)\"\nafter\nnew-template-line\n";
1377
1378 let merged = fallback_three_way_merge(base, active, template).unwrap();
1379
1380 assert_eq!(
1381 String::from_utf8(merged).unwrap(),
1382 "before\neval \"$(starship init zsh)\"\nafter\nnew-template-line\n"
1383 );
1384 }
1385
1386 #[test]
1387 fn fallback_three_way_merge_reports_conflict_for_same_line_edits() {
1388 let base = b"before\nvalue=old\nafter\n";
1389 let active = b"before\nvalue=user\nafter\n";
1390 let template = b"before\nvalue=shine\nafter\n";
1391
1392 assert!(fallback_three_way_merge(base, active, template).is_none());
1393 }
1394
1395 #[tokio::test]
1396 async fn update_sys_shell_profiles_writes_active_ubuntu_shell_and_removes_other_shell_block() {
1397 let dir = make_temp_dir().await;
1398 let mut config = Config::new_for_test(&dir);
1399 config.shell_type = ShellType::Bash;
1400 fs::write(
1401 dir.join(".zshrc"),
1402 "# before\n# >>> shine ubuntu sys >>>\nold\n# <<< shine ubuntu sys <<<\n# >>> shine ubuntu sys pre >>>\nold pre\n# <<< shine ubuntu sys pre <<<\n# >>> shine ubuntu sys post >>>\nold post\n# <<< shine ubuntu sys post <<<\n# after\n",
1403 )
1404 .await
1405 .unwrap();
1406
1407 let update = update_sys_shell_profiles(&config, "ubuntu", "bash")
1408 .await
1409 .unwrap();
1410
1411 assert!(update.updated);
1412 let bashrc = fs::read_to_string(dir.join(".bashrc")).await.unwrap();
1413 assert!(bashrc.contains("SHINE_UBUNTU_SYS_SHELL=\"bash\""));
1414 assert!(bashrc.contains("# >>> shine ubuntu sys pre >>>"));
1415 assert!(bashrc.contains("ubuntu-sys.pre.sh"));
1416 assert!(bashrc.contains("# >>> shine ubuntu sys post >>>"));
1417 assert!(bashrc.contains("ubuntu-sys.post.sh"));
1418 assert!(bashrc.contains("source \"$shine_ubuntu_sys_profile\""));
1419 assert!(
1420 bashrc.find("# >>> shine ubuntu sys pre >>>").unwrap()
1421 < bashrc.find("# >>> shine ubuntu sys post >>>").unwrap()
1422 );
1423 let zshrc = fs::read_to_string(dir.join(".zshrc")).await.unwrap();
1424 assert!(!zshrc.contains("# >>> shine ubuntu sys >>>"));
1425 assert!(!zshrc.contains("# >>> shine ubuntu sys pre >>>"));
1426 assert!(!zshrc.contains("# >>> shine ubuntu sys post >>>"));
1427 assert!(zshrc.contains("# before"));
1428 assert!(zshrc.contains("# after"));
1429
1430 fs::remove_dir_all(&dir).await.unwrap();
1431 }
1432
1433 #[tokio::test]
1434 async fn update_sys_shell_profiles_wraps_existing_profile_with_pre_and_post_blocks() {
1435 let dir = make_temp_dir().await;
1436 let mut config = Config::new_for_test(&dir);
1437 config.shell_type = ShellType::Zsh;
1438 fs::write(dir.join(".zshrc"), "# user config\n")
1439 .await
1440 .unwrap();
1441
1442 let update = update_sys_shell_profiles(&config, "ubuntu", "zsh")
1443 .await
1444 .unwrap();
1445
1446 assert!(update.updated);
1447 let zshrc = fs::read_to_string(dir.join(".zshrc")).await.unwrap();
1448 let pre = zshrc.find("# >>> shine ubuntu sys pre >>>").unwrap();
1449 let user = zshrc.find("# user config").unwrap();
1450 let post = zshrc.find("# >>> shine ubuntu sys post >>>").unwrap();
1451 assert!(pre < user);
1452 assert!(user < post);
1453 assert!(zshrc.contains("ubuntu-sys.pre.sh"));
1454 assert!(zshrc.contains("ubuntu-sys.post.sh"));
1455
1456 fs::remove_dir_all(&dir).await.unwrap();
1457 }
1458
1459 #[tokio::test]
1460 async fn update_sys_shell_profile_blocks_keeps_utf8_bom_at_file_start() {
1461 let dir = make_temp_dir().await;
1462 let profile = dir.join("Microsoft.PowerShell_profile.ps1");
1463 fs::write(&profile, "\u{feff}Import-Module posh-git\n")
1464 .await
1465 .unwrap();
1466
1467 update_sys_shell_profile_blocks(&profile, "windows", None)
1468 .await
1469 .unwrap();
1470
1471 let content = fs::read_to_string(&profile).await.unwrap();
1472 assert!(content.starts_with('\u{feff}'));
1473 assert_eq!(content.matches('\u{feff}').count(), 1);
1474 assert!(content.contains("\nImport-Module posh-git\n"));
1475
1476 let broken = content.trim_start_matches('\u{feff}').replacen(
1478 "\nImport-Module posh-git\n",
1479 "\n\u{feff}Import-Module posh-git\n",
1480 1,
1481 );
1482 fs::write(&profile, broken).await.unwrap();
1483
1484 assert!(
1485 update_sys_shell_profile_blocks(&profile, "windows", None)
1486 .await
1487 .unwrap()
1488 );
1489 let repaired = fs::read_to_string(&profile).await.unwrap();
1490 assert!(repaired.starts_with('\u{feff}'));
1491 assert_eq!(repaired.matches('\u{feff}').count(), 1);
1492 assert!(repaired.contains("\nImport-Module posh-git\n"));
1493
1494 fs::remove_dir_all(&dir).await.unwrap();
1495 }
1496
1497 #[tokio::test]
1498 async fn update_sys_shell_profiles_is_idempotent_after_pre_post_install() {
1499 let dir = make_temp_dir().await;
1500 let mut config = Config::new_for_test(&dir);
1501 config.shell_type = ShellType::Zsh;
1502
1503 let first = update_sys_shell_profiles(&config, "ubuntu", "zsh")
1504 .await
1505 .unwrap();
1506 let before = fs::read_to_string(dir.join(".zshrc")).await.unwrap();
1507 let second = update_sys_shell_profiles(&config, "ubuntu", "zsh")
1508 .await
1509 .unwrap();
1510 let after = fs::read_to_string(dir.join(".zshrc")).await.unwrap();
1511
1512 assert!(first.updated);
1513 assert!(!second.updated);
1514 assert_eq!(before, after);
1515
1516 fs::remove_dir_all(&dir).await.unwrap();
1517 }
1518
1519 #[test]
1522 fn embedded_entries_include_supported_systems() {
1523 let entries = load_embedded_sys_manifests().unwrap();
1524 let ids: Vec<&str> = entries.iter().map(|(id, _)| id.as_str()).collect();
1525 assert!(ids.contains(&"ubuntu"), "ubuntu missing: {ids:?}");
1526 assert!(ids.contains(&"macos"), "macos missing: {ids:?}");
1527 assert!(ids.contains(&"windows"), "windows missing: {ids:?}");
1528 }
1529
1530 #[test]
1531 fn embedded_entries_have_descriptions() {
1532 let entries = load_embedded_sys_manifests().unwrap();
1533 for (id, manifest) in &entries {
1534 assert!(
1535 !manifest.description.is_empty(),
1536 "description for {id} should not be empty"
1537 );
1538 }
1539 }
1540
1541 #[test]
1542 fn embedded_ubuntu_minimal_profile_is_headless_core_only() {
1543 let entries = load_embedded_sys_manifests().unwrap();
1544 let ubuntu = entries
1545 .iter()
1546 .find(|(id, _)| id == "ubuntu")
1547 .map(|(_, manifest)| manifest)
1548 .expect("missing ubuntu manifest");
1549 let minimal = ubuntu
1550 .profiles
1551 .get("minimal")
1552 .expect("ubuntu missing `minimal` profile");
1553 assert_eq!(
1554 minimal.items,
1555 vec!["neovim", "fzf", "bat", "eza", "zoxide"],
1556 "minimal profile should be the lean headless CLI core only"
1557 );
1558 assert_eq!(ubuntu.default_profile.as_deref(), Some("recommended"));
1560 }
1561
1562 #[test]
1563 fn embedded_current_platforms_expose_split_dns() {
1564 let entries = load_embedded_sys_manifests().unwrap();
1565 for os_id in ["macos", "ubuntu", "windows"] {
1566 let manifest = entries
1567 .iter()
1568 .find(|(candidate, _)| candidate == os_id)
1569 .map(|(_, manifest)| manifest)
1570 .unwrap_or_else(|| panic!("missing {os_id} manifest"));
1571 let item = manifest
1572 .items
1573 .iter()
1574 .find(|item| item.id == "split-dns")
1575 .unwrap_or_else(|| panic!("split-dns missing for {os_id}"));
1576 assert_eq!(item.mode, SysItemMode::Managed);
1577 assert_eq!(item.driver, SysDriverKind::SplitDns);
1578 }
1579 }
1580
1581 #[test]
1582 fn embedded_sys_manifests_are_valid() {
1583 for (id, _) in load_embedded_sys_manifests().unwrap() {
1584 let toml_path = format!("sys/{id}/shine.toml");
1585 let content = crate::presets::read_asset_bytes(&toml_path)
1586 .and_then(|bytes| String::from_utf8(bytes).ok())
1587 .unwrap_or_else(|| panic!("missing embedded manifest: {toml_path}"));
1588 parse_and_validate_manifest(&content)
1589 .unwrap_or_else(|err| panic!("invalid embedded manifest {toml_path}: {err}"));
1590 }
1591 }
1592
1593 #[test]
1594 fn composed_embedded_sys_profiles_reference_existing_assets() {
1595 for (os_id, manifest) in load_embedded_sys_manifests().unwrap() {
1596 if !manifest.profile_composition {
1597 continue;
1598 }
1599 let extension = if os_id == "windows" { "ps1" } else { "sh" };
1600 for phase in ["pre", "post"] {
1601 let path = format!("sys/{os_id}/profile/base.{phase}.{extension}");
1602 assert!(
1603 crate::presets::read_asset_bytes(&path).is_some(),
1604 "missing composed base profile asset: {path}"
1605 );
1606 }
1607 for item in &manifest.items {
1608 for integration in &item.shell {
1609 if let Some(fragment) = &integration.fragment {
1610 let path = format!("sys/{os_id}/{fragment}");
1611 assert!(
1612 crate::presets::read_asset_bytes(&path).is_some(),
1613 "missing fragment for sys/{}: {path}",
1614 item.id
1615 );
1616 }
1617 }
1618 }
1619 }
1620 }
1621
1622 #[test]
1623 fn embedded_split_dns_items_are_managed_and_safely_marked() {
1624 for (os_id, script_name) in [
1625 ("macos", "init.sh"),
1626 ("ubuntu", "init.sh"),
1627 ("windows", "init.ps1"),
1628 ] {
1629 let manifest_path = format!("sys/{os_id}/shine.toml");
1630 let content = crate::presets::read_asset_bytes(&manifest_path)
1631 .and_then(|bytes| String::from_utf8(bytes).ok())
1632 .unwrap();
1633 let manifest = parse_and_validate_manifest(&content).unwrap();
1634 let item = manifest
1635 .items
1636 .iter()
1637 .find(|item| item.id == "split-dns")
1638 .unwrap();
1639 assert_eq!(item.mode, SysItemMode::Managed);
1640 assert!(item.requires_admin);
1641 assert_eq!(item.driver, SysDriverKind::SplitDns);
1642 assert_eq!(
1643 item.required_env,
1644 ["PRIVATE_DNS_DOMAIN", "PRIVATE_DNS_SERVERS"]
1645 );
1646 assert_eq!(
1647 item.config.get("domain_env").and_then(toml::Value::as_str),
1648 Some("PRIVATE_DNS_DOMAIN")
1649 );
1650
1651 let script_path = format!("sys/{os_id}/{script_name}");
1652 let script = crate::presets::read_asset_bytes(&script_path)
1653 .and_then(|bytes| String::from_utf8(bytes).ok())
1654 .unwrap();
1655 assert!(!script.contains("Managed by shine: split-dns"));
1656 }
1657 }
1658
1659 #[test]
1660 fn embedded_ubuntu_profiles_cover_recommended_and_all_items() {
1661 let content = crate::presets::read_asset_bytes("sys/ubuntu/shine.toml")
1662 .and_then(|bytes| String::from_utf8(bytes).ok())
1663 .expect("missing embedded Ubuntu manifest");
1664 let manifest = parse_and_validate_manifest(&content).unwrap();
1665 let recommended = manifest
1666 .profiles
1667 .get("recommended")
1668 .expect("missing Ubuntu recommended profile");
1669 let all = manifest
1670 .profiles
1671 .get("all")
1672 .expect("missing Ubuntu all profile");
1673
1674 assert!(recommended.items.iter().any(|item| item == "starship"));
1675 assert!(recommended.items.iter().any(|item| item == "zoxide"));
1676 assert!(recommended.items.iter().any(|item| item == "zsh-vi-mode"));
1677 assert!(recommended.items.iter().any(|item| item == "fzf"));
1678 assert!(recommended.items.iter().any(|item| item == "bat"));
1679 assert!(recommended.items.iter().any(|item| item == "eza"));
1680 assert!(!recommended.items.iter().any(|item| item == "pnpm"));
1681 assert!(!recommended.items.iter().any(|item| item == "mise"));
1682 assert!(!recommended.items.iter().any(|item| item == "homebrew"));
1683
1684 let item_ids: BTreeSet<&str> = manifest
1685 .items
1686 .iter()
1687 .filter(|item| item.mode == SysItemMode::Init)
1688 .map(|item| item.id.as_str())
1689 .collect();
1690 let all_ids: BTreeSet<&str> = all.items.iter().map(String::as_str).collect();
1691 assert_eq!(
1692 all_ids, item_ids,
1693 "Ubuntu all profile should include every item"
1694 );
1695 }
1696
1697 #[test]
1698 fn embedded_windows_profiles_cover_required_recommended_and_all_items() {
1699 let content = crate::presets::read_asset_bytes("sys/windows/shine.toml")
1700 .and_then(|bytes| String::from_utf8(bytes).ok())
1701 .expect("missing embedded Windows manifest");
1702 let manifest = parse_and_validate_manifest(&content).unwrap();
1703 let required = manifest
1704 .profiles
1705 .get("required")
1706 .expect("missing Windows required profile");
1707 let recommended = manifest
1708 .profiles
1709 .get("recommended")
1710 .expect("missing Windows recommended profile");
1711 let all = manifest
1712 .profiles
1713 .get("all")
1714 .expect("missing Windows all profile");
1715
1716 assert_eq!(required.items, vec!["rust", "yazi", "starship"]);
1717 assert!(recommended.items.iter().any(|item| item == "zoxide"));
1718 assert!(recommended.items.iter().any(|item| item == "atuin"));
1719 assert!(recommended.items.iter().any(|item| item == "fzf"));
1720 assert!(recommended.items.iter().any(|item| item == "bat"));
1721 assert!(recommended.items.iter().any(|item| item == "eza"));
1722 assert!(recommended.items.iter().any(|item| item == "zerotier"));
1723 assert!(!recommended.items.iter().any(|item| item == "bun"));
1724 assert!(!recommended.items.iter().any(|item| item == "pnpm"));
1725 assert!(!recommended.items.iter().any(|item| item == "mise"));
1726
1727 let item_ids: BTreeSet<&str> = manifest
1728 .items
1729 .iter()
1730 .filter(|item| item.mode == SysItemMode::Init)
1731 .map(|item| item.id.as_str())
1732 .collect();
1733 let all_ids: BTreeSet<&str> = all.items.iter().map(String::as_str).collect();
1734 assert_eq!(
1735 all_ids, item_ids,
1736 "Windows all profile should include every item"
1737 );
1738 }
1739
1740 #[test]
1741 fn embedded_macos_profiles_cover_recommended_and_all_items() {
1742 let content = crate::presets::read_asset_bytes("sys/macos/shine.toml")
1743 .and_then(|bytes| String::from_utf8(bytes).ok())
1744 .expect("missing embedded macOS manifest");
1745 let manifest = parse_and_validate_manifest(&content).unwrap();
1746 let recommended = manifest
1747 .profiles
1748 .get("recommended")
1749 .expect("missing macOS recommended profile");
1750 let all = manifest
1751 .profiles
1752 .get("all")
1753 .expect("missing macOS all profile");
1754
1755 assert!(manifest.items.iter().any(|item| item.id == "rust"));
1756 assert!(manifest.items.iter().any(|item| item.id == "mise"));
1757 assert!(recommended.items.iter().any(|item| item == "rust"));
1758 assert!(!recommended.items.iter().any(|item| item == "mise"));
1759
1760 let item_ids: BTreeSet<&str> = manifest
1761 .items
1762 .iter()
1763 .filter(|item| item.mode == SysItemMode::Init)
1764 .map(|item| item.id.as_str())
1765 .collect();
1766 let all_ids: BTreeSet<&str> = all.items.iter().map(String::as_str).collect();
1767 assert_eq!(
1768 all_ids, item_ids,
1769 "macOS all profile should include every item"
1770 );
1771 }
1772
1773 #[test]
1774 fn embedded_windows_init_uses_current_atuin_winget_id() {
1775 let content = crate::presets::read_asset_bytes("sys/windows/init.ps1")
1776 .and_then(|bytes| String::from_utf8(bytes).ok())
1777 .expect("missing embedded Windows init script");
1778
1779 assert!(content.contains("\"Atuinsh.Atuin\""));
1780 assert!(!content.contains("\"atuinsh.atuin\""));
1781 }
1782
1783 #[test]
1784 fn embedded_sys_init_scripts_include_yazi_shell_wrapper() {
1785 for (path, marker) in [
1786 ("sys/ubuntu/profile.post.sh", "y() {"),
1787 ("sys/macos/profile.post.sh", "y() {"),
1788 ("sys/windows/profile.post.ps1", "function y {"),
1789 ] {
1790 let content = crate::presets::read_asset_bytes(path)
1791 .and_then(|bytes| String::from_utf8(bytes).ok())
1792 .unwrap_or_else(|| panic!("missing embedded sys bootstrap script: {path}"));
1793
1794 assert!(
1795 content.contains(marker),
1796 "{path} should define Yazi wrapper"
1797 );
1798 assert!(
1799 content.contains("--cwd-file"),
1800 "{path} should pass --cwd-file to yazi"
1801 );
1802 }
1803 }
1804
1805 #[test]
1806 fn embedded_ubuntu_init_installs_managed_profile_loader() {
1807 let content = crate::presets::read_asset_bytes("sys/ubuntu/init.sh")
1808 .and_then(|bytes| String::from_utf8(bytes).ok())
1809 .expect("missing embedded Ubuntu init script");
1810
1811 assert!(content.contains("SHINE_SYS_STATUS\\t%s\\t%s\\n"));
1812 assert!(content.contains("status \"already-installed\" \"$(atuin --version)\""));
1813 assert!(content.contains(
1814 "curl --proto '=https' --tlsv1.2 -LsSf https://setup.atuin.sh | sh\n load_atuin_env\n status \"installed\" \"$(atuin --version)\""
1815 ));
1816 assert!(content.contains("load_atuin_env"));
1817 assert!(content.contains(". \"$HOME/.atuin/bin/env\""));
1818 assert!(content.contains(
1819 "__shine_finalize) status \"completed\" \"profile is managed by shine CLI\""
1820 ));
1821 assert!(!content.contains("append_shell_block"));
1822 assert!(!content.contains("cp \"$template_path\" \"$managed_path\""));
1823 }
1824
1825 #[test]
1826 fn embedded_ubuntu_manual_update_guidance_avoids_noop_bootstrap() {
1827 let content = crate::presets::read_asset_bytes("sys/ubuntu/init.sh")
1828 .and_then(|bytes| String::from_utf8(bytes).ok())
1829 .expect("missing embedded Ubuntu init script");
1830
1831 assert!(content.contains("mise)"));
1832 assert!(content.contains(
1833 "Installation source is not recorded; standalone mise.run installs use 'mise self-update', while package-managed installs use their original package manager"
1834 ));
1835 assert!(
1836 content.contains("neovim|yazi|starship|zoxide|zsh-vi-mode|pnpm|homebrew|zerotier|eza")
1837 );
1838 assert!(content.contains(
1839 "Installation source is not recorded; use the updater for the existing installation source"
1840 ));
1841 assert!(!content.contains("rerun shine sys bootstrap and select"));
1842 assert!(!content.contains("git -C ~/.config/nvim pull"));
1843 }
1844
1845 #[test]
1846 fn embedded_macos_init_installs_managed_profile_loader() {
1847 let content = crate::presets::read_asset_bytes("sys/macos/init.sh")
1848 .and_then(|bytes| String::from_utf8(bytes).ok())
1849 .expect("missing embedded macOS init script");
1850
1851 assert!(content.contains(
1852 "__shine_finalize) status \"completed\" \"profile is managed by shine CLI\""
1853 ));
1854 assert!(content.contains("https://sh.rustup.rs | sh -s -- -y --no-modify-path"));
1855 assert!(content.contains("rust) install_rust ;;"));
1856 assert!(content.contains("mise) install_mise ;;"));
1857 assert!(!content.contains("append_zshrc_block"));
1858 assert!(!content.contains("cp \"$template_path\" \"$managed_path\""));
1859 }
1860
1861 #[test]
1862 fn embedded_macos_profile_initializes_homebrew_zsh_completions() {
1863 let content = crate::presets::read_asset_bytes("sys/macos/profile.pre.sh")
1864 .and_then(|bytes| String::from_utf8(bytes).ok())
1865 .expect("missing embedded macOS pre profile script");
1866
1867 assert!(content.contains("share/zsh/site-functions"));
1868 assert!(content.contains("ZSH_VERSION"));
1869 assert!(content.contains("typeset -U fpath"));
1870 assert!(content.contains("\"$HOME/.cargo/bin\""));
1871 assert!(content.contains("export PNPM_HOME=\"$HOME/Library/pnpm\""));
1872 assert!(content.contains("\"$PNPM_HOME/bin\""));
1873 assert!(!content.contains("[[ -d \"$PNPM_HOME/bin\" ]]"));
1874 }
1875
1876 #[test]
1877 fn embedded_unix_profiles_delegate_terminal_theme_sync_to_the_shine_binary() {
1878 for path in ["sys/ubuntu/profile.pre.sh", "sys/macos/profile.pre.sh"] {
1888 let content = crate::presets::read_asset_bytes(path)
1889 .and_then(|bytes| String::from_utf8(bytes).ok())
1890 .unwrap_or_else(|| panic!("missing embedded sys profile: {path}"));
1891
1892 assert!(content.contains("${SHINE_SYNC_TERMINAL_THEME:-1}"));
1893 assert!(content.contains("command -v shine"));
1894 assert!(content.contains("shine theme sync --auto --quiet"));
1895
1896 assert!(!content.contains("shine_apply_terminal_theme"));
1899 assert!(!content.contains("shine_sync_terminal_theme"));
1900 assert!(!content.contains("\\033]11;?\\033\\\\"));
1901 assert!(!content.contains("stty -echo"));
1902 assert!(!content.contains("read_timeout"));
1903 }
1904 }
1905
1906 #[test]
1907 fn embedded_macos_profile_initializes_mise() {
1908 let content = crate::presets::read_asset_bytes("sys/macos/profile.post.sh")
1909 .and_then(|bytes| String::from_utf8(bytes).ok())
1910 .expect("missing embedded macOS post profile script");
1911
1912 assert!(content.contains("mise activate zsh"));
1913 }
1914
1915 #[test]
1916 fn embedded_ubuntu_profile_initializes_atuin() {
1917 let pre = crate::presets::read_asset_bytes("sys/ubuntu/profile.pre.sh")
1918 .and_then(|bytes| String::from_utf8(bytes).ok())
1919 .expect("missing embedded Ubuntu pre profile script");
1920 let post = crate::presets::read_asset_bytes("sys/ubuntu/profile.post.sh")
1921 .and_then(|bytes| String::from_utf8(bytes).ok())
1922 .expect("missing embedded Ubuntu post profile script");
1923
1924 assert!(post.contains("atuin init"));
1925 assert!(post.contains("shine_ubuntu_sys_shell"));
1926 assert!(pre.contains(". \"$HOME/.atuin/bin/env\""));
1927 }
1928
1929 #[test]
1930 fn embedded_ubuntu_profile_initializes_homebrew_zsh_completions() {
1931 let content = crate::presets::read_asset_bytes("sys/ubuntu/profile.pre.sh")
1932 .and_then(|bytes| String::from_utf8(bytes).ok())
1933 .expect("missing embedded Ubuntu pre profile script");
1934
1935 assert!(content.contains("share/zsh/site-functions"));
1936 assert!(content.contains("shine_ubuntu_sys_shell"));
1937 assert!(content.contains("ZSH_VERSION"));
1938 assert!(content.contains("typeset -U fpath"));
1939 }
1940
1941 #[test]
1942 fn embedded_windows_init_installs_managed_profile_loader() {
1943 let content = crate::presets::read_asset_bytes("sys/windows/init.ps1")
1944 .and_then(|bytes| String::from_utf8(bytes).ok())
1945 .expect("missing embedded Windows init script");
1946
1947 assert!(content.contains("SHINE_SYS_PRESET_ROOT"));
1948 assert!(content.contains("SHINE_SYS_STATUS`t$State`t$Detail"));
1949 assert!(content.contains("\"__shine_finalize\" { Write-Status \"completed\" \"profile is managed by shine CLI\" }"));
1950 assert!(!content.contains("Update-ManagedProfiles"));
1951 assert!(!content.contains("Copy-Item -LiteralPath $profileTemplatePath"));
1952 }
1953
1954 #[test]
1955 fn embedded_entries_sorted_alphabetically() {
1956 let entries = load_embedded_sys_manifests().unwrap();
1957 let ids: Vec<&str> = entries.iter().map(|(id, _)| id.as_str()).collect();
1958 let mut sorted = ids.clone();
1959 sorted.sort();
1960 assert_eq!(ids, sorted, "entries should be alphabetically sorted");
1961 }
1962
1963 #[tokio::test]
1966 async fn list_fs_returns_empty_when_sys_dir_missing() {
1967 let dir = make_temp_dir().await;
1968 let entries = load_fs_sys_manifests(&dir).await.unwrap();
1969 assert!(entries.is_empty());
1970 fs::remove_dir_all(&dir).await.unwrap();
1971 }
1972
1973 #[tokio::test]
1974 async fn list_fs_reads_description_from_shine_toml() {
1975 let dir = make_temp_dir().await;
1976 let os_dir = dir.join("sys/testlinux");
1977 fs::create_dir_all(&os_dir).await.unwrap();
1978 fs::write(
1979 os_dir.join("shine.toml"),
1980 b"description = \"A test distro.\"\n",
1981 )
1982 .await
1983 .unwrap();
1984
1985 let entries = load_fs_sys_manifests(&dir).await.unwrap();
1986 assert_eq!(entries.len(), 1);
1987 assert_eq!(entries[0].0, "testlinux");
1988 assert_eq!(entries[0].1.description, "A test distro.");
1989
1990 fs::remove_dir_all(&dir).await.unwrap();
1991 }
1992
1993 #[tokio::test]
1994 async fn load_fs_rejects_invalid_manifest() {
1995 let dir = make_temp_dir().await;
1996 let os_dir = dir.join("sys/testlinux");
1997 fs::create_dir_all(&os_dir).await.unwrap();
1998 fs::write(
1999 os_dir.join("shine.toml"),
2000 b"[[items]]\nid = \"bad id\"\nlabel = \"Bad\"\n",
2001 )
2002 .await
2003 .unwrap();
2004
2005 let error = load_fs_sys_manifests(&dir).await.unwrap_err();
2006 assert!(error.to_string().contains("parsing"));
2007
2008 fs::remove_dir_all(&dir).await.unwrap();
2009 }
2010
2011 #[tokio::test]
2014 async fn handle_list_succeeds_with_embedded_presets() {
2015 let dir = make_temp_dir().await;
2016 let config = Config::new_for_test(&dir);
2017 handle_list(&config, false).await.unwrap();
2018 fs::remove_dir_all(&dir).await.unwrap();
2019 }
2020
2021 #[tokio::test]
2022 async fn load_sys_preset_refreshes_stale_embedded_runtime_files() {
2023 let dir = make_temp_dir().await;
2024 let config = Config::new_for_test(&dir);
2025 let os_dir = config.presets_dir().join("sys/ubuntu");
2026 fs::create_dir_all(&os_dir).await.unwrap();
2027 fs::write(
2028 os_dir.join("shine.toml"),
2029 r#"
2030description = "Stale Ubuntu"
2031default_profile = "recommended"
2032
2033[[items]]
2034id = "neovim"
2035label = "Neovim"
2036
2037[profiles.recommended]
2038items = ["neovim"]
2039"#,
2040 )
2041 .await
2042 .unwrap();
2043 fs::write(os_dir.join("init.sh"), b"#!/bin/bash\necho stale\n")
2044 .await
2045 .unwrap();
2046
2047 let loaded = load_sys_preset(&config, "ubuntu").await.unwrap();
2048
2049 assert!(
2050 loaded
2051 .manifest
2052 .items
2053 .iter()
2054 .any(|item| item.id == "homebrew"),
2055 "embedded Ubuntu manifest should refresh stale runtime files"
2056 );
2057 assert!(
2058 loaded
2059 .manifest
2060 .profiles
2061 .get("all")
2062 .is_some_and(|profile| profile.items.iter().any(|item| item == "homebrew")),
2063 "refreshed Ubuntu manifest should include all profile"
2064 );
2065
2066 fs::remove_dir_all(&dir).await.unwrap();
2067 }
2068
2069 #[cfg(unix)]
2072 #[tokio::test]
2073 async fn handle_init_dry_run_does_not_execute_script() {
2074 let dir = make_temp_dir().await;
2075 let os_dir = dir.join("presets/sys/fakeos");
2076 fs::create_dir_all(&os_dir).await.unwrap();
2077
2078 fs::write(
2079 os_dir.join("shine.toml"),
2080 r#"
2081description = "Fake OS"
2082default_profile = "recommended"
2083
2084[[items]]
2085id = "touch-file"
2086label = "Touch file"
2087
2088[profiles.recommended]
2089items = ["touch-file"]
2090"#,
2091 )
2092 .await
2093 .unwrap();
2094
2095 let sentinel = dir.join("executed");
2096 let script = format!("#!/bin/bash\ntouch {}\n", sentinel.display());
2097 fs::write(os_dir.join("init.sh"), script.as_bytes())
2098 .await
2099 .unwrap();
2100
2101 let mut config = Config::new_for_test(&dir);
2102 config.is_external_presets = true;
2103
2104 handle_init_for_os(&config, "fakeos", &[], None, true, false, false)
2105 .await
2106 .unwrap();
2107 assert!(!sentinel.exists(), "script must not have been executed");
2108 assert!(
2109 !dir.join(SYS_MANIFEST_FILE).exists(),
2110 "dry-run must not write sys manifest"
2111 );
2112
2113 fs::remove_dir_all(&dir).await.unwrap();
2114 }
2115
2116 #[cfg(unix)]
2117 #[tokio::test]
2118 async fn permission_declaration_does_not_bypass_external_sys_code_gate() {
2119 let dir = make_temp_dir().await;
2120 let os_dir = dir.join("presets/sys/fakeos");
2121 fs::create_dir_all(os_dir.join("install")).await.unwrap();
2122
2123 fs::write(
2124 os_dir.join("shine.toml"),
2125 r#"
2126version = 2
2127description = "Fake OS"
2128default_profile = "recommended"
2129
2130[[items]]
2131id = "touch-file"
2132label = "Touch file"
2133permissions = { schema_version = 1, filesystem = [{ access = ["execute"], base = "preset", path = "install/touch-file.sh" }], commands = ["sh"] }
2134install = { kind = "script", path = "install/touch-file.sh" }
2135
2136[profiles.recommended]
2137items = ["touch-file"]
2138"#,
2139 )
2140 .await
2141 .unwrap();
2142
2143 let sentinel = dir.join("executed");
2144 let script = format!("#!/bin/sh\ntouch {}\n", sentinel.display());
2145 fs::write(os_dir.join("install/touch-file.sh"), script)
2146 .await
2147 .unwrap();
2148
2149 let mut config = Config::new_for_test(&dir);
2150 config.is_external_presets = true;
2151
2152 let error = handle_init_for_os(&config, "fakeos", &[], None, false, false, false)
2153 .await
2154 .unwrap_err();
2155
2156 assert!(error.to_string().contains("scoped external-code trust"));
2157 assert!(!sentinel.exists(), "script must not have been executed");
2158
2159 fs::remove_dir_all(&dir).await.unwrap();
2160 }
2161
2162 #[cfg(unix)]
2163 #[tokio::test]
2164 async fn handle_init_executes_items_then_updates_profile_in_rust() {
2165 let dir = make_temp_dir().await;
2166 let os_dir = dir.join("presets/sys/fakeos");
2167 fs::create_dir_all(&os_dir).await.unwrap();
2168
2169 fs::write(
2170 os_dir.join("shine.toml"),
2171 r#"
2172description = "Fake OS"
2173default_profile = "recommended"
2174
2175[[items]]
2176id = "first"
2177label = "First"
2178
2179[[items]]
2180id = "second"
2181label = "Second"
2182
2183[profiles.recommended]
2184items = ["first", "second"]
2185"#,
2186 )
2187 .await
2188 .unwrap();
2189
2190 let calls = dir.join("calls");
2191 fs::write(os_dir.join("profile.pre.sh"), "echo fake pre profile\n")
2192 .await
2193 .unwrap();
2194 fs::write(os_dir.join("profile.post.sh"), "echo fake post profile\n")
2195 .await
2196 .unwrap();
2197
2198 let script = format!(
2199 r#"#!/bin/bash
2200set -euo pipefail
2201printf '%s\n' "$1" >> {calls:?}
2202case "$1" in
2203 first) printf 'SHINE_SYS_STATUS\tinstalled\tfirst ok\n' ;;
2204 second) printf 'legacy log\n' ;;
2205 *) exit 1 ;;
2206esac
2207"#
2208 );
2209 fs::write(os_dir.join("init.sh"), script.as_bytes())
2210 .await
2211 .unwrap();
2212
2213 let mut config = Config::new_for_test(&dir);
2214 config.is_external_presets = true;
2215
2216 handle_init_for_os(&config, "fakeos", &[], None, false, false, false)
2217 .await
2218 .unwrap();
2219
2220 let calls = fs::read_to_string(&calls).await.unwrap();
2221 assert_eq!(calls.lines().collect::<Vec<_>>(), ["first", "second"]);
2222 let sys_manifest = SysRunManifest::load(&shine_core::runtime::RealHost, config.shine_dir())
2223 .await
2224 .unwrap();
2225 assert_eq!(sys_manifest.entries.len(), 2);
2226 assert!(sys_manifest.entries.iter().any(|entry| {
2227 entry.os_id == "fakeos"
2228 && entry.item_id == "first"
2229 && entry.label == "First"
2230 && entry.status == SysItemStatus::Installed
2231 && entry.detail == "first ok"
2232 }));
2233 assert!(sys_manifest.entries.iter().any(|entry| {
2234 entry.os_id == "fakeos"
2235 && entry.item_id == "second"
2236 && entry.label == "Second"
2237 && entry.status == SysItemStatus::Completed
2238 && entry.detail.is_empty()
2239 }));
2240 assert!(
2241 !sys_manifest
2242 .entries
2243 .iter()
2244 .any(|entry| entry.item_id == "profile")
2245 );
2246 assert_eq!(
2247 fs::read_to_string(dir.join(".shine/profile/fakeos-sys.pre.sh"))
2248 .await
2249 .unwrap(),
2250 "echo fake pre profile\n"
2251 );
2252 assert_eq!(
2253 fs::read_to_string(dir.join(".shine/profile/fakeos-sys.pre.base.sh"))
2254 .await
2255 .unwrap(),
2256 "echo fake pre profile\n"
2257 );
2258 assert_eq!(
2259 fs::read_to_string(dir.join(".shine/profile/fakeos-sys.post.sh"))
2260 .await
2261 .unwrap(),
2262 "echo fake post profile\n"
2263 );
2264 assert_eq!(
2265 fs::read_to_string(dir.join(".shine/profile/fakeos-sys.post.base.sh"))
2266 .await
2267 .unwrap(),
2268 "echo fake post profile\n"
2269 );
2270
2271 fs::remove_dir_all(&dir).await.unwrap();
2272 }
2273
2274 #[cfg(unix)]
2275 #[tokio::test]
2276 async fn handle_init_stops_items_after_failure_but_updates_profile_for_successes() {
2277 let dir = make_temp_dir().await;
2278 let os_dir = dir.join("presets/sys/fakeos");
2279 fs::create_dir_all(&os_dir).await.unwrap();
2280
2281 fs::write(
2282 os_dir.join("shine.toml"),
2283 r#"
2284description = "Fake OS"
2285default_profile = "recommended"
2286
2287[[items]]
2288id = "first"
2289label = "First"
2290
2291[[items]]
2292id = "fails"
2293label = "Fails"
2294
2295[[items]]
2296id = "after"
2297label = "After"
2298
2299[profiles.recommended]
2300items = ["first", "fails", "after"]
2301"#,
2302 )
2303 .await
2304 .unwrap();
2305
2306 let calls = dir.join("calls");
2307 fs::write(os_dir.join("profile.pre.sh"), "echo fake pre profile\n")
2308 .await
2309 .unwrap();
2310 fs::write(os_dir.join("profile.post.sh"), "echo fake post profile\n")
2311 .await
2312 .unwrap();
2313
2314 let script = format!(
2315 r#"#!/bin/bash
2316set -euo pipefail
2317printf '%s\n' "$1" >> {calls:?}
2318case "$1" in
2319 first) printf 'SHINE_SYS_STATUS\tinstalled\tfirst ok\n' ;;
2320 fails) printf 'SHINE_SYS_STATUS\tfailed\tbad item\n'; exit 1 ;;
2321 after) printf 'SHINE_SYS_STATUS\tinstalled\tafter ok\n' ;;
2322 *) exit 1 ;;
2323esac
2324"#
2325 );
2326 fs::write(os_dir.join("init.sh"), script.as_bytes())
2327 .await
2328 .unwrap();
2329
2330 let mut config = Config::new_for_test(&dir);
2331 config.is_external_presets = true;
2332
2333 let err = handle_init_for_os(&config, "fakeos", &[], None, false, false, false)
2334 .await
2335 .unwrap_err();
2336
2337 assert!(err.to_string().contains("sys bootstrap failed"));
2338 let calls = fs::read_to_string(&calls).await.unwrap();
2339 assert_eq!(calls.lines().collect::<Vec<_>>(), ["first", "fails"]);
2340 let sys_manifest = SysRunManifest::load(&shine_core::runtime::RealHost, config.shine_dir())
2341 .await
2342 .unwrap();
2343 assert_eq!(sys_manifest.entries.len(), 1);
2344 assert_eq!(sys_manifest.entries[0].item_id, "first");
2345 assert_eq!(sys_manifest.entries[0].status, SysItemStatus::Installed);
2346 assert!(
2347 !sys_manifest
2348 .entries
2349 .iter()
2350 .any(|entry| entry.item_id == "fails" || entry.item_id == "after")
2351 );
2352 assert_eq!(
2353 fs::read_to_string(dir.join(".shine/profile/fakeos-sys.pre.sh"))
2354 .await
2355 .unwrap(),
2356 "echo fake pre profile\n"
2357 );
2358 assert_eq!(
2359 fs::read_to_string(dir.join(".shine/profile/fakeos-sys.pre.base.sh"))
2360 .await
2361 .unwrap(),
2362 "echo fake pre profile\n"
2363 );
2364 assert_eq!(
2365 fs::read_to_string(dir.join(".shine/profile/fakeos-sys.post.sh"))
2366 .await
2367 .unwrap(),
2368 "echo fake post profile\n"
2369 );
2370 assert_eq!(
2371 fs::read_to_string(dir.join(".shine/profile/fakeos-sys.post.base.sh"))
2372 .await
2373 .unwrap(),
2374 "echo fake post profile\n"
2375 );
2376
2377 fs::remove_dir_all(&dir).await.unwrap();
2378 }
2379
2380 #[tokio::test]
2381 async fn handle_status_succeeds_without_sys_manifest() {
2382 let dir = make_temp_dir().await;
2383 let config = Config::new_for_test(&dir);
2384
2385 handle_status(&config).await.unwrap();
2386
2387 fs::remove_dir_all(&dir).await.unwrap();
2388 }
2389
2390 #[test]
2391 fn bootstrap_preflight_error_reports_no_changes() {
2392 let error = bootstrap_preflight_error(anyhow::anyhow!("permission denied"));
2393 assert_eq!(
2394 error.to_string(),
2395 "permission denied\n\nNo system changes were made."
2396 );
2397 }
2398}