Skip to main content

cli/sys/
commands.rs

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