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
365async 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 status = match manifest.find_by_dest(&dest) {
376 None if file.generator.as_ref().is_some_and(|generator| {
377 generator.auto && env.contains_key(&generator.when_env)
378 }) && manifest.entries.iter().any(|entry| {
379 entry
380 .source
381 .strip_prefix("app/")
382 .and_then(|source| source.split_once('/'))
383 .is_some_and(|(category, _)| category == cat.name)
384 }) =>
385 {
386 if source_hash_for_file(config, cat, file, env).await.is_some() {
387 FileStatus::UpdateAvail
388 } else {
389 FileStatus::NotInstalled
390 }
391 }
392 None => FileStatus::NotInstalled,
393 Some(entry) => app_entry_status(config, cat, file, entry, env).await,
394 };
395 (Some(dest), status)
396 }
397 }
398}
399
400pub(crate) async fn app_entry_status(
409 config: &Config,
410 cat: &AppCategory,
411 file: &crate::apps::AppFile,
412 entry: &AppEntry,
413 env: &BTreeMap<String, String>,
414) -> FileStatus {
415 let generator_enabled = file
419 .generator
420 .as_ref()
421 .is_some_and(|generator| generator.auto && env.contains_key(&generator.when_env));
422 let manual_generator = file
423 .generator
424 .as_ref()
425 .is_some_and(|generator| !generator.auto);
426 let generated_source_hash = if generator_enabled {
427 source_hash_for_file(config, cat, file, env).await
428 } else {
429 None
430 };
431 if !entry.destination.exists() {
432 return FileStatus::Missing;
433 }
434 match tokio::fs::read(&entry.destination).await {
435 Err(_) => FileStatus::Missing,
436 Ok(dest_bytes) => {
437 let manifest_hash = entry.content_hash;
438 match installed_content_hash(file, &dest_bytes) {
439 Ok(Some(dest_hash)) if dest_hash == manifest_hash => {
440 if manual_generator {
441 return FileStatus::UpToDate;
442 }
443 let source_hash = if generator_enabled {
444 generated_source_hash
445 } else {
446 source_hash_for_file(config, cat, file, env).await
447 };
448 match source_hash {
449 Some(src) if src != manifest_hash => FileStatus::UpdateAvail,
450 _ => FileStatus::UpToDate,
451 }
452 }
453 Ok(None) => FileStatus::Missing,
454 Ok(Some(_)) | Err(_) => FileStatus::UserModified,
455 }
456 }
457 }
458}
459
460#[cfg(test)]
461mod tests {
462 use super::*;
463 use crate::apps::AppFile;
464 use crate::config::Config;
465 use crate::install_core::AppInstallStrategy;
466 #[cfg(windows)]
467 use crate::test_support::env_lock;
468 use std::path::PathBuf;
469 use tokio::fs;
470
471 async fn make_temp_dir() -> std::path::PathBuf {
472 crate::test_support::make_temp_dir("shine-check").await
473 }
474
475 fn sample_app_file() -> AppFile {
476 AppFile {
477 source_rel: PathBuf::from("dest.txt"),
478 target_rel: PathBuf::from("dest.txt"),
479 description: None,
480 display_name: None,
481 legacy_dest_annotation: None,
482 transforms: vec![],
483 install_strategy: AppInstallStrategy::Copy,
484 requires_admin: false,
485 restart_hint: None,
486 generator: None,
487 }
488 }
489
490 fn sample_app_category() -> AppCategory {
491 AppCategory {
492 name: "sample".to_string(),
493 description: None,
494 destination_root: None,
495 files: vec![sample_app_file()],
496 list_mode: AppListMode::Files,
497 post_upgrade: Vec::new(),
498 post_install: Vec::new(),
499 uses_metadata: true,
500 has_explicit_files: true,
501 artifact: None,
502 }
503 }
504
505 fn sample_app_entry(destination: PathBuf, content_hash: u64) -> AppEntry {
506 AppEntry {
507 source: "app/sample/dest.txt".to_string(),
508 destination,
509 backup: None,
510 content_hash,
511 install_strategy: AppInstallStrategy::Copy,
512 uses_env: false,
513 requires_admin: false,
514 }
515 }
516
517 #[tokio::test]
518 async fn app_entry_status_reports_missing_when_destination_absent() {
519 let dir = make_temp_dir().await;
520 let config = Config::new_for_test(&dir);
521 let dest = dir.join("dest.txt");
522 let entry = sample_app_entry(dest, crate::install_core::hash_content(b"hello"));
523
524 let status = app_entry_status(
525 &config,
526 &sample_app_category(),
527 &sample_app_file(),
528 &entry,
529 &BTreeMap::new(),
530 )
531 .await;
532
533 assert_eq!(status, FileStatus::Missing);
534 fs::remove_dir_all(&dir).await.unwrap();
535 }
536
537 #[tokio::test]
538 async fn app_entry_status_reports_user_modified_when_dest_hash_differs() {
539 let dir = make_temp_dir().await;
540 let config = Config::new_for_test(&dir);
541 let dest = dir.join("dest.txt");
542 fs::write(&dest, b"locally edited").await.unwrap();
543 let entry = sample_app_entry(dest, crate::install_core::hash_content(b"original"));
544
545 let status = app_entry_status(
546 &config,
547 &sample_app_category(),
548 &sample_app_file(),
549 &entry,
550 &BTreeMap::new(),
551 )
552 .await;
553
554 assert_eq!(status, FileStatus::UserModified);
555 fs::remove_dir_all(&dir).await.unwrap();
556 }
557
558 #[tokio::test]
559 async fn app_entry_status_reports_up_to_date_when_source_unreadable() {
560 let dir = make_temp_dir().await;
564 let config = Config::new_for_test(&dir);
565 let dest = dir.join("dest.txt");
566 fs::write(&dest, b"hello").await.unwrap();
567 let entry = sample_app_entry(dest, crate::install_core::hash_content(b"hello"));
568
569 let status = app_entry_status(
570 &config,
571 &sample_app_category(),
572 &sample_app_file(),
573 &entry,
574 &BTreeMap::new(),
575 )
576 .await;
577
578 assert_eq!(status, FileStatus::UpToDate);
579 fs::remove_dir_all(&dir).await.unwrap();
580 }
581
582 #[tokio::test]
583 async fn app_entry_status_reports_update_available_when_source_changed() {
584 let dir = make_temp_dir().await;
585 let mut config = Config::new_for_test(&dir);
586 config.is_external_presets = true;
587
588 let source_path = config.preset_path(Path::new("app").join("sample").join("dest.txt"));
589 fs::create_dir_all(source_path.parent().unwrap())
590 .await
591 .unwrap();
592 fs::write(&source_path, b"new upstream content")
593 .await
594 .unwrap();
595
596 let dest = dir.join("dest.txt");
597 fs::write(&dest, b"hello").await.unwrap();
598 let entry = sample_app_entry(dest, crate::install_core::hash_content(b"hello"));
599
600 let status = app_entry_status(
601 &config,
602 &sample_app_category(),
603 &sample_app_file(),
604 &entry,
605 &BTreeMap::new(),
606 )
607 .await;
608
609 assert_eq!(status, FileStatus::UpdateAvail);
610 fs::remove_dir_all(&dir).await.unwrap();
611 }
612
613 #[tokio::test]
614 async fn app_file_row_status_reports_not_installed_without_manifest_entry() {
615 let dir = make_temp_dir().await;
616 let config = Config::new_for_test(&dir);
617 let manifest = AppManifest::default();
618 let category = AppCategory {
619 destination_root: Some(dir.display().to_string()),
620 ..sample_app_category()
621 };
622
623 let (dest, status) = app_file_row_status(
624 &config,
625 &category,
626 &sample_app_file(),
627 &manifest,
628 &BTreeMap::new(),
629 )
630 .await;
631
632 assert!(dest.is_some());
633 assert_eq!(status, FileStatus::NotInstalled);
634 fs::remove_dir_all(&dir).await.unwrap();
635 }
636
637 #[cfg(not(unix))]
638 #[tokio::test]
639 async fn installed_shell_rows_use_windows_shim_path() {
640 let dir = make_temp_dir().await;
641 let cat_dir = dir.join("presets/shell/proxy");
642 fs::create_dir_all(&cat_dir).await.unwrap();
643 fs::write(
644 cat_dir.join("shine.toml"),
645 b"[[files]]\nsource = \"set_proxy.ps1\"\ntarget = \"setproxy\"\nneeds_source = true\n",
646 )
647 .await
648 .unwrap();
649 fs::write(cat_dir.join("set_proxy.ps1"), b"Write-Output proxy\n")
650 .await
651 .unwrap();
652
653 let mut config = Config::new_for_test(&dir);
654 config.is_external_presets = true;
655 fs::create_dir_all(config.bin_dir()).await.unwrap();
656 fs::write(config.bin_dir().join("setproxy.ps1"), b"# shine-managed\n")
657 .await
658 .unwrap();
659
660 let rows = build_shell_rows(&config).await.unwrap();
661 let row = rows
662 .iter()
663 .find(|row| row.label == "proxy/setproxy")
664 .expect("proxy/setproxy row should exist");
665
666 assert_eq!(row.status_sym, "✓");
667 assert_eq!(row.status_text, "up-to-date");
668 assert!(row.is_installed);
669
670 fs::remove_dir_all(&dir).await.unwrap();
671 }
672
673 #[cfg(unix)]
674 #[tokio::test]
675 async fn installed_shell_rows_report_up_to_date() {
676 let dir = make_temp_dir().await;
677 let cat_dir = dir.join("presets/shell/proxy");
678 fs::create_dir_all(&cat_dir).await.unwrap();
679 fs::write(
680 cat_dir.join("shine.toml"),
681 b"[[files]]\nsource = \"set_proxy.sh\"\ntarget = \"setproxy\"\nneeds_source = true\n",
682 )
683 .await
684 .unwrap();
685 let script = cat_dir.join("set_proxy.sh");
686 fs::write(&script, b"#!/bin/bash\necho proxy\n")
687 .await
688 .unwrap();
689 #[cfg(unix)]
690 {
691 use std::os::unix::fs::PermissionsExt;
692 let mut perms = fs::metadata(&script).await.unwrap().permissions();
693 perms.set_mode(0o755);
694 fs::set_permissions(&script, perms).await.unwrap();
695 }
696
697 let mut config = Config::new_for_test(&dir);
698 config.is_external_presets = true;
699 fs::create_dir_all(config.bin_dir()).await.unwrap();
700
701 crate::shells::handle_install(&config, Some("proxy"), false)
702 .await
703 .unwrap();
704
705 let rows = build_shell_rows(&config).await.unwrap();
706 let row = rows
707 .iter()
708 .find(|row| row.label == "proxy/setproxy")
709 .expect("proxy/setproxy row should exist");
710
711 assert_eq!(row.status_sym, "✓");
712 assert_eq!(row.status_text, "up-to-date");
713
714 fs::remove_dir_all(&dir).await.unwrap();
715 }
716
717 #[cfg(unix)]
718 #[tokio::test]
719 async fn external_template_shell_change_reports_update_available() {
720 let dir = make_temp_dir().await;
721 let cat_dir = dir.join("presets/shell/proxy");
722 fs::create_dir_all(&cat_dir).await.unwrap();
723 fs::write(
724 cat_dir.join("shine.toml"),
725 b"[[files]]\nsource = \"set_proxy.sh\"\ntarget = \"setproxy\"\nneeds_source = true\n",
726 )
727 .await
728 .unwrap();
729 let script = cat_dir.join("set_proxy.sh");
730 fs::write(
731 &script,
732 b"#!/bin/bash\n# shine-template: true\necho @@PROXY_HOST@@\n",
733 )
734 .await
735 .unwrap();
736
737 let mut config = Config::new_for_test(&dir);
738 config.is_external_presets = true;
739 fs::create_dir_all(config.bin_dir()).await.unwrap();
740
741 crate::shells::handle_install(&config, Some("proxy"), false)
742 .await
743 .unwrap();
744
745 fs::write(
746 &script,
747 b"#!/bin/bash\n# shine-template: true\necho changed @@PROXY_HOST@@\n",
748 )
749 .await
750 .unwrap();
751
752 let rows = build_shell_rows(&config).await.unwrap();
753 let row = rows
754 .iter()
755 .find(|row| row.label == "proxy/setproxy")
756 .expect("proxy/setproxy row should exist");
757
758 assert_eq!(row.status_sym, "↑");
759 assert_eq!(row.status_text, "update available");
760
761 fs::remove_dir_all(&dir).await.unwrap();
762 }
763
764 #[cfg(unix)]
765 #[tokio::test]
766 async fn live_raw_shell_change_stays_live_and_current() {
767 let dir = make_temp_dir().await;
768 let cat_dir = dir.join("presets/shell/custom");
769 fs::create_dir_all(&cat_dir).await.unwrap();
770 fs::write(
771 cat_dir.join("shine.toml"),
772 b"[[files]]\nsource = \"tool.sh\"\ntarget = \"mytool\"\n",
773 )
774 .await
775 .unwrap();
776 let source = cat_dir.join("tool.sh");
777 fs::write(&source, b"#!/bin/sh\necho first\n")
778 .await
779 .unwrap();
780
781 let mut config = Config::new_for_test(&dir);
782 config.is_external_presets = true;
783 config.external_shell_mode = crate::config::ExternalShellMode::Live;
784 fs::create_dir_all(config.bin_dir()).await.unwrap();
785 crate::shells::handle_install(&config, Some("custom"), false)
786 .await
787 .unwrap();
788 fs::write(&source, b"#!/bin/sh\necho second\n")
789 .await
790 .unwrap();
791
792 let rows = build_shell_rows(&config).await.unwrap();
793 let row = rows
794 .iter()
795 .find(|row| row.label == "custom/mytool")
796 .unwrap();
797 assert_eq!(row.status_sym, "✓");
798 assert_eq!(row.status_text, "live source");
799 fs::remove_dir_all(&dir).await.unwrap();
800 }
801
802 #[tokio::test]
803 async fn embedded_bun_source_change_reports_update_available() {
804 let dir = make_temp_dir().await;
805 let config = Config::new_for_test(&dir);
806 fs::create_dir_all(config.presets_dir()).await.unwrap();
807 fs::create_dir_all(config.bin_dir()).await.unwrap();
808
809 crate::shells::handle_install(&config, Some("agent"), false)
810 .await
811 .unwrap();
812
813 let extracted = config.presets_dir().join("shell/agent/cc.ts");
814 fs::write(&extracted, b"// stale extracted ccenv\n")
815 .await
816 .unwrap();
817
818 let rows = build_shell_rows(&config).await.unwrap();
819 let row = rows
820 .iter()
821 .find(|row| row.label == "agent/ccenv")
822 .expect("agent/ccenv row should exist");
823
824 assert_eq!(row.status_sym, "↑");
825 assert_eq!(row.status_text, "update available");
826
827 fs::remove_dir_all(&dir).await.unwrap();
828 }
829
830 #[tokio::test]
831 async fn embedded_shell_source_rename_reports_update_available() {
832 let dir = make_temp_dir().await;
833 let cat_dir = dir.join("presets/shell/agent");
834 fs::create_dir_all(&cat_dir).await.unwrap();
835 let old_source = if cfg!(windows) { "cc.ps1" } else { "cc.sh" };
836 fs::write(
837 cat_dir.join("shine.toml"),
838 format!(
839 "[[files]]\nsource = \"{old_source}\"\ntarget = \"ccenv\"\nneeds_source = true\n"
840 ),
841 )
842 .await
843 .unwrap();
844 fs::write(cat_dir.join(old_source), b"# old sourced ccenv\n")
845 .await
846 .unwrap();
847
848 let mut config = Config::new_for_test(&dir);
849 config.is_external_presets = true;
850 fs::create_dir_all(config.bin_dir()).await.unwrap();
851 crate::shells::handle_install(&config, Some("agent"), false)
852 .await
853 .unwrap();
854
855 config.is_external_presets = false;
856 let rows = build_shell_rows(&config).await.unwrap();
857 let row = rows
858 .iter()
859 .find(|row| row.label == "agent/ccenv")
860 .expect("embedded agent/ccenv row should exist");
861
862 assert_eq!(row.status_sym, "↑");
863 assert_eq!(row.status_text, "update available");
864
865 fs::remove_dir_all(&dir).await.unwrap();
866 }
867
868 #[tokio::test]
869 async fn external_shell_runtime_and_source_change_reports_update_available() {
870 let dir = make_temp_dir().await;
871 let cat_dir = dir.join("presets/shell/agent");
872 fs::create_dir_all(&cat_dir).await.unwrap();
873 let old_source = if cfg!(windows) { "cc.ps1" } else { "cc.sh" };
874 fs::write(
875 cat_dir.join("shine.toml"),
876 format!(
877 "[[files]]\nsource = \"{old_source}\"\ntarget = \"ccenv\"\nneeds_source = true\n"
878 ),
879 )
880 .await
881 .unwrap();
882 fs::write(cat_dir.join(old_source), b"# old sourced ccenv\n")
883 .await
884 .unwrap();
885
886 let mut config = Config::new_for_test(&dir);
887 config.is_external_presets = true;
888 fs::create_dir_all(config.bin_dir()).await.unwrap();
889 crate::shells::handle_install(&config, Some("agent"), false)
890 .await
891 .unwrap();
892
893 fs::write(
894 cat_dir.join("shine.toml"),
895 b"[[files]]\nsource = \"cc.ts\"\ntarget = \"ccenv\"\nruntime = \"bun\"\nplatforms = [\"unix\", \"windows\"]\n",
896 )
897 .await
898 .unwrap();
899 fs::write(cat_dir.join("cc.ts"), b"console.log('new ccenv');\n")
900 .await
901 .unwrap();
902
903 let rows = build_shell_rows(&config).await.unwrap();
904 let row = rows
905 .iter()
906 .find(|row| row.label == "agent/ccenv")
907 .expect("external agent/ccenv row should exist");
908
909 assert_eq!(row.status_sym, "↑");
910 assert_eq!(row.status_text, "update available");
911
912 fs::remove_dir_all(&dir).await.unwrap();
913 }
914
915 #[cfg(unix)]
916 #[tokio::test]
917 async fn shell_env_change_reports_update_available() {
918 let dir = make_temp_dir().await;
919 let cat_dir = dir.join("presets/shell/proxy");
920 fs::create_dir_all(&cat_dir).await.unwrap();
921 fs::write(
922 cat_dir.join("shine.toml"),
923 b"[[files]]\nsource = \"set_proxy.sh\"\ntarget = \"setproxy\"\nneeds_source = true\n",
924 )
925 .await
926 .unwrap();
927 fs::write(
928 cat_dir.join("set_proxy.sh"),
929 b"#!/bin/bash\n# shine-template: true\nPROXY_NO_PROXY=\"@@PROXY_NO_PROXY@@\"\n",
930 )
931 .await
932 .unwrap();
933
934 let mut config = Config::new_for_test(&dir);
935 config.is_external_presets = true;
936 fs::create_dir_all(config.bin_dir()).await.unwrap();
937
938 crate::shells::handle_install(&config, Some("proxy"), false)
939 .await
940 .unwrap();
941
942 config.env.insert(
943 "PROXY_NO_PROXY".to_string(),
944 "localhost,127.0.0.1,::1,.local".to_string(),
945 );
946
947 let rows = build_shell_rows(&config).await.unwrap();
948 let row = rows
949 .iter()
950 .find(|row| row.label == "proxy/setproxy")
951 .expect("proxy/setproxy row should exist");
952
953 assert_eq!(row.status_sym, "↑");
954 assert_eq!(row.status_text, "update available");
955
956 fs::remove_dir_all(&dir).await.unwrap();
957 }
958
959 #[tokio::test]
960 async fn category_list_mode_aggregates_explicit_app_files() {
961 let dir = make_temp_dir().await;
962 let config = Config::new_for_test(&dir);
963 fs::create_dir_all(config.shine_dir()).await.unwrap();
964
965 let category = AppCategory {
966 name: "ghostty".to_string(),
967 description: Some("Ghostty terminal configuration.".to_string()),
968 destination_root: Some(dir.join(".config/ghostty").display().to_string()),
969 files: vec![
970 AppFile {
971 source_rel: PathBuf::from("config.ghostty"),
972 target_rel: PathBuf::from("config.ghostty"),
973 description: None,
974 display_name: None,
975 legacy_dest_annotation: None,
976 transforms: vec![],
977 install_strategy: AppInstallStrategy::Copy,
978 requires_admin: false,
979 restart_hint: None,
980 generator: None,
981 },
982 AppFile {
983 source_rel: PathBuf::from("themes/shine-light"),
984 target_rel: PathBuf::from("themes/shine-light"),
985 description: None,
986 display_name: None,
987 legacy_dest_annotation: None,
988 transforms: vec!["template".to_string()],
989 install_strategy: AppInstallStrategy::Copy,
990 requires_admin: false,
991 restart_hint: None,
992 generator: None,
993 },
994 ],
995 list_mode: AppListMode::Category,
996 post_upgrade: Vec::new(),
997 post_install: Vec::new(),
998 uses_metadata: true,
999 has_explicit_files: true,
1000 artifact: None,
1001 };
1002
1003 let rows = build_app_rows(&config, &[category]).await.unwrap();
1004
1005 assert_eq!(rows.len(), 1);
1006 assert_eq!(rows[0].label, "ghostty");
1007 assert_eq!(rows[0].simple_label, "ghostty");
1008 assert_eq!(rows[0].dest.as_deref(), Some("~/.config/ghostty"));
1009 assert_eq!(rows[0].file_status, FileStatus::NotInstalled);
1010
1011 fs::remove_dir_all(&dir).await.unwrap();
1012 }
1013
1014 #[tokio::test]
1015 async fn file_list_mode_keeps_file_labels_for_multi_file_app_simple_list() {
1016 let dir = make_temp_dir().await;
1017 let config = Config::new_for_test(&dir);
1018 fs::create_dir_all(config.shine_dir()).await.unwrap();
1019
1020 let category = AppCategory {
1021 name: "sample".to_string(),
1022 description: None,
1023 destination_root: Some(dir.join(".config/sample").display().to_string()),
1024 files: vec![
1025 AppFile {
1026 source_rel: PathBuf::from("config.toml"),
1027 target_rel: PathBuf::from("config.toml"),
1028 description: None,
1029 display_name: None,
1030 legacy_dest_annotation: None,
1031 transforms: vec![],
1032 install_strategy: AppInstallStrategy::Copy,
1033 requires_admin: false,
1034 restart_hint: None,
1035 generator: None,
1036 },
1037 AppFile {
1038 source_rel: PathBuf::from("theme.toml"),
1039 target_rel: PathBuf::from("theme.toml"),
1040 description: None,
1041 display_name: None,
1042 legacy_dest_annotation: None,
1043 transforms: vec![],
1044 install_strategy: AppInstallStrategy::Copy,
1045 requires_admin: false,
1046 restart_hint: None,
1047 generator: None,
1048 },
1049 ],
1050 list_mode: AppListMode::Files,
1051 post_upgrade: Vec::new(),
1052 post_install: Vec::new(),
1053 uses_metadata: true,
1054 has_explicit_files: true,
1055 artifact: None,
1056 };
1057
1058 let rows = build_app_rows(&config, &[category]).await.unwrap();
1059
1060 assert_eq!(rows.len(), 2);
1061 assert_eq!(rows[0].label, "sample/config.toml");
1062 assert_eq!(rows[0].simple_label, "sample/config.toml");
1063 assert_eq!(rows[1].label, "sample/theme.toml");
1064 assert_eq!(rows[1].simple_label, "sample/theme.toml");
1065
1066 fs::remove_dir_all(&dir).await.unwrap();
1067 }
1068
1069 #[cfg(windows)]
1070 #[tokio::test]
1071 async fn windows_docker_engine_row_uses_engine_destination() {
1072 let _guard = env_lock();
1073 let dir = make_temp_dir().await;
1074 unsafe { std::env::set_var("HOME", dir.to_str().unwrap()) };
1077 let config = Config::new_for_test(&dir);
1078 fs::create_dir_all(config.shine_dir()).await.unwrap();
1079
1080 let categories = crate::apps::load_embedded_categories(Some("docker-engine")).unwrap();
1081 let rows = build_app_rows(&config, &categories).await.unwrap();
1082
1083 assert_eq!(rows.len(), 1);
1084 assert_eq!(rows[0].label, "docker-engine/daemon.jsonc");
1085 assert_eq!(rows[0].simple_label, "docker-engine");
1086 assert_eq!(rows[0].file_status, FileStatus::NotInstalled);
1087 assert_eq!(rows[0].dest.as_deref(), Some("~/.docker/daemon.json"));
1088
1089 unsafe { std::env::remove_var("HOME") };
1091 fs::remove_dir_all(&dir).await.unwrap();
1092 }
1093
1094 #[cfg(windows)]
1095 #[tokio::test]
1096 async fn windows_docker_desktop_row_uses_forward_slash_destination() {
1097 let _guard = env_lock();
1098 let dir = make_temp_dir().await;
1099 unsafe { std::env::set_var("HOME", dir.to_str().unwrap()) };
1102 let config = Config::new_for_test(&dir);
1103 fs::create_dir_all(config.shine_dir()).await.unwrap();
1104
1105 let categories = crate::apps::load_embedded_categories(Some("docker-desktop")).unwrap();
1106 let rows = build_app_rows(&config, &categories).await.unwrap();
1107
1108 assert_eq!(rows.len(), 1);
1109 assert_eq!(rows[0].label, "docker-desktop/settings-store.jsonc");
1110 assert_eq!(rows[0].simple_label, "docker-desktop");
1111 assert_eq!(rows[0].file_status, FileStatus::NotInstalled);
1112 assert_eq!(
1113 rows[0].dest.as_deref(),
1114 Some("~/AppData/Roaming/Docker/settings-store.json")
1115 );
1116
1117 unsafe { std::env::remove_var("HOME") };
1119 fs::remove_dir_all(&dir).await.unwrap();
1120 }
1121}