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