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