use crate::error::{AppError, Result};
use std::env;
use std::fs;
use std::path::PathBuf;
use super::shared::{MARKER_END, MARKER_START};
const SHELL_ADDITIONS: &str = r#"# TR-300 Machine Report
alias report='tr300'
# Auto-run on interactive shell; guards prevent spam-on-every-prompt
# when the binary is missing, and recursion in nested shells.
case "$-" in *i*)
if command -v tr300 >/dev/null 2>&1 && [ -z "${TR300_AUTORUN_RAN-}" ]; then
export TR300_AUTORUN_RAN=1
tr300 --fast
fi
;;
esac
# End TR-300"#;
pub fn install_path() -> PathBuf {
if let Some(home) = dirs::home_dir() {
let local_bin = home.join(".local").join("bin");
if local_bin.exists() {
return local_bin.join("tr300");
}
}
PathBuf::from("/usr/local/bin/tr300")
}
pub fn install() -> Result<()> {
refuse_root_install()?;
let home =
dirs::home_dir().ok_or_else(|| AppError::platform("Could not determine home directory"))?;
let mut modified_files = Vec::new();
let bashrc = home.join(".bashrc");
if bashrc.exists() && update_shell_profile(&bashrc)? {
modified_files.push(bashrc.display().to_string());
}
let zshrc = home.join(".zshrc");
if zshrc.exists() && update_shell_profile(&zshrc)? {
modified_files.push(zshrc.display().to_string());
}
if modified_files.is_empty() && !bashrc.exists() && !zshrc.exists() {
let default_rc = if cfg!(target_os = "macos") {
&zshrc
} else {
&bashrc
};
super::atomic_write(default_rc, SHELL_ADDITIONS).map_err(|e| {
AppError::platform(format!("Failed to create {}: {}", default_rc.display(), e))
})?;
modified_files.push(default_rc.display().to_string());
}
if modified_files.is_empty() {
return Err(AppError::platform("No shell profile found to update"));
}
println!("Modified shell profiles:");
for file in &modified_files {
println!(" - {}", file);
}
Ok(())
}
fn refuse_root_install() -> Result<()> {
let euid = unsafe { libc::geteuid() };
if euid == 0 {
return Err(AppError::platform(
"Don't run `tr300 install` with sudo / as root — TR-300 modifies your personal shell profile (~/.bashrc / ~/.zshrc). Running as root would either write the auto-run into root's profile (no benefit to your shell) or leave root-owned files in your home directory (the next non-sudo `tr300 install` would fail with permission denied). Re-run as your normal user without sudo.",
));
}
Ok(())
}
pub fn uninstall() -> Result<()> {
let home =
dirs::home_dir().ok_or_else(|| AppError::platform("Could not determine home directory"))?;
let mut modified_files = Vec::new();
let bashrc = home.join(".bashrc");
if bashrc.exists() && remove_from_profile(&bashrc)? {
modified_files.push(bashrc.display().to_string());
}
let zshrc = home.join(".zshrc");
if zshrc.exists() && remove_from_profile(&zshrc)? {
modified_files.push(zshrc.display().to_string());
}
if modified_files.is_empty() {
println!("No TR-300 configuration found in shell profiles.");
} else {
println!("Cleaned shell profiles:");
for file in &modified_files {
println!(" - {}", file);
}
}
Ok(())
}
fn update_shell_profile(path: &PathBuf) -> Result<bool> {
let content = fs::read_to_string(path)
.map_err(|e| AppError::platform(format!("Failed to read {}: {}", path.display(), e)))?;
super::check_marker_balance(&content, MARKER_START, MARKER_END).map_err(AppError::platform)?;
let _ = super::backup_once(path);
let cleaned_content = remove_tr300_block(&content);
let new_content = if cleaned_content.trim().is_empty() {
format!("{}\n", SHELL_ADDITIONS)
} else {
format!("{}\n\n{}\n", cleaned_content.trim_end(), SHELL_ADDITIONS)
};
super::atomic_write(path, &new_content)
.map_err(|e| AppError::platform(format!("Failed to write {}: {}", path.display(), e)))?;
Ok(true)
}
fn remove_tr300_block(content: &str) -> String {
let lines: Vec<&str> = content.lines().collect();
let lines = super::shared::remove_delimited_block(&lines, MARKER_START, MARKER_END);
let mut result = Vec::new();
let mut prev_blank = false;
for line in lines {
let is_blank = line.trim().is_empty();
if is_blank && prev_blank {
continue;
}
result.push(line);
prev_blank = is_blank;
}
while result.last().map(|s| s.trim().is_empty()).unwrap_or(false) {
result.pop();
}
if result.is_empty() {
String::new()
} else {
result.join("\n") + "\n"
}
}
fn remove_from_profile(path: &PathBuf) -> Result<bool> {
let content = fs::read_to_string(path)
.map_err(|e| AppError::platform(format!("Failed to read {}: {}", path.display(), e)))?;
if !content.contains(MARKER_START) {
return Ok(false);
}
super::check_marker_balance(&content, MARKER_START, MARKER_END).map_err(AppError::platform)?;
let lines: Vec<&str> = content.lines().collect();
let mut new_lines = super::shared::remove_delimited_block(&lines, MARKER_START, MARKER_END);
while new_lines.last().map(|s| s.is_empty()).unwrap_or(false) {
new_lines.pop();
}
let new_content = new_lines.join("\n") + "\n";
super::atomic_write(path, &new_content)
.map_err(|e| AppError::platform(format!("Failed to write {}: {}", path.display(), e)))?;
Ok(true)
}
pub fn find_binary_location() -> Option<PathBuf> {
if let Ok(exe_path) = env::current_exe() {
if exe_path.exists() {
return Some(exe_path);
}
}
let path = install_path();
if path.exists() {
return Some(path);
}
None
}
pub fn remove_binary(binary_path: &PathBuf) -> Result<()> {
if !binary_path.exists() {
return Ok(());
}
fs::remove_file(binary_path).map_err(|e| {
AppError::platform(format!(
"Failed to remove binary {}: {}",
binary_path.display(),
e
))
})?;
println!("Removed binary: {}", binary_path.display());
Ok(())
}
pub fn uninstall_complete() -> Result<()> {
uninstall()?;
if let Some(binary_path) = find_binary_location() {
remove_binary(&binary_path)?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::{MARKER_END, MARKER_START, SHELL_ADDITIONS};
use crate::install::shared::{ALIAS_NAME, AUTORUN_SENTINEL_VAR, BINARY_NAME};
#[test]
fn shell_additions_contains_shared_markers() {
assert!(SHELL_ADDITIONS.contains(MARKER_START));
assert!(SHELL_ADDITIONS.contains(MARKER_END));
assert!(SHELL_ADDITIONS.contains(ALIAS_NAME));
assert!(SHELL_ADDITIONS.contains(BINARY_NAME));
}
#[test]
fn shell_additions_has_path_guard() {
assert!(SHELL_ADDITIONS.contains("command -v tr300"));
}
#[test]
fn shell_additions_has_recursion_sentinel() {
assert!(SHELL_ADDITIONS.contains(AUTORUN_SENTINEL_VAR));
assert!(SHELL_ADDITIONS.contains("export TR300_AUTORUN_RAN=1"));
}
#[test]
fn shell_additions_gates_on_interactive_shell() {
assert!(SHELL_ADDITIONS.contains(r#"case "$-" in *i*"#));
}
}