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, PathBuf};
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
37#[derive(Debug, Clone, PartialEq, Eq)]
38pub(crate) enum UpdateChange {
39    ContentChanged,
40    SourceRelocated {
41        from: PathBuf,
42        to: PathBuf,
43    },
44    DestinationRelocated {
45        from: PathBuf,
46        to: PathBuf,
47    },
48    NewFile {
49        destination: PathBuf,
50    },
51    DeploymentChanged {
52        field: &'static str,
53        from: String,
54        to: String,
55    },
56    CommandEntryMissing {
57        path: PathBuf,
58    },
59    CommandEntryOutdated {
60        path: PathBuf,
61    },
62    ManifestEntryMissing {
63        target: String,
64    },
65}
66
67impl UpdateChange {
68    pub(crate) fn includes_content(changes: &[Self]) -> bool {
69        changes.contains(&Self::ContentChanged)
70    }
71}
72
73pub(crate) struct AppFileAssessment {
74    pub(crate) destination: Option<PathBuf>,
75    pub(crate) status: FileStatus,
76    pub(crate) changes: Vec<UpdateChange>,
77}
78
79pub struct ShellRow {
80    /// Shell preset category owning this command row. Lifecycle commands act
81    /// on this category; `label` remains the command-level diagnostic target.
82    pub category: String,
83    pub symbol: String,
84    pub label: String,
85    pub status_sym: &'static str,
86    pub status_text: &'static str,
87    /// `true` when at least one of preset-file or bin-symlink exists.
88    pub is_installed: bool,
89    pub(crate) changes: Vec<UpdateChange>,
90}
91
92pub struct AppRow {
93    /// App preset category owning this row. Unlike `label`, this is stable
94    /// even when a multi-file category supplies custom display names.
95    pub category: String,
96    pub sym: &'static str,
97    pub label: String,
98    pub simple_label: String,
99    pub dest: Option<String>,
100    pub status_text: &'static str,
101    pub file_status: FileStatus,
102}
103
104// ---------------------------------------------------------------------------
105// Shared row builders (data-only, no printing)
106// ---------------------------------------------------------------------------
107
108/// Build shell preset rows.  Does not include the PATH sentinel line.
109pub async fn build_shell_rows(config: &Config) -> Result<Vec<ShellRow>> {
110    let categories = crate::shells::metadata::load_active_categories(config, None).await?;
111    if categories.is_empty() {
112        return Ok(Vec::new());
113    }
114
115    let bin_dir = config.bin_dir();
116    let shell_manifest = crate::shells::deployment::ShellManifest::load(config).await?;
117    let mut rows: Vec<ShellRow> = Vec::new();
118
119    for cat in &categories {
120        let snapshot_current =
121            crate::shells::deployment::snapshot_category_current(config, &cat.name)
122                .await
123                .unwrap_or(false);
124        for script in &cat.files {
125            let desired_path = crate::shells::deployment::desired_source_path(
126                config,
127                &cat.name,
128                &script.source_rel,
129            );
130            let script_path = crate::shells::deployment::deployment_source_path(
131                config,
132                &cat.name,
133                &script.source_rel,
134            );
135            let source_key = format!("shell/{}/{}", cat.name, script.source_rel.display());
136            let display_name = format!("{}/{}", cat.name, script.command_name);
137            let rendered_path =
138                crate::shells::deployment::rendered_path(config, &cat.name, &script.source_rel);
139            let link_name = OsString::from(&script.command_name);
140            let link_path = crate::bin_links::command_path_for_name(bin_dir, &link_name);
141
142            let file_exists = script_path.exists();
143            let link_exists = link_path.exists() || {
144                tokio::fs::symlink_metadata(&link_path)
145                    .await
146                    .map(|m| m.file_type().is_symlink())
147                    .unwrap_or(false)
148            };
149            let effective_transforms =
150                crate::shells::deployment::effective_transforms(script, &desired_path)
151                    .await
152                    .unwrap_or_else(|_| script.transforms.clone());
153            let effective_source = if !effective_transforms.is_empty() {
154                &rendered_path
155            } else {
156                &script_path
157            };
158            let runtime_env = script
159                .env
160                .iter()
161                .map(crate::env::EnvVarSpec::to_with_arg)
162                .collect::<Vec<_>>();
163            let link_current = if link_exists {
164                let render_target = (config.is_external_presets
165                    && config.external_shell_mode == crate::config::ExternalShellMode::Live
166                    && !effective_transforms.is_empty())
167                .then(|| format!("shell/{}/{}", cat.name, script.command_name));
168                crate::bin_links::link_is_current(
169                    &link_path,
170                    effective_source,
171                    script.runtime,
172                    &runtime_env,
173                    render_target.as_deref(),
174                )
175                .await?
176            } else {
177                false
178            };
179
180            let (sym, status_text) = match (file_exists, link_exists) {
181                (true, true) => ("✓", "up-to-date"),
182                (true, false) => ("↑", "update available"),
183                (false, true) => ("~", "bin symlink present, preset missing"),
184                (false, false) => ("✗", "not installed"),
185            };
186
187            let canonical_target = format!("shell/{}/{}", cat.name, script.command_name);
188            let expected_runtime = match script.runtime {
189                crate::bin_links::LinkRuntime::Native => "native",
190                crate::bin_links::LinkRuntime::Bun => "bun",
191            };
192            let manifest_entry = shell_manifest.find(&canonical_target);
193            // Extracted preset files and category snapshots are shared deployment
194            // caches, not proof that every command in the category was installed.
195            // A command is active when it has a manifest receipt or a launcher
196            // (the latter preserves compatibility with pre-manifest installs).
197            let is_installed = manifest_entry.is_some() || link_exists;
198            let manifest_current = !config.is_external_presets
199                || manifest_entry.is_some_and(|entry| {
200                    entry.mode == config.external_shell_mode
201                        && entry.source_path == script_path
202                        && entry.runtime == expected_runtime
203                        && entry.transforms == effective_transforms
204                        && entry.env == runtime_env
205                        && entry.needs_source == script.needs_source
206                });
207
208            let source_status = shell_source_status(
209                config,
210                &source_key,
211                &desired_path,
212                &script_path,
213                &rendered_path,
214                &effective_transforms,
215            )
216            .await;
217            let mut changes = Vec::new();
218            if source_status == Some(FileStatus::UpdateAvail) {
219                changes.push(UpdateChange::ContentChanged);
220            }
221            if let Some(entry) = manifest_entry {
222                if entry.source_path != script_path {
223                    changes.push(UpdateChange::SourceRelocated {
224                        from: entry.source_path.clone(),
225                        to: script_path.clone(),
226                    });
227                    if !UpdateChange::includes_content(&changes)
228                        && tokio::fs::read(&script_path).await.is_ok_and(|bytes| {
229                            crate::install_core::hash_content(&bytes) != entry.content_hash
230                        })
231                    {
232                        changes.push(UpdateChange::ContentChanged);
233                    }
234                }
235                push_deployment_change(
236                    &mut changes,
237                    "mode",
238                    format!("{:?}", entry.mode).to_lowercase(),
239                    format!("{:?}", config.external_shell_mode).to_lowercase(),
240                );
241                push_deployment_change(
242                    &mut changes,
243                    "runtime",
244                    entry.runtime.clone(),
245                    expected_runtime.to_string(),
246                );
247                push_deployment_change(
248                    &mut changes,
249                    "transforms",
250                    format_list(&entry.transforms),
251                    format_list(&effective_transforms),
252                );
253                push_deployment_change(
254                    &mut changes,
255                    "env",
256                    format_list(&entry.env),
257                    format_list(&runtime_env),
258                );
259                push_deployment_change(
260                    &mut changes,
261                    "needs source",
262                    entry.needs_source.to_string(),
263                    script.needs_source.to_string(),
264                );
265            }
266            if is_installed && file_exists && !link_exists {
267                changes.push(UpdateChange::CommandEntryMissing {
268                    path: link_path.clone(),
269                });
270            }
271            if config.is_external_presets && manifest_entry.is_none() && link_exists {
272                changes.push(UpdateChange::ManifestEntryMissing {
273                    target: canonical_target.clone(),
274                });
275            }
276            if !snapshot_current
277                && source_status != Some(FileStatus::UpdateAvail)
278                && config.external_shell_mode == crate::config::ExternalShellMode::Snapshot
279            {
280                changes.push(UpdateChange::DeploymentChanged {
281                    field: "snapshot",
282                    from: "installed layout".to_string(),
283                    to: "active preset layout".to_string(),
284                });
285            }
286            let entry_rebuild_already_explained = changes.iter().any(|change| {
287                matches!(
288                    change,
289                    UpdateChange::SourceRelocated { .. }
290                        | UpdateChange::DeploymentChanged { .. }
291                        | UpdateChange::CommandEntryMissing { .. }
292                )
293            });
294            if !link_current && link_exists && !entry_rebuild_already_explained {
295                changes.push(UpdateChange::CommandEntryOutdated {
296                    path: link_path.clone(),
297                });
298            }
299            if !is_installed {
300                changes.clear();
301            }
302
303            let (sym, status_text) = if !is_installed {
304                ("✗", "not installed")
305            } else if link_exists && (!link_current || !manifest_current || !snapshot_current) {
306                ("↑", "update available")
307            } else {
308                match source_status {
309                    Some(FileStatus::UpdateAvail) if file_exists || link_exists => {
310                        ("↑", "update available")
311                    }
312                    Some(FileStatus::Missing) if link_exists => ("!", "rendered script missing"),
313                    _ if config.is_external_presets
314                        && config.external_shell_mode == crate::config::ExternalShellMode::Live
315                        && file_exists
316                        && link_exists =>
317                    {
318                        if effective_transforms.is_empty() {
319                            ("✓", "live source")
320                        } else {
321                            ("✓", "rendered on next run")
322                        }
323                    }
324                    _ => (sym, status_text),
325                }
326            };
327
328            rows.push(ShellRow {
329                category: cat.name.clone(),
330                symbol: colors::symbol(sym),
331                label: display_name,
332                status_sym: sym,
333                status_text,
334                is_installed,
335                changes,
336            });
337        }
338    }
339
340    Ok(rows)
341}
342
343fn format_list(values: &[String]) -> String {
344    if values.is_empty() {
345        "none".to_string()
346    } else {
347        values.join(", ")
348    }
349}
350
351fn push_deployment_change(
352    changes: &mut Vec<UpdateChange>,
353    field: &'static str,
354    from: String,
355    to: String,
356) {
357    if from != to {
358        changes.push(UpdateChange::DeploymentChanged { field, from, to });
359    }
360}
361
362async fn shell_source_status(
363    config: &Config,
364    source_key: &str,
365    desired_path: &Path,
366    script_path: &Path,
367    rendered_path: &Path,
368    declared_transforms: &[String],
369) -> Option<FileStatus> {
370    let source_bytes = if config.is_external_presets {
371        tokio::fs::read(desired_path).await.ok()?
372    } else {
373        crate::presets::read_asset_bytes(source_key)?
374    };
375    if !script_path.exists() {
376        return Some(FileStatus::UpdateAvail);
377    }
378    if config.is_external_presets
379        && config.external_shell_mode == crate::config::ExternalShellMode::Live
380    {
381        return Some(FileStatus::UpToDate);
382    }
383    let current_source = tokio::fs::read(script_path).await.ok()?;
384    if source_bytes != current_source {
385        return Some(FileStatus::UpdateAvail);
386    }
387    let transforms = declared_transforms.to_vec();
388    if transforms.is_empty() {
389        return Some(FileStatus::UpToDate);
390    }
391
392    if !rendered_path.exists() {
393        return Some(FileStatus::Missing);
394    }
395
396    let env = EnvConfig::load_or_init(config).await.ok()?;
397    let rendered = apply_transforms(&transforms, &source_bytes, env.as_map()).ok()?;
398    let current = tokio::fs::read(rendered_path).await.ok()?;
399
400    if rendered == current {
401        Some(FileStatus::UpToDate)
402    } else {
403        Some(FileStatus::UpdateAvail)
404    }
405}
406
407/// Build app config rows for the given pre-loaded categories.
408pub async fn build_app_rows(config: &Config, categories: &[AppCategory]) -> Result<Vec<AppRow>> {
409    let manifest = AppManifest::load(config.shine_dir()).await?;
410    let env = EnvConfig::load_or_init(config).await.ok();
411    let empty_map = BTreeMap::new();
412    let env_map = env.as_ref().map(|e| e.as_map()).unwrap_or(&empty_map);
413    let mut rows: Vec<AppRow> = Vec::new();
414
415    for cat in categories {
416        if cat.has_explicit_files && cat.list_mode == AppListMode::Files {
417            for file in &cat.files {
418                let (dest_opt, status) =
419                    app_file_row_status(config, cat, file, &manifest, env_map).await;
420
421                let label = file
422                    .display_name
423                    .clone()
424                    .unwrap_or_else(|| format!("{}/{}", cat.name, file.source_rel.display()));
425                let simple_label = if cat.files.len() == 1 {
426                    cat.name.clone()
427                } else {
428                    label.clone()
429                };
430
431                let dest_str = dest_opt.map(|d| path_display::format_home(&d, &config.home_dir));
432
433                let (sym, status_text) = match status {
434                    FileStatus::Missing => ("!", "destination missing"),
435                    FileStatus::UserModified => ("~", "user modified"),
436                    FileStatus::UpdateAvail => ("↑", "update available"),
437                    FileStatus::UpToDate => ("✓", "up-to-date"),
438                    FileStatus::NotInstalled | FileStatus::Partial => ("✗", "not installed"),
439                };
440
441                rows.push(AppRow {
442                    category: cat.name.clone(),
443                    sym,
444                    label,
445                    simple_label,
446                    dest: dest_str,
447                    status_text,
448                    file_status: status,
449                });
450            }
451        } else {
452            let mut file_statuses: Vec<FileStatus> = Vec::new();
453
454            for file in &cat.files {
455                let (_, status) = app_file_row_status(config, cat, file, &manifest, env_map).await;
456                file_statuses.push(status);
457            }
458
459            let has_installed = file_statuses.iter().any(|s| {
460                matches!(
461                    s,
462                    FileStatus::UpToDate | FileStatus::UpdateAvail | FileStatus::UserModified
463                )
464            });
465            let has_not_installed = file_statuses.contains(&FileStatus::NotInstalled);
466            let cat_status = if has_installed && has_not_installed {
467                // Use the max status of installed files. Only collapse to Partial
468                // when all installed files are up-to-date; higher-severity statuses
469                // (UpdateAvail, UserModified) take priority because the user action
470                // ("shine upgrade") handles updates for installed files.
471                let installed_max = file_statuses
472                    .iter()
473                    .copied()
474                    .filter(|s| *s != FileStatus::NotInstalled)
475                    .max()
476                    .unwrap_or(FileStatus::Partial);
477                if installed_max == FileStatus::UpToDate {
478                    FileStatus::Partial
479                } else {
480                    installed_max
481                }
482            } else {
483                file_statuses
484                    .iter()
485                    .copied()
486                    .max()
487                    .unwrap_or(FileStatus::NotInstalled)
488            };
489
490            let dest_display: Option<String> = if let Some(root) = &cat.destination_root {
491                Some(path_display::format_tilde_path(root, &config.home_dir))
492            } else if cat.files.len() == 1 {
493                resolve_install_destination(cat, &cat.files[0], config)
494                    .ok()
495                    .map(|p| path_display::format_home(&p, &config.home_dir))
496            } else {
497                None
498            };
499
500            let (sym, status_text) = match cat_status {
501                FileStatus::Missing => ("!", "destination missing"),
502                FileStatus::UserModified => ("~", "user modified"),
503                FileStatus::Partial => ("~", "partial install"),
504                FileStatus::UpdateAvail => ("↑", "update available"),
505                FileStatus::UpToDate => ("✓", "up-to-date"),
506                FileStatus::NotInstalled => ("✗", "not installed"),
507            };
508
509            rows.push(AppRow {
510                category: cat.name.clone(),
511                sym,
512                label: cat.name.clone(),
513                simple_label: cat.name.clone(),
514                dest: dest_display,
515                status_text,
516                file_status: cat_status,
517            });
518        }
519    }
520
521    Ok(rows)
522}
523
524pub(crate) async fn app_file_row_status(
525    config: &Config,
526    cat: &AppCategory,
527    file: &crate::apps::AppFile,
528    manifest: &AppManifest,
529    env: &BTreeMap<String, String>,
530) -> (Option<std::path::PathBuf>, FileStatus) {
531    let assessment = assess_app_file(config, cat, file, manifest, env).await;
532    (assessment.destination, assessment.status)
533}
534
535pub(crate) async fn assess_app_file(
536    config: &Config,
537    cat: &AppCategory,
538    file: &crate::apps::AppFile,
539    manifest: &AppManifest,
540    env: &BTreeMap<String, String>,
541) -> AppFileAssessment {
542    match resolve_install_destination(cat, file, config) {
543        Err(_) => AppFileAssessment {
544            destination: None,
545            status: FileStatus::NotInstalled,
546            changes: Vec::new(),
547        },
548        Ok(dest) => {
549            let source = format!("app/{}/{}", cat.name, file.source_rel.display());
550            let installed_category = manifest.entries.iter().any(|entry| {
551                entry
552                    .source
553                    .strip_prefix("app/")
554                    .and_then(|source| source.split_once('/'))
555                    .is_some_and(|(category, _)| category == cat.name)
556            });
557            let mut changes = Vec::new();
558            let status = match manifest.find_by_dest(&dest) {
559                Some(entry) => {
560                    let status = app_entry_status(config, cat, file, entry, env).await;
561                    if status == FileStatus::UpdateAvail {
562                        changes.push(UpdateChange::ContentChanged);
563                    }
564                    status
565                }
566                None => match manifest.find_by_source(&source) {
567                    Some(entry)
568                        if file
569                            .generator
570                            .as_ref()
571                            .is_some_and(|generator| !generator.auto) =>
572                    {
573                        return AppFileAssessment {
574                            destination: Some(entry.destination.clone()),
575                            status: app_entry_status(config, cat, file, entry, env).await,
576                            changes: Vec::new(),
577                        };
578                    }
579                    Some(entry) => {
580                        changes.push(UpdateChange::DestinationRelocated {
581                            from: entry.destination.clone(),
582                            to: dest.clone(),
583                        });
584                        if file
585                            .generator
586                            .as_ref()
587                            .is_none_or(|generator| generator.auto)
588                            && source_hash_for_file(config, cat, file, env)
589                                .await
590                                .is_some_and(|hash| hash != entry.content_hash)
591                        {
592                            changes.push(UpdateChange::ContentChanged);
593                        }
594                        FileStatus::UpdateAvail
595                    }
596                    None if installed_category
597                        && file
598                            .generator
599                            .as_ref()
600                            .is_none_or(|generator| generator.auto) =>
601                    {
602                        if source_hash_for_file(config, cat, file, env).await.is_some() {
603                            changes.push(UpdateChange::NewFile {
604                                destination: dest.clone(),
605                            });
606                            FileStatus::UpdateAvail
607                        } else {
608                            FileStatus::NotInstalled
609                        }
610                    }
611                    None => FileStatus::NotInstalled,
612                },
613            };
614            AppFileAssessment {
615                destination: Some(dest),
616                status,
617                changes,
618            }
619        }
620    }
621}
622
623/// Computes the status of an already-resolved manifest entry: compares its
624/// recorded content hash against what's currently on disk at
625/// `entry.destination`, and (if unchanged) against the current preset
626/// source to detect an available update.
627///
628/// Shared by `app_file_row_status` (used by `list`/`app info`) and `info`'s
629/// `collect_app_files` — both need this exact computation once an `AppEntry`
630/// has been resolved.
631pub(crate) async fn app_entry_status(
632    config: &Config,
633    cat: &AppCategory,
634    file: &crate::apps::AppFile,
635    entry: &AppEntry,
636    env: &BTreeMap<String, String>,
637) -> FileStatus {
638    // Generators are intentionally polled on every status/update pass, even
639    // when the installed destination was edited. Static sources keep the
640    // cheaper existing behavior and are read only after ownership is proven.
641    let generator_enabled = file
642        .generator
643        .as_ref()
644        .is_some_and(|generator| generator.auto && env.contains_key(&generator.when_env));
645    let manual_generator = file
646        .generator
647        .as_ref()
648        .is_some_and(|generator| !generator.auto);
649    let generated_source_hash = if generator_enabled {
650        source_hash_for_file(config, cat, file, env).await
651    } else {
652        None
653    };
654    if !entry.destination.exists() {
655        return FileStatus::Missing;
656    }
657    match tokio::fs::read(&entry.destination).await {
658        Err(_) => FileStatus::Missing,
659        Ok(dest_bytes) => {
660            let manifest_hash = entry.content_hash;
661            match installed_content_hash(file, &dest_bytes) {
662                Ok(Some(dest_hash)) if dest_hash == manifest_hash => {
663                    if manual_generator {
664                        return FileStatus::UpToDate;
665                    }
666                    let source_hash = if generator_enabled {
667                        generated_source_hash
668                    } else {
669                        source_hash_for_file(config, cat, file, env).await
670                    };
671                    match source_hash {
672                        Some(src) if src != manifest_hash => FileStatus::UpdateAvail,
673                        _ => FileStatus::UpToDate,
674                    }
675                }
676                Ok(None) => FileStatus::Missing,
677                Ok(Some(_)) | Err(_) => FileStatus::UserModified,
678            }
679        }
680    }
681}
682
683#[cfg(test)]
684mod tests {
685    use super::*;
686    use crate::apps::AppFile;
687    use crate::config::Config;
688    use crate::install_core::AppInstallStrategy;
689    #[cfg(windows)]
690    use crate::test_support::env_lock;
691    use std::path::PathBuf;
692    use tokio::fs;
693
694    async fn make_temp_dir() -> std::path::PathBuf {
695        crate::test_support::make_temp_dir("shine-check").await
696    }
697
698    fn sample_app_file() -> AppFile {
699        AppFile {
700            source_rel: PathBuf::from("dest.txt"),
701            target_rel: PathBuf::from("dest.txt"),
702            destination_root: None,
703            description: None,
704            display_name: None,
705            legacy_dest_annotation: None,
706            transforms: vec![],
707            install_strategy: AppInstallStrategy::Copy,
708            requires_admin: false,
709            restart_hint: None,
710            generator: None,
711        }
712    }
713
714    fn sample_app_category() -> AppCategory {
715        AppCategory {
716            name: "sample".to_string(),
717            description: None,
718            destination_root: None,
719            files: vec![sample_app_file()],
720            list_mode: AppListMode::Files,
721            post_upgrade: Vec::new(),
722            post_install: Vec::new(),
723            uses_metadata: true,
724            has_explicit_files: true,
725            artifact: None,
726        }
727    }
728
729    fn sample_app_entry(destination: PathBuf, content_hash: u64) -> AppEntry {
730        AppEntry {
731            source: "app/sample/dest.txt".to_string(),
732            destination,
733            backup: None,
734            content_hash,
735            install_strategy: AppInstallStrategy::Copy,
736            uses_env: false,
737            requires_admin: false,
738        }
739    }
740
741    #[tokio::test]
742    async fn app_entry_status_reports_missing_when_destination_absent() {
743        let dir = make_temp_dir().await;
744        let config = Config::new_for_test(&dir);
745        let dest = dir.join("dest.txt");
746        let entry = sample_app_entry(dest, crate::install_core::hash_content(b"hello"));
747
748        let status = app_entry_status(
749            &config,
750            &sample_app_category(),
751            &sample_app_file(),
752            &entry,
753            &BTreeMap::new(),
754        )
755        .await;
756
757        assert_eq!(status, FileStatus::Missing);
758        fs::remove_dir_all(&dir).await.unwrap();
759    }
760
761    #[tokio::test]
762    async fn app_entry_status_reports_user_modified_when_dest_hash_differs() {
763        let dir = make_temp_dir().await;
764        let config = Config::new_for_test(&dir);
765        let dest = dir.join("dest.txt");
766        fs::write(&dest, b"locally edited").await.unwrap();
767        let entry = sample_app_entry(dest, crate::install_core::hash_content(b"original"));
768
769        let status = app_entry_status(
770            &config,
771            &sample_app_category(),
772            &sample_app_file(),
773            &entry,
774            &BTreeMap::new(),
775        )
776        .await;
777
778        assert_eq!(status, FileStatus::UserModified);
779        fs::remove_dir_all(&dir).await.unwrap();
780    }
781
782    #[tokio::test]
783    async fn app_entry_status_reports_up_to_date_when_source_unreadable() {
784        // No embedded/external source exists for the synthetic "sample"
785        // category, so source_hash_for_file returns None and the status
786        // falls back to UpToDate once the dest hash matches the manifest.
787        let dir = make_temp_dir().await;
788        let config = Config::new_for_test(&dir);
789        let dest = dir.join("dest.txt");
790        fs::write(&dest, b"hello").await.unwrap();
791        let entry = sample_app_entry(dest, crate::install_core::hash_content(b"hello"));
792
793        let status = app_entry_status(
794            &config,
795            &sample_app_category(),
796            &sample_app_file(),
797            &entry,
798            &BTreeMap::new(),
799        )
800        .await;
801
802        assert_eq!(status, FileStatus::UpToDate);
803        fs::remove_dir_all(&dir).await.unwrap();
804    }
805
806    #[tokio::test]
807    async fn app_entry_status_reports_update_available_when_source_changed() {
808        let dir = make_temp_dir().await;
809        let mut config = Config::new_for_test(&dir);
810        config.is_external_presets = true;
811
812        let source_path = config.preset_path(Path::new("app").join("sample").join("dest.txt"));
813        fs::create_dir_all(source_path.parent().unwrap())
814            .await
815            .unwrap();
816        fs::write(&source_path, b"new upstream content")
817            .await
818            .unwrap();
819
820        let dest = dir.join("dest.txt");
821        fs::write(&dest, b"hello").await.unwrap();
822        let entry = sample_app_entry(dest, crate::install_core::hash_content(b"hello"));
823
824        let category = AppCategory {
825            destination_root: Some(dir.display().to_string()),
826            ..sample_app_category()
827        };
828        let assessment = assess_app_file(
829            &config,
830            &category,
831            &sample_app_file(),
832            &AppManifest {
833                entries: vec![entry],
834            },
835            &BTreeMap::new(),
836        )
837        .await;
838
839        assert_eq!(assessment.status, FileStatus::UpdateAvail);
840        assert_eq!(assessment.changes, vec![UpdateChange::ContentChanged]);
841        fs::remove_dir_all(&dir).await.unwrap();
842    }
843
844    #[tokio::test]
845    async fn app_file_row_status_reports_not_installed_without_manifest_entry() {
846        let dir = make_temp_dir().await;
847        let config = Config::new_for_test(&dir);
848        let manifest = AppManifest::default();
849        let category = AppCategory {
850            destination_root: Some(dir.display().to_string()),
851            ..sample_app_category()
852        };
853
854        let (dest, status) = app_file_row_status(
855            &config,
856            &category,
857            &sample_app_file(),
858            &manifest,
859            &BTreeMap::new(),
860        )
861        .await;
862
863        assert!(dest.is_some());
864        assert_eq!(status, FileStatus::NotInstalled);
865        fs::remove_dir_all(&dir).await.unwrap();
866    }
867
868    #[tokio::test]
869    async fn app_file_row_status_reports_new_file_in_installed_category_as_update() {
870        let dir = make_temp_dir().await;
871        let mut config = Config::new_for_test(&dir);
872        config.is_external_presets = true;
873        let source_dir = config.preset_path(Path::new("app/sample"));
874        fs::create_dir_all(&source_dir).await.unwrap();
875        fs::write(source_dir.join("new.txt"), b"new").await.unwrap();
876
877        let mut file = sample_app_file();
878        file.source_rel = PathBuf::from("new.txt");
879        file.target_rel = PathBuf::from("new.txt");
880        let category = AppCategory {
881            destination_root: Some(dir.join("dest").display().to_string()),
882            files: vec![file.clone()],
883            ..sample_app_category()
884        };
885        let manifest = AppManifest {
886            entries: vec![sample_app_entry(
887                dir.join("dest/old.txt"),
888                crate::install_core::hash_content(b"old"),
889            )],
890        };
891
892        let assessment =
893            assess_app_file(&config, &category, &file, &manifest, &BTreeMap::new()).await;
894
895        assert_eq!(assessment.status, FileStatus::UpdateAvail);
896        assert_eq!(
897            assessment.changes,
898            vec![UpdateChange::NewFile {
899                destination: dir.join("dest/new.txt")
900            }]
901        );
902        fs::remove_dir_all(&dir).await.unwrap();
903    }
904
905    #[tokio::test]
906    async fn app_file_row_status_reports_destination_move_as_update() {
907        let dir = make_temp_dir().await;
908        let mut config = Config::new_for_test(&dir);
909        config.is_external_presets = true;
910        let source_dir = config.preset_path(Path::new("app/sample"));
911        fs::create_dir_all(&source_dir).await.unwrap();
912        fs::write(source_dir.join("dest.txt"), b"managed")
913            .await
914            .unwrap();
915
916        let old_destination = dir.join("old/dest.txt");
917        let category = AppCategory {
918            destination_root: Some(dir.join("new").display().to_string()),
919            ..sample_app_category()
920        };
921        let manifest = AppManifest {
922            entries: vec![sample_app_entry(
923                old_destination,
924                crate::install_core::hash_content(b"managed"),
925            )],
926        };
927
928        let assessment = assess_app_file(
929            &config,
930            &category,
931            &category.files[0],
932            &manifest,
933            &BTreeMap::new(),
934        )
935        .await;
936
937        assert_eq!(assessment.status, FileStatus::UpdateAvail);
938        assert_eq!(
939            assessment.changes,
940            vec![UpdateChange::DestinationRelocated {
941                from: dir.join("old/dest.txt"),
942                to: dir.join("new/dest.txt"),
943            }]
944        );
945        fs::remove_dir_all(&dir).await.unwrap();
946    }
947
948    #[tokio::test]
949    async fn manual_generator_destination_move_preserves_installed_snapshot() {
950        let dir = make_temp_dir().await;
951        let mut config = Config::new_for_test(&dir);
952        config.is_external_presets = true;
953
954        let source_dir = config.preset_path(Path::new("app/sample"));
955        fs::create_dir_all(&source_dir).await.unwrap();
956        fs::write(source_dir.join("dest.txt"), b"static fallback")
957            .await
958            .unwrap();
959        fs::write(source_dir.join("generate.sh"), b"#!/bin/sh\n")
960            .await
961            .unwrap();
962        fs::write(
963            source_dir.join("shine.toml"),
964            format!(
965                "dest = {:?}\n\n[[files]]\nsource = \"dest.txt\"\ntarget = \"dest.txt\"\ngenerator = {{ script = \"generate.sh\", env = [\"SOURCE_URL\"], when_env = \"SOURCE_URL\", auto = false }}\n",
966                dir.join("new").display().to_string()
967            ),
968        )
969        .await
970        .unwrap();
971
972        let old_destination = dir.join("old/dest.txt");
973        fs::create_dir_all(old_destination.parent().unwrap())
974            .await
975            .unwrap();
976        fs::write(&old_destination, b"generated snapshot")
977            .await
978            .unwrap();
979
980        let mut categories = crate::apps::load_active_categories(&config, Some("sample"))
981            .await
982            .unwrap();
983        let category = categories.remove(0);
984        let file = category.files[0].clone();
985        let manifest = AppManifest {
986            entries: vec![sample_app_entry(
987                old_destination.clone(),
988                crate::install_core::hash_content(b"generated snapshot"),
989            )],
990        };
991
992        let assessment =
993            assess_app_file(&config, &category, &file, &manifest, &BTreeMap::new()).await;
994
995        assert_eq!(assessment.destination, Some(old_destination));
996        assert_eq!(assessment.status, FileStatus::UpToDate);
997        assert!(assessment.changes.is_empty());
998        fs::remove_dir_all(&dir).await.unwrap();
999    }
1000
1001    #[tokio::test]
1002    async fn app_destination_move_can_also_report_content_change() {
1003        let dir = make_temp_dir().await;
1004        let mut config = Config::new_for_test(&dir);
1005        config.is_external_presets = true;
1006        let source_dir = config.preset_path(Path::new("app/sample"));
1007        fs::create_dir_all(&source_dir).await.unwrap();
1008        fs::write(source_dir.join("dest.txt"), b"new content")
1009            .await
1010            .unwrap();
1011
1012        let category = AppCategory {
1013            destination_root: Some(dir.join("new").display().to_string()),
1014            ..sample_app_category()
1015        };
1016        let manifest = AppManifest {
1017            entries: vec![sample_app_entry(
1018                dir.join("old/dest.txt"),
1019                crate::install_core::hash_content(b"old content"),
1020            )],
1021        };
1022
1023        let assessment = assess_app_file(
1024            &config,
1025            &category,
1026            &category.files[0],
1027            &manifest,
1028            &BTreeMap::new(),
1029        )
1030        .await;
1031
1032        assert_eq!(assessment.status, FileStatus::UpdateAvail);
1033        assert_eq!(
1034            assessment.changes,
1035            vec![
1036                UpdateChange::DestinationRelocated {
1037                    from: dir.join("old/dest.txt"),
1038                    to: dir.join("new/dest.txt"),
1039                },
1040                UpdateChange::ContentChanged,
1041            ]
1042        );
1043        fs::remove_dir_all(&dir).await.unwrap();
1044    }
1045
1046    #[cfg(not(unix))]
1047    #[tokio::test]
1048    async fn installed_shell_rows_use_windows_shim_path() {
1049        let dir = make_temp_dir().await;
1050        let cat_dir = dir.join("presets/shell/proxy");
1051        fs::create_dir_all(&cat_dir).await.unwrap();
1052        fs::write(
1053            cat_dir.join("shine.toml"),
1054            b"[[files]]\nsource = \"set_proxy.ps1\"\ntarget = \"setproxy\"\nneeds_source = true\n",
1055        )
1056        .await
1057        .unwrap();
1058        fs::write(cat_dir.join("set_proxy.ps1"), b"Write-Output proxy\n")
1059            .await
1060            .unwrap();
1061
1062        let mut config = Config::new_for_test(&dir);
1063        config.is_external_presets = true;
1064        fs::create_dir_all(config.bin_dir()).await.unwrap();
1065        fs::write(config.bin_dir().join("setproxy.ps1"), b"# shine-managed\n")
1066            .await
1067            .unwrap();
1068
1069        let rows = build_shell_rows(&config).await.unwrap();
1070        let row = rows
1071            .iter()
1072            .find(|row| row.label == "proxy/setproxy")
1073            .expect("proxy/setproxy row should exist");
1074
1075        assert_eq!(row.status_sym, "✓");
1076        assert_eq!(row.status_text, "up-to-date");
1077        assert!(row.is_installed);
1078
1079        fs::remove_dir_all(&dir).await.unwrap();
1080    }
1081
1082    #[cfg(unix)]
1083    #[tokio::test]
1084    async fn installed_shell_rows_report_up_to_date() {
1085        let dir = make_temp_dir().await;
1086        let cat_dir = dir.join("presets/shell/proxy");
1087        fs::create_dir_all(&cat_dir).await.unwrap();
1088        fs::write(
1089            cat_dir.join("shine.toml"),
1090            b"[[files]]\nsource = \"set_proxy.sh\"\ntarget = \"setproxy\"\nneeds_source = true\n",
1091        )
1092        .await
1093        .unwrap();
1094        let script = cat_dir.join("set_proxy.sh");
1095        fs::write(&script, b"#!/bin/bash\necho proxy\n")
1096            .await
1097            .unwrap();
1098        #[cfg(unix)]
1099        {
1100            use std::os::unix::fs::PermissionsExt;
1101            let mut perms = fs::metadata(&script).await.unwrap().permissions();
1102            perms.set_mode(0o755);
1103            fs::set_permissions(&script, perms).await.unwrap();
1104        }
1105
1106        let mut config = Config::new_for_test(&dir);
1107        config.is_external_presets = true;
1108        fs::create_dir_all(config.bin_dir()).await.unwrap();
1109
1110        crate::shells::handle_install(&config, Some("proxy"), false)
1111            .await
1112            .unwrap();
1113
1114        let rows = build_shell_rows(&config).await.unwrap();
1115        let row = rows
1116            .iter()
1117            .find(|row| row.label == "proxy/setproxy")
1118            .expect("proxy/setproxy row should exist");
1119
1120        assert_eq!(row.status_sym, "✓");
1121        assert_eq!(row.status_text, "up-to-date");
1122
1123        fs::remove_dir_all(&dir).await.unwrap();
1124    }
1125
1126    #[cfg(unix)]
1127    #[tokio::test]
1128    async fn missing_shell_command_entry_is_an_update_reason() {
1129        let dir = make_temp_dir().await;
1130        let category = dir.join("presets/shell/custom");
1131        fs::create_dir_all(&category).await.unwrap();
1132        fs::write(
1133            category.join("shine.toml"),
1134            b"[[files]]\nsource = \"tool.sh\"\ntarget = \"mytool\"\n",
1135        )
1136        .await
1137        .unwrap();
1138        fs::write(category.join("tool.sh"), b"#!/bin/sh\necho same\n")
1139            .await
1140            .unwrap();
1141
1142        let mut config = Config::new_for_test(&dir);
1143        config.is_external_presets = true;
1144        fs::create_dir_all(config.bin_dir()).await.unwrap();
1145        crate::shells::handle_install(&config, Some("custom"), false)
1146            .await
1147            .unwrap();
1148        fs::remove_file(config.bin_dir().join("mytool"))
1149            .await
1150            .unwrap();
1151
1152        let rows = build_shell_rows(&config).await.unwrap();
1153        let row = rows
1154            .iter()
1155            .find(|row| row.label == "custom/mytool")
1156            .unwrap();
1157        assert_eq!(row.status_text, "update available");
1158        assert_eq!(
1159            row.changes,
1160            vec![UpdateChange::CommandEntryMissing {
1161                path: config.bin_dir().join("mytool"),
1162            }]
1163        );
1164
1165        fs::remove_file(config.shine_dir().join("shell-manifest.toml"))
1166            .await
1167            .unwrap();
1168        let rows = build_shell_rows(&config).await.unwrap();
1169        let row = rows
1170            .iter()
1171            .find(|row| row.label == "custom/mytool")
1172            .unwrap();
1173        assert!(!row.is_installed);
1174        assert_eq!(row.status_text, "not installed");
1175        assert!(row.changes.is_empty());
1176
1177        fs::remove_dir_all(&dir).await.unwrap();
1178    }
1179
1180    #[cfg(unix)]
1181    #[tokio::test]
1182    async fn external_template_shell_change_reports_update_available() {
1183        let dir = make_temp_dir().await;
1184        let cat_dir = dir.join("presets/shell/proxy");
1185        fs::create_dir_all(&cat_dir).await.unwrap();
1186        fs::write(
1187            cat_dir.join("shine.toml"),
1188            b"[[files]]\nsource = \"set_proxy.sh\"\ntarget = \"setproxy\"\nneeds_source = true\n",
1189        )
1190        .await
1191        .unwrap();
1192        let script = cat_dir.join("set_proxy.sh");
1193        fs::write(
1194            &script,
1195            b"#!/bin/bash\n# shine-template: true\necho @@PROXY_HOST@@\n",
1196        )
1197        .await
1198        .unwrap();
1199
1200        let mut config = Config::new_for_test(&dir);
1201        config.is_external_presets = true;
1202        fs::create_dir_all(config.bin_dir()).await.unwrap();
1203
1204        crate::shells::handle_install(&config, Some("proxy"), false)
1205            .await
1206            .unwrap();
1207
1208        fs::write(
1209            &script,
1210            b"#!/bin/bash\n# shine-template: true\necho changed @@PROXY_HOST@@\n",
1211        )
1212        .await
1213        .unwrap();
1214
1215        let rows = build_shell_rows(&config).await.unwrap();
1216        let row = rows
1217            .iter()
1218            .find(|row| row.label == "proxy/setproxy")
1219            .expect("proxy/setproxy row should exist");
1220
1221        assert_eq!(row.status_sym, "↑");
1222        assert_eq!(row.status_text, "update available");
1223
1224        fs::remove_dir_all(&dir).await.unwrap();
1225    }
1226
1227    #[cfg(unix)]
1228    #[tokio::test]
1229    async fn live_raw_shell_change_stays_live_and_current() {
1230        let dir = make_temp_dir().await;
1231        let cat_dir = dir.join("presets/shell/custom");
1232        fs::create_dir_all(&cat_dir).await.unwrap();
1233        fs::write(
1234            cat_dir.join("shine.toml"),
1235            b"[[files]]\nsource = \"tool.sh\"\ntarget = \"mytool\"\n",
1236        )
1237        .await
1238        .unwrap();
1239        let source = cat_dir.join("tool.sh");
1240        fs::write(&source, b"#!/bin/sh\necho first\n")
1241            .await
1242            .unwrap();
1243
1244        let mut config = Config::new_for_test(&dir);
1245        config.is_external_presets = true;
1246        config.external_shell_mode = crate::config::ExternalShellMode::Live;
1247        fs::create_dir_all(config.bin_dir()).await.unwrap();
1248        crate::shells::handle_install(&config, Some("custom"), false)
1249            .await
1250            .unwrap();
1251        fs::write(&source, b"#!/bin/sh\necho second\n")
1252            .await
1253            .unwrap();
1254
1255        let rows = build_shell_rows(&config).await.unwrap();
1256        let row = rows
1257            .iter()
1258            .find(|row| row.label == "custom/mytool")
1259            .unwrap();
1260        assert_eq!(row.status_sym, "✓");
1261        assert_eq!(row.status_text, "live source");
1262        fs::remove_dir_all(&dir).await.unwrap();
1263    }
1264
1265    #[cfg(unix)]
1266    #[tokio::test]
1267    async fn live_overlay_root_rename_reports_source_relocation_without_content_change() {
1268        let dir = make_temp_dir().await;
1269        let old_overlay = dir.join("shineOverlay");
1270        let new_overlay = dir.join("shineOverlayTest");
1271        let old_category = old_overlay.join("shell/custom");
1272        fs::create_dir_all(&old_category).await.unwrap();
1273        fs::write(
1274            old_category.join("shine.toml"),
1275            b"[[files]]\nsource = \"tool.sh\"\ntarget = \"mytool\"\n",
1276        )
1277        .await
1278        .unwrap();
1279        fs::write(old_category.join("tool.sh"), b"#!/bin/sh\necho same\n")
1280            .await
1281            .unwrap();
1282
1283        let mut old_config =
1284            Config::new_for_test(&dir).with_presets_overlay_dir_override(Some(old_overlay.clone()));
1285        old_config.is_external_presets = true;
1286        old_config.external_shell_mode = crate::config::ExternalShellMode::Live;
1287        fs::create_dir_all(old_config.bin_dir()).await.unwrap();
1288        crate::shells::handle_install(&old_config, Some("custom"), false)
1289            .await
1290            .unwrap();
1291
1292        fs::rename(&old_overlay, &new_overlay).await.unwrap();
1293        let mut new_config =
1294            Config::new_for_test(&dir).with_presets_overlay_dir_override(Some(new_overlay.clone()));
1295        new_config.is_external_presets = true;
1296        new_config.external_shell_mode = crate::config::ExternalShellMode::Live;
1297
1298        let rows = build_shell_rows(&new_config).await.unwrap();
1299        let row = rows
1300            .iter()
1301            .find(|row| row.label == "custom/mytool")
1302            .unwrap();
1303        assert_eq!(row.status_text, "update available");
1304        assert_eq!(
1305            row.changes,
1306            vec![UpdateChange::SourceRelocated {
1307                from: old_overlay.join("shell/custom/tool.sh"),
1308                to: new_overlay.join("shell/custom/tool.sh"),
1309            }]
1310        );
1311
1312        fs::write(
1313            new_overlay.join("shell/custom/tool.sh"),
1314            b"#!/bin/sh\necho changed\n",
1315        )
1316        .await
1317        .unwrap();
1318        let rows = build_shell_rows(&new_config).await.unwrap();
1319        let row = rows
1320            .iter()
1321            .find(|row| row.label == "custom/mytool")
1322            .unwrap();
1323        assert_eq!(
1324            row.changes,
1325            vec![
1326                UpdateChange::SourceRelocated {
1327                    from: old_overlay.join("shell/custom/tool.sh"),
1328                    to: new_overlay.join("shell/custom/tool.sh"),
1329                },
1330                UpdateChange::ContentChanged,
1331            ]
1332        );
1333
1334        fs::remove_dir_all(&dir).await.unwrap();
1335    }
1336
1337    #[cfg(unix)]
1338    #[tokio::test]
1339    async fn snapshot_overlay_root_rename_with_same_bytes_stays_current() {
1340        let dir = make_temp_dir().await;
1341        let old_overlay = dir.join("shineOverlay");
1342        let new_overlay = dir.join("shineOverlayTest");
1343        let old_category = old_overlay.join("shell/custom");
1344        fs::create_dir_all(&old_category).await.unwrap();
1345        fs::write(
1346            old_category.join("shine.toml"),
1347            b"[[files]]\nsource = \"tool.sh\"\ntarget = \"mytool\"\n",
1348        )
1349        .await
1350        .unwrap();
1351        fs::write(old_category.join("tool.sh"), b"#!/bin/sh\necho same\n")
1352            .await
1353            .unwrap();
1354
1355        let mut old_config =
1356            Config::new_for_test(&dir).with_presets_overlay_dir_override(Some(old_overlay.clone()));
1357        old_config.is_external_presets = true;
1358        fs::create_dir_all(old_config.bin_dir()).await.unwrap();
1359        crate::shells::handle_install(&old_config, Some("custom"), false)
1360            .await
1361            .unwrap();
1362
1363        fs::rename(&old_overlay, &new_overlay).await.unwrap();
1364        let mut new_config =
1365            Config::new_for_test(&dir).with_presets_overlay_dir_override(Some(new_overlay));
1366        new_config.is_external_presets = true;
1367
1368        let rows = build_shell_rows(&new_config).await.unwrap();
1369        let row = rows
1370            .iter()
1371            .find(|row| row.label == "custom/mytool")
1372            .unwrap();
1373        assert_eq!(row.status_text, "up-to-date");
1374        assert!(row.changes.is_empty());
1375
1376        fs::remove_dir_all(&dir).await.unwrap();
1377    }
1378
1379    #[cfg(unix)]
1380    #[tokio::test]
1381    async fn shell_manifest_metadata_changes_are_reported_field_by_field() {
1382        use crate::shells::deployment::{ShellManifest, ShellManifestEntry};
1383        use std::os::unix::fs::symlink;
1384
1385        let dir = make_temp_dir().await;
1386        let category = dir.join("presets/shell/custom");
1387        fs::create_dir_all(&category).await.unwrap();
1388        fs::write(
1389            category.join("shine.toml"),
1390            b"[[files]]\nsource = \"tool.sh\"\ntarget = \"mytool\"\n",
1391        )
1392        .await
1393        .unwrap();
1394        let source = category.join("tool.sh");
1395        let bytes = b"#!/bin/sh\necho same\n";
1396        fs::write(&source, bytes).await.unwrap();
1397
1398        let mut config = Config::new_for_test(&dir);
1399        config.is_external_presets = true;
1400        config.external_shell_mode = crate::config::ExternalShellMode::Live;
1401        fs::create_dir_all(config.bin_dir()).await.unwrap();
1402        symlink(&source, config.bin_dir().join("mytool")).unwrap();
1403
1404        ShellManifest {
1405            entries: vec![ShellManifestEntry {
1406                category: "custom".to_string(),
1407                command: "mytool".to_string(),
1408                mode: crate::config::ExternalShellMode::Snapshot,
1409                source_path: source.clone(),
1410                rendered_path: config.rendered_dir().join("shell/custom/tool.sh"),
1411                runtime: "bun".to_string(),
1412                transforms: vec!["template".to_string()],
1413                env: vec!["OLD_KEY".to_string()],
1414                needs_source: true,
1415                content_hash: crate::install_core::hash_content(bytes),
1416            }],
1417        }
1418        .save(&config)
1419        .await
1420        .unwrap();
1421
1422        let rows = build_shell_rows(&config).await.unwrap();
1423        let row = rows
1424            .iter()
1425            .find(|row| row.label == "custom/mytool")
1426            .unwrap();
1427        assert_eq!(row.status_text, "update available");
1428        assert_eq!(
1429            row.changes,
1430            vec![
1431                UpdateChange::DeploymentChanged {
1432                    field: "mode",
1433                    from: "snapshot".to_string(),
1434                    to: "live".to_string(),
1435                },
1436                UpdateChange::DeploymentChanged {
1437                    field: "runtime",
1438                    from: "bun".to_string(),
1439                    to: "native".to_string(),
1440                },
1441                UpdateChange::DeploymentChanged {
1442                    field: "transforms",
1443                    from: "template".to_string(),
1444                    to: "none".to_string(),
1445                },
1446                UpdateChange::DeploymentChanged {
1447                    field: "env",
1448                    from: "OLD_KEY".to_string(),
1449                    to: "none".to_string(),
1450                },
1451                UpdateChange::DeploymentChanged {
1452                    field: "needs source",
1453                    from: "true".to_string(),
1454                    to: "false".to_string(),
1455                },
1456            ]
1457        );
1458
1459        fs::remove_dir_all(&dir).await.unwrap();
1460    }
1461
1462    #[tokio::test]
1463    async fn embedded_bun_source_change_reports_update_available() {
1464        let dir = make_temp_dir().await;
1465        let config = Config::new_for_test(&dir);
1466        fs::create_dir_all(config.presets_dir()).await.unwrap();
1467        fs::create_dir_all(config.bin_dir()).await.unwrap();
1468
1469        crate::shells::handle_install(&config, Some("agent"), false)
1470            .await
1471            .unwrap();
1472
1473        let extracted = config.presets_dir().join("shell/agent/cc.ts");
1474        fs::write(&extracted, b"// stale extracted ccenv\n")
1475            .await
1476            .unwrap();
1477
1478        let rows = build_shell_rows(&config).await.unwrap();
1479        let row = rows
1480            .iter()
1481            .find(|row| row.label == "agent/ccenv")
1482            .expect("agent/ccenv row should exist");
1483
1484        assert_eq!(row.status_sym, "↑");
1485        assert_eq!(row.status_text, "update available");
1486
1487        fs::remove_dir_all(&dir).await.unwrap();
1488    }
1489
1490    #[tokio::test]
1491    async fn embedded_shell_source_rename_reports_update_available() {
1492        let dir = make_temp_dir().await;
1493        let cat_dir = dir.join("presets/shell/agent");
1494        fs::create_dir_all(&cat_dir).await.unwrap();
1495        let old_source = if cfg!(windows) { "cc.ps1" } else { "cc.sh" };
1496        fs::write(
1497            cat_dir.join("shine.toml"),
1498            format!(
1499                "[[files]]\nsource = \"{old_source}\"\ntarget = \"ccenv\"\nneeds_source = true\n"
1500            ),
1501        )
1502        .await
1503        .unwrap();
1504        fs::write(cat_dir.join(old_source), b"# old sourced ccenv\n")
1505            .await
1506            .unwrap();
1507
1508        let mut config = Config::new_for_test(&dir);
1509        config.is_external_presets = true;
1510        fs::create_dir_all(config.bin_dir()).await.unwrap();
1511        crate::shells::handle_install(&config, Some("agent"), false)
1512            .await
1513            .unwrap();
1514
1515        config.is_external_presets = false;
1516        let rows = build_shell_rows(&config).await.unwrap();
1517        let row = rows
1518            .iter()
1519            .find(|row| row.label == "agent/ccenv")
1520            .expect("embedded agent/ccenv row should exist");
1521
1522        assert_eq!(row.status_sym, "↑");
1523        assert_eq!(row.status_text, "update available");
1524
1525        fs::remove_dir_all(&dir).await.unwrap();
1526    }
1527
1528    #[tokio::test]
1529    async fn external_shell_runtime_and_source_change_reports_update_available() {
1530        let dir = make_temp_dir().await;
1531        let cat_dir = dir.join("presets/shell/agent");
1532        fs::create_dir_all(&cat_dir).await.unwrap();
1533        let old_source = if cfg!(windows) { "cc.ps1" } else { "cc.sh" };
1534        fs::write(
1535            cat_dir.join("shine.toml"),
1536            format!(
1537                "[[files]]\nsource = \"{old_source}\"\ntarget = \"ccenv\"\nneeds_source = true\n"
1538            ),
1539        )
1540        .await
1541        .unwrap();
1542        fs::write(cat_dir.join(old_source), b"# old sourced ccenv\n")
1543            .await
1544            .unwrap();
1545
1546        let mut config = Config::new_for_test(&dir);
1547        config.is_external_presets = true;
1548        fs::create_dir_all(config.bin_dir()).await.unwrap();
1549        crate::shells::handle_install(&config, Some("agent"), false)
1550            .await
1551            .unwrap();
1552
1553        fs::write(
1554            cat_dir.join("shine.toml"),
1555            b"[[files]]\nsource = \"cc.ts\"\ntarget = \"ccenv\"\nruntime = \"bun\"\nplatforms = [\"unix\", \"windows\"]\n",
1556        )
1557        .await
1558        .unwrap();
1559        fs::write(cat_dir.join("cc.ts"), b"console.log('new ccenv');\n")
1560            .await
1561            .unwrap();
1562
1563        let rows = build_shell_rows(&config).await.unwrap();
1564        let row = rows
1565            .iter()
1566            .find(|row| row.label == "agent/ccenv")
1567            .expect("external agent/ccenv row should exist");
1568
1569        assert_eq!(row.status_sym, "↑");
1570        assert_eq!(row.status_text, "update available");
1571
1572        fs::remove_dir_all(&dir).await.unwrap();
1573    }
1574
1575    #[cfg(unix)]
1576    #[tokio::test]
1577    async fn shell_env_change_reports_update_available() {
1578        let dir = make_temp_dir().await;
1579        let cat_dir = dir.join("presets/shell/proxy");
1580        fs::create_dir_all(&cat_dir).await.unwrap();
1581        fs::write(
1582            cat_dir.join("shine.toml"),
1583            b"[[files]]\nsource = \"set_proxy.sh\"\ntarget = \"setproxy\"\nneeds_source = true\n",
1584        )
1585        .await
1586        .unwrap();
1587        fs::write(
1588            cat_dir.join("set_proxy.sh"),
1589            b"#!/bin/bash\n# shine-template: true\nPROXY_NO_PROXY=\"@@PROXY_NO_PROXY@@\"\n",
1590        )
1591        .await
1592        .unwrap();
1593
1594        let mut config = Config::new_for_test(&dir);
1595        config.is_external_presets = true;
1596        fs::create_dir_all(config.bin_dir()).await.unwrap();
1597
1598        crate::shells::handle_install(&config, Some("proxy"), false)
1599            .await
1600            .unwrap();
1601
1602        config.env.insert(
1603            "PROXY_NO_PROXY".to_string(),
1604            "localhost,127.0.0.1,::1,.local".to_string(),
1605        );
1606
1607        let rows = build_shell_rows(&config).await.unwrap();
1608        let row = rows
1609            .iter()
1610            .find(|row| row.label == "proxy/setproxy")
1611            .expect("proxy/setproxy row should exist");
1612
1613        assert_eq!(row.status_sym, "↑");
1614        assert_eq!(row.status_text, "update available");
1615
1616        fs::remove_dir_all(&dir).await.unwrap();
1617    }
1618
1619    #[tokio::test]
1620    async fn category_list_mode_aggregates_explicit_app_files() {
1621        let dir = make_temp_dir().await;
1622        let config = Config::new_for_test(&dir);
1623        fs::create_dir_all(config.shine_dir()).await.unwrap();
1624
1625        let category = AppCategory {
1626            name: "ghostty".to_string(),
1627            description: Some("Ghostty terminal configuration.".to_string()),
1628            destination_root: Some(dir.join(".config/ghostty").display().to_string()),
1629            files: vec![
1630                AppFile {
1631                    source_rel: PathBuf::from("config.ghostty"),
1632                    target_rel: PathBuf::from("config.ghostty"),
1633                    destination_root: None,
1634                    description: None,
1635                    display_name: None,
1636                    legacy_dest_annotation: None,
1637                    transforms: vec![],
1638                    install_strategy: AppInstallStrategy::Copy,
1639                    requires_admin: false,
1640                    restart_hint: None,
1641                    generator: None,
1642                },
1643                AppFile {
1644                    source_rel: PathBuf::from("themes/shine-light"),
1645                    target_rel: PathBuf::from("themes/shine-light"),
1646                    destination_root: None,
1647                    description: None,
1648                    display_name: None,
1649                    legacy_dest_annotation: None,
1650                    transforms: vec!["template".to_string()],
1651                    install_strategy: AppInstallStrategy::Copy,
1652                    requires_admin: false,
1653                    restart_hint: None,
1654                    generator: None,
1655                },
1656            ],
1657            list_mode: AppListMode::Category,
1658            post_upgrade: Vec::new(),
1659            post_install: Vec::new(),
1660            uses_metadata: true,
1661            has_explicit_files: true,
1662            artifact: None,
1663        };
1664
1665        let rows = build_app_rows(&config, &[category]).await.unwrap();
1666
1667        assert_eq!(rows.len(), 1);
1668        assert_eq!(rows[0].label, "ghostty");
1669        assert_eq!(rows[0].simple_label, "ghostty");
1670        assert_eq!(rows[0].dest.as_deref(), Some("~/.config/ghostty"));
1671        assert_eq!(rows[0].file_status, FileStatus::NotInstalled);
1672
1673        fs::remove_dir_all(&dir).await.unwrap();
1674    }
1675
1676    #[tokio::test]
1677    async fn file_list_mode_keeps_file_labels_for_multi_file_app_simple_list() {
1678        let dir = make_temp_dir().await;
1679        let config = Config::new_for_test(&dir);
1680        fs::create_dir_all(config.shine_dir()).await.unwrap();
1681
1682        let category = AppCategory {
1683            name: "sample".to_string(),
1684            description: None,
1685            destination_root: Some(dir.join(".config/sample").display().to_string()),
1686            files: vec![
1687                AppFile {
1688                    source_rel: PathBuf::from("config.toml"),
1689                    target_rel: PathBuf::from("config.toml"),
1690                    destination_root: None,
1691                    description: None,
1692                    display_name: None,
1693                    legacy_dest_annotation: None,
1694                    transforms: vec![],
1695                    install_strategy: AppInstallStrategy::Copy,
1696                    requires_admin: false,
1697                    restart_hint: None,
1698                    generator: None,
1699                },
1700                AppFile {
1701                    source_rel: PathBuf::from("theme.toml"),
1702                    target_rel: PathBuf::from("theme.toml"),
1703                    destination_root: None,
1704                    description: None,
1705                    display_name: None,
1706                    legacy_dest_annotation: None,
1707                    transforms: vec![],
1708                    install_strategy: AppInstallStrategy::Copy,
1709                    requires_admin: false,
1710                    restart_hint: None,
1711                    generator: None,
1712                },
1713            ],
1714            list_mode: AppListMode::Files,
1715            post_upgrade: Vec::new(),
1716            post_install: Vec::new(),
1717            uses_metadata: true,
1718            has_explicit_files: true,
1719            artifact: None,
1720        };
1721
1722        let rows = build_app_rows(&config, &[category]).await.unwrap();
1723
1724        assert_eq!(rows.len(), 2);
1725        assert_eq!(rows[0].label, "sample/config.toml");
1726        assert_eq!(rows[0].simple_label, "sample/config.toml");
1727        assert_eq!(rows[1].label, "sample/theme.toml");
1728        assert_eq!(rows[1].simple_label, "sample/theme.toml");
1729
1730        fs::remove_dir_all(&dir).await.unwrap();
1731    }
1732
1733    #[cfg(windows)]
1734    #[tokio::test]
1735    async fn windows_docker_engine_row_uses_engine_destination() {
1736        let _guard = env_lock();
1737        let dir = make_temp_dir().await;
1738        // SAFETY: env_lock() serialises all env-mutation tests in this module,
1739        // preventing concurrent writes to the process environment from other test threads.
1740        unsafe { std::env::set_var("HOME", dir.to_str().unwrap()) };
1741        let config = Config::new_for_test(&dir);
1742        fs::create_dir_all(config.shine_dir()).await.unwrap();
1743
1744        let categories = crate::apps::load_embedded_categories(Some("docker-engine")).unwrap();
1745        let rows = build_app_rows(&config, &categories).await.unwrap();
1746
1747        assert_eq!(rows.len(), 1);
1748        assert_eq!(rows[0].label, "docker-engine/daemon.jsonc");
1749        assert_eq!(rows[0].simple_label, "docker-engine");
1750        assert_eq!(rows[0].file_status, FileStatus::NotInstalled);
1751        assert_eq!(rows[0].dest.as_deref(), Some("~/.docker/daemon.json"));
1752
1753        // SAFETY: same env_lock() guard as above.
1754        unsafe { std::env::remove_var("HOME") };
1755        fs::remove_dir_all(&dir).await.unwrap();
1756    }
1757
1758    #[cfg(windows)]
1759    #[tokio::test]
1760    async fn windows_docker_desktop_row_uses_forward_slash_destination() {
1761        let _guard = env_lock();
1762        let dir = make_temp_dir().await;
1763        // SAFETY: env_lock() serialises all env-mutation tests in this module,
1764        // preventing concurrent writes to the process environment from other test threads.
1765        unsafe { std::env::set_var("HOME", dir.to_str().unwrap()) };
1766        let config = Config::new_for_test(&dir);
1767        fs::create_dir_all(config.shine_dir()).await.unwrap();
1768
1769        let categories = crate::apps::load_embedded_categories(Some("docker-desktop")).unwrap();
1770        let rows = build_app_rows(&config, &categories).await.unwrap();
1771
1772        assert_eq!(rows.len(), 1);
1773        assert_eq!(rows[0].label, "docker-desktop/settings-store.jsonc");
1774        assert_eq!(rows[0].simple_label, "docker-desktop");
1775        assert_eq!(rows[0].file_status, FileStatus::NotInstalled);
1776        assert_eq!(
1777            rows[0].dest.as_deref(),
1778            Some("~/AppData/Roaming/Docker/settings-store.json")
1779        );
1780
1781        // SAFETY: same env_lock() guard as above.
1782        unsafe { std::env::remove_var("HOME") };
1783        fs::remove_dir_all(&dir).await.unwrap();
1784    }
1785}