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;
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
37pub struct ShellRow {
38 pub symbol: String,
39 pub label: String,
40 pub status_sym: &'static str,
41 pub status_text: &'static str,
42 pub is_installed: bool,
44}
45
46pub struct AppRow {
47 pub category: String,
50 pub sym: &'static str,
51 pub label: String,
52 pub simple_label: String,
53 pub dest: Option<String>,
54 pub status_text: &'static str,
55 pub file_status: FileStatus,
56}
57
58pub async fn build_shell_rows(config: &Config) -> Result<Vec<ShellRow>> {
64 let categories = crate::shells::metadata::load_active_categories(config, None).await?;
65 if categories.is_empty() {
66 return Ok(Vec::new());
67 }
68
69 let bin_dir = config.bin_dir();
70 let shell_manifest = crate::shells::deployment::ShellManifest::load(config).await?;
71 let mut rows: Vec<ShellRow> = Vec::new();
72
73 for cat in &categories {
74 let snapshot_current =
75 crate::shells::deployment::snapshot_category_current(config, &cat.name)
76 .await
77 .unwrap_or(false);
78 for script in &cat.files {
79 let desired_path = crate::shells::deployment::desired_source_path(
80 config,
81 &cat.name,
82 &script.source_rel,
83 );
84 let script_path = crate::shells::deployment::deployment_source_path(
85 config,
86 &cat.name,
87 &script.source_rel,
88 );
89 let source_key = format!("shell/{}/{}", cat.name, script.source_rel.display());
90 let display_name = format!("{}/{}", cat.name, script.command_name);
91 let rendered_path =
92 crate::shells::deployment::rendered_path(config, &cat.name, &script.source_rel);
93 let link_name = OsString::from(&script.command_name);
94 let link_path = crate::bin_links::command_path_for_name(bin_dir, &link_name);
95
96 let file_exists = script_path.exists();
97 let link_exists = link_path.exists() || {
98 tokio::fs::symlink_metadata(&link_path)
99 .await
100 .map(|m| m.file_type().is_symlink())
101 .unwrap_or(false)
102 };
103 let effective_transforms =
104 crate::shells::deployment::effective_transforms(script, &desired_path)
105 .await
106 .unwrap_or_else(|_| script.transforms.clone());
107 let effective_source = if !effective_transforms.is_empty() {
108 &rendered_path
109 } else {
110 &script_path
111 };
112 let runtime_env = script
113 .env
114 .iter()
115 .map(crate::env::EnvVarSpec::to_with_arg)
116 .collect::<Vec<_>>();
117 let link_current = if link_exists {
118 let render_target = (config.is_external_presets
119 && config.external_shell_mode == crate::config::ExternalShellMode::Live
120 && !effective_transforms.is_empty())
121 .then(|| format!("shell/{}/{}", cat.name, script.command_name));
122 crate::bin_links::link_is_current(
123 &link_path,
124 effective_source,
125 script.runtime,
126 &runtime_env,
127 render_target.as_deref(),
128 )
129 .await?
130 } else {
131 false
132 };
133
134 let (sym, status_text) = match (file_exists, link_exists) {
135 (true, true) => ("✓", "up-to-date"),
136 (true, false) => ("~", "preset present, bin symlink missing"),
137 (false, true) => ("~", "bin symlink present, preset missing"),
138 (false, false) => ("✗", "not installed"),
139 };
140
141 let canonical_target = format!("shell/{}/{}", cat.name, script.command_name);
142 let expected_runtime = match script.runtime {
143 crate::bin_links::LinkRuntime::Native => "native",
144 crate::bin_links::LinkRuntime::Bun => "bun",
145 };
146 let manifest_current = !config.is_external_presets
147 || shell_manifest.find(&canonical_target).is_some_and(|entry| {
148 entry.mode == config.external_shell_mode
149 && entry.source_path == script_path
150 && entry.runtime == expected_runtime
151 && entry.transforms == effective_transforms
152 && entry.env == runtime_env
153 && entry.needs_source == script.needs_source
154 });
155
156 let (sym, status_text) = if link_exists
157 && (!link_current || !manifest_current || !snapshot_current)
158 {
159 ("↑", "update available")
160 } else {
161 match shell_source_status(
162 config,
163 &source_key,
164 &desired_path,
165 &script_path,
166 &rendered_path,
167 &effective_transforms,
168 )
169 .await
170 {
171 Some(FileStatus::UpdateAvail) if file_exists || link_exists => {
172 ("↑", "update available")
173 }
174 Some(FileStatus::Missing) if link_exists => ("!", "rendered script missing"),
175 _ if config.is_external_presets
176 && config.external_shell_mode == crate::config::ExternalShellMode::Live
177 && file_exists
178 && link_exists =>
179 {
180 if effective_transforms.is_empty() {
181 ("✓", "live source")
182 } else {
183 ("✓", "rendered on next run")
184 }
185 }
186 _ => (sym, status_text),
187 }
188 };
189
190 rows.push(ShellRow {
191 symbol: colors::symbol(sym),
192 label: display_name,
193 status_sym: sym,
194 status_text,
195 is_installed: file_exists || link_exists,
196 });
197 }
198 }
199
200 Ok(rows)
201}
202
203async fn shell_source_status(
204 config: &Config,
205 source_key: &str,
206 desired_path: &Path,
207 script_path: &Path,
208 rendered_path: &Path,
209 declared_transforms: &[String],
210) -> Option<FileStatus> {
211 let source_bytes = if config.is_external_presets {
212 tokio::fs::read(desired_path).await.ok()?
213 } else {
214 crate::presets::read_asset_bytes(source_key)?
215 };
216 if !script_path.exists() {
217 return Some(FileStatus::UpdateAvail);
218 }
219 if config.is_external_presets
220 && config.external_shell_mode == crate::config::ExternalShellMode::Live
221 {
222 return Some(FileStatus::UpToDate);
223 }
224 let current_source = tokio::fs::read(script_path).await.ok()?;
225 if source_bytes != current_source {
226 return Some(FileStatus::UpdateAvail);
227 }
228 let transforms = declared_transforms.to_vec();
229 if transforms.is_empty() {
230 return Some(FileStatus::UpToDate);
231 }
232
233 if !rendered_path.exists() {
234 return Some(FileStatus::Missing);
235 }
236
237 let env = EnvConfig::load_or_init(config).await.ok()?;
238 let rendered = apply_transforms(&transforms, &source_bytes, env.as_map()).ok()?;
239 let current = tokio::fs::read(rendered_path).await.ok()?;
240
241 if rendered == current {
242 Some(FileStatus::UpToDate)
243 } else {
244 Some(FileStatus::UpdateAvail)
245 }
246}
247
248pub async fn build_app_rows(config: &Config, categories: &[AppCategory]) -> Result<Vec<AppRow>> {
250 let manifest = AppManifest::load(config.shine_dir()).await?;
251 let env = EnvConfig::load_or_init(config).await.ok();
252 let empty_map = BTreeMap::new();
253 let env_map = env.as_ref().map(|e| e.as_map()).unwrap_or(&empty_map);
254 let mut rows: Vec<AppRow> = Vec::new();
255
256 for cat in categories {
257 if cat.has_explicit_files && cat.list_mode == AppListMode::Files {
258 for file in &cat.files {
259 let (dest_opt, status) =
260 app_file_row_status(config, cat, file, &manifest, env_map).await;
261
262 let label = file
263 .display_name
264 .clone()
265 .unwrap_or_else(|| format!("{}/{}", cat.name, file.source_rel.display()));
266 let simple_label = if cat.files.len() == 1 {
267 cat.name.clone()
268 } else {
269 label.clone()
270 };
271
272 let dest_str = dest_opt.map(|d| path_display::format_home(&d, &config.home_dir));
273
274 let (sym, status_text) = match status {
275 FileStatus::Missing => ("!", "destination missing"),
276 FileStatus::UserModified => ("~", "user modified"),
277 FileStatus::UpdateAvail => ("↑", "update available"),
278 FileStatus::UpToDate => ("✓", "up-to-date"),
279 FileStatus::NotInstalled | FileStatus::Partial => ("✗", "not installed"),
280 };
281
282 rows.push(AppRow {
283 category: cat.name.clone(),
284 sym,
285 label,
286 simple_label,
287 dest: dest_str,
288 status_text,
289 file_status: status,
290 });
291 }
292 } else {
293 let mut file_statuses: Vec<FileStatus> = Vec::new();
294
295 for file in &cat.files {
296 let (_, status) = app_file_row_status(config, cat, file, &manifest, env_map).await;
297 file_statuses.push(status);
298 }
299
300 let has_installed = file_statuses.iter().any(|s| {
301 matches!(
302 s,
303 FileStatus::UpToDate | FileStatus::UpdateAvail | FileStatus::UserModified
304 )
305 });
306 let has_not_installed = file_statuses.contains(&FileStatus::NotInstalled);
307 let cat_status = if has_installed && has_not_installed {
308 let installed_max = file_statuses
313 .iter()
314 .copied()
315 .filter(|s| *s != FileStatus::NotInstalled)
316 .max()
317 .unwrap_or(FileStatus::Partial);
318 if installed_max == FileStatus::UpToDate {
319 FileStatus::Partial
320 } else {
321 installed_max
322 }
323 } else {
324 file_statuses
325 .iter()
326 .copied()
327 .max()
328 .unwrap_or(FileStatus::NotInstalled)
329 };
330
331 let dest_display: Option<String> = if let Some(root) = &cat.destination_root {
332 Some(path_display::format_tilde_path(root, &config.home_dir))
333 } else if cat.files.len() == 1 {
334 resolve_install_destination(cat, &cat.files[0], config)
335 .ok()
336 .map(|p| path_display::format_home(&p, &config.home_dir))
337 } else {
338 None
339 };
340
341 let (sym, status_text) = match cat_status {
342 FileStatus::Missing => ("!", "destination missing"),
343 FileStatus::UserModified => ("~", "user modified"),
344 FileStatus::Partial => ("~", "partial install"),
345 FileStatus::UpdateAvail => ("↑", "update available"),
346 FileStatus::UpToDate => ("✓", "up-to-date"),
347 FileStatus::NotInstalled => ("✗", "not installed"),
348 };
349
350 rows.push(AppRow {
351 category: cat.name.clone(),
352 sym,
353 label: cat.name.clone(),
354 simple_label: cat.name.clone(),
355 dest: dest_display,
356 status_text,
357 file_status: cat_status,
358 });
359 }
360 }
361
362 Ok(rows)
363}
364
365pub(crate) async fn app_file_row_status(
366 config: &Config,
367 cat: &AppCategory,
368 file: &crate::apps::AppFile,
369 manifest: &AppManifest,
370 env: &BTreeMap<String, String>,
371) -> (Option<std::path::PathBuf>, FileStatus) {
372 match resolve_install_destination(cat, file, config) {
373 Err(_) => (None, FileStatus::NotInstalled),
374 Ok(dest) => {
375 let source = format!("app/{}/{}", cat.name, file.source_rel.display());
376 let installed_category = manifest.entries.iter().any(|entry| {
377 entry
378 .source
379 .strip_prefix("app/")
380 .and_then(|source| source.split_once('/'))
381 .is_some_and(|(category, _)| category == cat.name)
382 });
383 let status = match manifest.find_by_dest(&dest) {
384 Some(entry) => app_entry_status(config, cat, file, entry, env).await,
385 None => match manifest.find_by_source(&source) {
386 Some(entry)
387 if file
388 .generator
389 .as_ref()
390 .is_some_and(|generator| !generator.auto) =>
391 {
392 app_entry_status(config, cat, file, entry, env).await
393 }
394 Some(_) => FileStatus::UpdateAvail,
395 None if installed_category
396 && file
397 .generator
398 .as_ref()
399 .is_none_or(|generator| generator.auto) =>
400 {
401 if source_hash_for_file(config, cat, file, env).await.is_some() {
402 FileStatus::UpdateAvail
403 } else {
404 FileStatus::NotInstalled
405 }
406 }
407 None => FileStatus::NotInstalled,
408 },
409 };
410 (Some(dest), status)
411 }
412 }
413}
414
415pub(crate) async fn app_entry_status(
424 config: &Config,
425 cat: &AppCategory,
426 file: &crate::apps::AppFile,
427 entry: &AppEntry,
428 env: &BTreeMap<String, String>,
429) -> FileStatus {
430 let generator_enabled = file
434 .generator
435 .as_ref()
436 .is_some_and(|generator| generator.auto && env.contains_key(&generator.when_env));
437 let manual_generator = file
438 .generator
439 .as_ref()
440 .is_some_and(|generator| !generator.auto);
441 let generated_source_hash = if generator_enabled {
442 source_hash_for_file(config, cat, file, env).await
443 } else {
444 None
445 };
446 if !entry.destination.exists() {
447 return FileStatus::Missing;
448 }
449 match tokio::fs::read(&entry.destination).await {
450 Err(_) => FileStatus::Missing,
451 Ok(dest_bytes) => {
452 let manifest_hash = entry.content_hash;
453 match installed_content_hash(file, &dest_bytes) {
454 Ok(Some(dest_hash)) if dest_hash == manifest_hash => {
455 if manual_generator {
456 return FileStatus::UpToDate;
457 }
458 let source_hash = if generator_enabled {
459 generated_source_hash
460 } else {
461 source_hash_for_file(config, cat, file, env).await
462 };
463 match source_hash {
464 Some(src) if src != manifest_hash => FileStatus::UpdateAvail,
465 _ => FileStatus::UpToDate,
466 }
467 }
468 Ok(None) => FileStatus::Missing,
469 Ok(Some(_)) | Err(_) => FileStatus::UserModified,
470 }
471 }
472 }
473}
474
475#[cfg(test)]
476mod tests {
477 use super::*;
478 use crate::apps::AppFile;
479 use crate::config::Config;
480 use crate::install_core::AppInstallStrategy;
481 #[cfg(windows)]
482 use crate::test_support::env_lock;
483 use std::path::PathBuf;
484 use tokio::fs;
485
486 async fn make_temp_dir() -> std::path::PathBuf {
487 crate::test_support::make_temp_dir("shine-check").await
488 }
489
490 fn sample_app_file() -> AppFile {
491 AppFile {
492 source_rel: PathBuf::from("dest.txt"),
493 target_rel: PathBuf::from("dest.txt"),
494 destination_root: None,
495 description: None,
496 display_name: None,
497 legacy_dest_annotation: None,
498 transforms: vec![],
499 install_strategy: AppInstallStrategy::Copy,
500 requires_admin: false,
501 restart_hint: None,
502 generator: None,
503 }
504 }
505
506 fn sample_app_category() -> AppCategory {
507 AppCategory {
508 name: "sample".to_string(),
509 description: None,
510 destination_root: None,
511 files: vec![sample_app_file()],
512 list_mode: AppListMode::Files,
513 post_upgrade: Vec::new(),
514 post_install: Vec::new(),
515 uses_metadata: true,
516 has_explicit_files: true,
517 artifact: None,
518 }
519 }
520
521 fn sample_app_entry(destination: PathBuf, content_hash: u64) -> AppEntry {
522 AppEntry {
523 source: "app/sample/dest.txt".to_string(),
524 destination,
525 backup: None,
526 content_hash,
527 install_strategy: AppInstallStrategy::Copy,
528 uses_env: false,
529 requires_admin: false,
530 }
531 }
532
533 #[tokio::test]
534 async fn app_entry_status_reports_missing_when_destination_absent() {
535 let dir = make_temp_dir().await;
536 let config = Config::new_for_test(&dir);
537 let dest = dir.join("dest.txt");
538 let entry = sample_app_entry(dest, crate::install_core::hash_content(b"hello"));
539
540 let status = app_entry_status(
541 &config,
542 &sample_app_category(),
543 &sample_app_file(),
544 &entry,
545 &BTreeMap::new(),
546 )
547 .await;
548
549 assert_eq!(status, FileStatus::Missing);
550 fs::remove_dir_all(&dir).await.unwrap();
551 }
552
553 #[tokio::test]
554 async fn app_entry_status_reports_user_modified_when_dest_hash_differs() {
555 let dir = make_temp_dir().await;
556 let config = Config::new_for_test(&dir);
557 let dest = dir.join("dest.txt");
558 fs::write(&dest, b"locally edited").await.unwrap();
559 let entry = sample_app_entry(dest, crate::install_core::hash_content(b"original"));
560
561 let status = app_entry_status(
562 &config,
563 &sample_app_category(),
564 &sample_app_file(),
565 &entry,
566 &BTreeMap::new(),
567 )
568 .await;
569
570 assert_eq!(status, FileStatus::UserModified);
571 fs::remove_dir_all(&dir).await.unwrap();
572 }
573
574 #[tokio::test]
575 async fn app_entry_status_reports_up_to_date_when_source_unreadable() {
576 let dir = make_temp_dir().await;
580 let config = Config::new_for_test(&dir);
581 let dest = dir.join("dest.txt");
582 fs::write(&dest, b"hello").await.unwrap();
583 let entry = sample_app_entry(dest, crate::install_core::hash_content(b"hello"));
584
585 let status = app_entry_status(
586 &config,
587 &sample_app_category(),
588 &sample_app_file(),
589 &entry,
590 &BTreeMap::new(),
591 )
592 .await;
593
594 assert_eq!(status, FileStatus::UpToDate);
595 fs::remove_dir_all(&dir).await.unwrap();
596 }
597
598 #[tokio::test]
599 async fn app_entry_status_reports_update_available_when_source_changed() {
600 let dir = make_temp_dir().await;
601 let mut config = Config::new_for_test(&dir);
602 config.is_external_presets = true;
603
604 let source_path = config.preset_path(Path::new("app").join("sample").join("dest.txt"));
605 fs::create_dir_all(source_path.parent().unwrap())
606 .await
607 .unwrap();
608 fs::write(&source_path, b"new upstream content")
609 .await
610 .unwrap();
611
612 let dest = dir.join("dest.txt");
613 fs::write(&dest, b"hello").await.unwrap();
614 let entry = sample_app_entry(dest, crate::install_core::hash_content(b"hello"));
615
616 let status = app_entry_status(
617 &config,
618 &sample_app_category(),
619 &sample_app_file(),
620 &entry,
621 &BTreeMap::new(),
622 )
623 .await;
624
625 assert_eq!(status, FileStatus::UpdateAvail);
626 fs::remove_dir_all(&dir).await.unwrap();
627 }
628
629 #[tokio::test]
630 async fn app_file_row_status_reports_not_installed_without_manifest_entry() {
631 let dir = make_temp_dir().await;
632 let config = Config::new_for_test(&dir);
633 let manifest = AppManifest::default();
634 let category = AppCategory {
635 destination_root: Some(dir.display().to_string()),
636 ..sample_app_category()
637 };
638
639 let (dest, status) = app_file_row_status(
640 &config,
641 &category,
642 &sample_app_file(),
643 &manifest,
644 &BTreeMap::new(),
645 )
646 .await;
647
648 assert!(dest.is_some());
649 assert_eq!(status, FileStatus::NotInstalled);
650 fs::remove_dir_all(&dir).await.unwrap();
651 }
652
653 #[tokio::test]
654 async fn app_file_row_status_reports_new_file_in_installed_category_as_update() {
655 let dir = make_temp_dir().await;
656 let mut config = Config::new_for_test(&dir);
657 config.is_external_presets = true;
658 let source_dir = config.preset_path(Path::new("app/sample"));
659 fs::create_dir_all(&source_dir).await.unwrap();
660 fs::write(source_dir.join("new.txt"), b"new").await.unwrap();
661
662 let mut file = sample_app_file();
663 file.source_rel = PathBuf::from("new.txt");
664 file.target_rel = PathBuf::from("new.txt");
665 let category = AppCategory {
666 destination_root: Some(dir.join("dest").display().to_string()),
667 files: vec![file.clone()],
668 ..sample_app_category()
669 };
670 let manifest = AppManifest {
671 entries: vec![sample_app_entry(
672 dir.join("dest/old.txt"),
673 crate::install_core::hash_content(b"old"),
674 )],
675 };
676
677 let (_, status) =
678 app_file_row_status(&config, &category, &file, &manifest, &BTreeMap::new()).await;
679
680 assert_eq!(status, FileStatus::UpdateAvail);
681 fs::remove_dir_all(&dir).await.unwrap();
682 }
683
684 #[tokio::test]
685 async fn app_file_row_status_reports_destination_move_as_update() {
686 let dir = make_temp_dir().await;
687 let mut config = Config::new_for_test(&dir);
688 config.is_external_presets = true;
689 let source_dir = config.preset_path(Path::new("app/sample"));
690 fs::create_dir_all(&source_dir).await.unwrap();
691 fs::write(source_dir.join("dest.txt"), b"managed")
692 .await
693 .unwrap();
694
695 let old_destination = dir.join("old/dest.txt");
696 let category = AppCategory {
697 destination_root: Some(dir.join("new").display().to_string()),
698 ..sample_app_category()
699 };
700 let manifest = AppManifest {
701 entries: vec![sample_app_entry(
702 old_destination,
703 crate::install_core::hash_content(b"managed"),
704 )],
705 };
706
707 let (_, status) = app_file_row_status(
708 &config,
709 &category,
710 &category.files[0],
711 &manifest,
712 &BTreeMap::new(),
713 )
714 .await;
715
716 assert_eq!(status, FileStatus::UpdateAvail);
717 fs::remove_dir_all(&dir).await.unwrap();
718 }
719
720 #[cfg(not(unix))]
721 #[tokio::test]
722 async fn installed_shell_rows_use_windows_shim_path() {
723 let dir = make_temp_dir().await;
724 let cat_dir = dir.join("presets/shell/proxy");
725 fs::create_dir_all(&cat_dir).await.unwrap();
726 fs::write(
727 cat_dir.join("shine.toml"),
728 b"[[files]]\nsource = \"set_proxy.ps1\"\ntarget = \"setproxy\"\nneeds_source = true\n",
729 )
730 .await
731 .unwrap();
732 fs::write(cat_dir.join("set_proxy.ps1"), b"Write-Output proxy\n")
733 .await
734 .unwrap();
735
736 let mut config = Config::new_for_test(&dir);
737 config.is_external_presets = true;
738 fs::create_dir_all(config.bin_dir()).await.unwrap();
739 fs::write(config.bin_dir().join("setproxy.ps1"), b"# shine-managed\n")
740 .await
741 .unwrap();
742
743 let rows = build_shell_rows(&config).await.unwrap();
744 let row = rows
745 .iter()
746 .find(|row| row.label == "proxy/setproxy")
747 .expect("proxy/setproxy row should exist");
748
749 assert_eq!(row.status_sym, "✓");
750 assert_eq!(row.status_text, "up-to-date");
751 assert!(row.is_installed);
752
753 fs::remove_dir_all(&dir).await.unwrap();
754 }
755
756 #[cfg(unix)]
757 #[tokio::test]
758 async fn installed_shell_rows_report_up_to_date() {
759 let dir = make_temp_dir().await;
760 let cat_dir = dir.join("presets/shell/proxy");
761 fs::create_dir_all(&cat_dir).await.unwrap();
762 fs::write(
763 cat_dir.join("shine.toml"),
764 b"[[files]]\nsource = \"set_proxy.sh\"\ntarget = \"setproxy\"\nneeds_source = true\n",
765 )
766 .await
767 .unwrap();
768 let script = cat_dir.join("set_proxy.sh");
769 fs::write(&script, b"#!/bin/bash\necho proxy\n")
770 .await
771 .unwrap();
772 #[cfg(unix)]
773 {
774 use std::os::unix::fs::PermissionsExt;
775 let mut perms = fs::metadata(&script).await.unwrap().permissions();
776 perms.set_mode(0o755);
777 fs::set_permissions(&script, perms).await.unwrap();
778 }
779
780 let mut config = Config::new_for_test(&dir);
781 config.is_external_presets = true;
782 fs::create_dir_all(config.bin_dir()).await.unwrap();
783
784 crate::shells::handle_install(&config, Some("proxy"), false)
785 .await
786 .unwrap();
787
788 let rows = build_shell_rows(&config).await.unwrap();
789 let row = rows
790 .iter()
791 .find(|row| row.label == "proxy/setproxy")
792 .expect("proxy/setproxy row should exist");
793
794 assert_eq!(row.status_sym, "✓");
795 assert_eq!(row.status_text, "up-to-date");
796
797 fs::remove_dir_all(&dir).await.unwrap();
798 }
799
800 #[cfg(unix)]
801 #[tokio::test]
802 async fn external_template_shell_change_reports_update_available() {
803 let dir = make_temp_dir().await;
804 let cat_dir = dir.join("presets/shell/proxy");
805 fs::create_dir_all(&cat_dir).await.unwrap();
806 fs::write(
807 cat_dir.join("shine.toml"),
808 b"[[files]]\nsource = \"set_proxy.sh\"\ntarget = \"setproxy\"\nneeds_source = true\n",
809 )
810 .await
811 .unwrap();
812 let script = cat_dir.join("set_proxy.sh");
813 fs::write(
814 &script,
815 b"#!/bin/bash\n# shine-template: true\necho @@PROXY_HOST@@\n",
816 )
817 .await
818 .unwrap();
819
820 let mut config = Config::new_for_test(&dir);
821 config.is_external_presets = true;
822 fs::create_dir_all(config.bin_dir()).await.unwrap();
823
824 crate::shells::handle_install(&config, Some("proxy"), false)
825 .await
826 .unwrap();
827
828 fs::write(
829 &script,
830 b"#!/bin/bash\n# shine-template: true\necho changed @@PROXY_HOST@@\n",
831 )
832 .await
833 .unwrap();
834
835 let rows = build_shell_rows(&config).await.unwrap();
836 let row = rows
837 .iter()
838 .find(|row| row.label == "proxy/setproxy")
839 .expect("proxy/setproxy row should exist");
840
841 assert_eq!(row.status_sym, "↑");
842 assert_eq!(row.status_text, "update available");
843
844 fs::remove_dir_all(&dir).await.unwrap();
845 }
846
847 #[cfg(unix)]
848 #[tokio::test]
849 async fn live_raw_shell_change_stays_live_and_current() {
850 let dir = make_temp_dir().await;
851 let cat_dir = dir.join("presets/shell/custom");
852 fs::create_dir_all(&cat_dir).await.unwrap();
853 fs::write(
854 cat_dir.join("shine.toml"),
855 b"[[files]]\nsource = \"tool.sh\"\ntarget = \"mytool\"\n",
856 )
857 .await
858 .unwrap();
859 let source = cat_dir.join("tool.sh");
860 fs::write(&source, b"#!/bin/sh\necho first\n")
861 .await
862 .unwrap();
863
864 let mut config = Config::new_for_test(&dir);
865 config.is_external_presets = true;
866 config.external_shell_mode = crate::config::ExternalShellMode::Live;
867 fs::create_dir_all(config.bin_dir()).await.unwrap();
868 crate::shells::handle_install(&config, Some("custom"), false)
869 .await
870 .unwrap();
871 fs::write(&source, b"#!/bin/sh\necho second\n")
872 .await
873 .unwrap();
874
875 let rows = build_shell_rows(&config).await.unwrap();
876 let row = rows
877 .iter()
878 .find(|row| row.label == "custom/mytool")
879 .unwrap();
880 assert_eq!(row.status_sym, "✓");
881 assert_eq!(row.status_text, "live source");
882 fs::remove_dir_all(&dir).await.unwrap();
883 }
884
885 #[tokio::test]
886 async fn embedded_bun_source_change_reports_update_available() {
887 let dir = make_temp_dir().await;
888 let config = Config::new_for_test(&dir);
889 fs::create_dir_all(config.presets_dir()).await.unwrap();
890 fs::create_dir_all(config.bin_dir()).await.unwrap();
891
892 crate::shells::handle_install(&config, Some("agent"), false)
893 .await
894 .unwrap();
895
896 let extracted = config.presets_dir().join("shell/agent/cc.ts");
897 fs::write(&extracted, b"// stale extracted ccenv\n")
898 .await
899 .unwrap();
900
901 let rows = build_shell_rows(&config).await.unwrap();
902 let row = rows
903 .iter()
904 .find(|row| row.label == "agent/ccenv")
905 .expect("agent/ccenv row should exist");
906
907 assert_eq!(row.status_sym, "↑");
908 assert_eq!(row.status_text, "update available");
909
910 fs::remove_dir_all(&dir).await.unwrap();
911 }
912
913 #[tokio::test]
914 async fn embedded_shell_source_rename_reports_update_available() {
915 let dir = make_temp_dir().await;
916 let cat_dir = dir.join("presets/shell/agent");
917 fs::create_dir_all(&cat_dir).await.unwrap();
918 let old_source = if cfg!(windows) { "cc.ps1" } else { "cc.sh" };
919 fs::write(
920 cat_dir.join("shine.toml"),
921 format!(
922 "[[files]]\nsource = \"{old_source}\"\ntarget = \"ccenv\"\nneeds_source = true\n"
923 ),
924 )
925 .await
926 .unwrap();
927 fs::write(cat_dir.join(old_source), b"# old sourced ccenv\n")
928 .await
929 .unwrap();
930
931 let mut config = Config::new_for_test(&dir);
932 config.is_external_presets = true;
933 fs::create_dir_all(config.bin_dir()).await.unwrap();
934 crate::shells::handle_install(&config, Some("agent"), false)
935 .await
936 .unwrap();
937
938 config.is_external_presets = false;
939 let rows = build_shell_rows(&config).await.unwrap();
940 let row = rows
941 .iter()
942 .find(|row| row.label == "agent/ccenv")
943 .expect("embedded agent/ccenv row should exist");
944
945 assert_eq!(row.status_sym, "↑");
946 assert_eq!(row.status_text, "update available");
947
948 fs::remove_dir_all(&dir).await.unwrap();
949 }
950
951 #[tokio::test]
952 async fn external_shell_runtime_and_source_change_reports_update_available() {
953 let dir = make_temp_dir().await;
954 let cat_dir = dir.join("presets/shell/agent");
955 fs::create_dir_all(&cat_dir).await.unwrap();
956 let old_source = if cfg!(windows) { "cc.ps1" } else { "cc.sh" };
957 fs::write(
958 cat_dir.join("shine.toml"),
959 format!(
960 "[[files]]\nsource = \"{old_source}\"\ntarget = \"ccenv\"\nneeds_source = true\n"
961 ),
962 )
963 .await
964 .unwrap();
965 fs::write(cat_dir.join(old_source), b"# old sourced ccenv\n")
966 .await
967 .unwrap();
968
969 let mut config = Config::new_for_test(&dir);
970 config.is_external_presets = true;
971 fs::create_dir_all(config.bin_dir()).await.unwrap();
972 crate::shells::handle_install(&config, Some("agent"), false)
973 .await
974 .unwrap();
975
976 fs::write(
977 cat_dir.join("shine.toml"),
978 b"[[files]]\nsource = \"cc.ts\"\ntarget = \"ccenv\"\nruntime = \"bun\"\nplatforms = [\"unix\", \"windows\"]\n",
979 )
980 .await
981 .unwrap();
982 fs::write(cat_dir.join("cc.ts"), b"console.log('new ccenv');\n")
983 .await
984 .unwrap();
985
986 let rows = build_shell_rows(&config).await.unwrap();
987 let row = rows
988 .iter()
989 .find(|row| row.label == "agent/ccenv")
990 .expect("external agent/ccenv row should exist");
991
992 assert_eq!(row.status_sym, "↑");
993 assert_eq!(row.status_text, "update available");
994
995 fs::remove_dir_all(&dir).await.unwrap();
996 }
997
998 #[cfg(unix)]
999 #[tokio::test]
1000 async fn shell_env_change_reports_update_available() {
1001 let dir = make_temp_dir().await;
1002 let cat_dir = dir.join("presets/shell/proxy");
1003 fs::create_dir_all(&cat_dir).await.unwrap();
1004 fs::write(
1005 cat_dir.join("shine.toml"),
1006 b"[[files]]\nsource = \"set_proxy.sh\"\ntarget = \"setproxy\"\nneeds_source = true\n",
1007 )
1008 .await
1009 .unwrap();
1010 fs::write(
1011 cat_dir.join("set_proxy.sh"),
1012 b"#!/bin/bash\n# shine-template: true\nPROXY_NO_PROXY=\"@@PROXY_NO_PROXY@@\"\n",
1013 )
1014 .await
1015 .unwrap();
1016
1017 let mut config = Config::new_for_test(&dir);
1018 config.is_external_presets = true;
1019 fs::create_dir_all(config.bin_dir()).await.unwrap();
1020
1021 crate::shells::handle_install(&config, Some("proxy"), false)
1022 .await
1023 .unwrap();
1024
1025 config.env.insert(
1026 "PROXY_NO_PROXY".to_string(),
1027 "localhost,127.0.0.1,::1,.local".to_string(),
1028 );
1029
1030 let rows = build_shell_rows(&config).await.unwrap();
1031 let row = rows
1032 .iter()
1033 .find(|row| row.label == "proxy/setproxy")
1034 .expect("proxy/setproxy row should exist");
1035
1036 assert_eq!(row.status_sym, "↑");
1037 assert_eq!(row.status_text, "update available");
1038
1039 fs::remove_dir_all(&dir).await.unwrap();
1040 }
1041
1042 #[tokio::test]
1043 async fn category_list_mode_aggregates_explicit_app_files() {
1044 let dir = make_temp_dir().await;
1045 let config = Config::new_for_test(&dir);
1046 fs::create_dir_all(config.shine_dir()).await.unwrap();
1047
1048 let category = AppCategory {
1049 name: "ghostty".to_string(),
1050 description: Some("Ghostty terminal configuration.".to_string()),
1051 destination_root: Some(dir.join(".config/ghostty").display().to_string()),
1052 files: vec![
1053 AppFile {
1054 source_rel: PathBuf::from("config.ghostty"),
1055 target_rel: PathBuf::from("config.ghostty"),
1056 destination_root: None,
1057 description: None,
1058 display_name: None,
1059 legacy_dest_annotation: None,
1060 transforms: vec![],
1061 install_strategy: AppInstallStrategy::Copy,
1062 requires_admin: false,
1063 restart_hint: None,
1064 generator: None,
1065 },
1066 AppFile {
1067 source_rel: PathBuf::from("themes/shine-light"),
1068 target_rel: PathBuf::from("themes/shine-light"),
1069 destination_root: None,
1070 description: None,
1071 display_name: None,
1072 legacy_dest_annotation: None,
1073 transforms: vec!["template".to_string()],
1074 install_strategy: AppInstallStrategy::Copy,
1075 requires_admin: false,
1076 restart_hint: None,
1077 generator: None,
1078 },
1079 ],
1080 list_mode: AppListMode::Category,
1081 post_upgrade: Vec::new(),
1082 post_install: Vec::new(),
1083 uses_metadata: true,
1084 has_explicit_files: true,
1085 artifact: None,
1086 };
1087
1088 let rows = build_app_rows(&config, &[category]).await.unwrap();
1089
1090 assert_eq!(rows.len(), 1);
1091 assert_eq!(rows[0].label, "ghostty");
1092 assert_eq!(rows[0].simple_label, "ghostty");
1093 assert_eq!(rows[0].dest.as_deref(), Some("~/.config/ghostty"));
1094 assert_eq!(rows[0].file_status, FileStatus::NotInstalled);
1095
1096 fs::remove_dir_all(&dir).await.unwrap();
1097 }
1098
1099 #[tokio::test]
1100 async fn file_list_mode_keeps_file_labels_for_multi_file_app_simple_list() {
1101 let dir = make_temp_dir().await;
1102 let config = Config::new_for_test(&dir);
1103 fs::create_dir_all(config.shine_dir()).await.unwrap();
1104
1105 let category = AppCategory {
1106 name: "sample".to_string(),
1107 description: None,
1108 destination_root: Some(dir.join(".config/sample").display().to_string()),
1109 files: vec![
1110 AppFile {
1111 source_rel: PathBuf::from("config.toml"),
1112 target_rel: PathBuf::from("config.toml"),
1113 destination_root: None,
1114 description: None,
1115 display_name: None,
1116 legacy_dest_annotation: None,
1117 transforms: vec![],
1118 install_strategy: AppInstallStrategy::Copy,
1119 requires_admin: false,
1120 restart_hint: None,
1121 generator: None,
1122 },
1123 AppFile {
1124 source_rel: PathBuf::from("theme.toml"),
1125 target_rel: PathBuf::from("theme.toml"),
1126 destination_root: None,
1127 description: None,
1128 display_name: None,
1129 legacy_dest_annotation: None,
1130 transforms: vec![],
1131 install_strategy: AppInstallStrategy::Copy,
1132 requires_admin: false,
1133 restart_hint: None,
1134 generator: None,
1135 },
1136 ],
1137 list_mode: AppListMode::Files,
1138 post_upgrade: Vec::new(),
1139 post_install: Vec::new(),
1140 uses_metadata: true,
1141 has_explicit_files: true,
1142 artifact: None,
1143 };
1144
1145 let rows = build_app_rows(&config, &[category]).await.unwrap();
1146
1147 assert_eq!(rows.len(), 2);
1148 assert_eq!(rows[0].label, "sample/config.toml");
1149 assert_eq!(rows[0].simple_label, "sample/config.toml");
1150 assert_eq!(rows[1].label, "sample/theme.toml");
1151 assert_eq!(rows[1].simple_label, "sample/theme.toml");
1152
1153 fs::remove_dir_all(&dir).await.unwrap();
1154 }
1155
1156 #[cfg(windows)]
1157 #[tokio::test]
1158 async fn windows_docker_engine_row_uses_engine_destination() {
1159 let _guard = env_lock();
1160 let dir = make_temp_dir().await;
1161 unsafe { std::env::set_var("HOME", dir.to_str().unwrap()) };
1164 let config = Config::new_for_test(&dir);
1165 fs::create_dir_all(config.shine_dir()).await.unwrap();
1166
1167 let categories = crate::apps::load_embedded_categories(Some("docker-engine")).unwrap();
1168 let rows = build_app_rows(&config, &categories).await.unwrap();
1169
1170 assert_eq!(rows.len(), 1);
1171 assert_eq!(rows[0].label, "docker-engine/daemon.jsonc");
1172 assert_eq!(rows[0].simple_label, "docker-engine");
1173 assert_eq!(rows[0].file_status, FileStatus::NotInstalled);
1174 assert_eq!(rows[0].dest.as_deref(), Some("~/.docker/daemon.json"));
1175
1176 unsafe { std::env::remove_var("HOME") };
1178 fs::remove_dir_all(&dir).await.unwrap();
1179 }
1180
1181 #[cfg(windows)]
1182 #[tokio::test]
1183 async fn windows_docker_desktop_row_uses_forward_slash_destination() {
1184 let _guard = env_lock();
1185 let dir = make_temp_dir().await;
1186 unsafe { std::env::set_var("HOME", dir.to_str().unwrap()) };
1189 let config = Config::new_for_test(&dir);
1190 fs::create_dir_all(config.shine_dir()).await.unwrap();
1191
1192 let categories = crate::apps::load_embedded_categories(Some("docker-desktop")).unwrap();
1193 let rows = build_app_rows(&config, &categories).await.unwrap();
1194
1195 assert_eq!(rows.len(), 1);
1196 assert_eq!(rows[0].label, "docker-desktop/settings-store.jsonc");
1197 assert_eq!(rows[0].simple_label, "docker-desktop");
1198 assert_eq!(rows[0].file_status, FileStatus::NotInstalled);
1199 assert_eq!(
1200 rows[0].dest.as_deref(),
1201 Some("~/AppData/Roaming/Docker/settings-store.json")
1202 );
1203
1204 unsafe { std::env::remove_var("HOME") };
1206 fs::remove_dir_all(&dir).await.unwrap();
1207 }
1208}