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_eq!(row.status_sym, "✓");
1110        assert_eq!(row.status_text, "up-to-date");
1111        assert!(row.is_installed);
1112
1113        fs::remove_dir_all(&dir).await.unwrap();
1114    }
1115
1116    #[cfg(unix)]
1117    #[tokio::test]
1118    async fn installed_shell_rows_report_up_to_date() {
1119        let dir = make_temp_dir().await;
1120        let cat_dir = dir.join("presets/shell/proxy");
1121        fs::create_dir_all(&cat_dir).await.unwrap();
1122        fs::write(
1123            cat_dir.join("shine.toml"),
1124            b"[[files]]\nsource = \"set_proxy.sh\"\ntarget = \"setproxy\"\nneeds_source = true\n",
1125        )
1126        .await
1127        .unwrap();
1128        let script = cat_dir.join("set_proxy.sh");
1129        fs::write(&script, b"#!/bin/bash\necho proxy\n")
1130            .await
1131            .unwrap();
1132        #[cfg(unix)]
1133        {
1134            use std::os::unix::fs::PermissionsExt;
1135            let mut perms = fs::metadata(&script).await.unwrap().permissions();
1136            perms.set_mode(0o755);
1137            fs::set_permissions(&script, perms).await.unwrap();
1138        }
1139
1140        let mut config = Config::new_for_test(&dir);
1141        config.is_external_presets = true;
1142        fs::create_dir_all(config.bin_dir()).await.unwrap();
1143
1144        crate::shells::handle_install(&config, Some("proxy"), false)
1145            .await
1146            .unwrap();
1147
1148        let rows = build_shell_rows(&config).await.unwrap();
1149        let row = rows
1150            .iter()
1151            .find(|row| row.label == "proxy/setproxy")
1152            .expect("proxy/setproxy row should exist");
1153
1154        assert_eq!(row.status_sym, "✓");
1155        assert_eq!(row.status_text, "up-to-date");
1156
1157        fs::remove_dir_all(&dir).await.unwrap();
1158    }
1159
1160    #[cfg(unix)]
1161    #[tokio::test]
1162    async fn missing_shell_command_entry_is_an_update_reason() {
1163        let dir = make_temp_dir().await;
1164        let category = dir.join("presets/shell/custom");
1165        fs::create_dir_all(&category).await.unwrap();
1166        fs::write(
1167            category.join("shine.toml"),
1168            b"[[files]]\nsource = \"tool.sh\"\ntarget = \"mytool\"\n",
1169        )
1170        .await
1171        .unwrap();
1172        fs::write(category.join("tool.sh"), b"#!/bin/sh\necho same\n")
1173            .await
1174            .unwrap();
1175
1176        let mut config = Config::new_for_test(&dir);
1177        config.is_external_presets = true;
1178        fs::create_dir_all(config.bin_dir()).await.unwrap();
1179        crate::shells::handle_install(&config, Some("custom"), false)
1180            .await
1181            .unwrap();
1182        fs::remove_file(config.bin_dir().join("mytool"))
1183            .await
1184            .unwrap();
1185
1186        let rows = build_shell_rows(&config).await.unwrap();
1187        let row = rows
1188            .iter()
1189            .find(|row| row.label == "custom/mytool")
1190            .unwrap();
1191        assert_eq!(row.status_text, "update available");
1192        assert_eq!(
1193            row.changes,
1194            vec![UpdateChange::CommandEntryMissing {
1195                path: config.bin_dir().join("mytool"),
1196            }]
1197        );
1198
1199        fs::remove_file(config.shine_dir().join("shell-manifest.toml"))
1200            .await
1201            .unwrap();
1202        let rows = build_shell_rows(&config).await.unwrap();
1203        let row = rows
1204            .iter()
1205            .find(|row| row.label == "custom/mytool")
1206            .unwrap();
1207        assert!(!row.is_installed);
1208        assert_eq!(row.status_text, "not installed");
1209        assert!(row.changes.is_empty());
1210
1211        fs::remove_dir_all(&dir).await.unwrap();
1212    }
1213
1214    #[cfg(unix)]
1215    #[tokio::test]
1216    async fn external_template_shell_change_reports_update_available() {
1217        let dir = make_temp_dir().await;
1218        let cat_dir = dir.join("presets/shell/proxy");
1219        fs::create_dir_all(&cat_dir).await.unwrap();
1220        fs::write(
1221            cat_dir.join("shine.toml"),
1222            b"[[files]]\nsource = \"set_proxy.sh\"\ntarget = \"setproxy\"\nneeds_source = true\n",
1223        )
1224        .await
1225        .unwrap();
1226        let script = cat_dir.join("set_proxy.sh");
1227        fs::write(
1228            &script,
1229            b"#!/bin/bash\n# shine-template: true\necho @@PROXY_HOST@@\n",
1230        )
1231        .await
1232        .unwrap();
1233
1234        let mut config = Config::new_for_test(&dir);
1235        config.is_external_presets = true;
1236        fs::create_dir_all(config.bin_dir()).await.unwrap();
1237
1238        crate::shells::handle_install(&config, Some("proxy"), false)
1239            .await
1240            .unwrap();
1241
1242        fs::write(
1243            &script,
1244            b"#!/bin/bash\n# shine-template: true\necho changed @@PROXY_HOST@@\n",
1245        )
1246        .await
1247        .unwrap();
1248
1249        let rows = build_shell_rows(&config).await.unwrap();
1250        let row = rows
1251            .iter()
1252            .find(|row| row.label == "proxy/setproxy")
1253            .expect("proxy/setproxy row should exist");
1254
1255        assert_eq!(row.status_sym, "↑");
1256        assert_eq!(row.status_text, "update available");
1257
1258        fs::remove_dir_all(&dir).await.unwrap();
1259    }
1260
1261    #[cfg(unix)]
1262    #[tokio::test]
1263    async fn live_raw_shell_change_stays_live_and_current() {
1264        let dir = make_temp_dir().await;
1265        let cat_dir = dir.join("presets/shell/custom");
1266        fs::create_dir_all(&cat_dir).await.unwrap();
1267        fs::write(
1268            cat_dir.join("shine.toml"),
1269            b"[[files]]\nsource = \"tool.sh\"\ntarget = \"mytool\"\n",
1270        )
1271        .await
1272        .unwrap();
1273        let source = cat_dir.join("tool.sh");
1274        fs::write(&source, b"#!/bin/sh\necho first\n")
1275            .await
1276            .unwrap();
1277
1278        let mut config = Config::new_for_test(&dir);
1279        config.is_external_presets = true;
1280        config.external_shell_mode = crate::config::ExternalShellMode::Live;
1281        fs::create_dir_all(config.bin_dir()).await.unwrap();
1282        crate::shells::handle_install(&config, Some("custom"), false)
1283            .await
1284            .unwrap();
1285        fs::write(&source, b"#!/bin/sh\necho second\n")
1286            .await
1287            .unwrap();
1288
1289        let rows = build_shell_rows(&config).await.unwrap();
1290        let row = rows
1291            .iter()
1292            .find(|row| row.label == "custom/mytool")
1293            .unwrap();
1294        assert_eq!(row.status_sym, "✓");
1295        assert_eq!(row.status_text, "live source");
1296        fs::remove_dir_all(&dir).await.unwrap();
1297    }
1298
1299    #[cfg(unix)]
1300    #[tokio::test]
1301    async fn live_overlay_root_rename_reports_source_relocation_without_content_change() {
1302        let dir = make_temp_dir().await;
1303        let old_overlay = dir.join("shineOverlay");
1304        let new_overlay = dir.join("shineOverlayTest");
1305        let old_category = old_overlay.join("shell/custom");
1306        fs::create_dir_all(&old_category).await.unwrap();
1307        fs::write(
1308            old_category.join("shine.toml"),
1309            b"[[files]]\nsource = \"tool.sh\"\ntarget = \"mytool\"\n",
1310        )
1311        .await
1312        .unwrap();
1313        fs::write(old_category.join("tool.sh"), b"#!/bin/sh\necho same\n")
1314            .await
1315            .unwrap();
1316
1317        let mut old_config =
1318            Config::new_for_test(&dir).with_presets_overlay_dir_override(Some(old_overlay.clone()));
1319        old_config.is_external_presets = true;
1320        old_config.external_shell_mode = crate::config::ExternalShellMode::Live;
1321        fs::create_dir_all(old_config.bin_dir()).await.unwrap();
1322        crate::shells::handle_install(&old_config, Some("custom"), false)
1323            .await
1324            .unwrap();
1325
1326        fs::rename(&old_overlay, &new_overlay).await.unwrap();
1327        let mut new_config =
1328            Config::new_for_test(&dir).with_presets_overlay_dir_override(Some(new_overlay.clone()));
1329        new_config.is_external_presets = true;
1330        new_config.external_shell_mode = crate::config::ExternalShellMode::Live;
1331
1332        let rows = build_shell_rows(&new_config).await.unwrap();
1333        let row = rows
1334            .iter()
1335            .find(|row| row.label == "custom/mytool")
1336            .unwrap();
1337        assert_eq!(row.status_text, "update available");
1338        assert_eq!(
1339            row.changes,
1340            vec![UpdateChange::SourceRelocated {
1341                from: old_overlay.join("shell/custom/tool.sh"),
1342                to: new_overlay.join("shell/custom/tool.sh"),
1343            }]
1344        );
1345
1346        fs::write(
1347            new_overlay.join("shell/custom/tool.sh"),
1348            b"#!/bin/sh\necho changed\n",
1349        )
1350        .await
1351        .unwrap();
1352        let rows = build_shell_rows(&new_config).await.unwrap();
1353        let row = rows
1354            .iter()
1355            .find(|row| row.label == "custom/mytool")
1356            .unwrap();
1357        assert_eq!(
1358            row.changes,
1359            vec![
1360                UpdateChange::SourceRelocated {
1361                    from: old_overlay.join("shell/custom/tool.sh"),
1362                    to: new_overlay.join("shell/custom/tool.sh"),
1363                },
1364                UpdateChange::ContentChanged,
1365            ]
1366        );
1367
1368        fs::remove_dir_all(&dir).await.unwrap();
1369    }
1370
1371    #[cfg(unix)]
1372    #[tokio::test]
1373    async fn snapshot_overlay_root_rename_with_same_bytes_stays_current() {
1374        let dir = make_temp_dir().await;
1375        let old_overlay = dir.join("shineOverlay");
1376        let new_overlay = dir.join("shineOverlayTest");
1377        let old_category = old_overlay.join("shell/custom");
1378        fs::create_dir_all(&old_category).await.unwrap();
1379        fs::write(
1380            old_category.join("shine.toml"),
1381            b"[[files]]\nsource = \"tool.sh\"\ntarget = \"mytool\"\n",
1382        )
1383        .await
1384        .unwrap();
1385        fs::write(old_category.join("tool.sh"), b"#!/bin/sh\necho same\n")
1386            .await
1387            .unwrap();
1388
1389        let mut old_config =
1390            Config::new_for_test(&dir).with_presets_overlay_dir_override(Some(old_overlay.clone()));
1391        old_config.is_external_presets = true;
1392        fs::create_dir_all(old_config.bin_dir()).await.unwrap();
1393        crate::shells::handle_install(&old_config, Some("custom"), false)
1394            .await
1395            .unwrap();
1396
1397        fs::rename(&old_overlay, &new_overlay).await.unwrap();
1398        let mut new_config =
1399            Config::new_for_test(&dir).with_presets_overlay_dir_override(Some(new_overlay));
1400        new_config.is_external_presets = true;
1401
1402        let rows = build_shell_rows(&new_config).await.unwrap();
1403        let row = rows
1404            .iter()
1405            .find(|row| row.label == "custom/mytool")
1406            .unwrap();
1407        assert_eq!(row.status_text, "up-to-date");
1408        assert!(row.changes.is_empty());
1409
1410        fs::remove_dir_all(&dir).await.unwrap();
1411    }
1412
1413    #[cfg(unix)]
1414    #[tokio::test]
1415    async fn shell_manifest_metadata_changes_are_reported_field_by_field() {
1416        use crate::shells::deployment::{ShellManifest, ShellManifestEntry};
1417        use std::os::unix::fs::symlink;
1418
1419        let dir = make_temp_dir().await;
1420        let category = dir.join("presets/shell/custom");
1421        fs::create_dir_all(&category).await.unwrap();
1422        fs::write(
1423            category.join("shine.toml"),
1424            b"[[files]]\nsource = \"tool.sh\"\ntarget = \"mytool\"\n",
1425        )
1426        .await
1427        .unwrap();
1428        let source = category.join("tool.sh");
1429        let bytes = b"#!/bin/sh\necho same\n";
1430        fs::write(&source, bytes).await.unwrap();
1431
1432        let mut config = Config::new_for_test(&dir);
1433        config.is_external_presets = true;
1434        config.external_shell_mode = crate::config::ExternalShellMode::Live;
1435        fs::create_dir_all(config.bin_dir()).await.unwrap();
1436        symlink(&source, config.bin_dir().join("mytool")).unwrap();
1437
1438        ShellManifest {
1439            entries: vec![ShellManifestEntry {
1440                category: "custom".to_string(),
1441                command: "mytool".to_string(),
1442                mode: crate::config::ExternalShellMode::Snapshot,
1443                source_path: source.clone(),
1444                rendered_path: config.rendered_dir().join("shell/custom/tool.sh"),
1445                runtime: "bun".to_string(),
1446                bun_dependencies: None,
1447                dependency_hash: None,
1448                transforms: vec!["template".to_string()],
1449                env: vec!["OLD_KEY".to_string()],
1450                needs_source: true,
1451                content_hash: crate::install_core::hash_content(bytes),
1452            }],
1453        }
1454        .save(&config)
1455        .await
1456        .unwrap();
1457
1458        let rows = build_shell_rows(&config).await.unwrap();
1459        let row = rows
1460            .iter()
1461            .find(|row| row.label == "custom/mytool")
1462            .unwrap();
1463        assert_eq!(row.status_text, "update available");
1464        assert_eq!(
1465            row.changes,
1466            vec![
1467                UpdateChange::DeploymentChanged {
1468                    field: "mode",
1469                    from: "snapshot".to_string(),
1470                    to: "live".to_string(),
1471                },
1472                UpdateChange::DeploymentChanged {
1473                    field: "runtime",
1474                    from: "bun".to_string(),
1475                    to: "native".to_string(),
1476                },
1477                UpdateChange::DeploymentChanged {
1478                    field: "transforms",
1479                    from: "template".to_string(),
1480                    to: "none".to_string(),
1481                },
1482                UpdateChange::DeploymentChanged {
1483                    field: "env",
1484                    from: "OLD_KEY".to_string(),
1485                    to: "none".to_string(),
1486                },
1487                UpdateChange::DeploymentChanged {
1488                    field: "needs source",
1489                    from: "true".to_string(),
1490                    to: "false".to_string(),
1491                },
1492            ]
1493        );
1494
1495        fs::remove_dir_all(&dir).await.unwrap();
1496    }
1497
1498    #[cfg(unix)]
1499    #[tokio::test]
1500    async fn external_bun_lock_change_reports_update_available() {
1501        let dir = make_temp_dir().await;
1502        let category = dir.join("presets/shell/custom");
1503        fs::create_dir_all(&category).await.unwrap();
1504        fs::write(
1505            category.join("shine.toml"),
1506            b"[[files]]\nsource = \"tool.ts\"\ntarget = \"mytool\"\nruntime = \"bun\"\n",
1507        )
1508        .await
1509        .unwrap();
1510        fs::write(category.join("tool.ts"), b"import 'zod'\n")
1511            .await
1512            .unwrap();
1513        fs::write(
1514            category.join("package.json"),
1515            b"{\"dependencies\":{\"zod\":\"4.0.0\"}}",
1516        )
1517        .await
1518        .unwrap();
1519        fs::write(category.join("bun.lock"), b"lockfileVersion = 1\n")
1520            .await
1521            .unwrap();
1522        let mut config = Config::new_for_test(&dir);
1523        config.is_external_presets = true;
1524        fs::create_dir_all(config.bin_dir()).await.unwrap();
1525        crate::shells::handle_install(&config, Some("custom"), false)
1526            .await
1527            .unwrap();
1528
1529        fs::write(
1530            category.join("bun.lock"),
1531            b"lockfileVersion = 1\n# dependency changed\n",
1532        )
1533        .await
1534        .unwrap();
1535        let rows = build_shell_rows(&config).await.unwrap();
1536        let row = rows
1537            .iter()
1538            .find(|row| row.label == "custom/mytool")
1539            .unwrap();
1540        assert_eq!(row.status_text, "update available");
1541        assert!(row.changes.iter().any(|change| matches!(
1542            change,
1543            UpdateChange::DeploymentChanged {
1544                field: "dependency lock",
1545                ..
1546            }
1547        )));
1548
1549        fs::remove_dir_all(&dir).await.unwrap();
1550    }
1551
1552    #[tokio::test]
1553    async fn embedded_bun_source_change_reports_update_available() {
1554        let dir = make_temp_dir().await;
1555        let config = Config::new_for_test(&dir);
1556        fs::create_dir_all(config.presets_dir()).await.unwrap();
1557        fs::create_dir_all(config.bin_dir()).await.unwrap();
1558
1559        crate::shells::handle_install(&config, Some("agent"), false)
1560            .await
1561            .unwrap();
1562
1563        let extracted = config.presets_dir().join("shell/agent/cc.ts");
1564        fs::write(&extracted, b"// stale extracted ccenv\n")
1565            .await
1566            .unwrap();
1567
1568        let rows = build_shell_rows(&config).await.unwrap();
1569        let row = rows
1570            .iter()
1571            .find(|row| row.label == "agent/ccenv")
1572            .expect("agent/ccenv row should exist");
1573
1574        assert_eq!(row.status_sym, "↑");
1575        assert_eq!(row.status_text, "update available");
1576
1577        fs::remove_dir_all(&dir).await.unwrap();
1578    }
1579
1580    #[tokio::test]
1581    async fn embedded_shell_source_rename_reports_update_available() {
1582        let dir = make_temp_dir().await;
1583        let cat_dir = dir.join("presets/shell/agent");
1584        fs::create_dir_all(&cat_dir).await.unwrap();
1585        let old_source = if cfg!(windows) { "cc.ps1" } else { "cc.sh" };
1586        fs::write(
1587            cat_dir.join("shine.toml"),
1588            format!(
1589                "[[files]]\nsource = \"{old_source}\"\ntarget = \"ccenv\"\nneeds_source = true\n"
1590            ),
1591        )
1592        .await
1593        .unwrap();
1594        fs::write(cat_dir.join(old_source), b"# old sourced ccenv\n")
1595            .await
1596            .unwrap();
1597
1598        let mut config = Config::new_for_test(&dir);
1599        config.is_external_presets = true;
1600        fs::create_dir_all(config.bin_dir()).await.unwrap();
1601        crate::shells::handle_install(&config, Some("agent"), false)
1602            .await
1603            .unwrap();
1604
1605        config.is_external_presets = false;
1606        let rows = build_shell_rows(&config).await.unwrap();
1607        let row = rows
1608            .iter()
1609            .find(|row| row.label == "agent/ccenv")
1610            .expect("embedded agent/ccenv row should exist");
1611
1612        assert_eq!(row.status_sym, "↑");
1613        assert_eq!(row.status_text, "update available");
1614
1615        fs::remove_dir_all(&dir).await.unwrap();
1616    }
1617
1618    #[tokio::test]
1619    async fn external_shell_runtime_and_source_change_reports_update_available() {
1620        let dir = make_temp_dir().await;
1621        let cat_dir = dir.join("presets/shell/agent");
1622        fs::create_dir_all(&cat_dir).await.unwrap();
1623        let old_source = if cfg!(windows) { "cc.ps1" } else { "cc.sh" };
1624        fs::write(
1625            cat_dir.join("shine.toml"),
1626            format!(
1627                "[[files]]\nsource = \"{old_source}\"\ntarget = \"ccenv\"\nneeds_source = true\n"
1628            ),
1629        )
1630        .await
1631        .unwrap();
1632        fs::write(cat_dir.join(old_source), b"# old sourced ccenv\n")
1633            .await
1634            .unwrap();
1635
1636        let mut config = Config::new_for_test(&dir);
1637        config.is_external_presets = true;
1638        fs::create_dir_all(config.bin_dir()).await.unwrap();
1639        crate::shells::handle_install(&config, Some("agent"), false)
1640            .await
1641            .unwrap();
1642
1643        fs::write(
1644            cat_dir.join("shine.toml"),
1645            b"[[files]]\nsource = \"cc.ts\"\ntarget = \"ccenv\"\nruntime = \"bun\"\nplatforms = [\"unix\", \"windows\"]\n",
1646        )
1647        .await
1648        .unwrap();
1649        fs::write(cat_dir.join("cc.ts"), b"console.log('new ccenv');\n")
1650            .await
1651            .unwrap();
1652
1653        let rows = build_shell_rows(&config).await.unwrap();
1654        let row = rows
1655            .iter()
1656            .find(|row| row.label == "agent/ccenv")
1657            .expect("external agent/ccenv row should exist");
1658
1659        assert_eq!(row.status_sym, "↑");
1660        assert_eq!(row.status_text, "update available");
1661
1662        fs::remove_dir_all(&dir).await.unwrap();
1663    }
1664
1665    #[cfg(unix)]
1666    #[tokio::test]
1667    async fn shell_env_change_reports_update_available() {
1668        let dir = make_temp_dir().await;
1669        let cat_dir = dir.join("presets/shell/proxy");
1670        fs::create_dir_all(&cat_dir).await.unwrap();
1671        fs::write(
1672            cat_dir.join("shine.toml"),
1673            b"[[files]]\nsource = \"set_proxy.sh\"\ntarget = \"setproxy\"\nneeds_source = true\n",
1674        )
1675        .await
1676        .unwrap();
1677        fs::write(
1678            cat_dir.join("set_proxy.sh"),
1679            b"#!/bin/bash\n# shine-template: true\nPROXY_NO_PROXY=\"@@PROXY_NO_PROXY@@\"\n",
1680        )
1681        .await
1682        .unwrap();
1683
1684        let mut config = Config::new_for_test(&dir);
1685        config.is_external_presets = true;
1686        fs::create_dir_all(config.bin_dir()).await.unwrap();
1687
1688        crate::shells::handle_install(&config, Some("proxy"), false)
1689            .await
1690            .unwrap();
1691
1692        config.env.insert(
1693            "PROXY_NO_PROXY".to_string(),
1694            "localhost,127.0.0.1,::1,.local".to_string(),
1695        );
1696
1697        let rows = build_shell_rows(&config).await.unwrap();
1698        let row = rows
1699            .iter()
1700            .find(|row| row.label == "proxy/setproxy")
1701            .expect("proxy/setproxy row should exist");
1702
1703        assert_eq!(row.status_sym, "↑");
1704        assert_eq!(row.status_text, "update available");
1705
1706        fs::remove_dir_all(&dir).await.unwrap();
1707    }
1708
1709    #[tokio::test]
1710    async fn category_list_mode_aggregates_explicit_app_files() {
1711        let dir = make_temp_dir().await;
1712        let config = Config::new_for_test(&dir);
1713        fs::create_dir_all(config.shine_dir()).await.unwrap();
1714
1715        let category = AppCategory {
1716            name: "ghostty".to_string(),
1717            description: Some("Ghostty terminal configuration.".to_string()),
1718            destination_root: Some(dir.join(".config/ghostty").display().to_string()),
1719            files: vec![
1720                AppFile {
1721                    source_rel: PathBuf::from("config.ghostty"),
1722                    target_rel: PathBuf::from("config.ghostty"),
1723                    destination_root: None,
1724                    description: None,
1725                    display_name: None,
1726                    legacy_dest_annotation: None,
1727                    transforms: vec![],
1728                    install_strategy: AppInstallStrategy::Copy,
1729                    requires_admin: false,
1730                    restart_hint: None,
1731                    generator: None,
1732                },
1733                AppFile {
1734                    source_rel: PathBuf::from("themes/shine-light"),
1735                    target_rel: PathBuf::from("themes/shine-light"),
1736                    destination_root: None,
1737                    description: None,
1738                    display_name: None,
1739                    legacy_dest_annotation: None,
1740                    transforms: vec!["template".to_string()],
1741                    install_strategy: AppInstallStrategy::Copy,
1742                    requires_admin: false,
1743                    restart_hint: None,
1744                    generator: None,
1745                },
1746            ],
1747            list_mode: AppListMode::Category,
1748            post_upgrade: Vec::new(),
1749            post_install: Vec::new(),
1750            uses_metadata: true,
1751            has_explicit_files: true,
1752            artifact: None,
1753        };
1754
1755        let rows = build_app_rows(&config, &[category]).await.unwrap();
1756
1757        assert_eq!(rows.len(), 1);
1758        assert_eq!(rows[0].label, "ghostty");
1759        assert_eq!(rows[0].simple_label, "ghostty");
1760        assert_eq!(rows[0].dest.as_deref(), Some("~/.config/ghostty"));
1761        assert_eq!(rows[0].file_status, FileStatus::NotInstalled);
1762
1763        fs::remove_dir_all(&dir).await.unwrap();
1764    }
1765
1766    #[tokio::test]
1767    async fn file_list_mode_keeps_file_labels_for_multi_file_app_simple_list() {
1768        let dir = make_temp_dir().await;
1769        let config = Config::new_for_test(&dir);
1770        fs::create_dir_all(config.shine_dir()).await.unwrap();
1771
1772        let category = AppCategory {
1773            name: "sample".to_string(),
1774            description: None,
1775            destination_root: Some(dir.join(".config/sample").display().to_string()),
1776            files: vec![
1777                AppFile {
1778                    source_rel: PathBuf::from("config.toml"),
1779                    target_rel: PathBuf::from("config.toml"),
1780                    destination_root: None,
1781                    description: None,
1782                    display_name: None,
1783                    legacy_dest_annotation: None,
1784                    transforms: vec![],
1785                    install_strategy: AppInstallStrategy::Copy,
1786                    requires_admin: false,
1787                    restart_hint: None,
1788                    generator: None,
1789                },
1790                AppFile {
1791                    source_rel: PathBuf::from("theme.toml"),
1792                    target_rel: PathBuf::from("theme.toml"),
1793                    destination_root: None,
1794                    description: None,
1795                    display_name: None,
1796                    legacy_dest_annotation: None,
1797                    transforms: vec![],
1798                    install_strategy: AppInstallStrategy::Copy,
1799                    requires_admin: false,
1800                    restart_hint: None,
1801                    generator: None,
1802                },
1803            ],
1804            list_mode: AppListMode::Files,
1805            post_upgrade: Vec::new(),
1806            post_install: Vec::new(),
1807            uses_metadata: true,
1808            has_explicit_files: true,
1809            artifact: None,
1810        };
1811
1812        let rows = build_app_rows(&config, &[category]).await.unwrap();
1813
1814        assert_eq!(rows.len(), 2);
1815        assert_eq!(rows[0].label, "sample/config.toml");
1816        assert_eq!(rows[0].simple_label, "sample/config.toml");
1817        assert_eq!(rows[1].label, "sample/theme.toml");
1818        assert_eq!(rows[1].simple_label, "sample/theme.toml");
1819
1820        fs::remove_dir_all(&dir).await.unwrap();
1821    }
1822
1823    #[cfg(windows)]
1824    #[tokio::test]
1825    async fn windows_docker_engine_row_uses_engine_destination() {
1826        let _guard = env_lock();
1827        let dir = make_temp_dir().await;
1828        // SAFETY: env_lock() serialises all env-mutation tests in this module,
1829        // preventing concurrent writes to the process environment from other test threads.
1830        unsafe { std::env::set_var("HOME", dir.to_str().unwrap()) };
1831        let config = Config::new_for_test(&dir);
1832        fs::create_dir_all(config.shine_dir()).await.unwrap();
1833
1834        let categories = crate::apps::load_embedded_categories(Some("docker-engine")).unwrap();
1835        let rows = build_app_rows(&config, &categories).await.unwrap();
1836
1837        assert_eq!(rows.len(), 1);
1838        assert_eq!(rows[0].label, "docker-engine/daemon.jsonc");
1839        assert_eq!(rows[0].simple_label, "docker-engine");
1840        assert_eq!(rows[0].file_status, FileStatus::NotInstalled);
1841        assert_eq!(rows[0].dest.as_deref(), Some("~/.docker/daemon.json"));
1842
1843        // SAFETY: same env_lock() guard as above.
1844        unsafe { std::env::remove_var("HOME") };
1845        fs::remove_dir_all(&dir).await.unwrap();
1846    }
1847
1848    #[cfg(windows)]
1849    #[tokio::test]
1850    async fn windows_docker_desktop_row_uses_forward_slash_destination() {
1851        let _guard = env_lock();
1852        let dir = make_temp_dir().await;
1853        // SAFETY: env_lock() serialises all env-mutation tests in this module,
1854        // preventing concurrent writes to the process environment from other test threads.
1855        unsafe { std::env::set_var("HOME", dir.to_str().unwrap()) };
1856        let config = Config::new_for_test(&dir);
1857        fs::create_dir_all(config.shine_dir()).await.unwrap();
1858
1859        let categories = crate::apps::load_embedded_categories(Some("docker-desktop")).unwrap();
1860        let rows = build_app_rows(&config, &categories).await.unwrap();
1861
1862        assert_eq!(rows.len(), 1);
1863        assert_eq!(rows[0].label, "docker-desktop/settings-store.jsonc");
1864        assert_eq!(rows[0].simple_label, "docker-desktop");
1865        assert_eq!(rows[0].file_status, FileStatus::NotInstalled);
1866        assert_eq!(
1867            rows[0].dest.as_deref(),
1868            Some("~/AppData/Roaming/Docker/settings-store.json")
1869        );
1870
1871        // SAFETY: same env_lock() guard as above.
1872        unsafe { std::env::remove_var("HOME") };
1873        fs::remove_dir_all(&dir).await.unwrap();
1874    }
1875}