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