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