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