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