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