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
365pub(crate) async 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 source = format!("app/{}/{}", cat.name, file.source_rel.display());
376            let installed_category = manifest.entries.iter().any(|entry| {
377                entry
378                    .source
379                    .strip_prefix("app/")
380                    .and_then(|source| source.split_once('/'))
381                    .is_some_and(|(category, _)| category == cat.name)
382            });
383            let status = match manifest.find_by_dest(&dest) {
384                Some(entry) => app_entry_status(config, cat, file, entry, env).await,
385                None => match manifest.find_by_source(&source) {
386                    Some(entry)
387                        if file
388                            .generator
389                            .as_ref()
390                            .is_some_and(|generator| !generator.auto) =>
391                    {
392                        app_entry_status(config, cat, file, entry, env).await
393                    }
394                    Some(_) => FileStatus::UpdateAvail,
395                    None if installed_category
396                        && file
397                            .generator
398                            .as_ref()
399                            .is_none_or(|generator| generator.auto) =>
400                    {
401                        if source_hash_for_file(config, cat, file, env).await.is_some() {
402                            FileStatus::UpdateAvail
403                        } else {
404                            FileStatus::NotInstalled
405                        }
406                    }
407                    None => FileStatus::NotInstalled,
408                },
409            };
410            (Some(dest), status)
411        }
412    }
413}
414
415/// Computes the status of an already-resolved manifest entry: compares its
416/// recorded content hash against what's currently on disk at
417/// `entry.destination`, and (if unchanged) against the current preset
418/// source to detect an available update.
419///
420/// Shared by `app_file_row_status` (used by `list`/`app info`) and `info`'s
421/// `collect_app_files` — both need this exact computation once an `AppEntry`
422/// has been resolved.
423pub(crate) async fn app_entry_status(
424    config: &Config,
425    cat: &AppCategory,
426    file: &crate::apps::AppFile,
427    entry: &AppEntry,
428    env: &BTreeMap<String, String>,
429) -> FileStatus {
430    // Generators are intentionally polled on every status/update pass, even
431    // when the installed destination was edited. Static sources keep the
432    // cheaper existing behavior and are read only after ownership is proven.
433    let generator_enabled = file
434        .generator
435        .as_ref()
436        .is_some_and(|generator| generator.auto && env.contains_key(&generator.when_env));
437    let manual_generator = file
438        .generator
439        .as_ref()
440        .is_some_and(|generator| !generator.auto);
441    let generated_source_hash = if generator_enabled {
442        source_hash_for_file(config, cat, file, env).await
443    } else {
444        None
445    };
446    if !entry.destination.exists() {
447        return FileStatus::Missing;
448    }
449    match tokio::fs::read(&entry.destination).await {
450        Err(_) => FileStatus::Missing,
451        Ok(dest_bytes) => {
452            let manifest_hash = entry.content_hash;
453            match installed_content_hash(file, &dest_bytes) {
454                Ok(Some(dest_hash)) if dest_hash == manifest_hash => {
455                    if manual_generator {
456                        return FileStatus::UpToDate;
457                    }
458                    let source_hash = if generator_enabled {
459                        generated_source_hash
460                    } else {
461                        source_hash_for_file(config, cat, file, env).await
462                    };
463                    match source_hash {
464                        Some(src) if src != manifest_hash => FileStatus::UpdateAvail,
465                        _ => FileStatus::UpToDate,
466                    }
467                }
468                Ok(None) => FileStatus::Missing,
469                Ok(Some(_)) | Err(_) => FileStatus::UserModified,
470            }
471        }
472    }
473}
474
475#[cfg(test)]
476mod tests {
477    use super::*;
478    use crate::apps::AppFile;
479    use crate::config::Config;
480    use crate::install_core::AppInstallStrategy;
481    #[cfg(windows)]
482    use crate::test_support::env_lock;
483    use std::path::PathBuf;
484    use tokio::fs;
485
486    async fn make_temp_dir() -> std::path::PathBuf {
487        crate::test_support::make_temp_dir("shine-check").await
488    }
489
490    fn sample_app_file() -> AppFile {
491        AppFile {
492            source_rel: PathBuf::from("dest.txt"),
493            target_rel: PathBuf::from("dest.txt"),
494            destination_root: None,
495            description: None,
496            display_name: None,
497            legacy_dest_annotation: None,
498            transforms: vec![],
499            install_strategy: AppInstallStrategy::Copy,
500            requires_admin: false,
501            restart_hint: None,
502            generator: None,
503        }
504    }
505
506    fn sample_app_category() -> AppCategory {
507        AppCategory {
508            name: "sample".to_string(),
509            description: None,
510            destination_root: None,
511            files: vec![sample_app_file()],
512            list_mode: AppListMode::Files,
513            post_upgrade: Vec::new(),
514            post_install: Vec::new(),
515            uses_metadata: true,
516            has_explicit_files: true,
517            artifact: None,
518        }
519    }
520
521    fn sample_app_entry(destination: PathBuf, content_hash: u64) -> AppEntry {
522        AppEntry {
523            source: "app/sample/dest.txt".to_string(),
524            destination,
525            backup: None,
526            content_hash,
527            install_strategy: AppInstallStrategy::Copy,
528            uses_env: false,
529            requires_admin: false,
530        }
531    }
532
533    #[tokio::test]
534    async fn app_entry_status_reports_missing_when_destination_absent() {
535        let dir = make_temp_dir().await;
536        let config = Config::new_for_test(&dir);
537        let dest = dir.join("dest.txt");
538        let entry = sample_app_entry(dest, crate::install_core::hash_content(b"hello"));
539
540        let status = app_entry_status(
541            &config,
542            &sample_app_category(),
543            &sample_app_file(),
544            &entry,
545            &BTreeMap::new(),
546        )
547        .await;
548
549        assert_eq!(status, FileStatus::Missing);
550        fs::remove_dir_all(&dir).await.unwrap();
551    }
552
553    #[tokio::test]
554    async fn app_entry_status_reports_user_modified_when_dest_hash_differs() {
555        let dir = make_temp_dir().await;
556        let config = Config::new_for_test(&dir);
557        let dest = dir.join("dest.txt");
558        fs::write(&dest, b"locally edited").await.unwrap();
559        let entry = sample_app_entry(dest, crate::install_core::hash_content(b"original"));
560
561        let status = app_entry_status(
562            &config,
563            &sample_app_category(),
564            &sample_app_file(),
565            &entry,
566            &BTreeMap::new(),
567        )
568        .await;
569
570        assert_eq!(status, FileStatus::UserModified);
571        fs::remove_dir_all(&dir).await.unwrap();
572    }
573
574    #[tokio::test]
575    async fn app_entry_status_reports_up_to_date_when_source_unreadable() {
576        // No embedded/external source exists for the synthetic "sample"
577        // category, so source_hash_for_file returns None and the status
578        // falls back to UpToDate once the dest hash matches the manifest.
579        let dir = make_temp_dir().await;
580        let config = Config::new_for_test(&dir);
581        let dest = dir.join("dest.txt");
582        fs::write(&dest, b"hello").await.unwrap();
583        let entry = sample_app_entry(dest, crate::install_core::hash_content(b"hello"));
584
585        let status = app_entry_status(
586            &config,
587            &sample_app_category(),
588            &sample_app_file(),
589            &entry,
590            &BTreeMap::new(),
591        )
592        .await;
593
594        assert_eq!(status, FileStatus::UpToDate);
595        fs::remove_dir_all(&dir).await.unwrap();
596    }
597
598    #[tokio::test]
599    async fn app_entry_status_reports_update_available_when_source_changed() {
600        let dir = make_temp_dir().await;
601        let mut config = Config::new_for_test(&dir);
602        config.is_external_presets = true;
603
604        let source_path = config.preset_path(Path::new("app").join("sample").join("dest.txt"));
605        fs::create_dir_all(source_path.parent().unwrap())
606            .await
607            .unwrap();
608        fs::write(&source_path, b"new upstream content")
609            .await
610            .unwrap();
611
612        let dest = dir.join("dest.txt");
613        fs::write(&dest, b"hello").await.unwrap();
614        let entry = sample_app_entry(dest, crate::install_core::hash_content(b"hello"));
615
616        let status = app_entry_status(
617            &config,
618            &sample_app_category(),
619            &sample_app_file(),
620            &entry,
621            &BTreeMap::new(),
622        )
623        .await;
624
625        assert_eq!(status, FileStatus::UpdateAvail);
626        fs::remove_dir_all(&dir).await.unwrap();
627    }
628
629    #[tokio::test]
630    async fn app_file_row_status_reports_not_installed_without_manifest_entry() {
631        let dir = make_temp_dir().await;
632        let config = Config::new_for_test(&dir);
633        let manifest = AppManifest::default();
634        let category = AppCategory {
635            destination_root: Some(dir.display().to_string()),
636            ..sample_app_category()
637        };
638
639        let (dest, status) = app_file_row_status(
640            &config,
641            &category,
642            &sample_app_file(),
643            &manifest,
644            &BTreeMap::new(),
645        )
646        .await;
647
648        assert!(dest.is_some());
649        assert_eq!(status, FileStatus::NotInstalled);
650        fs::remove_dir_all(&dir).await.unwrap();
651    }
652
653    #[tokio::test]
654    async fn app_file_row_status_reports_new_file_in_installed_category_as_update() {
655        let dir = make_temp_dir().await;
656        let mut config = Config::new_for_test(&dir);
657        config.is_external_presets = true;
658        let source_dir = config.preset_path(Path::new("app/sample"));
659        fs::create_dir_all(&source_dir).await.unwrap();
660        fs::write(source_dir.join("new.txt"), b"new").await.unwrap();
661
662        let mut file = sample_app_file();
663        file.source_rel = PathBuf::from("new.txt");
664        file.target_rel = PathBuf::from("new.txt");
665        let category = AppCategory {
666            destination_root: Some(dir.join("dest").display().to_string()),
667            files: vec![file.clone()],
668            ..sample_app_category()
669        };
670        let manifest = AppManifest {
671            entries: vec![sample_app_entry(
672                dir.join("dest/old.txt"),
673                crate::install_core::hash_content(b"old"),
674            )],
675        };
676
677        let (_, status) =
678            app_file_row_status(&config, &category, &file, &manifest, &BTreeMap::new()).await;
679
680        assert_eq!(status, FileStatus::UpdateAvail);
681        fs::remove_dir_all(&dir).await.unwrap();
682    }
683
684    #[tokio::test]
685    async fn app_file_row_status_reports_destination_move_as_update() {
686        let dir = make_temp_dir().await;
687        let mut config = Config::new_for_test(&dir);
688        config.is_external_presets = true;
689        let source_dir = config.preset_path(Path::new("app/sample"));
690        fs::create_dir_all(&source_dir).await.unwrap();
691        fs::write(source_dir.join("dest.txt"), b"managed")
692            .await
693            .unwrap();
694
695        let old_destination = dir.join("old/dest.txt");
696        let category = AppCategory {
697            destination_root: Some(dir.join("new").display().to_string()),
698            ..sample_app_category()
699        };
700        let manifest = AppManifest {
701            entries: vec![sample_app_entry(
702                old_destination,
703                crate::install_core::hash_content(b"managed"),
704            )],
705        };
706
707        let (_, status) = app_file_row_status(
708            &config,
709            &category,
710            &category.files[0],
711            &manifest,
712            &BTreeMap::new(),
713        )
714        .await;
715
716        assert_eq!(status, FileStatus::UpdateAvail);
717        fs::remove_dir_all(&dir).await.unwrap();
718    }
719
720    #[cfg(not(unix))]
721    #[tokio::test]
722    async fn installed_shell_rows_use_windows_shim_path() {
723        let dir = make_temp_dir().await;
724        let cat_dir = dir.join("presets/shell/proxy");
725        fs::create_dir_all(&cat_dir).await.unwrap();
726        fs::write(
727            cat_dir.join("shine.toml"),
728            b"[[files]]\nsource = \"set_proxy.ps1\"\ntarget = \"setproxy\"\nneeds_source = true\n",
729        )
730        .await
731        .unwrap();
732        fs::write(cat_dir.join("set_proxy.ps1"), b"Write-Output proxy\n")
733            .await
734            .unwrap();
735
736        let mut config = Config::new_for_test(&dir);
737        config.is_external_presets = true;
738        fs::create_dir_all(config.bin_dir()).await.unwrap();
739        fs::write(config.bin_dir().join("setproxy.ps1"), b"# shine-managed\n")
740            .await
741            .unwrap();
742
743        let rows = build_shell_rows(&config).await.unwrap();
744        let row = rows
745            .iter()
746            .find(|row| row.label == "proxy/setproxy")
747            .expect("proxy/setproxy row should exist");
748
749        assert_eq!(row.status_sym, "✓");
750        assert_eq!(row.status_text, "up-to-date");
751        assert!(row.is_installed);
752
753        fs::remove_dir_all(&dir).await.unwrap();
754    }
755
756    #[cfg(unix)]
757    #[tokio::test]
758    async fn installed_shell_rows_report_up_to_date() {
759        let dir = make_temp_dir().await;
760        let cat_dir = dir.join("presets/shell/proxy");
761        fs::create_dir_all(&cat_dir).await.unwrap();
762        fs::write(
763            cat_dir.join("shine.toml"),
764            b"[[files]]\nsource = \"set_proxy.sh\"\ntarget = \"setproxy\"\nneeds_source = true\n",
765        )
766        .await
767        .unwrap();
768        let script = cat_dir.join("set_proxy.sh");
769        fs::write(&script, b"#!/bin/bash\necho proxy\n")
770            .await
771            .unwrap();
772        #[cfg(unix)]
773        {
774            use std::os::unix::fs::PermissionsExt;
775            let mut perms = fs::metadata(&script).await.unwrap().permissions();
776            perms.set_mode(0o755);
777            fs::set_permissions(&script, perms).await.unwrap();
778        }
779
780        let mut config = Config::new_for_test(&dir);
781        config.is_external_presets = true;
782        fs::create_dir_all(config.bin_dir()).await.unwrap();
783
784        crate::shells::handle_install(&config, Some("proxy"), false)
785            .await
786            .unwrap();
787
788        let rows = build_shell_rows(&config).await.unwrap();
789        let row = rows
790            .iter()
791            .find(|row| row.label == "proxy/setproxy")
792            .expect("proxy/setproxy row should exist");
793
794        assert_eq!(row.status_sym, "✓");
795        assert_eq!(row.status_text, "up-to-date");
796
797        fs::remove_dir_all(&dir).await.unwrap();
798    }
799
800    #[cfg(unix)]
801    #[tokio::test]
802    async fn external_template_shell_change_reports_update_available() {
803        let dir = make_temp_dir().await;
804        let cat_dir = dir.join("presets/shell/proxy");
805        fs::create_dir_all(&cat_dir).await.unwrap();
806        fs::write(
807            cat_dir.join("shine.toml"),
808            b"[[files]]\nsource = \"set_proxy.sh\"\ntarget = \"setproxy\"\nneeds_source = true\n",
809        )
810        .await
811        .unwrap();
812        let script = cat_dir.join("set_proxy.sh");
813        fs::write(
814            &script,
815            b"#!/bin/bash\n# shine-template: true\necho @@PROXY_HOST@@\n",
816        )
817        .await
818        .unwrap();
819
820        let mut config = Config::new_for_test(&dir);
821        config.is_external_presets = true;
822        fs::create_dir_all(config.bin_dir()).await.unwrap();
823
824        crate::shells::handle_install(&config, Some("proxy"), false)
825            .await
826            .unwrap();
827
828        fs::write(
829            &script,
830            b"#!/bin/bash\n# shine-template: true\necho changed @@PROXY_HOST@@\n",
831        )
832        .await
833        .unwrap();
834
835        let rows = build_shell_rows(&config).await.unwrap();
836        let row = rows
837            .iter()
838            .find(|row| row.label == "proxy/setproxy")
839            .expect("proxy/setproxy row should exist");
840
841        assert_eq!(row.status_sym, "↑");
842        assert_eq!(row.status_text, "update available");
843
844        fs::remove_dir_all(&dir).await.unwrap();
845    }
846
847    #[cfg(unix)]
848    #[tokio::test]
849    async fn live_raw_shell_change_stays_live_and_current() {
850        let dir = make_temp_dir().await;
851        let cat_dir = dir.join("presets/shell/custom");
852        fs::create_dir_all(&cat_dir).await.unwrap();
853        fs::write(
854            cat_dir.join("shine.toml"),
855            b"[[files]]\nsource = \"tool.sh\"\ntarget = \"mytool\"\n",
856        )
857        .await
858        .unwrap();
859        let source = cat_dir.join("tool.sh");
860        fs::write(&source, b"#!/bin/sh\necho first\n")
861            .await
862            .unwrap();
863
864        let mut config = Config::new_for_test(&dir);
865        config.is_external_presets = true;
866        config.external_shell_mode = crate::config::ExternalShellMode::Live;
867        fs::create_dir_all(config.bin_dir()).await.unwrap();
868        crate::shells::handle_install(&config, Some("custom"), false)
869            .await
870            .unwrap();
871        fs::write(&source, b"#!/bin/sh\necho second\n")
872            .await
873            .unwrap();
874
875        let rows = build_shell_rows(&config).await.unwrap();
876        let row = rows
877            .iter()
878            .find(|row| row.label == "custom/mytool")
879            .unwrap();
880        assert_eq!(row.status_sym, "✓");
881        assert_eq!(row.status_text, "live source");
882        fs::remove_dir_all(&dir).await.unwrap();
883    }
884
885    #[tokio::test]
886    async fn embedded_bun_source_change_reports_update_available() {
887        let dir = make_temp_dir().await;
888        let config = Config::new_for_test(&dir);
889        fs::create_dir_all(config.presets_dir()).await.unwrap();
890        fs::create_dir_all(config.bin_dir()).await.unwrap();
891
892        crate::shells::handle_install(&config, Some("agent"), false)
893            .await
894            .unwrap();
895
896        let extracted = config.presets_dir().join("shell/agent/cc.ts");
897        fs::write(&extracted, b"// stale extracted ccenv\n")
898            .await
899            .unwrap();
900
901        let rows = build_shell_rows(&config).await.unwrap();
902        let row = rows
903            .iter()
904            .find(|row| row.label == "agent/ccenv")
905            .expect("agent/ccenv row should exist");
906
907        assert_eq!(row.status_sym, "↑");
908        assert_eq!(row.status_text, "update available");
909
910        fs::remove_dir_all(&dir).await.unwrap();
911    }
912
913    #[tokio::test]
914    async fn embedded_shell_source_rename_reports_update_available() {
915        let dir = make_temp_dir().await;
916        let cat_dir = dir.join("presets/shell/agent");
917        fs::create_dir_all(&cat_dir).await.unwrap();
918        let old_source = if cfg!(windows) { "cc.ps1" } else { "cc.sh" };
919        fs::write(
920            cat_dir.join("shine.toml"),
921            format!(
922                "[[files]]\nsource = \"{old_source}\"\ntarget = \"ccenv\"\nneeds_source = true\n"
923            ),
924        )
925        .await
926        .unwrap();
927        fs::write(cat_dir.join(old_source), b"# old sourced ccenv\n")
928            .await
929            .unwrap();
930
931        let mut config = Config::new_for_test(&dir);
932        config.is_external_presets = true;
933        fs::create_dir_all(config.bin_dir()).await.unwrap();
934        crate::shells::handle_install(&config, Some("agent"), false)
935            .await
936            .unwrap();
937
938        config.is_external_presets = false;
939        let rows = build_shell_rows(&config).await.unwrap();
940        let row = rows
941            .iter()
942            .find(|row| row.label == "agent/ccenv")
943            .expect("embedded agent/ccenv row should exist");
944
945        assert_eq!(row.status_sym, "↑");
946        assert_eq!(row.status_text, "update available");
947
948        fs::remove_dir_all(&dir).await.unwrap();
949    }
950
951    #[tokio::test]
952    async fn external_shell_runtime_and_source_change_reports_update_available() {
953        let dir = make_temp_dir().await;
954        let cat_dir = dir.join("presets/shell/agent");
955        fs::create_dir_all(&cat_dir).await.unwrap();
956        let old_source = if cfg!(windows) { "cc.ps1" } else { "cc.sh" };
957        fs::write(
958            cat_dir.join("shine.toml"),
959            format!(
960                "[[files]]\nsource = \"{old_source}\"\ntarget = \"ccenv\"\nneeds_source = true\n"
961            ),
962        )
963        .await
964        .unwrap();
965        fs::write(cat_dir.join(old_source), b"# old sourced ccenv\n")
966            .await
967            .unwrap();
968
969        let mut config = Config::new_for_test(&dir);
970        config.is_external_presets = true;
971        fs::create_dir_all(config.bin_dir()).await.unwrap();
972        crate::shells::handle_install(&config, Some("agent"), false)
973            .await
974            .unwrap();
975
976        fs::write(
977            cat_dir.join("shine.toml"),
978            b"[[files]]\nsource = \"cc.ts\"\ntarget = \"ccenv\"\nruntime = \"bun\"\nplatforms = [\"unix\", \"windows\"]\n",
979        )
980        .await
981        .unwrap();
982        fs::write(cat_dir.join("cc.ts"), b"console.log('new ccenv');\n")
983            .await
984            .unwrap();
985
986        let rows = build_shell_rows(&config).await.unwrap();
987        let row = rows
988            .iter()
989            .find(|row| row.label == "agent/ccenv")
990            .expect("external agent/ccenv row should exist");
991
992        assert_eq!(row.status_sym, "↑");
993        assert_eq!(row.status_text, "update available");
994
995        fs::remove_dir_all(&dir).await.unwrap();
996    }
997
998    #[cfg(unix)]
999    #[tokio::test]
1000    async fn shell_env_change_reports_update_available() {
1001        let dir = make_temp_dir().await;
1002        let cat_dir = dir.join("presets/shell/proxy");
1003        fs::create_dir_all(&cat_dir).await.unwrap();
1004        fs::write(
1005            cat_dir.join("shine.toml"),
1006            b"[[files]]\nsource = \"set_proxy.sh\"\ntarget = \"setproxy\"\nneeds_source = true\n",
1007        )
1008        .await
1009        .unwrap();
1010        fs::write(
1011            cat_dir.join("set_proxy.sh"),
1012            b"#!/bin/bash\n# shine-template: true\nPROXY_NO_PROXY=\"@@PROXY_NO_PROXY@@\"\n",
1013        )
1014        .await
1015        .unwrap();
1016
1017        let mut config = Config::new_for_test(&dir);
1018        config.is_external_presets = true;
1019        fs::create_dir_all(config.bin_dir()).await.unwrap();
1020
1021        crate::shells::handle_install(&config, Some("proxy"), false)
1022            .await
1023            .unwrap();
1024
1025        config.env.insert(
1026            "PROXY_NO_PROXY".to_string(),
1027            "localhost,127.0.0.1,::1,.local".to_string(),
1028        );
1029
1030        let rows = build_shell_rows(&config).await.unwrap();
1031        let row = rows
1032            .iter()
1033            .find(|row| row.label == "proxy/setproxy")
1034            .expect("proxy/setproxy row should exist");
1035
1036        assert_eq!(row.status_sym, "↑");
1037        assert_eq!(row.status_text, "update available");
1038
1039        fs::remove_dir_all(&dir).await.unwrap();
1040    }
1041
1042    #[tokio::test]
1043    async fn category_list_mode_aggregates_explicit_app_files() {
1044        let dir = make_temp_dir().await;
1045        let config = Config::new_for_test(&dir);
1046        fs::create_dir_all(config.shine_dir()).await.unwrap();
1047
1048        let category = AppCategory {
1049            name: "ghostty".to_string(),
1050            description: Some("Ghostty terminal configuration.".to_string()),
1051            destination_root: Some(dir.join(".config/ghostty").display().to_string()),
1052            files: vec![
1053                AppFile {
1054                    source_rel: PathBuf::from("config.ghostty"),
1055                    target_rel: PathBuf::from("config.ghostty"),
1056                    destination_root: None,
1057                    description: None,
1058                    display_name: None,
1059                    legacy_dest_annotation: None,
1060                    transforms: vec![],
1061                    install_strategy: AppInstallStrategy::Copy,
1062                    requires_admin: false,
1063                    restart_hint: None,
1064                    generator: None,
1065                },
1066                AppFile {
1067                    source_rel: PathBuf::from("themes/shine-light"),
1068                    target_rel: PathBuf::from("themes/shine-light"),
1069                    destination_root: None,
1070                    description: None,
1071                    display_name: None,
1072                    legacy_dest_annotation: None,
1073                    transforms: vec!["template".to_string()],
1074                    install_strategy: AppInstallStrategy::Copy,
1075                    requires_admin: false,
1076                    restart_hint: None,
1077                    generator: None,
1078                },
1079            ],
1080            list_mode: AppListMode::Category,
1081            post_upgrade: Vec::new(),
1082            post_install: Vec::new(),
1083            uses_metadata: true,
1084            has_explicit_files: true,
1085            artifact: None,
1086        };
1087
1088        let rows = build_app_rows(&config, &[category]).await.unwrap();
1089
1090        assert_eq!(rows.len(), 1);
1091        assert_eq!(rows[0].label, "ghostty");
1092        assert_eq!(rows[0].simple_label, "ghostty");
1093        assert_eq!(rows[0].dest.as_deref(), Some("~/.config/ghostty"));
1094        assert_eq!(rows[0].file_status, FileStatus::NotInstalled);
1095
1096        fs::remove_dir_all(&dir).await.unwrap();
1097    }
1098
1099    #[tokio::test]
1100    async fn file_list_mode_keeps_file_labels_for_multi_file_app_simple_list() {
1101        let dir = make_temp_dir().await;
1102        let config = Config::new_for_test(&dir);
1103        fs::create_dir_all(config.shine_dir()).await.unwrap();
1104
1105        let category = AppCategory {
1106            name: "sample".to_string(),
1107            description: None,
1108            destination_root: Some(dir.join(".config/sample").display().to_string()),
1109            files: vec![
1110                AppFile {
1111                    source_rel: PathBuf::from("config.toml"),
1112                    target_rel: PathBuf::from("config.toml"),
1113                    destination_root: None,
1114                    description: None,
1115                    display_name: None,
1116                    legacy_dest_annotation: None,
1117                    transforms: vec![],
1118                    install_strategy: AppInstallStrategy::Copy,
1119                    requires_admin: false,
1120                    restart_hint: None,
1121                    generator: None,
1122                },
1123                AppFile {
1124                    source_rel: PathBuf::from("theme.toml"),
1125                    target_rel: PathBuf::from("theme.toml"),
1126                    destination_root: None,
1127                    description: None,
1128                    display_name: None,
1129                    legacy_dest_annotation: None,
1130                    transforms: vec![],
1131                    install_strategy: AppInstallStrategy::Copy,
1132                    requires_admin: false,
1133                    restart_hint: None,
1134                    generator: None,
1135                },
1136            ],
1137            list_mode: AppListMode::Files,
1138            post_upgrade: Vec::new(),
1139            post_install: Vec::new(),
1140            uses_metadata: true,
1141            has_explicit_files: true,
1142            artifact: None,
1143        };
1144
1145        let rows = build_app_rows(&config, &[category]).await.unwrap();
1146
1147        assert_eq!(rows.len(), 2);
1148        assert_eq!(rows[0].label, "sample/config.toml");
1149        assert_eq!(rows[0].simple_label, "sample/config.toml");
1150        assert_eq!(rows[1].label, "sample/theme.toml");
1151        assert_eq!(rows[1].simple_label, "sample/theme.toml");
1152
1153        fs::remove_dir_all(&dir).await.unwrap();
1154    }
1155
1156    #[cfg(windows)]
1157    #[tokio::test]
1158    async fn windows_docker_engine_row_uses_engine_destination() {
1159        let _guard = env_lock();
1160        let dir = make_temp_dir().await;
1161        // SAFETY: env_lock() serialises all env-mutation tests in this module,
1162        // preventing concurrent writes to the process environment from other test threads.
1163        unsafe { std::env::set_var("HOME", dir.to_str().unwrap()) };
1164        let config = Config::new_for_test(&dir);
1165        fs::create_dir_all(config.shine_dir()).await.unwrap();
1166
1167        let categories = crate::apps::load_embedded_categories(Some("docker-engine")).unwrap();
1168        let rows = build_app_rows(&config, &categories).await.unwrap();
1169
1170        assert_eq!(rows.len(), 1);
1171        assert_eq!(rows[0].label, "docker-engine/daemon.jsonc");
1172        assert_eq!(rows[0].simple_label, "docker-engine");
1173        assert_eq!(rows[0].file_status, FileStatus::NotInstalled);
1174        assert_eq!(rows[0].dest.as_deref(), Some("~/.docker/daemon.json"));
1175
1176        // SAFETY: same env_lock() guard as above.
1177        unsafe { std::env::remove_var("HOME") };
1178        fs::remove_dir_all(&dir).await.unwrap();
1179    }
1180
1181    #[cfg(windows)]
1182    #[tokio::test]
1183    async fn windows_docker_desktop_row_uses_forward_slash_destination() {
1184        let _guard = env_lock();
1185        let dir = make_temp_dir().await;
1186        // SAFETY: env_lock() serialises all env-mutation tests in this module,
1187        // preventing concurrent writes to the process environment from other test threads.
1188        unsafe { std::env::set_var("HOME", dir.to_str().unwrap()) };
1189        let config = Config::new_for_test(&dir);
1190        fs::create_dir_all(config.shine_dir()).await.unwrap();
1191
1192        let categories = crate::apps::load_embedded_categories(Some("docker-desktop")).unwrap();
1193        let rows = build_app_rows(&config, &categories).await.unwrap();
1194
1195        assert_eq!(rows.len(), 1);
1196        assert_eq!(rows[0].label, "docker-desktop/settings-store.jsonc");
1197        assert_eq!(rows[0].simple_label, "docker-desktop");
1198        assert_eq!(rows[0].file_status, FileStatus::NotInstalled);
1199        assert_eq!(
1200            rows[0].dest.as_deref(),
1201            Some("~/AppData/Roaming/Docker/settings-store.json")
1202        );
1203
1204        // SAFETY: same env_lock() guard as above.
1205        unsafe { std::env::remove_var("HOME") };
1206        fs::remove_dir_all(&dir).await.unwrap();
1207    }
1208}