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_eq!(row.status_sym, "✓");
1110 assert_eq!(row.status_text, "up-to-date");
1111 assert!(row.is_installed);
1112
1113 fs::remove_dir_all(&dir).await.unwrap();
1114 }
1115
1116 #[cfg(unix)]
1117 #[tokio::test]
1118 async fn installed_shell_rows_report_up_to_date() {
1119 let dir = make_temp_dir().await;
1120 let cat_dir = dir.join("presets/shell/proxy");
1121 fs::create_dir_all(&cat_dir).await.unwrap();
1122 fs::write(
1123 cat_dir.join("shine.toml"),
1124 b"[[files]]\nsource = \"set_proxy.sh\"\ntarget = \"setproxy\"\nneeds_source = true\n",
1125 )
1126 .await
1127 .unwrap();
1128 let script = cat_dir.join("set_proxy.sh");
1129 fs::write(&script, b"#!/bin/bash\necho proxy\n")
1130 .await
1131 .unwrap();
1132 #[cfg(unix)]
1133 {
1134 use std::os::unix::fs::PermissionsExt;
1135 let mut perms = fs::metadata(&script).await.unwrap().permissions();
1136 perms.set_mode(0o755);
1137 fs::set_permissions(&script, perms).await.unwrap();
1138 }
1139
1140 let mut config = Config::new_for_test(&dir);
1141 config.is_external_presets = true;
1142 fs::create_dir_all(config.bin_dir()).await.unwrap();
1143
1144 crate::shells::handle_install(&config, Some("proxy"), false)
1145 .await
1146 .unwrap();
1147
1148 let rows = build_shell_rows(&config).await.unwrap();
1149 let row = rows
1150 .iter()
1151 .find(|row| row.label == "proxy/setproxy")
1152 .expect("proxy/setproxy row should exist");
1153
1154 assert_eq!(row.status_sym, "✓");
1155 assert_eq!(row.status_text, "up-to-date");
1156
1157 fs::remove_dir_all(&dir).await.unwrap();
1158 }
1159
1160 #[cfg(unix)]
1161 #[tokio::test]
1162 async fn missing_shell_command_entry_is_an_update_reason() {
1163 let dir = make_temp_dir().await;
1164 let category = dir.join("presets/shell/custom");
1165 fs::create_dir_all(&category).await.unwrap();
1166 fs::write(
1167 category.join("shine.toml"),
1168 b"[[files]]\nsource = \"tool.sh\"\ntarget = \"mytool\"\n",
1169 )
1170 .await
1171 .unwrap();
1172 fs::write(category.join("tool.sh"), b"#!/bin/sh\necho same\n")
1173 .await
1174 .unwrap();
1175
1176 let mut config = Config::new_for_test(&dir);
1177 config.is_external_presets = true;
1178 fs::create_dir_all(config.bin_dir()).await.unwrap();
1179 crate::shells::handle_install(&config, Some("custom"), false)
1180 .await
1181 .unwrap();
1182 fs::remove_file(config.bin_dir().join("mytool"))
1183 .await
1184 .unwrap();
1185
1186 let rows = build_shell_rows(&config).await.unwrap();
1187 let row = rows
1188 .iter()
1189 .find(|row| row.label == "custom/mytool")
1190 .unwrap();
1191 assert_eq!(row.status_text, "update available");
1192 assert_eq!(
1193 row.changes,
1194 vec![UpdateChange::CommandEntryMissing {
1195 path: config.bin_dir().join("mytool"),
1196 }]
1197 );
1198
1199 fs::remove_file(config.shine_dir().join("shell-manifest.toml"))
1200 .await
1201 .unwrap();
1202 let rows = build_shell_rows(&config).await.unwrap();
1203 let row = rows
1204 .iter()
1205 .find(|row| row.label == "custom/mytool")
1206 .unwrap();
1207 assert!(!row.is_installed);
1208 assert_eq!(row.status_text, "not installed");
1209 assert!(row.changes.is_empty());
1210
1211 fs::remove_dir_all(&dir).await.unwrap();
1212 }
1213
1214 #[cfg(unix)]
1215 #[tokio::test]
1216 async fn external_template_shell_change_reports_update_available() {
1217 let dir = make_temp_dir().await;
1218 let cat_dir = dir.join("presets/shell/proxy");
1219 fs::create_dir_all(&cat_dir).await.unwrap();
1220 fs::write(
1221 cat_dir.join("shine.toml"),
1222 b"[[files]]\nsource = \"set_proxy.sh\"\ntarget = \"setproxy\"\nneeds_source = true\n",
1223 )
1224 .await
1225 .unwrap();
1226 let script = cat_dir.join("set_proxy.sh");
1227 fs::write(
1228 &script,
1229 b"#!/bin/bash\n# shine-template: true\necho @@PROXY_HOST@@\n",
1230 )
1231 .await
1232 .unwrap();
1233
1234 let mut config = Config::new_for_test(&dir);
1235 config.is_external_presets = true;
1236 fs::create_dir_all(config.bin_dir()).await.unwrap();
1237
1238 crate::shells::handle_install(&config, Some("proxy"), false)
1239 .await
1240 .unwrap();
1241
1242 fs::write(
1243 &script,
1244 b"#!/bin/bash\n# shine-template: true\necho changed @@PROXY_HOST@@\n",
1245 )
1246 .await
1247 .unwrap();
1248
1249 let rows = build_shell_rows(&config).await.unwrap();
1250 let row = rows
1251 .iter()
1252 .find(|row| row.label == "proxy/setproxy")
1253 .expect("proxy/setproxy row should exist");
1254
1255 assert_eq!(row.status_sym, "↑");
1256 assert_eq!(row.status_text, "update available");
1257
1258 fs::remove_dir_all(&dir).await.unwrap();
1259 }
1260
1261 #[cfg(unix)]
1262 #[tokio::test]
1263 async fn live_raw_shell_change_stays_live_and_current() {
1264 let dir = make_temp_dir().await;
1265 let cat_dir = dir.join("presets/shell/custom");
1266 fs::create_dir_all(&cat_dir).await.unwrap();
1267 fs::write(
1268 cat_dir.join("shine.toml"),
1269 b"[[files]]\nsource = \"tool.sh\"\ntarget = \"mytool\"\n",
1270 )
1271 .await
1272 .unwrap();
1273 let source = cat_dir.join("tool.sh");
1274 fs::write(&source, b"#!/bin/sh\necho first\n")
1275 .await
1276 .unwrap();
1277
1278 let mut config = Config::new_for_test(&dir);
1279 config.is_external_presets = true;
1280 config.external_shell_mode = crate::config::ExternalShellMode::Live;
1281 fs::create_dir_all(config.bin_dir()).await.unwrap();
1282 crate::shells::handle_install(&config, Some("custom"), false)
1283 .await
1284 .unwrap();
1285 fs::write(&source, b"#!/bin/sh\necho second\n")
1286 .await
1287 .unwrap();
1288
1289 let rows = build_shell_rows(&config).await.unwrap();
1290 let row = rows
1291 .iter()
1292 .find(|row| row.label == "custom/mytool")
1293 .unwrap();
1294 assert_eq!(row.status_sym, "✓");
1295 assert_eq!(row.status_text, "live source");
1296 fs::remove_dir_all(&dir).await.unwrap();
1297 }
1298
1299 #[cfg(unix)]
1300 #[tokio::test]
1301 async fn live_overlay_root_rename_reports_source_relocation_without_content_change() {
1302 let dir = make_temp_dir().await;
1303 let old_overlay = dir.join("shineOverlay");
1304 let new_overlay = dir.join("shineOverlayTest");
1305 let old_category = old_overlay.join("shell/custom");
1306 fs::create_dir_all(&old_category).await.unwrap();
1307 fs::write(
1308 old_category.join("shine.toml"),
1309 b"[[files]]\nsource = \"tool.sh\"\ntarget = \"mytool\"\n",
1310 )
1311 .await
1312 .unwrap();
1313 fs::write(old_category.join("tool.sh"), b"#!/bin/sh\necho same\n")
1314 .await
1315 .unwrap();
1316
1317 let mut old_config =
1318 Config::new_for_test(&dir).with_presets_overlay_dir_override(Some(old_overlay.clone()));
1319 old_config.is_external_presets = true;
1320 old_config.external_shell_mode = crate::config::ExternalShellMode::Live;
1321 fs::create_dir_all(old_config.bin_dir()).await.unwrap();
1322 crate::shells::handle_install(&old_config, Some("custom"), false)
1323 .await
1324 .unwrap();
1325
1326 fs::rename(&old_overlay, &new_overlay).await.unwrap();
1327 let mut new_config =
1328 Config::new_for_test(&dir).with_presets_overlay_dir_override(Some(new_overlay.clone()));
1329 new_config.is_external_presets = true;
1330 new_config.external_shell_mode = crate::config::ExternalShellMode::Live;
1331
1332 let rows = build_shell_rows(&new_config).await.unwrap();
1333 let row = rows
1334 .iter()
1335 .find(|row| row.label == "custom/mytool")
1336 .unwrap();
1337 assert_eq!(row.status_text, "update available");
1338 assert_eq!(
1339 row.changes,
1340 vec![UpdateChange::SourceRelocated {
1341 from: old_overlay.join("shell/custom/tool.sh"),
1342 to: new_overlay.join("shell/custom/tool.sh"),
1343 }]
1344 );
1345
1346 fs::write(
1347 new_overlay.join("shell/custom/tool.sh"),
1348 b"#!/bin/sh\necho changed\n",
1349 )
1350 .await
1351 .unwrap();
1352 let rows = build_shell_rows(&new_config).await.unwrap();
1353 let row = rows
1354 .iter()
1355 .find(|row| row.label == "custom/mytool")
1356 .unwrap();
1357 assert_eq!(
1358 row.changes,
1359 vec![
1360 UpdateChange::SourceRelocated {
1361 from: old_overlay.join("shell/custom/tool.sh"),
1362 to: new_overlay.join("shell/custom/tool.sh"),
1363 },
1364 UpdateChange::ContentChanged,
1365 ]
1366 );
1367
1368 fs::remove_dir_all(&dir).await.unwrap();
1369 }
1370
1371 #[cfg(unix)]
1372 #[tokio::test]
1373 async fn snapshot_overlay_root_rename_with_same_bytes_stays_current() {
1374 let dir = make_temp_dir().await;
1375 let old_overlay = dir.join("shineOverlay");
1376 let new_overlay = dir.join("shineOverlayTest");
1377 let old_category = old_overlay.join("shell/custom");
1378 fs::create_dir_all(&old_category).await.unwrap();
1379 fs::write(
1380 old_category.join("shine.toml"),
1381 b"[[files]]\nsource = \"tool.sh\"\ntarget = \"mytool\"\n",
1382 )
1383 .await
1384 .unwrap();
1385 fs::write(old_category.join("tool.sh"), b"#!/bin/sh\necho same\n")
1386 .await
1387 .unwrap();
1388
1389 let mut old_config =
1390 Config::new_for_test(&dir).with_presets_overlay_dir_override(Some(old_overlay.clone()));
1391 old_config.is_external_presets = true;
1392 fs::create_dir_all(old_config.bin_dir()).await.unwrap();
1393 crate::shells::handle_install(&old_config, Some("custom"), false)
1394 .await
1395 .unwrap();
1396
1397 fs::rename(&old_overlay, &new_overlay).await.unwrap();
1398 let mut new_config =
1399 Config::new_for_test(&dir).with_presets_overlay_dir_override(Some(new_overlay));
1400 new_config.is_external_presets = true;
1401
1402 let rows = build_shell_rows(&new_config).await.unwrap();
1403 let row = rows
1404 .iter()
1405 .find(|row| row.label == "custom/mytool")
1406 .unwrap();
1407 assert_eq!(row.status_text, "up-to-date");
1408 assert!(row.changes.is_empty());
1409
1410 fs::remove_dir_all(&dir).await.unwrap();
1411 }
1412
1413 #[cfg(unix)]
1414 #[tokio::test]
1415 async fn shell_manifest_metadata_changes_are_reported_field_by_field() {
1416 use crate::shells::deployment::{ShellManifest, ShellManifestEntry};
1417 use std::os::unix::fs::symlink;
1418
1419 let dir = make_temp_dir().await;
1420 let category = dir.join("presets/shell/custom");
1421 fs::create_dir_all(&category).await.unwrap();
1422 fs::write(
1423 category.join("shine.toml"),
1424 b"[[files]]\nsource = \"tool.sh\"\ntarget = \"mytool\"\n",
1425 )
1426 .await
1427 .unwrap();
1428 let source = category.join("tool.sh");
1429 let bytes = b"#!/bin/sh\necho same\n";
1430 fs::write(&source, bytes).await.unwrap();
1431
1432 let mut config = Config::new_for_test(&dir);
1433 config.is_external_presets = true;
1434 config.external_shell_mode = crate::config::ExternalShellMode::Live;
1435 fs::create_dir_all(config.bin_dir()).await.unwrap();
1436 symlink(&source, config.bin_dir().join("mytool")).unwrap();
1437
1438 ShellManifest {
1439 entries: vec![ShellManifestEntry {
1440 category: "custom".to_string(),
1441 command: "mytool".to_string(),
1442 mode: crate::config::ExternalShellMode::Snapshot,
1443 source_path: source.clone(),
1444 rendered_path: config.rendered_dir().join("shell/custom/tool.sh"),
1445 runtime: "bun".to_string(),
1446 bun_dependencies: None,
1447 dependency_hash: None,
1448 transforms: vec!["template".to_string()],
1449 env: vec!["OLD_KEY".to_string()],
1450 needs_source: true,
1451 content_hash: crate::install_core::hash_content(bytes),
1452 }],
1453 }
1454 .save(&config)
1455 .await
1456 .unwrap();
1457
1458 let rows = build_shell_rows(&config).await.unwrap();
1459 let row = rows
1460 .iter()
1461 .find(|row| row.label == "custom/mytool")
1462 .unwrap();
1463 assert_eq!(row.status_text, "update available");
1464 assert_eq!(
1465 row.changes,
1466 vec![
1467 UpdateChange::DeploymentChanged {
1468 field: "mode",
1469 from: "snapshot".to_string(),
1470 to: "live".to_string(),
1471 },
1472 UpdateChange::DeploymentChanged {
1473 field: "runtime",
1474 from: "bun".to_string(),
1475 to: "native".to_string(),
1476 },
1477 UpdateChange::DeploymentChanged {
1478 field: "transforms",
1479 from: "template".to_string(),
1480 to: "none".to_string(),
1481 },
1482 UpdateChange::DeploymentChanged {
1483 field: "env",
1484 from: "OLD_KEY".to_string(),
1485 to: "none".to_string(),
1486 },
1487 UpdateChange::DeploymentChanged {
1488 field: "needs source",
1489 from: "true".to_string(),
1490 to: "false".to_string(),
1491 },
1492 ]
1493 );
1494
1495 fs::remove_dir_all(&dir).await.unwrap();
1496 }
1497
1498 #[cfg(unix)]
1499 #[tokio::test]
1500 async fn external_bun_lock_change_reports_update_available() {
1501 let dir = make_temp_dir().await;
1502 let category = dir.join("presets/shell/custom");
1503 fs::create_dir_all(&category).await.unwrap();
1504 fs::write(
1505 category.join("shine.toml"),
1506 b"[[files]]\nsource = \"tool.ts\"\ntarget = \"mytool\"\nruntime = \"bun\"\n",
1507 )
1508 .await
1509 .unwrap();
1510 fs::write(category.join("tool.ts"), b"import 'zod'\n")
1511 .await
1512 .unwrap();
1513 fs::write(
1514 category.join("package.json"),
1515 b"{\"dependencies\":{\"zod\":\"4.0.0\"}}",
1516 )
1517 .await
1518 .unwrap();
1519 fs::write(category.join("bun.lock"), b"lockfileVersion = 1\n")
1520 .await
1521 .unwrap();
1522 let mut config = Config::new_for_test(&dir);
1523 config.is_external_presets = true;
1524 fs::create_dir_all(config.bin_dir()).await.unwrap();
1525 crate::shells::handle_install(&config, Some("custom"), false)
1526 .await
1527 .unwrap();
1528
1529 fs::write(
1530 category.join("bun.lock"),
1531 b"lockfileVersion = 1\n# dependency changed\n",
1532 )
1533 .await
1534 .unwrap();
1535 let rows = build_shell_rows(&config).await.unwrap();
1536 let row = rows
1537 .iter()
1538 .find(|row| row.label == "custom/mytool")
1539 .unwrap();
1540 assert_eq!(row.status_text, "update available");
1541 assert!(row.changes.iter().any(|change| matches!(
1542 change,
1543 UpdateChange::DeploymentChanged {
1544 field: "dependency lock",
1545 ..
1546 }
1547 )));
1548
1549 fs::remove_dir_all(&dir).await.unwrap();
1550 }
1551
1552 #[tokio::test]
1553 async fn embedded_bun_source_change_reports_update_available() {
1554 let dir = make_temp_dir().await;
1555 let config = Config::new_for_test(&dir);
1556 fs::create_dir_all(config.presets_dir()).await.unwrap();
1557 fs::create_dir_all(config.bin_dir()).await.unwrap();
1558
1559 crate::shells::handle_install(&config, Some("agent"), false)
1560 .await
1561 .unwrap();
1562
1563 let extracted = config.presets_dir().join("shell/agent/cc.ts");
1564 fs::write(&extracted, b"// stale extracted ccenv\n")
1565 .await
1566 .unwrap();
1567
1568 let rows = build_shell_rows(&config).await.unwrap();
1569 let row = rows
1570 .iter()
1571 .find(|row| row.label == "agent/ccenv")
1572 .expect("agent/ccenv row should exist");
1573
1574 assert_eq!(row.status_sym, "↑");
1575 assert_eq!(row.status_text, "update available");
1576
1577 fs::remove_dir_all(&dir).await.unwrap();
1578 }
1579
1580 #[tokio::test]
1581 async fn embedded_shell_source_rename_reports_update_available() {
1582 let dir = make_temp_dir().await;
1583 let cat_dir = dir.join("presets/shell/agent");
1584 fs::create_dir_all(&cat_dir).await.unwrap();
1585 let old_source = if cfg!(windows) { "cc.ps1" } else { "cc.sh" };
1586 fs::write(
1587 cat_dir.join("shine.toml"),
1588 format!(
1589 "[[files]]\nsource = \"{old_source}\"\ntarget = \"ccenv\"\nneeds_source = true\n"
1590 ),
1591 )
1592 .await
1593 .unwrap();
1594 fs::write(cat_dir.join(old_source), b"# old sourced ccenv\n")
1595 .await
1596 .unwrap();
1597
1598 let mut config = Config::new_for_test(&dir);
1599 config.is_external_presets = true;
1600 fs::create_dir_all(config.bin_dir()).await.unwrap();
1601 crate::shells::handle_install(&config, Some("agent"), false)
1602 .await
1603 .unwrap();
1604
1605 config.is_external_presets = false;
1606 let rows = build_shell_rows(&config).await.unwrap();
1607 let row = rows
1608 .iter()
1609 .find(|row| row.label == "agent/ccenv")
1610 .expect("embedded agent/ccenv row should exist");
1611
1612 assert_eq!(row.status_sym, "↑");
1613 assert_eq!(row.status_text, "update available");
1614
1615 fs::remove_dir_all(&dir).await.unwrap();
1616 }
1617
1618 #[tokio::test]
1619 async fn external_shell_runtime_and_source_change_reports_update_available() {
1620 let dir = make_temp_dir().await;
1621 let cat_dir = dir.join("presets/shell/agent");
1622 fs::create_dir_all(&cat_dir).await.unwrap();
1623 let old_source = if cfg!(windows) { "cc.ps1" } else { "cc.sh" };
1624 fs::write(
1625 cat_dir.join("shine.toml"),
1626 format!(
1627 "[[files]]\nsource = \"{old_source}\"\ntarget = \"ccenv\"\nneeds_source = true\n"
1628 ),
1629 )
1630 .await
1631 .unwrap();
1632 fs::write(cat_dir.join(old_source), b"# old sourced ccenv\n")
1633 .await
1634 .unwrap();
1635
1636 let mut config = Config::new_for_test(&dir);
1637 config.is_external_presets = true;
1638 fs::create_dir_all(config.bin_dir()).await.unwrap();
1639 crate::shells::handle_install(&config, Some("agent"), false)
1640 .await
1641 .unwrap();
1642
1643 fs::write(
1644 cat_dir.join("shine.toml"),
1645 b"[[files]]\nsource = \"cc.ts\"\ntarget = \"ccenv\"\nruntime = \"bun\"\nplatforms = [\"unix\", \"windows\"]\n",
1646 )
1647 .await
1648 .unwrap();
1649 fs::write(cat_dir.join("cc.ts"), b"console.log('new ccenv');\n")
1650 .await
1651 .unwrap();
1652
1653 let rows = build_shell_rows(&config).await.unwrap();
1654 let row = rows
1655 .iter()
1656 .find(|row| row.label == "agent/ccenv")
1657 .expect("external agent/ccenv row should exist");
1658
1659 assert_eq!(row.status_sym, "↑");
1660 assert_eq!(row.status_text, "update available");
1661
1662 fs::remove_dir_all(&dir).await.unwrap();
1663 }
1664
1665 #[cfg(unix)]
1666 #[tokio::test]
1667 async fn shell_env_change_reports_update_available() {
1668 let dir = make_temp_dir().await;
1669 let cat_dir = dir.join("presets/shell/proxy");
1670 fs::create_dir_all(&cat_dir).await.unwrap();
1671 fs::write(
1672 cat_dir.join("shine.toml"),
1673 b"[[files]]\nsource = \"set_proxy.sh\"\ntarget = \"setproxy\"\nneeds_source = true\n",
1674 )
1675 .await
1676 .unwrap();
1677 fs::write(
1678 cat_dir.join("set_proxy.sh"),
1679 b"#!/bin/bash\n# shine-template: true\nPROXY_NO_PROXY=\"@@PROXY_NO_PROXY@@\"\n",
1680 )
1681 .await
1682 .unwrap();
1683
1684 let mut config = Config::new_for_test(&dir);
1685 config.is_external_presets = true;
1686 fs::create_dir_all(config.bin_dir()).await.unwrap();
1687
1688 crate::shells::handle_install(&config, Some("proxy"), false)
1689 .await
1690 .unwrap();
1691
1692 config.env.insert(
1693 "PROXY_NO_PROXY".to_string(),
1694 "localhost,127.0.0.1,::1,.local".to_string(),
1695 );
1696
1697 let rows = build_shell_rows(&config).await.unwrap();
1698 let row = rows
1699 .iter()
1700 .find(|row| row.label == "proxy/setproxy")
1701 .expect("proxy/setproxy row should exist");
1702
1703 assert_eq!(row.status_sym, "↑");
1704 assert_eq!(row.status_text, "update available");
1705
1706 fs::remove_dir_all(&dir).await.unwrap();
1707 }
1708
1709 #[tokio::test]
1710 async fn category_list_mode_aggregates_explicit_app_files() {
1711 let dir = make_temp_dir().await;
1712 let config = Config::new_for_test(&dir);
1713 fs::create_dir_all(config.shine_dir()).await.unwrap();
1714
1715 let category = AppCategory {
1716 name: "ghostty".to_string(),
1717 description: Some("Ghostty terminal configuration.".to_string()),
1718 destination_root: Some(dir.join(".config/ghostty").display().to_string()),
1719 files: vec![
1720 AppFile {
1721 source_rel: PathBuf::from("config.ghostty"),
1722 target_rel: PathBuf::from("config.ghostty"),
1723 destination_root: None,
1724 description: None,
1725 display_name: None,
1726 legacy_dest_annotation: None,
1727 transforms: vec![],
1728 install_strategy: AppInstallStrategy::Copy,
1729 requires_admin: false,
1730 restart_hint: None,
1731 generator: None,
1732 },
1733 AppFile {
1734 source_rel: PathBuf::from("themes/shine-light"),
1735 target_rel: PathBuf::from("themes/shine-light"),
1736 destination_root: None,
1737 description: None,
1738 display_name: None,
1739 legacy_dest_annotation: None,
1740 transforms: vec!["template".to_string()],
1741 install_strategy: AppInstallStrategy::Copy,
1742 requires_admin: false,
1743 restart_hint: None,
1744 generator: None,
1745 },
1746 ],
1747 list_mode: AppListMode::Category,
1748 post_upgrade: Vec::new(),
1749 post_install: Vec::new(),
1750 uses_metadata: true,
1751 has_explicit_files: true,
1752 artifact: None,
1753 };
1754
1755 let rows = build_app_rows(&config, &[category]).await.unwrap();
1756
1757 assert_eq!(rows.len(), 1);
1758 assert_eq!(rows[0].label, "ghostty");
1759 assert_eq!(rows[0].simple_label, "ghostty");
1760 assert_eq!(rows[0].dest.as_deref(), Some("~/.config/ghostty"));
1761 assert_eq!(rows[0].file_status, FileStatus::NotInstalled);
1762
1763 fs::remove_dir_all(&dir).await.unwrap();
1764 }
1765
1766 #[tokio::test]
1767 async fn file_list_mode_keeps_file_labels_for_multi_file_app_simple_list() {
1768 let dir = make_temp_dir().await;
1769 let config = Config::new_for_test(&dir);
1770 fs::create_dir_all(config.shine_dir()).await.unwrap();
1771
1772 let category = AppCategory {
1773 name: "sample".to_string(),
1774 description: None,
1775 destination_root: Some(dir.join(".config/sample").display().to_string()),
1776 files: vec![
1777 AppFile {
1778 source_rel: PathBuf::from("config.toml"),
1779 target_rel: PathBuf::from("config.toml"),
1780 destination_root: None,
1781 description: None,
1782 display_name: None,
1783 legacy_dest_annotation: None,
1784 transforms: vec![],
1785 install_strategy: AppInstallStrategy::Copy,
1786 requires_admin: false,
1787 restart_hint: None,
1788 generator: None,
1789 },
1790 AppFile {
1791 source_rel: PathBuf::from("theme.toml"),
1792 target_rel: PathBuf::from("theme.toml"),
1793 destination_root: None,
1794 description: None,
1795 display_name: None,
1796 legacy_dest_annotation: None,
1797 transforms: vec![],
1798 install_strategy: AppInstallStrategy::Copy,
1799 requires_admin: false,
1800 restart_hint: None,
1801 generator: None,
1802 },
1803 ],
1804 list_mode: AppListMode::Files,
1805 post_upgrade: Vec::new(),
1806 post_install: Vec::new(),
1807 uses_metadata: true,
1808 has_explicit_files: true,
1809 artifact: None,
1810 };
1811
1812 let rows = build_app_rows(&config, &[category]).await.unwrap();
1813
1814 assert_eq!(rows.len(), 2);
1815 assert_eq!(rows[0].label, "sample/config.toml");
1816 assert_eq!(rows[0].simple_label, "sample/config.toml");
1817 assert_eq!(rows[1].label, "sample/theme.toml");
1818 assert_eq!(rows[1].simple_label, "sample/theme.toml");
1819
1820 fs::remove_dir_all(&dir).await.unwrap();
1821 }
1822
1823 #[cfg(windows)]
1824 #[tokio::test]
1825 async fn windows_docker_engine_row_uses_engine_destination() {
1826 let _guard = env_lock();
1827 let dir = make_temp_dir().await;
1828 unsafe { std::env::set_var("HOME", dir.to_str().unwrap()) };
1831 let config = Config::new_for_test(&dir);
1832 fs::create_dir_all(config.shine_dir()).await.unwrap();
1833
1834 let categories = crate::apps::load_embedded_categories(Some("docker-engine")).unwrap();
1835 let rows = build_app_rows(&config, &categories).await.unwrap();
1836
1837 assert_eq!(rows.len(), 1);
1838 assert_eq!(rows[0].label, "docker-engine/daemon.jsonc");
1839 assert_eq!(rows[0].simple_label, "docker-engine");
1840 assert_eq!(rows[0].file_status, FileStatus::NotInstalled);
1841 assert_eq!(rows[0].dest.as_deref(), Some("~/.docker/daemon.json"));
1842
1843 unsafe { std::env::remove_var("HOME") };
1845 fs::remove_dir_all(&dir).await.unwrap();
1846 }
1847
1848 #[cfg(windows)]
1849 #[tokio::test]
1850 async fn windows_docker_desktop_row_uses_forward_slash_destination() {
1851 let _guard = env_lock();
1852 let dir = make_temp_dir().await;
1853 unsafe { std::env::set_var("HOME", dir.to_str().unwrap()) };
1856 let config = Config::new_for_test(&dir);
1857 fs::create_dir_all(config.shine_dir()).await.unwrap();
1858
1859 let categories = crate::apps::load_embedded_categories(Some("docker-desktop")).unwrap();
1860 let rows = build_app_rows(&config, &categories).await.unwrap();
1861
1862 assert_eq!(rows.len(), 1);
1863 assert_eq!(rows[0].label, "docker-desktop/settings-store.jsonc");
1864 assert_eq!(rows[0].simple_label, "docker-desktop");
1865 assert_eq!(rows[0].file_status, FileStatus::NotInstalled);
1866 assert_eq!(
1867 rows[0].dest.as_deref(),
1868 Some("~/AppData/Roaming/Docker/settings-store.json")
1869 );
1870
1871 unsafe { std::env::remove_var("HOME") };
1873 fs::remove_dir_all(&dir).await.unwrap();
1874 }
1875}