Skip to main content

cli/sys/
commands.rs

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