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