1use 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
25pub(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 pub category: String,
43 pub symbol: String,
44 pub label: String,
45 pub status_sym: &'static str,
46 pub status_text: &'static str,
47 pub is_installed: bool,
49 pub(crate) link_conflict: bool,
52 pub(crate) changes: Vec<UpdateChange>,
53}
54
55pub struct AppRow {
56 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 pub(crate) upgrade_available: bool,
67 pub(crate) refresh_sources: Vec<String>,
70}
71
72pub 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#[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 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 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 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 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 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 unsafe { std::env::remove_var("HOME") };
1897 fs::remove_dir_all(&dir).await.unwrap();
1898 }
1899}