use crate::apps::{
AppCategory, AppListMode, installed_content_hash, resolve_install_destination,
source_hash_for_file,
};
use crate::colors;
use crate::config::Config;
use crate::env::EnvConfig;
use crate::install_core::{AppEntry, AppManifest, apply_transforms};
use crate::path_display;
use anyhow::Result;
use std::collections::BTreeMap;
use std::ffi::OsString;
use std::path::Path;
#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Clone, Copy)]
pub enum FileStatus {
NotInstalled,
UpToDate,
UpdateAvail,
Partial,
UserModified,
Missing,
}
pub struct ShellRow {
pub symbol: String,
pub label: String,
pub status_sym: &'static str,
pub status_text: &'static str,
pub is_installed: bool,
}
pub struct AppRow {
pub category: String,
pub sym: &'static str,
pub label: String,
pub simple_label: String,
pub dest: Option<String>,
pub status_text: &'static str,
pub file_status: FileStatus,
}
pub async fn build_shell_rows(config: &Config) -> Result<Vec<ShellRow>> {
let categories = crate::shells::metadata::load_active_categories(config, None).await?;
if categories.is_empty() {
return Ok(Vec::new());
}
let bin_dir = config.bin_dir();
let shell_manifest = crate::shells::deployment::ShellManifest::load(config).await?;
let mut rows: Vec<ShellRow> = Vec::new();
for cat in &categories {
let snapshot_current =
crate::shells::deployment::snapshot_category_current(config, &cat.name)
.await
.unwrap_or(false);
for script in &cat.files {
let desired_path = crate::shells::deployment::desired_source_path(
config,
&cat.name,
&script.source_rel,
);
let script_path = crate::shells::deployment::deployment_source_path(
config,
&cat.name,
&script.source_rel,
);
let source_key = format!("shell/{}/{}", cat.name, script.source_rel.display());
let display_name = format!("{}/{}", cat.name, script.command_name);
let rendered_path =
crate::shells::deployment::rendered_path(config, &cat.name, &script.source_rel);
let link_name = OsString::from(&script.command_name);
let link_path = crate::bin_links::command_path_for_name(bin_dir, &link_name);
let file_exists = script_path.exists();
let link_exists = link_path.exists() || {
tokio::fs::symlink_metadata(&link_path)
.await
.map(|m| m.file_type().is_symlink())
.unwrap_or(false)
};
let effective_transforms =
crate::shells::deployment::effective_transforms(script, &desired_path)
.await
.unwrap_or_else(|_| script.transforms.clone());
let effective_source = if !effective_transforms.is_empty() {
&rendered_path
} else {
&script_path
};
let runtime_env = script
.env
.iter()
.map(crate::env::EnvVarSpec::to_with_arg)
.collect::<Vec<_>>();
let link_current = if link_exists {
let render_target = (config.is_external_presets
&& config.external_shell_mode == crate::config::ExternalShellMode::Live
&& !effective_transforms.is_empty())
.then(|| format!("shell/{}/{}", cat.name, script.command_name));
crate::bin_links::link_is_current(
&link_path,
effective_source,
script.runtime,
&runtime_env,
render_target.as_deref(),
)
.await?
} else {
false
};
let (sym, status_text) = match (file_exists, link_exists) {
(true, true) => ("✓", "up-to-date"),
(true, false) => ("~", "preset present, bin symlink missing"),
(false, true) => ("~", "bin symlink present, preset missing"),
(false, false) => ("✗", "not installed"),
};
let canonical_target = format!("shell/{}/{}", cat.name, script.command_name);
let expected_runtime = match script.runtime {
crate::bin_links::LinkRuntime::Native => "native",
crate::bin_links::LinkRuntime::Bun => "bun",
};
let manifest_current = !config.is_external_presets
|| shell_manifest.find(&canonical_target).is_some_and(|entry| {
entry.mode == config.external_shell_mode
&& entry.source_path == script_path
&& entry.runtime == expected_runtime
&& entry.transforms == effective_transforms
&& entry.env == runtime_env
&& entry.needs_source == script.needs_source
});
let (sym, status_text) = if link_exists
&& (!link_current || !manifest_current || !snapshot_current)
{
("↑", "update available")
} else {
match shell_source_status(
config,
&source_key,
&desired_path,
&script_path,
&rendered_path,
&effective_transforms,
)
.await
{
Some(FileStatus::UpdateAvail) if file_exists || link_exists => {
("↑", "update available")
}
Some(FileStatus::Missing) if link_exists => ("!", "rendered script missing"),
_ if config.is_external_presets
&& config.external_shell_mode == crate::config::ExternalShellMode::Live
&& file_exists
&& link_exists =>
{
if effective_transforms.is_empty() {
("✓", "live source")
} else {
("✓", "rendered on next run")
}
}
_ => (sym, status_text),
}
};
rows.push(ShellRow {
symbol: colors::symbol(sym),
label: display_name,
status_sym: sym,
status_text,
is_installed: file_exists || link_exists,
});
}
}
Ok(rows)
}
async fn shell_source_status(
config: &Config,
source_key: &str,
desired_path: &Path,
script_path: &Path,
rendered_path: &Path,
declared_transforms: &[String],
) -> Option<FileStatus> {
let source_bytes = if config.is_external_presets {
tokio::fs::read(desired_path).await.ok()?
} else {
crate::presets::read_asset_bytes(source_key)?
};
if !script_path.exists() {
return Some(FileStatus::UpdateAvail);
}
if config.is_external_presets
&& config.external_shell_mode == crate::config::ExternalShellMode::Live
{
return Some(FileStatus::UpToDate);
}
let current_source = tokio::fs::read(script_path).await.ok()?;
if source_bytes != current_source {
return Some(FileStatus::UpdateAvail);
}
let transforms = declared_transforms.to_vec();
if transforms.is_empty() {
return Some(FileStatus::UpToDate);
}
if !rendered_path.exists() {
return Some(FileStatus::Missing);
}
let env = EnvConfig::load_or_init(config).await.ok()?;
let rendered = apply_transforms(&transforms, &source_bytes, env.as_map()).ok()?;
let current = tokio::fs::read(rendered_path).await.ok()?;
if rendered == current {
Some(FileStatus::UpToDate)
} else {
Some(FileStatus::UpdateAvail)
}
}
pub async fn build_app_rows(config: &Config, categories: &[AppCategory]) -> Result<Vec<AppRow>> {
let manifest = AppManifest::load(config.shine_dir()).await?;
let env = EnvConfig::load_or_init(config).await.ok();
let empty_map = BTreeMap::new();
let env_map = env.as_ref().map(|e| e.as_map()).unwrap_or(&empty_map);
let mut rows: Vec<AppRow> = Vec::new();
for cat in categories {
if cat.has_explicit_files && cat.list_mode == AppListMode::Files {
for file in &cat.files {
let (dest_opt, status) =
app_file_row_status(config, cat, file, &manifest, env_map).await;
let label = file
.display_name
.clone()
.unwrap_or_else(|| format!("{}/{}", cat.name, file.source_rel.display()));
let simple_label = if cat.files.len() == 1 {
cat.name.clone()
} else {
label.clone()
};
let dest_str = dest_opt.map(|d| path_display::format_home(&d, &config.home_dir));
let (sym, status_text) = match status {
FileStatus::Missing => ("!", "destination missing"),
FileStatus::UserModified => ("~", "user modified"),
FileStatus::UpdateAvail => ("↑", "update available"),
FileStatus::UpToDate => ("✓", "up-to-date"),
FileStatus::NotInstalled | FileStatus::Partial => ("✗", "not installed"),
};
rows.push(AppRow {
category: cat.name.clone(),
sym,
label,
simple_label,
dest: dest_str,
status_text,
file_status: status,
});
}
} else {
let mut file_statuses: Vec<FileStatus> = Vec::new();
for file in &cat.files {
let (_, status) = app_file_row_status(config, cat, file, &manifest, env_map).await;
file_statuses.push(status);
}
let has_installed = file_statuses.iter().any(|s| {
matches!(
s,
FileStatus::UpToDate | FileStatus::UpdateAvail | FileStatus::UserModified
)
});
let has_not_installed = file_statuses.contains(&FileStatus::NotInstalled);
let cat_status = if has_installed && has_not_installed {
let installed_max = file_statuses
.iter()
.copied()
.filter(|s| *s != FileStatus::NotInstalled)
.max()
.unwrap_or(FileStatus::Partial);
if installed_max == FileStatus::UpToDate {
FileStatus::Partial
} else {
installed_max
}
} else {
file_statuses
.iter()
.copied()
.max()
.unwrap_or(FileStatus::NotInstalled)
};
let dest_display: Option<String> = if let Some(root) = &cat.destination_root {
Some(path_display::format_tilde_path(root, &config.home_dir))
} else if cat.files.len() == 1 {
resolve_install_destination(cat, &cat.files[0], config)
.ok()
.map(|p| path_display::format_home(&p, &config.home_dir))
} else {
None
};
let (sym, status_text) = match cat_status {
FileStatus::Missing => ("!", "destination missing"),
FileStatus::UserModified => ("~", "user modified"),
FileStatus::Partial => ("~", "partial install"),
FileStatus::UpdateAvail => ("↑", "update available"),
FileStatus::UpToDate => ("✓", "up-to-date"),
FileStatus::NotInstalled => ("✗", "not installed"),
};
rows.push(AppRow {
category: cat.name.clone(),
sym,
label: cat.name.clone(),
simple_label: cat.name.clone(),
dest: dest_display,
status_text,
file_status: cat_status,
});
}
}
Ok(rows)
}
async fn app_file_row_status(
config: &Config,
cat: &AppCategory,
file: &crate::apps::AppFile,
manifest: &AppManifest,
env: &BTreeMap<String, String>,
) -> (Option<std::path::PathBuf>, FileStatus) {
match resolve_install_destination(cat, file, config) {
Err(_) => (None, FileStatus::NotInstalled),
Ok(dest) => {
let status = match manifest.find_by_dest(&dest) {
None if file.generator.as_ref().is_some_and(|generator| {
generator.auto && env.contains_key(&generator.when_env)
}) && manifest.entries.iter().any(|entry| {
entry
.source
.strip_prefix("app/")
.and_then(|source| source.split_once('/'))
.is_some_and(|(category, _)| category == cat.name)
}) =>
{
if source_hash_for_file(config, cat, file, env).await.is_some() {
FileStatus::UpdateAvail
} else {
FileStatus::NotInstalled
}
}
None => FileStatus::NotInstalled,
Some(entry) => app_entry_status(config, cat, file, entry, env).await,
};
(Some(dest), status)
}
}
}
pub(crate) async fn app_entry_status(
config: &Config,
cat: &AppCategory,
file: &crate::apps::AppFile,
entry: &AppEntry,
env: &BTreeMap<String, String>,
) -> FileStatus {
let generator_enabled = file
.generator
.as_ref()
.is_some_and(|generator| generator.auto && env.contains_key(&generator.when_env));
let manual_generator = file
.generator
.as_ref()
.is_some_and(|generator| !generator.auto);
let generated_source_hash = if generator_enabled {
source_hash_for_file(config, cat, file, env).await
} else {
None
};
if !entry.destination.exists() {
return FileStatus::Missing;
}
match tokio::fs::read(&entry.destination).await {
Err(_) => FileStatus::Missing,
Ok(dest_bytes) => {
let manifest_hash = entry.content_hash;
match installed_content_hash(file, &dest_bytes) {
Ok(Some(dest_hash)) if dest_hash == manifest_hash => {
if manual_generator {
return FileStatus::UpToDate;
}
let source_hash = if generator_enabled {
generated_source_hash
} else {
source_hash_for_file(config, cat, file, env).await
};
match source_hash {
Some(src) if src != manifest_hash => FileStatus::UpdateAvail,
_ => FileStatus::UpToDate,
}
}
Ok(None) => FileStatus::Missing,
Ok(Some(_)) | Err(_) => FileStatus::UserModified,
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::apps::AppFile;
use crate::config::Config;
use crate::install_core::AppInstallStrategy;
#[cfg(windows)]
use crate::test_support::env_lock;
use std::path::PathBuf;
use tokio::fs;
async fn make_temp_dir() -> std::path::PathBuf {
crate::test_support::make_temp_dir("shine-check").await
}
fn sample_app_file() -> AppFile {
AppFile {
source_rel: PathBuf::from("dest.txt"),
target_rel: PathBuf::from("dest.txt"),
description: None,
display_name: None,
legacy_dest_annotation: None,
transforms: vec![],
install_strategy: AppInstallStrategy::Copy,
requires_admin: false,
restart_hint: None,
generator: None,
}
}
fn sample_app_category() -> AppCategory {
AppCategory {
name: "sample".to_string(),
description: None,
destination_root: None,
files: vec![sample_app_file()],
list_mode: AppListMode::Files,
post_upgrade: Vec::new(),
post_install: Vec::new(),
uses_metadata: true,
has_explicit_files: true,
artifact: None,
}
}
fn sample_app_entry(destination: PathBuf, content_hash: u64) -> AppEntry {
AppEntry {
source: "app/sample/dest.txt".to_string(),
destination,
backup: None,
content_hash,
install_strategy: AppInstallStrategy::Copy,
uses_env: false,
requires_admin: false,
}
}
#[tokio::test]
async fn app_entry_status_reports_missing_when_destination_absent() {
let dir = make_temp_dir().await;
let config = Config::new_for_test(&dir);
let dest = dir.join("dest.txt");
let entry = sample_app_entry(dest, crate::install_core::hash_content(b"hello"));
let status = app_entry_status(
&config,
&sample_app_category(),
&sample_app_file(),
&entry,
&BTreeMap::new(),
)
.await;
assert_eq!(status, FileStatus::Missing);
fs::remove_dir_all(&dir).await.unwrap();
}
#[tokio::test]
async fn app_entry_status_reports_user_modified_when_dest_hash_differs() {
let dir = make_temp_dir().await;
let config = Config::new_for_test(&dir);
let dest = dir.join("dest.txt");
fs::write(&dest, b"locally edited").await.unwrap();
let entry = sample_app_entry(dest, crate::install_core::hash_content(b"original"));
let status = app_entry_status(
&config,
&sample_app_category(),
&sample_app_file(),
&entry,
&BTreeMap::new(),
)
.await;
assert_eq!(status, FileStatus::UserModified);
fs::remove_dir_all(&dir).await.unwrap();
}
#[tokio::test]
async fn app_entry_status_reports_up_to_date_when_source_unreadable() {
let dir = make_temp_dir().await;
let config = Config::new_for_test(&dir);
let dest = dir.join("dest.txt");
fs::write(&dest, b"hello").await.unwrap();
let entry = sample_app_entry(dest, crate::install_core::hash_content(b"hello"));
let status = app_entry_status(
&config,
&sample_app_category(),
&sample_app_file(),
&entry,
&BTreeMap::new(),
)
.await;
assert_eq!(status, FileStatus::UpToDate);
fs::remove_dir_all(&dir).await.unwrap();
}
#[tokio::test]
async fn app_entry_status_reports_update_available_when_source_changed() {
let dir = make_temp_dir().await;
let mut config = Config::new_for_test(&dir);
config.is_external_presets = true;
let source_path = config.preset_path(Path::new("app").join("sample").join("dest.txt"));
fs::create_dir_all(source_path.parent().unwrap())
.await
.unwrap();
fs::write(&source_path, b"new upstream content")
.await
.unwrap();
let dest = dir.join("dest.txt");
fs::write(&dest, b"hello").await.unwrap();
let entry = sample_app_entry(dest, crate::install_core::hash_content(b"hello"));
let status = app_entry_status(
&config,
&sample_app_category(),
&sample_app_file(),
&entry,
&BTreeMap::new(),
)
.await;
assert_eq!(status, FileStatus::UpdateAvail);
fs::remove_dir_all(&dir).await.unwrap();
}
#[tokio::test]
async fn app_file_row_status_reports_not_installed_without_manifest_entry() {
let dir = make_temp_dir().await;
let config = Config::new_for_test(&dir);
let manifest = AppManifest::default();
let category = AppCategory {
destination_root: Some(dir.display().to_string()),
..sample_app_category()
};
let (dest, status) = app_file_row_status(
&config,
&category,
&sample_app_file(),
&manifest,
&BTreeMap::new(),
)
.await;
assert!(dest.is_some());
assert_eq!(status, FileStatus::NotInstalled);
fs::remove_dir_all(&dir).await.unwrap();
}
#[cfg(not(unix))]
#[tokio::test]
async fn installed_shell_rows_use_windows_shim_path() {
let dir = make_temp_dir().await;
let cat_dir = dir.join("presets/shell/proxy");
fs::create_dir_all(&cat_dir).await.unwrap();
fs::write(
cat_dir.join("shine.toml"),
b"[[files]]\nsource = \"set_proxy.ps1\"\ntarget = \"setproxy\"\nneeds_source = true\n",
)
.await
.unwrap();
fs::write(cat_dir.join("set_proxy.ps1"), b"Write-Output proxy\n")
.await
.unwrap();
let mut config = Config::new_for_test(&dir);
config.is_external_presets = true;
fs::create_dir_all(config.bin_dir()).await.unwrap();
fs::write(config.bin_dir().join("setproxy.ps1"), b"# shine-managed\n")
.await
.unwrap();
let rows = build_shell_rows(&config).await.unwrap();
let row = rows
.iter()
.find(|row| row.label == "proxy/setproxy")
.expect("proxy/setproxy row should exist");
assert_eq!(row.status_sym, "✓");
assert_eq!(row.status_text, "up-to-date");
assert!(row.is_installed);
fs::remove_dir_all(&dir).await.unwrap();
}
#[cfg(unix)]
#[tokio::test]
async fn installed_shell_rows_report_up_to_date() {
let dir = make_temp_dir().await;
let cat_dir = dir.join("presets/shell/proxy");
fs::create_dir_all(&cat_dir).await.unwrap();
fs::write(
cat_dir.join("shine.toml"),
b"[[files]]\nsource = \"set_proxy.sh\"\ntarget = \"setproxy\"\nneeds_source = true\n",
)
.await
.unwrap();
let script = cat_dir.join("set_proxy.sh");
fs::write(&script, b"#!/bin/bash\necho proxy\n")
.await
.unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = fs::metadata(&script).await.unwrap().permissions();
perms.set_mode(0o755);
fs::set_permissions(&script, perms).await.unwrap();
}
let mut config = Config::new_for_test(&dir);
config.is_external_presets = true;
fs::create_dir_all(config.bin_dir()).await.unwrap();
crate::shells::handle_install(&config, Some("proxy"), false)
.await
.unwrap();
let rows = build_shell_rows(&config).await.unwrap();
let row = rows
.iter()
.find(|row| row.label == "proxy/setproxy")
.expect("proxy/setproxy row should exist");
assert_eq!(row.status_sym, "✓");
assert_eq!(row.status_text, "up-to-date");
fs::remove_dir_all(&dir).await.unwrap();
}
#[cfg(unix)]
#[tokio::test]
async fn external_template_shell_change_reports_update_available() {
let dir = make_temp_dir().await;
let cat_dir = dir.join("presets/shell/proxy");
fs::create_dir_all(&cat_dir).await.unwrap();
fs::write(
cat_dir.join("shine.toml"),
b"[[files]]\nsource = \"set_proxy.sh\"\ntarget = \"setproxy\"\nneeds_source = true\n",
)
.await
.unwrap();
let script = cat_dir.join("set_proxy.sh");
fs::write(
&script,
b"#!/bin/bash\n# shine-template: true\necho @@PROXY_HOST@@\n",
)
.await
.unwrap();
let mut config = Config::new_for_test(&dir);
config.is_external_presets = true;
fs::create_dir_all(config.bin_dir()).await.unwrap();
crate::shells::handle_install(&config, Some("proxy"), false)
.await
.unwrap();
fs::write(
&script,
b"#!/bin/bash\n# shine-template: true\necho changed @@PROXY_HOST@@\n",
)
.await
.unwrap();
let rows = build_shell_rows(&config).await.unwrap();
let row = rows
.iter()
.find(|row| row.label == "proxy/setproxy")
.expect("proxy/setproxy row should exist");
assert_eq!(row.status_sym, "↑");
assert_eq!(row.status_text, "update available");
fs::remove_dir_all(&dir).await.unwrap();
}
#[cfg(unix)]
#[tokio::test]
async fn live_raw_shell_change_stays_live_and_current() {
let dir = make_temp_dir().await;
let cat_dir = dir.join("presets/shell/custom");
fs::create_dir_all(&cat_dir).await.unwrap();
fs::write(
cat_dir.join("shine.toml"),
b"[[files]]\nsource = \"tool.sh\"\ntarget = \"mytool\"\n",
)
.await
.unwrap();
let source = cat_dir.join("tool.sh");
fs::write(&source, b"#!/bin/sh\necho first\n")
.await
.unwrap();
let mut config = Config::new_for_test(&dir);
config.is_external_presets = true;
config.external_shell_mode = crate::config::ExternalShellMode::Live;
fs::create_dir_all(config.bin_dir()).await.unwrap();
crate::shells::handle_install(&config, Some("custom"), false)
.await
.unwrap();
fs::write(&source, b"#!/bin/sh\necho second\n")
.await
.unwrap();
let rows = build_shell_rows(&config).await.unwrap();
let row = rows
.iter()
.find(|row| row.label == "custom/mytool")
.unwrap();
assert_eq!(row.status_sym, "✓");
assert_eq!(row.status_text, "live source");
fs::remove_dir_all(&dir).await.unwrap();
}
#[tokio::test]
async fn embedded_bun_source_change_reports_update_available() {
let dir = make_temp_dir().await;
let config = Config::new_for_test(&dir);
fs::create_dir_all(config.presets_dir()).await.unwrap();
fs::create_dir_all(config.bin_dir()).await.unwrap();
crate::shells::handle_install(&config, Some("agent"), false)
.await
.unwrap();
let extracted = config.presets_dir().join("shell/agent/cc.ts");
fs::write(&extracted, b"// stale extracted ccenv\n")
.await
.unwrap();
let rows = build_shell_rows(&config).await.unwrap();
let row = rows
.iter()
.find(|row| row.label == "agent/ccenv")
.expect("agent/ccenv row should exist");
assert_eq!(row.status_sym, "↑");
assert_eq!(row.status_text, "update available");
fs::remove_dir_all(&dir).await.unwrap();
}
#[tokio::test]
async fn embedded_shell_source_rename_reports_update_available() {
let dir = make_temp_dir().await;
let cat_dir = dir.join("presets/shell/agent");
fs::create_dir_all(&cat_dir).await.unwrap();
let old_source = if cfg!(windows) { "cc.ps1" } else { "cc.sh" };
fs::write(
cat_dir.join("shine.toml"),
format!(
"[[files]]\nsource = \"{old_source}\"\ntarget = \"ccenv\"\nneeds_source = true\n"
),
)
.await
.unwrap();
fs::write(cat_dir.join(old_source), b"# old sourced ccenv\n")
.await
.unwrap();
let mut config = Config::new_for_test(&dir);
config.is_external_presets = true;
fs::create_dir_all(config.bin_dir()).await.unwrap();
crate::shells::handle_install(&config, Some("agent"), false)
.await
.unwrap();
config.is_external_presets = false;
let rows = build_shell_rows(&config).await.unwrap();
let row = rows
.iter()
.find(|row| row.label == "agent/ccenv")
.expect("embedded agent/ccenv row should exist");
assert_eq!(row.status_sym, "↑");
assert_eq!(row.status_text, "update available");
fs::remove_dir_all(&dir).await.unwrap();
}
#[tokio::test]
async fn external_shell_runtime_and_source_change_reports_update_available() {
let dir = make_temp_dir().await;
let cat_dir = dir.join("presets/shell/agent");
fs::create_dir_all(&cat_dir).await.unwrap();
let old_source = if cfg!(windows) { "cc.ps1" } else { "cc.sh" };
fs::write(
cat_dir.join("shine.toml"),
format!(
"[[files]]\nsource = \"{old_source}\"\ntarget = \"ccenv\"\nneeds_source = true\n"
),
)
.await
.unwrap();
fs::write(cat_dir.join(old_source), b"# old sourced ccenv\n")
.await
.unwrap();
let mut config = Config::new_for_test(&dir);
config.is_external_presets = true;
fs::create_dir_all(config.bin_dir()).await.unwrap();
crate::shells::handle_install(&config, Some("agent"), false)
.await
.unwrap();
fs::write(
cat_dir.join("shine.toml"),
b"[[files]]\nsource = \"cc.ts\"\ntarget = \"ccenv\"\nruntime = \"bun\"\nplatforms = [\"unix\", \"windows\"]\n",
)
.await
.unwrap();
fs::write(cat_dir.join("cc.ts"), b"console.log('new ccenv');\n")
.await
.unwrap();
let rows = build_shell_rows(&config).await.unwrap();
let row = rows
.iter()
.find(|row| row.label == "agent/ccenv")
.expect("external agent/ccenv row should exist");
assert_eq!(row.status_sym, "↑");
assert_eq!(row.status_text, "update available");
fs::remove_dir_all(&dir).await.unwrap();
}
#[cfg(unix)]
#[tokio::test]
async fn shell_env_change_reports_update_available() {
let dir = make_temp_dir().await;
let cat_dir = dir.join("presets/shell/proxy");
fs::create_dir_all(&cat_dir).await.unwrap();
fs::write(
cat_dir.join("shine.toml"),
b"[[files]]\nsource = \"set_proxy.sh\"\ntarget = \"setproxy\"\nneeds_source = true\n",
)
.await
.unwrap();
fs::write(
cat_dir.join("set_proxy.sh"),
b"#!/bin/bash\n# shine-template: true\nPROXY_NO_PROXY=\"@@PROXY_NO_PROXY@@\"\n",
)
.await
.unwrap();
let mut config = Config::new_for_test(&dir);
config.is_external_presets = true;
fs::create_dir_all(config.bin_dir()).await.unwrap();
crate::shells::handle_install(&config, Some("proxy"), false)
.await
.unwrap();
config.env.insert(
"PROXY_NO_PROXY".to_string(),
"localhost,127.0.0.1,::1,.local".to_string(),
);
let rows = build_shell_rows(&config).await.unwrap();
let row = rows
.iter()
.find(|row| row.label == "proxy/setproxy")
.expect("proxy/setproxy row should exist");
assert_eq!(row.status_sym, "↑");
assert_eq!(row.status_text, "update available");
fs::remove_dir_all(&dir).await.unwrap();
}
#[tokio::test]
async fn category_list_mode_aggregates_explicit_app_files() {
let dir = make_temp_dir().await;
let config = Config::new_for_test(&dir);
fs::create_dir_all(config.shine_dir()).await.unwrap();
let category = AppCategory {
name: "ghostty".to_string(),
description: Some("Ghostty terminal configuration.".to_string()),
destination_root: Some(dir.join(".config/ghostty").display().to_string()),
files: vec![
AppFile {
source_rel: PathBuf::from("config.ghostty"),
target_rel: PathBuf::from("config.ghostty"),
description: None,
display_name: None,
legacy_dest_annotation: None,
transforms: vec![],
install_strategy: AppInstallStrategy::Copy,
requires_admin: false,
restart_hint: None,
generator: None,
},
AppFile {
source_rel: PathBuf::from("themes/shine-light"),
target_rel: PathBuf::from("themes/shine-light"),
description: None,
display_name: None,
legacy_dest_annotation: None,
transforms: vec!["template".to_string()],
install_strategy: AppInstallStrategy::Copy,
requires_admin: false,
restart_hint: None,
generator: None,
},
],
list_mode: AppListMode::Category,
post_upgrade: Vec::new(),
post_install: Vec::new(),
uses_metadata: true,
has_explicit_files: true,
artifact: None,
};
let rows = build_app_rows(&config, &[category]).await.unwrap();
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].label, "ghostty");
assert_eq!(rows[0].simple_label, "ghostty");
assert_eq!(rows[0].dest.as_deref(), Some("~/.config/ghostty"));
assert_eq!(rows[0].file_status, FileStatus::NotInstalled);
fs::remove_dir_all(&dir).await.unwrap();
}
#[tokio::test]
async fn file_list_mode_keeps_file_labels_for_multi_file_app_simple_list() {
let dir = make_temp_dir().await;
let config = Config::new_for_test(&dir);
fs::create_dir_all(config.shine_dir()).await.unwrap();
let category = AppCategory {
name: "sample".to_string(),
description: None,
destination_root: Some(dir.join(".config/sample").display().to_string()),
files: vec![
AppFile {
source_rel: PathBuf::from("config.toml"),
target_rel: PathBuf::from("config.toml"),
description: None,
display_name: None,
legacy_dest_annotation: None,
transforms: vec![],
install_strategy: AppInstallStrategy::Copy,
requires_admin: false,
restart_hint: None,
generator: None,
},
AppFile {
source_rel: PathBuf::from("theme.toml"),
target_rel: PathBuf::from("theme.toml"),
description: None,
display_name: None,
legacy_dest_annotation: None,
transforms: vec![],
install_strategy: AppInstallStrategy::Copy,
requires_admin: false,
restart_hint: None,
generator: None,
},
],
list_mode: AppListMode::Files,
post_upgrade: Vec::new(),
post_install: Vec::new(),
uses_metadata: true,
has_explicit_files: true,
artifact: None,
};
let rows = build_app_rows(&config, &[category]).await.unwrap();
assert_eq!(rows.len(), 2);
assert_eq!(rows[0].label, "sample/config.toml");
assert_eq!(rows[0].simple_label, "sample/config.toml");
assert_eq!(rows[1].label, "sample/theme.toml");
assert_eq!(rows[1].simple_label, "sample/theme.toml");
fs::remove_dir_all(&dir).await.unwrap();
}
#[cfg(windows)]
#[tokio::test]
async fn windows_docker_engine_row_uses_engine_destination() {
let _guard = env_lock();
let dir = make_temp_dir().await;
unsafe { std::env::set_var("HOME", dir.to_str().unwrap()) };
let config = Config::new_for_test(&dir);
fs::create_dir_all(config.shine_dir()).await.unwrap();
let categories = crate::apps::load_embedded_categories(Some("docker-engine")).unwrap();
let rows = build_app_rows(&config, &categories).await.unwrap();
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].label, "docker-engine/daemon.jsonc");
assert_eq!(rows[0].simple_label, "docker-engine");
assert_eq!(rows[0].file_status, FileStatus::NotInstalled);
assert_eq!(rows[0].dest.as_deref(), Some("~/.docker/daemon.json"));
unsafe { std::env::remove_var("HOME") };
fs::remove_dir_all(&dir).await.unwrap();
}
#[cfg(windows)]
#[tokio::test]
async fn windows_docker_desktop_row_uses_forward_slash_destination() {
let _guard = env_lock();
let dir = make_temp_dir().await;
unsafe { std::env::set_var("HOME", dir.to_str().unwrap()) };
let config = Config::new_for_test(&dir);
fs::create_dir_all(config.shine_dir()).await.unwrap();
let categories = crate::apps::load_embedded_categories(Some("docker-desktop")).unwrap();
let rows = build_app_rows(&config, &categories).await.unwrap();
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].label, "docker-desktop/settings-store.jsonc");
assert_eq!(rows[0].simple_label, "docker-desktop");
assert_eq!(rows[0].file_status, FileStatus::NotInstalled);
assert_eq!(
rows[0].dest.as_deref(),
Some("~/AppData/Roaming/Docker/settings-store.json")
);
unsafe { std::env::remove_var("HOME") };
fs::remove_dir_all(&dir).await.unwrap();
}
}