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