use std::path::{Path, PathBuf};
use crate::error::{OlError, ERR_MODEL_RELAY_IO};
pub const MARKER: &str = "# openlatch-model-relay";
pub fn posix_line(env_sh: &Path) -> String {
let p = env_sh.display();
format!("if [ -f '{p}' ]; then . '{p}'; fi {MARKER}")
}
pub fn powershell_line(env_ps1: &Path) -> String {
let p = env_ps1.display();
format!("if (Test-Path -LiteralPath '{p}') {{ . '{p}' }} {MARKER}")
}
fn io_err(e: impl std::fmt::Display) -> OlError {
OlError::new(ERR_MODEL_RELAY_IO, e.to_string())
}
fn ensure_line_in_file(path: &Path, line: &str) -> Result<bool, OlError> {
let existing = std::fs::read_to_string(path).unwrap_or_default();
let mut offset = 0usize;
let mut found: Option<(usize, usize, &str)> = None;
for raw_line in existing.split_inclusive('\n') {
let content = raw_line.strip_suffix('\n').unwrap_or(raw_line);
let content = content.strip_suffix('\r').unwrap_or(content);
if content.ends_with(MARKER) {
let ending = if raw_line.ends_with("\r\n") {
"\r\n"
} else if raw_line.ends_with('\n') {
"\n"
} else {
""
};
found = Some((offset, offset + raw_line.len(), ending));
break;
}
offset += raw_line.len();
}
let new_content = match found {
Some((start, end, ending)) => {
if existing[start..end] == *format!("{line}{ending}") {
return Ok(false);
}
format!("{}{line}{ending}{}", &existing[..start], &existing[end..])
}
None => {
let mut out = existing.clone();
if !out.is_empty() && !out.ends_with('\n') {
out.push('\n');
}
out.push_str(line);
out.push('\n');
out
}
};
if new_content == existing {
return Ok(false);
}
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(io_err)?;
}
crate::fs_secure::write_preserving_mode(path, new_content.as_bytes()).map_err(io_err)?;
Ok(true)
}
fn remove_marker_lines(path: &Path) -> Result<bool, OlError> {
let existing = std::fs::read_to_string(path).map_err(io_err)?;
let mut out = String::with_capacity(existing.len());
let mut changed = false;
for raw_line in existing.split_inclusive('\n') {
let content = raw_line.strip_suffix('\n').unwrap_or(raw_line);
let content = content.strip_suffix('\r').unwrap_or(content);
if content.ends_with(MARKER) {
changed = true;
continue;
}
out.push_str(raw_line);
}
if !changed {
return Ok(false);
}
crate::fs_secure::write_preserving_mode(path, out.as_bytes()).map_err(io_err)?;
Ok(true)
}
fn posix_candidates(home: &Path, login_shell: Option<&str>, create_posix_rc: bool) -> Vec<PathBuf> {
let candidates = [
home.join(".zshrc"),
home.join(".bashrc"),
home.join(".bash_profile"),
home.join(".profile"),
];
let existing: Vec<PathBuf> = candidates.iter().filter(|p| p.exists()).cloned().collect();
if !existing.is_empty() || !create_posix_rc {
return existing;
}
let name = match login_shell {
Some(s) if s.ends_with("zsh") => ".zshrc",
Some(s) if s.ends_with("bash") => ".bashrc",
_ => ".profile",
};
vec![home.join(name)]
}
fn powershell_candidates(documents: Option<&Path>) -> Vec<PathBuf> {
match documents {
None => Vec::new(),
Some(documents) => [
documents.join("PowerShell").join("profile.ps1"),
documents.join("WindowsPowerShell").join("profile.ps1"),
]
.into_iter()
.filter(|p| p.exists())
.collect(),
}
}
pub(crate) fn ensure_line_with(
home: &Path,
documents: Option<&Path>,
env_sh: &Path,
env_ps1: &Path,
login_shell: Option<&str>,
create_posix_rc: bool,
) -> Result<Vec<PathBuf>, OlError> {
let mut touched = Vec::new();
let line = posix_line(env_sh);
for path in posix_candidates(home, login_shell, create_posix_rc) {
if ensure_line_in_file(&path, &line)? {
touched.push(path);
}
}
let ps1_line = powershell_line(env_ps1);
for path in powershell_candidates(documents) {
if ensure_line_in_file(&path, &ps1_line)? {
touched.push(path);
}
}
Ok(touched)
}
pub fn ensure_line(
home: &Path,
documents: Option<&Path>,
env_sh: &Path,
env_ps1: &Path,
) -> Result<Vec<PathBuf>, OlError> {
ensure_line_with(
home,
documents,
env_sh,
env_ps1,
std::env::var("SHELL").ok().as_deref(),
!cfg!(windows),
)
}
pub fn remove_line(home: &Path, documents: Option<&Path>) -> Result<Vec<PathBuf>, OlError> {
let mut touched = Vec::new();
for path in posix_candidates(home, None, false)
.into_iter()
.chain(powershell_candidates(documents))
{
if remove_marker_lines(&path)? {
touched.push(path);
}
}
Ok(touched)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn posix_line_is_exact() {
let env_sh = Path::new("/OL/model-relay/env.sh");
assert_eq!(
posix_line(env_sh),
"if [ -f '/OL/model-relay/env.sh' ]; then . '/OL/model-relay/env.sh'; fi \
# openlatch-model-relay"
);
}
#[test]
fn powershell_line_is_exact() {
let env_ps1 = Path::new("C:\\OL\\model-relay\\env.ps1");
assert_eq!(
powershell_line(env_ps1),
"if (Test-Path -LiteralPath 'C:\\OL\\model-relay\\env.ps1') { . \
'C:\\OL\\model-relay\\env.ps1' } # openlatch-model-relay"
);
}
#[test]
fn the_line_is_added_once_to_existing_files_only() {
let dir = tempfile::tempdir().expect("tempdir");
let home = dir.path();
let zshrc_seed = "export PATH=/usr/local/bin:$PATH\n";
let bashrc_seed = "alias ll='ls -la'\n";
std::fs::write(home.join(".zshrc"), zshrc_seed).expect("seed zshrc");
std::fs::write(home.join(".bashrc"), bashrc_seed).expect("seed bashrc");
let env_sh = home.join("ol").join("env.sh");
let env_ps1 = home.join("ol").join("env.ps1");
let touched = ensure_line_with(home, None, &env_sh, &env_ps1, None, true).expect("ensure");
assert_eq!(touched, vec![home.join(".zshrc"), home.join(".bashrc")]);
assert!(
!home.join(".profile").exists(),
".bash_profile/.profile must not be created when .zshrc and .bashrc exist"
);
assert!(!home.join(".bash_profile").exists());
let zshrc_after = std::fs::read_to_string(home.join(".zshrc")).unwrap();
assert_eq!(
zshrc_after,
format!("{zshrc_seed}{}\n", posix_line(&env_sh)),
"the customer's line is byte-identical, ours is appended after it"
);
let bashrc_after = std::fs::read_to_string(home.join(".bashrc")).unwrap();
assert_eq!(
bashrc_after,
format!("{bashrc_seed}{}\n", posix_line(&env_sh))
);
let touched2 =
ensure_line_with(home, None, &env_sh, &env_ps1, None, true).expect("second call");
assert!(touched2.is_empty(), "a second call must change nothing");
assert_eq!(
std::fs::read_to_string(home.join(".zshrc")).unwrap(),
zshrc_after
);
assert_eq!(
std::fs::read_to_string(home.join(".bashrc")).unwrap(),
bashrc_after
);
}
#[test]
fn the_login_shells_rc_is_created_when_none_exists() {
let dir = tempfile::tempdir().expect("tempdir");
let home = dir.path();
let env_sh = home.join("ol").join("env.sh");
let env_ps1 = home.join("ol").join("env.ps1");
let touched =
ensure_line_with(home, None, &env_sh, &env_ps1, Some("/bin/zsh"), true).expect("zsh");
assert_eq!(touched, vec![home.join(".zshrc")]);
std::fs::remove_file(home.join(".zshrc")).unwrap();
let touched =
ensure_line_with(home, None, &env_sh, &env_ps1, Some("/bin/bash"), true).expect("bash");
assert_eq!(touched, vec![home.join(".bashrc")]);
std::fs::remove_file(home.join(".bashrc")).unwrap();
let touched =
ensure_line_with(home, None, &env_sh, &env_ps1, None, true).expect("no login shell");
assert_eq!(touched, vec![home.join(".profile")]);
std::fs::remove_file(home.join(".profile")).unwrap();
let touched = ensure_line_with(home, None, &env_sh, &env_ps1, Some("/bin/zsh"), false)
.expect("no create");
assert!(touched.is_empty(), "the result must hold no POSIX path");
for name in [".zshrc", ".bashrc", ".bash_profile", ".profile"] {
assert!(!home.join(name).exists(), "{name} must not be created");
}
std::fs::write(home.join(".bashrc"), "").unwrap();
let touched = ensure_line_with(home, None, &env_sh, &env_ps1, Some("/bin/zsh"), false)
.expect("existing bashrc, no create");
assert_eq!(touched, vec![home.join(".bashrc")]);
assert!(!home.join(".zshrc").exists());
assert!(!home.join(".profile").exists());
}
#[test]
fn the_line_is_removed_cleanly() {
let dir = tempfile::tempdir().expect("tempdir");
let home = dir.path();
let seed = "export PATH=/usr/local/bin:$PATH\n";
std::fs::write(home.join(".zshrc"), seed).expect("seed");
let env_sh = home.join("ol").join("env.sh");
let env_ps1 = home.join("ol").join("env.ps1");
ensure_line_with(home, None, &env_sh, &env_ps1, Some("/bin/zsh"), true).expect("ensure");
let zshrc = home.join(".zshrc");
assert_eq!(
std::fs::read_to_string(&zshrc).unwrap(),
format!("{seed}{}\n", posix_line(&env_sh))
);
let touched = remove_line(home, None).expect("remove");
assert_eq!(touched, vec![zshrc.clone()]);
assert!(zshrc.exists(), "the file itself is never deleted");
assert_eq!(
std::fs::read_to_string(&zshrc).unwrap(),
seed,
"the remainder is byte-identical to the original"
);
assert!(remove_line(home, None).expect("remove again").is_empty());
}
#[test]
fn powershell_profiles_are_edited_only_when_they_exist() {
let dir = tempfile::tempdir().expect("tempdir");
let home = dir.path();
let documents = dir.path().join("Documents");
let ps_dir = documents.join("PowerShell");
std::fs::create_dir_all(&ps_dir).expect("dir");
let profile = ps_dir.join("profile.ps1");
std::fs::write(&profile, "").expect("seed");
let env_sh = home.join("ol").join("env.sh");
let env_ps1 = home.join("ol").join("env.ps1");
let touched = ensure_line_with(home, Some(&documents), &env_sh, &env_ps1, None, false)
.expect("ensure");
assert_eq!(touched, vec![profile.clone()]);
assert!(std::fs::read_to_string(&profile).unwrap().contains(MARKER));
assert!(
!documents.join("WindowsPowerShell").exists(),
"an absent profile must never be created"
);
}
#[test]
#[cfg(unix)]
fn the_sourcing_line_is_inert_without_the_env_file() {
let dir = tempfile::tempdir().expect("tempdir");
let home = dir.path();
let profile = home.join(".zshrc");
let env_sh = home.join("ol").join("env.sh");
std::fs::write(&profile, format!("{}\n", posix_line(&env_sh))).expect("seed");
let out = std::process::Command::new("sh")
.arg("-c")
.arg(format!(". '{}'; echo ok", profile.display()))
.output()
.expect("run sh");
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
assert_eq!(String::from_utf8_lossy(&out.stdout), "ok\n");
}
}