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