Skip to main content

cli/
status.rs

1//! Shared install-status row builders consumed by `list` and `info`.
2//!
3//! Not a routed command itself (`shine check` was removed) — this is a
4//! status-row library: it computes per-file/per-category install status
5//! (`FileStatus`) and renders it into `AppRow`/`ShellRow` for display.
6
7use crate::apps::{
8    AppCategory, AppListMode, installed_content_hash, resolve_install_destination,
9    source_hash_for_file,
10};
11use crate::colors;
12use crate::config::Config;
13use crate::env::EnvConfig;
14use crate::install_core::{AppEntry, AppManifest, apply_transforms};
15use crate::path_display;
16use anyhow::Result;
17use std::collections::BTreeMap;
18use std::ffi::OsString;
19use std::path::Path;
20
21// ---------------------------------------------------------------------------
22// Shared row types
23// ---------------------------------------------------------------------------
24
25/// Per-file status used for aggregation within a category.
26/// Higher discriminant = higher priority (wins in fold).
27#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Clone, Copy)]
28pub enum FileStatus {
29    NotInstalled,
30    UpToDate,
31    UpdateAvail,
32    Partial,
33    UserModified,
34    Missing,
35}
36
37pub struct ShellRow {
38    pub symbol: String,
39    pub label: String,
40    pub status_sym: &'static str,
41    pub status_text: &'static str,
42    /// `true` when at least one of preset-file or bin-symlink exists.
43    pub is_installed: bool,
44}
45
46pub struct AppRow {
47    /// App preset category owning this row. Unlike `label`, this is stable
48    /// even when a multi-file category supplies custom display names.
49    pub category: String,
50    pub sym: &'static str,
51    pub label: String,
52    pub simple_label: String,
53    pub dest: Option<String>,
54    pub status_text: &'static str,
55    pub file_status: FileStatus,
56}
57
58// ---------------------------------------------------------------------------
59// Shared row builders (data-only, no printing)
60// ---------------------------------------------------------------------------
61
62/// Build shell preset rows.  Does not include the PATH sentinel line.
63pub async fn build_shell_rows(config: &Config) -> Result<Vec<ShellRow>> {
64    let categories = crate::shells::metadata::load_active_categories(config, None).await?;
65    if categories.is_empty() {
66        return Ok(Vec::new());
67    }
68
69    let bin_dir = config.bin_dir();
70    let shell_manifest = crate::shells::deployment::ShellManifest::load(config).await?;
71    let mut rows: Vec<ShellRow> = Vec::new();
72
73    for cat in &categories {
74        let snapshot_current =
75            crate::shells::deployment::snapshot_category_current(config, &cat.name)
76                .await
77                .unwrap_or(false);
78        for script in &cat.files {
79            let desired_path = crate::shells::deployment::desired_source_path(
80                config,
81                &cat.name,
82                &script.source_rel,
83            );
84            let script_path = crate::shells::deployment::deployment_source_path(
85                config,
86                &cat.name,
87                &script.source_rel,
88            );
89            let source_key = format!("shell/{}/{}", cat.name, script.source_rel.display());
90            let display_name = format!("{}/{}", cat.name, script.command_name);
91            let rendered_path =
92                crate::shells::deployment::rendered_path(config, &cat.name, &script.source_rel);
93            let link_name = OsString::from(&script.command_name);
94            let link_path = crate::bin_links::command_path_for_name(bin_dir, &link_name);
95
96            let file_exists = script_path.exists();
97            let link_exists = link_path.exists() || {
98                tokio::fs::symlink_metadata(&link_path)
99                    .await
100                    .map(|m| m.file_type().is_symlink())
101                    .unwrap_or(false)
102            };
103            let effective_transforms =
104                crate::shells::deployment::effective_transforms(script, &desired_path)
105                    .await
106                    .unwrap_or_else(|_| script.transforms.clone());
107            let effective_source = if !effective_transforms.is_empty() {
108                &rendered_path
109            } else {
110                &script_path
111            };
112            let runtime_env = script
113                .env
114                .iter()
115                .map(crate::env::EnvVarSpec::to_with_arg)
116                .collect::<Vec<_>>();
117            let link_current = if link_exists {
118                let render_target = (config.is_external_presets
119                    && config.external_shell_mode == crate::config::ExternalShellMode::Live
120                    && !effective_transforms.is_empty())
121                .then(|| format!("shell/{}/{}", cat.name, script.command_name));
122                crate::bin_links::link_is_current(
123                    &link_path,
124                    effective_source,
125                    script.runtime,
126                    &runtime_env,
127                    render_target.as_deref(),
128                )
129                .await?
130            } else {
131                false
132            };
133
134            let (sym, status_text) = match (file_exists, link_exists) {
135                (true, true) => ("✓", "up-to-date"),
136                (true, false) => ("~", "preset present, bin symlink missing"),
137                (false, true) => ("~", "bin symlink present, preset missing"),
138                (false, false) => ("✗", "not installed"),
139            };
140
141            let canonical_target = format!("shell/{}/{}", cat.name, script.command_name);
142            let expected_runtime = match script.runtime {
143                crate::bin_links::LinkRuntime::Native => "native",
144                crate::bin_links::LinkRuntime::Bun => "bun",
145            };
146            let manifest_current = !config.is_external_presets
147                || shell_manifest.find(&canonical_target).is_some_and(|entry| {
148                    entry.mode == config.external_shell_mode
149                        && entry.source_path == script_path
150                        && entry.runtime == expected_runtime
151                        && entry.transforms == effective_transforms
152                        && entry.env == runtime_env
153                        && entry.needs_source == script.needs_source
154                });
155
156            let (sym, status_text) = if link_exists
157                && (!link_current || !manifest_current || !snapshot_current)
158            {
159                ("↑", "update available")
160            } else {
161                match shell_source_status(
162                    config,
163                    &source_key,
164                    &desired_path,
165                    &script_path,
166                    &rendered_path,
167                    &effective_transforms,
168                )
169                .await
170                {
171                    Some(FileStatus::UpdateAvail) if file_exists || link_exists => {
172                        ("↑", "update available")
173                    }
174                    Some(FileStatus::Missing) if link_exists => ("!", "rendered script missing"),
175                    _ if config.is_external_presets
176                        && config.external_shell_mode == crate::config::ExternalShellMode::Live
177                        && file_exists
178                        && link_exists =>
179                    {
180                        if effective_transforms.is_empty() {
181                            ("✓", "live source")
182                        } else {
183                            ("✓", "rendered on next run")
184                        }
185                    }
186                    _ => (sym, status_text),
187                }
188            };
189
190            rows.push(ShellRow {
191                symbol: colors::symbol(sym),
192                label: display_name,
193                status_sym: sym,
194                status_text,
195                is_installed: file_exists || link_exists,
196            });
197        }
198    }
199
200    Ok(rows)
201}
202
203async fn shell_source_status(
204    config: &Config,
205    source_key: &str,
206    desired_path: &Path,
207    script_path: &Path,
208    rendered_path: &Path,
209    declared_transforms: &[String],
210) -> Option<FileStatus> {
211    let source_bytes = if config.is_external_presets {
212        tokio::fs::read(desired_path).await.ok()?
213    } else {
214        crate::presets::read_asset_bytes(source_key)?
215    };
216    if !script_path.exists() {
217        return Some(FileStatus::UpdateAvail);
218    }
219    if config.is_external_presets
220        && config.external_shell_mode == crate::config::ExternalShellMode::Live
221    {
222        return Some(FileStatus::UpToDate);
223    }
224    let current_source = tokio::fs::read(script_path).await.ok()?;
225    if source_bytes != current_source {
226        return Some(FileStatus::UpdateAvail);
227    }
228    let transforms = declared_transforms.to_vec();
229    if transforms.is_empty() {
230        return Some(FileStatus::UpToDate);
231    }
232
233    if !rendered_path.exists() {
234        return Some(FileStatus::Missing);
235    }
236
237    let env = EnvConfig::load_or_init(config).await.ok()?;
238    let rendered = apply_transforms(&transforms, &source_bytes, env.as_map()).ok()?;
239    let current = tokio::fs::read(rendered_path).await.ok()?;
240
241    if rendered == current {
242        Some(FileStatus::UpToDate)
243    } else {
244        Some(FileStatus::UpdateAvail)
245    }
246}
247
248/// Build app config rows for the given pre-loaded categories.
249pub async fn build_app_rows(config: &Config, categories: &[AppCategory]) -> Result<Vec<AppRow>> {
250    let manifest = AppManifest::load(config.shine_dir()).await?;
251    let env = EnvConfig::load_or_init(config).await.ok();
252    let empty_map = BTreeMap::new();
253    let env_map = env.as_ref().map(|e| e.as_map()).unwrap_or(&empty_map);
254    let mut rows: Vec<AppRow> = Vec::new();
255
256    for cat in categories {
257        if cat.has_explicit_files && cat.list_mode == AppListMode::Files {
258            for file in &cat.files {
259                let (dest_opt, status) =
260                    app_file_row_status(config, cat, file, &manifest, env_map).await;
261
262                let label = file
263                    .display_name
264                    .clone()
265                    .unwrap_or_else(|| format!("{}/{}", cat.name, file.source_rel.display()));
266                let simple_label = if cat.files.len() == 1 {
267                    cat.name.clone()
268                } else {
269                    label.clone()
270                };
271
272                let dest_str = dest_opt.map(|d| path_display::format_home(&d, &config.home_dir));
273
274                let (sym, status_text) = match status {
275                    FileStatus::Missing => ("!", "destination missing"),
276                    FileStatus::UserModified => ("~", "user modified"),
277                    FileStatus::UpdateAvail => ("↑", "update available"),
278                    FileStatus::UpToDate => ("✓", "up-to-date"),
279                    FileStatus::NotInstalled | FileStatus::Partial => ("✗", "not installed"),
280                };
281
282                rows.push(AppRow {
283                    category: cat.name.clone(),
284                    sym,
285                    label,
286                    simple_label,
287                    dest: dest_str,
288                    status_text,
289                    file_status: status,
290                });
291            }
292        } else {
293            let mut file_statuses: Vec<FileStatus> = Vec::new();
294
295            for file in &cat.files {
296                let (_, status) = app_file_row_status(config, cat, file, &manifest, env_map).await;
297                file_statuses.push(status);
298            }
299
300            let has_installed = file_statuses.iter().any(|s| {
301                matches!(
302                    s,
303                    FileStatus::UpToDate | FileStatus::UpdateAvail | FileStatus::UserModified
304                )
305            });
306            let has_not_installed = file_statuses.contains(&FileStatus::NotInstalled);
307            let cat_status = if has_installed && has_not_installed {
308                // Use the max status of installed files. Only collapse to Partial
309                // when all installed files are up-to-date; higher-severity statuses
310                // (UpdateAvail, UserModified) take priority because the user action
311                // ("shine upgrade") handles updates for installed files.
312                let installed_max = file_statuses
313                    .iter()
314                    .copied()
315                    .filter(|s| *s != FileStatus::NotInstalled)
316                    .max()
317                    .unwrap_or(FileStatus::Partial);
318                if installed_max == FileStatus::UpToDate {
319                    FileStatus::Partial
320                } else {
321                    installed_max
322                }
323            } else {
324                file_statuses
325                    .iter()
326                    .copied()
327                    .max()
328                    .unwrap_or(FileStatus::NotInstalled)
329            };
330
331            let dest_display: Option<String> = if let Some(root) = &cat.destination_root {
332                Some(path_display::format_tilde_path(root, &config.home_dir))
333            } else if cat.files.len() == 1 {
334                resolve_install_destination(cat, &cat.files[0], config)
335                    .ok()
336                    .map(|p| path_display::format_home(&p, &config.home_dir))
337            } else {
338                None
339            };
340
341            let (sym, status_text) = match cat_status {
342                FileStatus::Missing => ("!", "destination missing"),
343                FileStatus::UserModified => ("~", "user modified"),
344                FileStatus::Partial => ("~", "partial install"),
345                FileStatus::UpdateAvail => ("↑", "update available"),
346                FileStatus::UpToDate => ("✓", "up-to-date"),
347                FileStatus::NotInstalled => ("✗", "not installed"),
348            };
349
350            rows.push(AppRow {
351                category: cat.name.clone(),
352                sym,
353                label: cat.name.clone(),
354                simple_label: cat.name.clone(),
355                dest: dest_display,
356                status_text,
357                file_status: cat_status,
358            });
359        }
360    }
361
362    Ok(rows)
363}
364
365async fn app_file_row_status(
366    config: &Config,
367    cat: &AppCategory,
368    file: &crate::apps::AppFile,
369    manifest: &AppManifest,
370    env: &BTreeMap<String, String>,
371) -> (Option<std::path::PathBuf>, FileStatus) {
372    match resolve_install_destination(cat, file, config) {
373        Err(_) => (None, FileStatus::NotInstalled),
374        Ok(dest) => {
375            let status = match manifest.find_by_dest(&dest) {
376                None if file.generator.as_ref().is_some_and(|generator| {
377                    generator.auto && env.contains_key(&generator.when_env)
378                }) && manifest.entries.iter().any(|entry| {
379                    entry
380                        .source
381                        .strip_prefix("app/")
382                        .and_then(|source| source.split_once('/'))
383                        .is_some_and(|(category, _)| category == cat.name)
384                }) =>
385                {
386                    if source_hash_for_file(config, cat, file, env).await.is_some() {
387                        FileStatus::UpdateAvail
388                    } else {
389                        FileStatus::NotInstalled
390                    }
391                }
392                None => FileStatus::NotInstalled,
393                Some(entry) => app_entry_status(config, cat, file, entry, env).await,
394            };
395            (Some(dest), status)
396        }
397    }
398}
399
400/// Computes the status of an already-resolved manifest entry: compares its
401/// recorded content hash against what's currently on disk at
402/// `entry.destination`, and (if unchanged) against the current preset
403/// source to detect an available update.
404///
405/// Shared by `app_file_row_status` (used by `list`/`app info`) and `info`'s
406/// `collect_app_files` — both need this exact computation once an `AppEntry`
407/// has been resolved.
408pub(crate) async fn app_entry_status(
409    config: &Config,
410    cat: &AppCategory,
411    file: &crate::apps::AppFile,
412    entry: &AppEntry,
413    env: &BTreeMap<String, String>,
414) -> FileStatus {
415    // Generators are intentionally polled on every status/update pass, even
416    // when the installed destination was edited. Static sources keep the
417    // cheaper existing behavior and are read only after ownership is proven.
418    let generator_enabled = file
419        .generator
420        .as_ref()
421        .is_some_and(|generator| generator.auto && env.contains_key(&generator.when_env));
422    let manual_generator = file
423        .generator
424        .as_ref()
425        .is_some_and(|generator| !generator.auto);
426    let generated_source_hash = if generator_enabled {
427        source_hash_for_file(config, cat, file, env).await
428    } else {
429        None
430    };
431    if !entry.destination.exists() {
432        return FileStatus::Missing;
433    }
434    match tokio::fs::read(&entry.destination).await {
435        Err(_) => FileStatus::Missing,
436        Ok(dest_bytes) => {
437            let manifest_hash = entry.content_hash;
438            match installed_content_hash(file, &dest_bytes) {
439                Ok(Some(dest_hash)) if dest_hash == manifest_hash => {
440                    if manual_generator {
441                        return FileStatus::UpToDate;
442                    }
443                    let source_hash = if generator_enabled {
444                        generated_source_hash
445                    } else {
446                        source_hash_for_file(config, cat, file, env).await
447                    };
448                    match source_hash {
449                        Some(src) if src != manifest_hash => FileStatus::UpdateAvail,
450                        _ => FileStatus::UpToDate,
451                    }
452                }
453                Ok(None) => FileStatus::Missing,
454                Ok(Some(_)) | Err(_) => FileStatus::UserModified,
455            }
456        }
457    }
458}
459
460#[cfg(test)]
461mod tests {
462    use super::*;
463    use crate::apps::AppFile;
464    use crate::config::Config;
465    use crate::install_core::AppInstallStrategy;
466    #[cfg(windows)]
467    use crate::test_support::env_lock;
468    use std::path::PathBuf;
469    use tokio::fs;
470
471    async fn make_temp_dir() -> std::path::PathBuf {
472        crate::test_support::make_temp_dir("shine-check").await
473    }
474
475    fn sample_app_file() -> AppFile {
476        AppFile {
477            source_rel: PathBuf::from("dest.txt"),
478            target_rel: PathBuf::from("dest.txt"),
479            description: None,
480            display_name: None,
481            legacy_dest_annotation: None,
482            transforms: vec![],
483            install_strategy: AppInstallStrategy::Copy,
484            requires_admin: false,
485            restart_hint: None,
486            generator: None,
487        }
488    }
489
490    fn sample_app_category() -> AppCategory {
491        AppCategory {
492            name: "sample".to_string(),
493            description: None,
494            destination_root: None,
495            files: vec![sample_app_file()],
496            list_mode: AppListMode::Files,
497            post_upgrade: Vec::new(),
498            post_install: Vec::new(),
499            uses_metadata: true,
500            has_explicit_files: true,
501            artifact: None,
502        }
503    }
504
505    fn sample_app_entry(destination: PathBuf, content_hash: u64) -> AppEntry {
506        AppEntry {
507            source: "app/sample/dest.txt".to_string(),
508            destination,
509            backup: None,
510            content_hash,
511            install_strategy: AppInstallStrategy::Copy,
512            uses_env: false,
513            requires_admin: false,
514        }
515    }
516
517    #[tokio::test]
518    async fn app_entry_status_reports_missing_when_destination_absent() {
519        let dir = make_temp_dir().await;
520        let config = Config::new_for_test(&dir);
521        let dest = dir.join("dest.txt");
522        let entry = sample_app_entry(dest, crate::install_core::hash_content(b"hello"));
523
524        let status = app_entry_status(
525            &config,
526            &sample_app_category(),
527            &sample_app_file(),
528            &entry,
529            &BTreeMap::new(),
530        )
531        .await;
532
533        assert_eq!(status, FileStatus::Missing);
534        fs::remove_dir_all(&dir).await.unwrap();
535    }
536
537    #[tokio::test]
538    async fn app_entry_status_reports_user_modified_when_dest_hash_differs() {
539        let dir = make_temp_dir().await;
540        let config = Config::new_for_test(&dir);
541        let dest = dir.join("dest.txt");
542        fs::write(&dest, b"locally edited").await.unwrap();
543        let entry = sample_app_entry(dest, crate::install_core::hash_content(b"original"));
544
545        let status = app_entry_status(
546            &config,
547            &sample_app_category(),
548            &sample_app_file(),
549            &entry,
550            &BTreeMap::new(),
551        )
552        .await;
553
554        assert_eq!(status, FileStatus::UserModified);
555        fs::remove_dir_all(&dir).await.unwrap();
556    }
557
558    #[tokio::test]
559    async fn app_entry_status_reports_up_to_date_when_source_unreadable() {
560        // No embedded/external source exists for the synthetic "sample"
561        // category, so source_hash_for_file returns None and the status
562        // falls back to UpToDate once the dest hash matches the manifest.
563        let dir = make_temp_dir().await;
564        let config = Config::new_for_test(&dir);
565        let dest = dir.join("dest.txt");
566        fs::write(&dest, b"hello").await.unwrap();
567        let entry = sample_app_entry(dest, crate::install_core::hash_content(b"hello"));
568
569        let status = app_entry_status(
570            &config,
571            &sample_app_category(),
572            &sample_app_file(),
573            &entry,
574            &BTreeMap::new(),
575        )
576        .await;
577
578        assert_eq!(status, FileStatus::UpToDate);
579        fs::remove_dir_all(&dir).await.unwrap();
580    }
581
582    #[tokio::test]
583    async fn app_entry_status_reports_update_available_when_source_changed() {
584        let dir = make_temp_dir().await;
585        let mut config = Config::new_for_test(&dir);
586        config.is_external_presets = true;
587
588        let source_path = config.preset_path(Path::new("app").join("sample").join("dest.txt"));
589        fs::create_dir_all(source_path.parent().unwrap())
590            .await
591            .unwrap();
592        fs::write(&source_path, b"new upstream content")
593            .await
594            .unwrap();
595
596        let dest = dir.join("dest.txt");
597        fs::write(&dest, b"hello").await.unwrap();
598        let entry = sample_app_entry(dest, crate::install_core::hash_content(b"hello"));
599
600        let status = app_entry_status(
601            &config,
602            &sample_app_category(),
603            &sample_app_file(),
604            &entry,
605            &BTreeMap::new(),
606        )
607        .await;
608
609        assert_eq!(status, FileStatus::UpdateAvail);
610        fs::remove_dir_all(&dir).await.unwrap();
611    }
612
613    #[tokio::test]
614    async fn app_file_row_status_reports_not_installed_without_manifest_entry() {
615        let dir = make_temp_dir().await;
616        let config = Config::new_for_test(&dir);
617        let manifest = AppManifest::default();
618        let category = AppCategory {
619            destination_root: Some(dir.display().to_string()),
620            ..sample_app_category()
621        };
622
623        let (dest, status) = app_file_row_status(
624            &config,
625            &category,
626            &sample_app_file(),
627            &manifest,
628            &BTreeMap::new(),
629        )
630        .await;
631
632        assert!(dest.is_some());
633        assert_eq!(status, FileStatus::NotInstalled);
634        fs::remove_dir_all(&dir).await.unwrap();
635    }
636
637    #[cfg(not(unix))]
638    #[tokio::test]
639    async fn installed_shell_rows_use_windows_shim_path() {
640        let dir = make_temp_dir().await;
641        let cat_dir = dir.join("presets/shell/proxy");
642        fs::create_dir_all(&cat_dir).await.unwrap();
643        fs::write(
644            cat_dir.join("shine.toml"),
645            b"[[files]]\nsource = \"set_proxy.ps1\"\ntarget = \"setproxy\"\nneeds_source = true\n",
646        )
647        .await
648        .unwrap();
649        fs::write(cat_dir.join("set_proxy.ps1"), b"Write-Output proxy\n")
650            .await
651            .unwrap();
652
653        let mut config = Config::new_for_test(&dir);
654        config.is_external_presets = true;
655        fs::create_dir_all(config.bin_dir()).await.unwrap();
656        fs::write(config.bin_dir().join("setproxy.ps1"), b"# shine-managed\n")
657            .await
658            .unwrap();
659
660        let rows = build_shell_rows(&config).await.unwrap();
661        let row = rows
662            .iter()
663            .find(|row| row.label == "proxy/setproxy")
664            .expect("proxy/setproxy row should exist");
665
666        assert_eq!(row.status_sym, "✓");
667        assert_eq!(row.status_text, "up-to-date");
668        assert!(row.is_installed);
669
670        fs::remove_dir_all(&dir).await.unwrap();
671    }
672
673    #[cfg(unix)]
674    #[tokio::test]
675    async fn installed_shell_rows_report_up_to_date() {
676        let dir = make_temp_dir().await;
677        let cat_dir = dir.join("presets/shell/proxy");
678        fs::create_dir_all(&cat_dir).await.unwrap();
679        fs::write(
680            cat_dir.join("shine.toml"),
681            b"[[files]]\nsource = \"set_proxy.sh\"\ntarget = \"setproxy\"\nneeds_source = true\n",
682        )
683        .await
684        .unwrap();
685        let script = cat_dir.join("set_proxy.sh");
686        fs::write(&script, b"#!/bin/bash\necho proxy\n")
687            .await
688            .unwrap();
689        #[cfg(unix)]
690        {
691            use std::os::unix::fs::PermissionsExt;
692            let mut perms = fs::metadata(&script).await.unwrap().permissions();
693            perms.set_mode(0o755);
694            fs::set_permissions(&script, perms).await.unwrap();
695        }
696
697        let mut config = Config::new_for_test(&dir);
698        config.is_external_presets = true;
699        fs::create_dir_all(config.bin_dir()).await.unwrap();
700
701        crate::shells::handle_install(&config, Some("proxy"), false)
702            .await
703            .unwrap();
704
705        let rows = build_shell_rows(&config).await.unwrap();
706        let row = rows
707            .iter()
708            .find(|row| row.label == "proxy/setproxy")
709            .expect("proxy/setproxy row should exist");
710
711        assert_eq!(row.status_sym, "✓");
712        assert_eq!(row.status_text, "up-to-date");
713
714        fs::remove_dir_all(&dir).await.unwrap();
715    }
716
717    #[cfg(unix)]
718    #[tokio::test]
719    async fn external_template_shell_change_reports_update_available() {
720        let dir = make_temp_dir().await;
721        let cat_dir = dir.join("presets/shell/proxy");
722        fs::create_dir_all(&cat_dir).await.unwrap();
723        fs::write(
724            cat_dir.join("shine.toml"),
725            b"[[files]]\nsource = \"set_proxy.sh\"\ntarget = \"setproxy\"\nneeds_source = true\n",
726        )
727        .await
728        .unwrap();
729        let script = cat_dir.join("set_proxy.sh");
730        fs::write(
731            &script,
732            b"#!/bin/bash\n# shine-template: true\necho @@PROXY_HOST@@\n",
733        )
734        .await
735        .unwrap();
736
737        let mut config = Config::new_for_test(&dir);
738        config.is_external_presets = true;
739        fs::create_dir_all(config.bin_dir()).await.unwrap();
740
741        crate::shells::handle_install(&config, Some("proxy"), false)
742            .await
743            .unwrap();
744
745        fs::write(
746            &script,
747            b"#!/bin/bash\n# shine-template: true\necho changed @@PROXY_HOST@@\n",
748        )
749        .await
750        .unwrap();
751
752        let rows = build_shell_rows(&config).await.unwrap();
753        let row = rows
754            .iter()
755            .find(|row| row.label == "proxy/setproxy")
756            .expect("proxy/setproxy row should exist");
757
758        assert_eq!(row.status_sym, "↑");
759        assert_eq!(row.status_text, "update available");
760
761        fs::remove_dir_all(&dir).await.unwrap();
762    }
763
764    #[cfg(unix)]
765    #[tokio::test]
766    async fn live_raw_shell_change_stays_live_and_current() {
767        let dir = make_temp_dir().await;
768        let cat_dir = dir.join("presets/shell/custom");
769        fs::create_dir_all(&cat_dir).await.unwrap();
770        fs::write(
771            cat_dir.join("shine.toml"),
772            b"[[files]]\nsource = \"tool.sh\"\ntarget = \"mytool\"\n",
773        )
774        .await
775        .unwrap();
776        let source = cat_dir.join("tool.sh");
777        fs::write(&source, b"#!/bin/sh\necho first\n")
778            .await
779            .unwrap();
780
781        let mut config = Config::new_for_test(&dir);
782        config.is_external_presets = true;
783        config.external_shell_mode = crate::config::ExternalShellMode::Live;
784        fs::create_dir_all(config.bin_dir()).await.unwrap();
785        crate::shells::handle_install(&config, Some("custom"), false)
786            .await
787            .unwrap();
788        fs::write(&source, b"#!/bin/sh\necho second\n")
789            .await
790            .unwrap();
791
792        let rows = build_shell_rows(&config).await.unwrap();
793        let row = rows
794            .iter()
795            .find(|row| row.label == "custom/mytool")
796            .unwrap();
797        assert_eq!(row.status_sym, "✓");
798        assert_eq!(row.status_text, "live source");
799        fs::remove_dir_all(&dir).await.unwrap();
800    }
801
802    #[tokio::test]
803    async fn embedded_bun_source_change_reports_update_available() {
804        let dir = make_temp_dir().await;
805        let config = Config::new_for_test(&dir);
806        fs::create_dir_all(config.presets_dir()).await.unwrap();
807        fs::create_dir_all(config.bin_dir()).await.unwrap();
808
809        crate::shells::handle_install(&config, Some("agent"), false)
810            .await
811            .unwrap();
812
813        let extracted = config.presets_dir().join("shell/agent/cc.ts");
814        fs::write(&extracted, b"// stale extracted ccenv\n")
815            .await
816            .unwrap();
817
818        let rows = build_shell_rows(&config).await.unwrap();
819        let row = rows
820            .iter()
821            .find(|row| row.label == "agent/ccenv")
822            .expect("agent/ccenv row should exist");
823
824        assert_eq!(row.status_sym, "↑");
825        assert_eq!(row.status_text, "update available");
826
827        fs::remove_dir_all(&dir).await.unwrap();
828    }
829
830    #[tokio::test]
831    async fn embedded_shell_source_rename_reports_update_available() {
832        let dir = make_temp_dir().await;
833        let cat_dir = dir.join("presets/shell/agent");
834        fs::create_dir_all(&cat_dir).await.unwrap();
835        let old_source = if cfg!(windows) { "cc.ps1" } else { "cc.sh" };
836        fs::write(
837            cat_dir.join("shine.toml"),
838            format!(
839                "[[files]]\nsource = \"{old_source}\"\ntarget = \"ccenv\"\nneeds_source = true\n"
840            ),
841        )
842        .await
843        .unwrap();
844        fs::write(cat_dir.join(old_source), b"# old sourced ccenv\n")
845            .await
846            .unwrap();
847
848        let mut config = Config::new_for_test(&dir);
849        config.is_external_presets = true;
850        fs::create_dir_all(config.bin_dir()).await.unwrap();
851        crate::shells::handle_install(&config, Some("agent"), false)
852            .await
853            .unwrap();
854
855        config.is_external_presets = false;
856        let rows = build_shell_rows(&config).await.unwrap();
857        let row = rows
858            .iter()
859            .find(|row| row.label == "agent/ccenv")
860            .expect("embedded agent/ccenv row should exist");
861
862        assert_eq!(row.status_sym, "↑");
863        assert_eq!(row.status_text, "update available");
864
865        fs::remove_dir_all(&dir).await.unwrap();
866    }
867
868    #[tokio::test]
869    async fn external_shell_runtime_and_source_change_reports_update_available() {
870        let dir = make_temp_dir().await;
871        let cat_dir = dir.join("presets/shell/agent");
872        fs::create_dir_all(&cat_dir).await.unwrap();
873        let old_source = if cfg!(windows) { "cc.ps1" } else { "cc.sh" };
874        fs::write(
875            cat_dir.join("shine.toml"),
876            format!(
877                "[[files]]\nsource = \"{old_source}\"\ntarget = \"ccenv\"\nneeds_source = true\n"
878            ),
879        )
880        .await
881        .unwrap();
882        fs::write(cat_dir.join(old_source), b"# old sourced ccenv\n")
883            .await
884            .unwrap();
885
886        let mut config = Config::new_for_test(&dir);
887        config.is_external_presets = true;
888        fs::create_dir_all(config.bin_dir()).await.unwrap();
889        crate::shells::handle_install(&config, Some("agent"), false)
890            .await
891            .unwrap();
892
893        fs::write(
894            cat_dir.join("shine.toml"),
895            b"[[files]]\nsource = \"cc.ts\"\ntarget = \"ccenv\"\nruntime = \"bun\"\nplatforms = [\"unix\", \"windows\"]\n",
896        )
897        .await
898        .unwrap();
899        fs::write(cat_dir.join("cc.ts"), b"console.log('new ccenv');\n")
900            .await
901            .unwrap();
902
903        let rows = build_shell_rows(&config).await.unwrap();
904        let row = rows
905            .iter()
906            .find(|row| row.label == "agent/ccenv")
907            .expect("external agent/ccenv row should exist");
908
909        assert_eq!(row.status_sym, "↑");
910        assert_eq!(row.status_text, "update available");
911
912        fs::remove_dir_all(&dir).await.unwrap();
913    }
914
915    #[cfg(unix)]
916    #[tokio::test]
917    async fn shell_env_change_reports_update_available() {
918        let dir = make_temp_dir().await;
919        let cat_dir = dir.join("presets/shell/proxy");
920        fs::create_dir_all(&cat_dir).await.unwrap();
921        fs::write(
922            cat_dir.join("shine.toml"),
923            b"[[files]]\nsource = \"set_proxy.sh\"\ntarget = \"setproxy\"\nneeds_source = true\n",
924        )
925        .await
926        .unwrap();
927        fs::write(
928            cat_dir.join("set_proxy.sh"),
929            b"#!/bin/bash\n# shine-template: true\nPROXY_NO_PROXY=\"@@PROXY_NO_PROXY@@\"\n",
930        )
931        .await
932        .unwrap();
933
934        let mut config = Config::new_for_test(&dir);
935        config.is_external_presets = true;
936        fs::create_dir_all(config.bin_dir()).await.unwrap();
937
938        crate::shells::handle_install(&config, Some("proxy"), false)
939            .await
940            .unwrap();
941
942        config.env.insert(
943            "PROXY_NO_PROXY".to_string(),
944            "localhost,127.0.0.1,::1,.local".to_string(),
945        );
946
947        let rows = build_shell_rows(&config).await.unwrap();
948        let row = rows
949            .iter()
950            .find(|row| row.label == "proxy/setproxy")
951            .expect("proxy/setproxy row should exist");
952
953        assert_eq!(row.status_sym, "↑");
954        assert_eq!(row.status_text, "update available");
955
956        fs::remove_dir_all(&dir).await.unwrap();
957    }
958
959    #[tokio::test]
960    async fn category_list_mode_aggregates_explicit_app_files() {
961        let dir = make_temp_dir().await;
962        let config = Config::new_for_test(&dir);
963        fs::create_dir_all(config.shine_dir()).await.unwrap();
964
965        let category = AppCategory {
966            name: "ghostty".to_string(),
967            description: Some("Ghostty terminal configuration.".to_string()),
968            destination_root: Some(dir.join(".config/ghostty").display().to_string()),
969            files: vec![
970                AppFile {
971                    source_rel: PathBuf::from("config.ghostty"),
972                    target_rel: PathBuf::from("config.ghostty"),
973                    description: None,
974                    display_name: None,
975                    legacy_dest_annotation: None,
976                    transforms: vec![],
977                    install_strategy: AppInstallStrategy::Copy,
978                    requires_admin: false,
979                    restart_hint: None,
980                    generator: None,
981                },
982                AppFile {
983                    source_rel: PathBuf::from("themes/shine-light"),
984                    target_rel: PathBuf::from("themes/shine-light"),
985                    description: None,
986                    display_name: None,
987                    legacy_dest_annotation: None,
988                    transforms: vec!["template".to_string()],
989                    install_strategy: AppInstallStrategy::Copy,
990                    requires_admin: false,
991                    restart_hint: None,
992                    generator: None,
993                },
994            ],
995            list_mode: AppListMode::Category,
996            post_upgrade: Vec::new(),
997            post_install: Vec::new(),
998            uses_metadata: true,
999            has_explicit_files: true,
1000            artifact: None,
1001        };
1002
1003        let rows = build_app_rows(&config, &[category]).await.unwrap();
1004
1005        assert_eq!(rows.len(), 1);
1006        assert_eq!(rows[0].label, "ghostty");
1007        assert_eq!(rows[0].simple_label, "ghostty");
1008        assert_eq!(rows[0].dest.as_deref(), Some("~/.config/ghostty"));
1009        assert_eq!(rows[0].file_status, FileStatus::NotInstalled);
1010
1011        fs::remove_dir_all(&dir).await.unwrap();
1012    }
1013
1014    #[tokio::test]
1015    async fn file_list_mode_keeps_file_labels_for_multi_file_app_simple_list() {
1016        let dir = make_temp_dir().await;
1017        let config = Config::new_for_test(&dir);
1018        fs::create_dir_all(config.shine_dir()).await.unwrap();
1019
1020        let category = AppCategory {
1021            name: "sample".to_string(),
1022            description: None,
1023            destination_root: Some(dir.join(".config/sample").display().to_string()),
1024            files: vec![
1025                AppFile {
1026                    source_rel: PathBuf::from("config.toml"),
1027                    target_rel: PathBuf::from("config.toml"),
1028                    description: None,
1029                    display_name: None,
1030                    legacy_dest_annotation: None,
1031                    transforms: vec![],
1032                    install_strategy: AppInstallStrategy::Copy,
1033                    requires_admin: false,
1034                    restart_hint: None,
1035                    generator: None,
1036                },
1037                AppFile {
1038                    source_rel: PathBuf::from("theme.toml"),
1039                    target_rel: PathBuf::from("theme.toml"),
1040                    description: None,
1041                    display_name: None,
1042                    legacy_dest_annotation: None,
1043                    transforms: vec![],
1044                    install_strategy: AppInstallStrategy::Copy,
1045                    requires_admin: false,
1046                    restart_hint: None,
1047                    generator: None,
1048                },
1049            ],
1050            list_mode: AppListMode::Files,
1051            post_upgrade: Vec::new(),
1052            post_install: Vec::new(),
1053            uses_metadata: true,
1054            has_explicit_files: true,
1055            artifact: None,
1056        };
1057
1058        let rows = build_app_rows(&config, &[category]).await.unwrap();
1059
1060        assert_eq!(rows.len(), 2);
1061        assert_eq!(rows[0].label, "sample/config.toml");
1062        assert_eq!(rows[0].simple_label, "sample/config.toml");
1063        assert_eq!(rows[1].label, "sample/theme.toml");
1064        assert_eq!(rows[1].simple_label, "sample/theme.toml");
1065
1066        fs::remove_dir_all(&dir).await.unwrap();
1067    }
1068
1069    #[cfg(windows)]
1070    #[tokio::test]
1071    async fn windows_docker_engine_row_uses_engine_destination() {
1072        let _guard = env_lock();
1073        let dir = make_temp_dir().await;
1074        // SAFETY: env_lock() serialises all env-mutation tests in this module,
1075        // preventing concurrent writes to the process environment from other test threads.
1076        unsafe { std::env::set_var("HOME", dir.to_str().unwrap()) };
1077        let config = Config::new_for_test(&dir);
1078        fs::create_dir_all(config.shine_dir()).await.unwrap();
1079
1080        let categories = crate::apps::load_embedded_categories(Some("docker-engine")).unwrap();
1081        let rows = build_app_rows(&config, &categories).await.unwrap();
1082
1083        assert_eq!(rows.len(), 1);
1084        assert_eq!(rows[0].label, "docker-engine/daemon.jsonc");
1085        assert_eq!(rows[0].simple_label, "docker-engine");
1086        assert_eq!(rows[0].file_status, FileStatus::NotInstalled);
1087        assert_eq!(rows[0].dest.as_deref(), Some("~/.docker/daemon.json"));
1088
1089        // SAFETY: same env_lock() guard as above.
1090        unsafe { std::env::remove_var("HOME") };
1091        fs::remove_dir_all(&dir).await.unwrap();
1092    }
1093
1094    #[cfg(windows)]
1095    #[tokio::test]
1096    async fn windows_docker_desktop_row_uses_forward_slash_destination() {
1097        let _guard = env_lock();
1098        let dir = make_temp_dir().await;
1099        // SAFETY: env_lock() serialises all env-mutation tests in this module,
1100        // preventing concurrent writes to the process environment from other test threads.
1101        unsafe { std::env::set_var("HOME", dir.to_str().unwrap()) };
1102        let config = Config::new_for_test(&dir);
1103        fs::create_dir_all(config.shine_dir()).await.unwrap();
1104
1105        let categories = crate::apps::load_embedded_categories(Some("docker-desktop")).unwrap();
1106        let rows = build_app_rows(&config, &categories).await.unwrap();
1107
1108        assert_eq!(rows.len(), 1);
1109        assert_eq!(rows[0].label, "docker-desktop/settings-store.jsonc");
1110        assert_eq!(rows[0].simple_label, "docker-desktop");
1111        assert_eq!(rows[0].file_status, FileStatus::NotInstalled);
1112        assert_eq!(
1113            rows[0].dest.as_deref(),
1114            Some("~/AppData/Roaming/Docker/settings-store.json")
1115        );
1116
1117        // SAFETY: same env_lock() guard as above.
1118        unsafe { std::env::remove_var("HOME") };
1119        fs::remove_dir_all(&dir).await.unwrap();
1120    }
1121}