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