use anyhow::{Context, Result};
use std::path::Path;
use crate::install::eol_eq;
#[cfg(test)]
use crate::runtime::RealHost;
use crate::runtime::{
FileSystemHost, SYS_PROFILE_PHASES, ShellProfileBlockPosition, ShellType, SysProfilePhase,
};
use super::{SysProfileRuntimeConfig, SysShellProfileUpdate};
pub(super) async fn update_sys_shell_profiles(
host: &impl FileSystemHost,
config: &SysProfileRuntimeConfig,
os_id: &str,
sys_shell: &str,
) -> Result<SysShellProfileUpdate> {
match os_id {
"macos" => update_macos_shell_profile(host, config, os_id).await,
"ubuntu" => update_ubuntu_shell_profile(host, config, os_id, sys_shell).await,
"windows" => update_windows_shell_profile(host, config, os_id).await,
_ => Ok(SysShellProfileUpdate {
updated: false,
unsupported_shell: true,
detail: format!("unsupported OS for sys profile: {os_id}"),
}),
}
}
async fn update_macos_shell_profile(
host: &impl FileSystemHost,
config: &SysProfileRuntimeConfig,
os_id: &str,
) -> Result<SysShellProfileUpdate> {
let path = config.home_dir.join(".zshrc");
let updated = update_sys_shell_profile_blocks_with_host(host, &path, os_id, None).await?;
Ok(SysShellProfileUpdate {
updated,
unsupported_shell: false,
detail: format!("~/.zshrc -> {}", sys_loader_display(os_id)),
})
}
async fn update_ubuntu_shell_profile(
host: &impl FileSystemHost,
config: &SysProfileRuntimeConfig,
os_id: &str,
sys_shell: &str,
) -> Result<SysShellProfileUpdate> {
match config.shell_type {
ShellType::Bash => {
let updated = update_sys_shell_profile_blocks_with_host(
host,
&config.home_dir.join(".bashrc"),
os_id,
Some("bash"),
)
.await?;
remove_sys_shell_profile_blocks(host, &config.home_dir.join(".zshrc"), os_id).await?;
Ok(SysShellProfileUpdate {
updated,
unsupported_shell: false,
detail: format!("~/.bashrc -> {}", sys_loader_display(os_id)),
})
}
ShellType::Zsh => {
let updated = update_sys_shell_profile_blocks_with_host(
host,
&config.home_dir.join(".zshrc"),
os_id,
Some("zsh"),
)
.await?;
remove_sys_shell_profile_blocks(host, &config.home_dir.join(".bashrc"), os_id).await?;
Ok(SysShellProfileUpdate {
updated,
unsupported_shell: false,
detail: format!("~/.zshrc -> {}", sys_loader_display(os_id)),
})
}
_ => Ok(SysShellProfileUpdate {
updated: false,
unsupported_shell: true,
detail: format!("unsupported shell for sys profile: {sys_shell}"),
}),
}
}
async fn update_windows_shell_profile(
host: &impl FileSystemHost,
config: &SysProfileRuntimeConfig,
os_id: &str,
) -> Result<SysShellProfileUpdate> {
let mut updated = false;
for path in [
config
.home_dir
.join("Documents/PowerShell/Microsoft.PowerShell_profile.ps1"),
config
.home_dir
.join("Documents/WindowsPowerShell/Microsoft.PowerShell_profile.ps1"),
] {
updated |= update_sys_shell_profile_blocks_with_host(host, &path, os_id, None).await?;
}
Ok(SysShellProfileUpdate {
updated,
unsupported_shell: false,
detail: "PowerShell profiles".to_string(),
})
}
#[cfg(test)]
pub(super) async fn update_sys_shell_profile_blocks(
path: &Path,
os_id: &str,
shell_name: Option<&str>,
) -> Result<bool> {
update_sys_shell_profile_blocks_with_host(&RealHost, path, os_id, shell_name).await
}
async fn update_sys_shell_profile_blocks_with_host(
host: &impl FileSystemHost,
path: &Path,
os_id: &str,
shell_name: Option<&str>,
) -> Result<bool> {
if let Some(parent) = path.parent() {
host.create_dir_all(parent)
.await
.map_err(|error| error.into_anyhow("creating sys shell profile directory"))?;
}
let content = match host.read(path).await {
Ok(bytes) => {
String::from_utf8(bytes).with_context(|| format!("{} is not UTF-8", path.display()))?
}
Err(error) if error.is_not_found() => String::new(),
Err(error) => return Err(error.into_anyhow("reading sys shell profile")),
};
let updated = desired_sys_profile_content(&content, os_id, shell_name, true);
if updated == content {
return Ok(false);
}
host.write_atomic(path, updated.as_bytes())
.await
.map_err(|error| error.into_anyhow("writing sys shell profile"))?;
Ok(true)
}
pub(crate) fn desired_sys_profile_content(
content: &str,
os_id: &str,
shell_name: Option<&str>,
install: bool,
) -> String {
let had_utf8_bom = content.contains('\u{feff}');
let bom_was_at_start = content.starts_with('\u{feff}');
let content_without_bom = content.replace('\u{feff}', "");
let pre_block = sys_shell_profile_block(os_id, SysProfilePhase::Pre, shell_name);
let post_block = sys_shell_profile_block(os_id, SysProfilePhase::Post, shell_name);
let pre_sentinel = sys_sentinel(os_id, SysProfilePhase::Pre);
let post_sentinel = sys_sentinel(os_id, SysProfilePhase::Post);
let block_matches = |extracted: Option<&str>, expected: &str| {
extracted.is_some_and(|block| {
eol_eq(
block.trim_end_matches(['\r', '\n']).as_bytes(),
expected.trim_end_matches(['\r', '\n']).as_bytes(),
)
})
};
if install
&& extract_sentinel_block(&content_without_bom, legacy_sys_sentinel(os_id)).is_none()
&& block_matches(
extract_sentinel_block(&content_without_bom, pre_sentinel),
&pre_block,
)
&& block_matches(
extract_sentinel_block(&content_without_bom, post_sentinel),
&post_block,
)
&& sentinel_order_is_valid(&content_without_bom, pre_sentinel, post_sentinel)
&& (!had_utf8_bom || bom_was_at_start)
{
return content.to_string();
}
let mut updated = remove_all_sys_blocks(&content_without_bom, os_id);
updated = trim_outer_blank_lines(&updated);
if install {
updated =
insert_shell_profile_block(&updated, &pre_block, ShellProfileBlockPosition::Start);
updated = insert_shell_profile_block(&updated, &post_block, ShellProfileBlockPosition::End);
}
if had_utf8_bom {
updated.insert(0, '\u{feff}');
}
updated
}
pub(crate) fn sys_owned_blocks_hash(content: &str, os_id: &str) -> Option<u64> {
let blocks = std::iter::once(legacy_sys_sentinel(os_id))
.chain(
SYS_PROFILE_PHASES
.into_iter()
.map(|phase| sys_sentinel(os_id, phase)),
)
.filter_map(|sentinel| extract_sentinel_block(content, sentinel))
.collect::<Vec<_>>();
(!blocks.is_empty()).then(|| crate::install::hash_content(blocks.join("\n").as_bytes()))
}
pub(crate) fn restore_sys_owned_blocks(current: &str, previous: &str, os_id: &str) -> String {
let had_utf8_bom = current.starts_with('\u{feff}');
let current = current.replace('\u{feff}', "");
let previous = previous.replace('\u{feff}', "");
let mut restored = trim_outer_blank_lines(&remove_all_sys_blocks(¤t, os_id));
if let Some(legacy) = extract_sentinel_block(&previous, legacy_sys_sentinel(os_id)) {
restored = insert_shell_profile_block(&restored, legacy, ShellProfileBlockPosition::End);
} else {
if let Some(pre) =
extract_sentinel_block(&previous, sys_sentinel(os_id, SysProfilePhase::Pre))
{
restored = insert_shell_profile_block(&restored, pre, ShellProfileBlockPosition::Start);
}
if let Some(post) =
extract_sentinel_block(&previous, sys_sentinel(os_id, SysProfilePhase::Post))
{
restored = insert_shell_profile_block(&restored, post, ShellProfileBlockPosition::End);
}
}
if had_utf8_bom {
restored.insert(0, '\u{feff}');
}
restored
}
fn remove_all_sys_blocks(content: &str, os_id: &str) -> String {
let mut updated = remove_sentinel_block(content, legacy_sys_sentinel(os_id));
for phase in SYS_PROFILE_PHASES {
updated = remove_sentinel_block(&updated, sys_sentinel(os_id, phase));
}
updated
}
async fn remove_sys_shell_profile_blocks(
host: &impl FileSystemHost,
path: &Path,
os_id: &str,
) -> Result<bool> {
let mut updated = remove_shell_profile_block(host, path, legacy_sys_sentinel(os_id)).await?;
for phase in SYS_PROFILE_PHASES {
updated |= remove_shell_profile_block(host, path, sys_sentinel(os_id, phase)).await?;
}
Ok(updated)
}
fn legacy_sys_sentinel(os_id: &str) -> (&'static str, &'static str) {
match os_id {
"macos" => ("# >>> shine macos sys >>>", "# <<< shine macos sys <<<"),
"windows" => ("# >>> shine windows sys >>>", "# <<< shine windows sys <<<"),
_ => ("# >>> shine ubuntu sys >>>", "# <<< shine ubuntu sys <<<"),
}
}
fn sys_sentinel(os_id: &str, phase: SysProfilePhase) -> (&'static str, &'static str) {
match (os_id, phase) {
("macos", SysProfilePhase::Pre) => (
"# >>> shine macos sys pre >>>",
"# <<< shine macos sys pre <<<",
),
("macos", SysProfilePhase::Post) => (
"# >>> shine macos sys post >>>",
"# <<< shine macos sys post <<<",
),
("windows", SysProfilePhase::Pre) => (
"# >>> shine windows sys pre >>>",
"# <<< shine windows sys pre <<<",
),
("windows", SysProfilePhase::Post) => (
"# >>> shine windows sys post >>>",
"# <<< shine windows sys post <<<",
),
(_, SysProfilePhase::Pre) => (
"# >>> shine ubuntu sys pre >>>",
"# <<< shine ubuntu sys pre <<<",
),
(_, SysProfilePhase::Post) => (
"# >>> shine ubuntu sys post >>>",
"# <<< shine ubuntu sys post <<<",
),
}
}
fn sys_shell_profile_block(
os_id: &str,
phase: SysProfilePhase,
shell_name: Option<&str>,
) -> String {
let (start, end) = sys_sentinel(os_id, phase);
match os_id {
"windows" => format!(
r#"{start}
$shineWindowsSysProfile = Join-Path $HOME ".shine\profile\windows-sys.{phase}.ps1"
if (Test-Path -LiteralPath $shineWindowsSysProfile) {{
. $shineWindowsSysProfile
}}
{end}
"#,
phase = phase.as_str()
),
"macos" => format!(
r#"{start}
shine_macos_sys_profile="$HOME/.shine/profile/macos-sys.{phase}.sh"
if [[ -f "$shine_macos_sys_profile" ]]; then
source "$shine_macos_sys_profile"
fi
{end}
"#,
phase = phase.as_str()
),
_ => {
let shell_name = shell_name.unwrap_or("bash");
format!(
r#"{start}
shine_ubuntu_sys_profile="$HOME/.shine/profile/ubuntu-sys.{phase}.sh"
if [[ -f "$shine_ubuntu_sys_profile" ]]; then
SHINE_UBUNTU_SYS_SHELL="{shell_name}"
source "$shine_ubuntu_sys_profile"
fi
{end}
"#,
phase = phase.as_str()
)
}
}
}
fn to_shared_sentinel<'a>(sentinel: (&'a str, &'a str)) -> crate::sentinel::Sentinel<'a> {
crate::sentinel::Sentinel {
start: sentinel.0,
end: sentinel.1,
}
}
fn insert_shell_profile_block(
content: &str,
desired_block: &str,
position: ShellProfileBlockPosition,
) -> String {
let at = match position {
ShellProfileBlockPosition::Start => crate::sentinel::InsertAt::Start,
ShellProfileBlockPosition::End => crate::sentinel::InsertAt::End,
};
crate::sentinel::insert_block(content, desired_block, at)
}
fn sentinel_order_is_valid(content: &str, first: (&str, &str), second: (&str, &str)) -> bool {
match (content.find(first.0), content.find(second.0)) {
(Some(first), Some(second)) => first < second,
_ => false,
}
}
fn trim_outer_blank_lines(content: &str) -> String {
crate::sentinel::trim_outer_blank_lines(content)
}
async fn remove_shell_profile_block(
host: &impl FileSystemHost,
path: &Path,
sentinel: (&str, &str),
) -> Result<bool> {
let Ok(bytes) = host.read(path).await else {
return Ok(false);
};
let content =
String::from_utf8(bytes).with_context(|| format!("{} is not UTF-8", path.display()))?;
let updated = remove_sentinel_block(&content, sentinel);
if updated == content {
return Ok(false);
}
host.write_atomic(path, updated.as_bytes())
.await
.map_err(|error| error.into_anyhow("writing sys shell profile"))?;
Ok(true)
}
fn extract_sentinel_block<'a>(content: &'a str, sentinel: (&str, &str)) -> Option<&'a str> {
crate::sentinel::extract_block_with_newline(content, &to_shared_sentinel(sentinel))
}
fn remove_sentinel_block(content: &str, sentinel: (&str, &str)) -> String {
crate::sentinel::remove_block_linewise(content, &to_shared_sentinel(sentinel))
}
fn sys_loader_display(os_id: &str) -> String {
format!(
"~/.shine/profile/{os_id}-sys.{{pre,post}}.{}",
if os_id == "windows" { "ps1" } else { "sh" }
)
}
#[cfg(test)]
mod tests {
use super::*;
async fn make_temp_dir(label: &str) -> std::path::PathBuf {
let path = std::env::temp_dir().join(format!("{label}-{}", uuid::Uuid::new_v4()));
tokio::fs::create_dir_all(&path).await.unwrap();
path
}
const SENTINEL: (&str, &str) = ("START", "END");
#[test]
fn remove_sentinel_block_returns_unchanged_when_sentinel_absent() {
let content = "no sentinel here\n";
assert_eq!(remove_sentinel_block(content, SENTINEL), content);
}
#[test]
fn remove_sentinel_block_removes_bounded_lines_but_keeps_preceding_blank_line() {
let content = "before\n\nSTART\nbody\nEND\nafter\n";
assert_eq!(
remove_sentinel_block(content, SENTINEL),
"before\n\nafter\n"
);
}
#[test]
fn remove_sentinel_block_normalizes_crlf_to_lf_even_without_sentinel() {
let content = "before\r\nafter\r\n";
assert_eq!(remove_sentinel_block(content, SENTINEL), "before\nafter\n");
}
#[test]
fn remove_sentinel_block_preserves_trailing_newline_presence() {
let with_newline = "before\nSTART\nbody\nEND\nafter\n";
let without_newline = "before\nSTART\nbody\nEND\nafter";
assert!(remove_sentinel_block(with_newline, SENTINEL).ends_with('\n'));
assert!(!remove_sentinel_block(without_newline, SENTINEL).ends_with('\n'));
}
#[test]
fn extract_sentinel_block_returns_none_when_start_missing() {
assert_eq!(extract_sentinel_block("no markers here", SENTINEL), None);
}
#[test]
fn extract_sentinel_block_returns_none_when_end_missing_after_start() {
assert_eq!(extract_sentinel_block("STARTonly, no end", SENTINEL), None);
}
#[test]
fn extract_sentinel_block_includes_one_trailing_newline_when_present() {
let content = "pre\nSTART\nbody\nEND\npost";
assert_eq!(
extract_sentinel_block(content, SENTINEL),
Some("START\nbody\nEND\n")
);
}
#[test]
fn extract_sentinel_block_omits_trailing_newline_when_absent() {
let content = "pre\nSTART\nbody\nEND";
assert_eq!(
extract_sentinel_block(content, SENTINEL),
Some("START\nbody\nEND")
);
}
#[test]
fn insert_start_adds_exactly_one_blank_line_before_nonempty_content() {
let updated =
insert_shell_profile_block("rest", "BLOCK\n", ShellProfileBlockPosition::Start);
assert_eq!(updated, "BLOCK\n\nrest");
}
#[test]
fn insert_start_into_empty_content_has_no_trailing_blank_line() {
let updated = insert_shell_profile_block("", "BLOCK\n", ShellProfileBlockPosition::Start);
assert_eq!(updated, "BLOCK\n");
}
#[test]
fn insert_end_adds_exactly_one_blank_line_regardless_of_existing_trailing_newline() {
let with_newline =
insert_shell_profile_block("rest\n", "BLOCK\n", ShellProfileBlockPosition::End);
let without_newline =
insert_shell_profile_block("rest", "BLOCK\n", ShellProfileBlockPosition::End);
assert_eq!(with_newline, "rest\n\nBLOCK\n");
assert_eq!(without_newline, "rest\n\nBLOCK\n");
}
#[test]
fn insert_end_into_empty_content_has_no_leading_blank_line() {
let updated = insert_shell_profile_block("", "BLOCK\n", ShellProfileBlockPosition::End);
assert_eq!(updated, "BLOCK\n");
}
#[test]
fn trim_outer_blank_lines_removes_all_leading_and_trailing_newlines() {
assert_eq!(trim_outer_blank_lines("\n\n\nfoo\nbar\n\n"), "foo\nbar");
}
#[test]
fn sentinel_order_is_valid_requires_first_before_second() {
let first = ("FIRST_START", "FIRST_END");
let second = ("SECOND_START", "SECOND_END");
assert!(sentinel_order_is_valid(
"x FIRST_START y SECOND_START z",
first,
second
));
assert!(!sentinel_order_is_valid(
"x SECOND_START y FIRST_START z",
first,
second
));
assert!(!sentinel_order_is_valid(
"neither marker present",
first,
second
));
}
#[tokio::test]
async fn update_sys_shell_profile_blocks_inserts_pre_and_post_on_empty_file() {
let dir = make_temp_dir("shine-sys-profile").await;
let path = dir.join("profile.sh");
let updated = update_sys_shell_profile_blocks(&path, "ubuntu", Some("bash"))
.await
.unwrap();
assert!(updated);
let content = tokio::fs::read_to_string(&path).await.unwrap();
assert!(content.contains("shine ubuntu sys pre"));
assert!(content.contains("shine ubuntu sys post"));
assert!(content.find("sys pre").unwrap() < content.find("sys post").unwrap());
tokio::fs::remove_dir_all(&dir).await.unwrap();
}
#[tokio::test]
async fn update_sys_shell_profile_blocks_is_idempotent_when_already_up_to_date() {
let dir = make_temp_dir("shine-sys-profile").await;
let path = dir.join("profile.sh");
assert!(
update_sys_shell_profile_blocks(&path, "ubuntu", Some("bash"))
.await
.unwrap()
);
assert!(
!update_sys_shell_profile_blocks(&path, "ubuntu", Some("bash"))
.await
.unwrap()
);
tokio::fs::remove_dir_all(&dir).await.unwrap();
}
#[tokio::test]
async fn update_sys_shell_profile_blocks_ignores_crlf_and_preserves_endings() {
let dir = make_temp_dir("shine-sys-profile").await;
let path = dir.join("profile.sh");
update_sys_shell_profile_blocks(&path, "ubuntu", Some("bash"))
.await
.unwrap();
let lf = tokio::fs::read_to_string(&path).await.unwrap();
let crlf = lf.replace('\n', "\r\n");
tokio::fs::write(&path, &crlf).await.unwrap();
assert!(
!update_sys_shell_profile_blocks(&path, "ubuntu", Some("bash"))
.await
.unwrap()
);
let after = tokio::fs::read_to_string(&path).await.unwrap();
assert_eq!(after, crlf);
tokio::fs::remove_dir_all(&dir).await.unwrap();
}
#[tokio::test]
async fn update_sys_shell_profile_blocks_preserves_leading_bom() {
let dir = make_temp_dir("shine-sys-profile").await;
let path = dir.join("profile.ps1");
tokio::fs::write(&path, "\u{feff}# existing content\n")
.await
.unwrap();
update_sys_shell_profile_blocks(&path, "windows", None)
.await
.unwrap();
let content = tokio::fs::read_to_string(&path).await.unwrap();
assert!(
content.starts_with('\u{feff}'),
"BOM must be preserved at the start of the file"
);
assert_eq!(
content.matches('\u{feff}').count(),
1,
"exactly one BOM must remain, not re-duplicated"
);
tokio::fs::remove_dir_all(&dir).await.unwrap();
}
#[tokio::test]
async fn update_sys_shell_profile_blocks_migrates_legacy_sentinel() {
let dir = make_temp_dir("shine-sys-profile").await;
let path = dir.join("profile.sh");
let (legacy_start, legacy_end) = legacy_sys_sentinel("ubuntu");
tokio::fs::write(&path, format!("{legacy_start}\nold body\n{legacy_end}\n"))
.await
.unwrap();
let updated = update_sys_shell_profile_blocks(&path, "ubuntu", Some("bash"))
.await
.unwrap();
assert!(updated);
let content = tokio::fs::read_to_string(&path).await.unwrap();
assert!(
!content.contains(legacy_start),
"legacy sentinel must be removed"
);
assert!(content.contains("shine ubuntu sys pre"));
assert!(content.contains("shine ubuntu sys post"));
tokio::fs::remove_dir_all(&dir).await.unwrap();
}
}