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