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