tr300 3.15.2

Cross-platform system information report
//! Self-installation utilities
//!
//! Provides commands to install/uninstall tr300 to system paths.

#[cfg(unix)]
pub mod unix;

#[cfg(windows)]
pub mod windows;

pub mod prompt;
mod shared;

use crate::error::Result;
use std::fs;
use std::io::{self, Write};
use std::path::{Path, PathBuf};

pub use prompt::{confirm_complete_uninstall, prompt_uninstall_option, UninstallOption};

// ── Shared file-write safety primitives (v3.15.2+) ──────────────────
//
// The install/uninstall flow mutates user-edited config files (~/.bashrc,
// ~/.zshrc, $PROFILE) that have no canonical copy elsewhere. A partial
// write or a stray "drop everything below TR-300 marker" parser bug
// deletes the user's hand-tuned shell config with no warning. These
// helpers are the safety net that makes both classes of failure
// recoverable.

/// Atomically write `content` to `path` via write-temp-then-rename.
///
/// `std::fs::write` opens the target with `O_TRUNC` / `CREATE_ALWAYS` and
/// then writes — if the process dies between the truncate and the write
/// completion (Ctrl-C, power loss, antivirus quarantine mid-write), the
/// rc file is left empty or partial. For files the user has invested
/// real time in (shell profiles), that loss is catastrophic and
/// irrecoverable.
///
/// This helper writes to a sibling temp file (`.<name>.tr300-tmp` in the
/// same parent directory, guaranteed same volume so `rename` is atomic),
/// fsyncs, and atomically renames over the target. The target file ends
/// up either fully replaced or completely untouched — never partial.
pub(crate) fn atomic_write(path: &Path, content: &str) -> io::Result<()> {
    let parent = path.parent().ok_or_else(|| {
        io::Error::new(
            io::ErrorKind::InvalidInput,
            format!("path has no parent: {}", path.display()),
        )
    })?;
    let file_name = path.file_name().ok_or_else(|| {
        io::Error::new(
            io::ErrorKind::InvalidInput,
            format!("path has no filename: {}", path.display()),
        )
    })?;
    let mut tmp_name = std::ffi::OsString::from(".");
    tmp_name.push(file_name);
    tmp_name.push(".tr300-tmp");
    let tmp_path = parent.join(tmp_name);

    let write_result = (|| -> io::Result<()> {
        let mut tmp = fs::File::create(&tmp_path)?;
        tmp.write_all(content.as_bytes())?;
        tmp.sync_all()?;
        Ok(())
    })();
    if let Err(e) = write_result {
        let _ = fs::remove_file(&tmp_path);
        return Err(e);
    }

    if let Err(e) = fs::rename(&tmp_path, path) {
        let _ = fs::remove_file(&tmp_path);
        return Err(e);
    }

    Ok(())
}

/// Copy `path` to `<path>.tr300-backup` if no backup exists yet.
///
/// Idempotent: on second and subsequent install runs, the existing
/// backup (which captures the rc file from BEFORE TR-300 ever touched
/// it) is preserved. We never overwrite a backup with a TR-300-modified
/// version — that would silently destroy the user's original config.
/// Best-effort: a failure here is non-fatal; the atomic write itself is
/// the load-bearing safety net.
pub(crate) fn backup_once(path: &Path) -> io::Result<()> {
    if !path.exists() {
        return Ok(());
    }
    let mut bak_name = path.file_name().unwrap_or_default().to_os_string();
    bak_name.push(".tr300-backup");
    let bak_path = path.with_file_name(bak_name);
    if bak_path.exists() {
        return Ok(());
    }
    fs::copy(path, &bak_path)?;
    Ok(())
}

/// Verify that `MARKER_START` / `MARKER_END` line counts match before
/// the block parser mutates the file.
///
/// `remove_delimited_block` opens a block on any line containing
/// `MARKER_START` and closes it on any line containing `MARKER_END`. If
/// the user hand-edited `MARKER_END` out of their rc file — a real and
/// plausible failure mode when tidying up shell config — the parser
/// silently drops every line from `MARKER_START` to EOF on the next
/// install run. That's user-data loss with no warning.
///
/// This balance check refuses the write up-front with an actionable
/// error message instead, leaving the file untouched so the user can
/// repair it by hand.
pub(crate) fn check_marker_balance(
    content: &str,
    start: &str,
    end: &str,
) -> std::result::Result<(), String> {
    let starts = content.lines().filter(|l| l.contains(start)).count();
    let ends = content.lines().filter(|l| l.contains(end)).count();
    if starts == ends {
        return Ok(());
    }
    Err(format!(
        "TR-300 marker block in your shell profile looks mutilated:\n  found {starts} `{start}` line(s) and {ends} `{end}` line(s) (counts must match)\n\nThis usually means the `{end}` line was accidentally deleted. The TR-300\nblock parser would otherwise silently drop every line below `{start}` on\ninstall, so we refuse to write until you clean it up by hand:\n  - if you want TR-300 gone, remove the whole block manually and re-run\n  - if you want TR-300 installed, re-add the missing `{end}` line below your block"
    ))
}

/// Install tr300 to the system
pub fn install() -> Result<()> {
    #[cfg(unix)]
    {
        unix::install()
    }

    #[cfg(windows)]
    {
        windows::install()
    }

    #[cfg(not(any(unix, windows)))]
    {
        Err(crate::error::AppError::platform(
            "Self-installation not supported on this platform",
        ))
    }
}

/// Uninstall tr300 from the system
pub fn uninstall() -> Result<()> {
    #[cfg(unix)]
    {
        unix::uninstall()
    }

    #[cfg(windows)]
    {
        windows::uninstall()
    }

    #[cfg(not(any(unix, windows)))]
    {
        Err(crate::error::AppError::platform(
            "Self-uninstallation not supported on this platform",
        ))
    }
}

/// Get the installation path
pub fn install_path() -> Option<PathBuf> {
    #[cfg(unix)]
    {
        Some(unix::install_path())
    }

    #[cfg(windows)]
    {
        Some(windows::install_path())
    }

    #[cfg(not(any(unix, windows)))]
    {
        None
    }
}

/// Find the location of the currently running binary
pub fn find_binary_location() -> Option<PathBuf> {
    #[cfg(unix)]
    {
        unix::find_binary_location()
    }

    #[cfg(windows)]
    {
        windows::find_binary_location()
    }

    #[cfg(not(any(unix, windows)))]
    {
        None
    }
}

/// Get the parent directory of the binary (for cleanup on Windows)
pub fn get_binary_parent_dir(binary_path: &std::path::Path) -> Option<PathBuf> {
    #[cfg(unix)]
    {
        let _ = binary_path;
        None // Unix doesn't need directory cleanup
    }

    #[cfg(windows)]
    {
        windows::get_binary_parent_dir(binary_path)
    }

    #[cfg(not(any(unix, windows)))]
    {
        let _ = binary_path;
        None
    }
}

/// Perform complete uninstall (profile + binary)
pub fn uninstall_complete() -> Result<()> {
    #[cfg(unix)]
    {
        unix::uninstall_complete()
    }

    #[cfg(windows)]
    {
        windows::uninstall_complete()
    }

    #[cfg(not(any(unix, windows)))]
    {
        Err(crate::error::AppError::platform(
            "Complete uninstallation not supported on this platform",
        ))
    }
}

#[cfg(test)]
mod shared_tests {
    use super::{atomic_write, backup_once, check_marker_balance};
    use std::fs;

    #[test]
    fn atomic_write_replaces_existing_file() {
        let dir = tempdir_in_target();
        let path = dir.join("test.txt");
        fs::write(&path, b"old content").unwrap();
        atomic_write(&path, "new content").unwrap();
        assert_eq!(fs::read_to_string(&path).unwrap(), "new content");
        let _ = fs::remove_dir_all(&dir);
    }

    #[test]
    fn atomic_write_creates_new_file() {
        let dir = tempdir_in_target();
        let path = dir.join("new.txt");
        atomic_write(&path, "hello").unwrap();
        assert_eq!(fs::read_to_string(&path).unwrap(), "hello");
        let _ = fs::remove_dir_all(&dir);
    }

    #[test]
    fn atomic_write_cleans_up_temp_on_failure() {
        // A non-existent parent dir makes File::create fail; verify no
        // orphan temp file is left behind.
        let dir = tempdir_in_target();
        let nonexistent_parent = dir.join("does_not_exist");
        let path = nonexistent_parent.join("file.txt");
        assert!(atomic_write(&path, "data").is_err());
        // Nothing to leak — parent doesn't exist — but verify the
        // sibling temp wasn't created.
        let tmp = nonexistent_parent.join(".file.txt.tr300-tmp");
        assert!(!tmp.exists());
        let _ = fs::remove_dir_all(&dir);
    }

    #[test]
    fn backup_once_creates_sidecar_when_missing() {
        let dir = tempdir_in_target();
        let path = dir.join(".bashrc");
        fs::write(&path, b"export FOO=bar\n").unwrap();
        backup_once(&path).unwrap();
        let bak = dir.join(".bashrc.tr300-backup");
        assert!(bak.exists());
        assert_eq!(fs::read_to_string(&bak).unwrap(), "export FOO=bar\n");
        let _ = fs::remove_dir_all(&dir);
    }

    #[test]
    fn backup_once_does_not_overwrite_existing_backup() {
        // The whole point of `_once`: a second install must not destroy
        // the original (pre-TR-300) backup.
        let dir = tempdir_in_target();
        let path = dir.join(".bashrc");
        let bak = dir.join(".bashrc.tr300-backup");
        fs::write(&bak, b"ORIGINAL untouched content\n").unwrap();
        fs::write(&path, b"# TR-300 already modified\n").unwrap();
        backup_once(&path).unwrap();
        assert_eq!(
            fs::read_to_string(&bak).unwrap(),
            "ORIGINAL untouched content\n",
            "second backup_once must not clobber the original backup"
        );
        let _ = fs::remove_dir_all(&dir);
    }

    #[test]
    fn backup_once_is_noop_when_source_missing() {
        let dir = tempdir_in_target();
        let path = dir.join("never_existed");
        backup_once(&path).unwrap();
        let bak = dir.join("never_existed.tr300-backup");
        assert!(!bak.exists());
        let _ = fs::remove_dir_all(&dir);
    }

    #[test]
    fn marker_balance_clean_file_passes() {
        let content = "export FOO=bar\nalias ll='ls -l'\n";
        assert!(check_marker_balance(content, "# TR-300 Machine Report", "# End TR-300").is_ok());
    }

    #[test]
    fn marker_balance_well_formed_block_passes() {
        let content =
            "export FOO=bar\n\n# TR-300 Machine Report\nalias report='tr300'\n# End TR-300\n";
        assert!(check_marker_balance(content, "# TR-300 Machine Report", "# End TR-300").is_ok());
    }

    #[test]
    fn marker_balance_two_well_formed_blocks_passes() {
        // Pathological but technically balanced (two installs without
        // cleanup). Parser handles this; balance check should too.
        let content = "# TR-300 Machine Report\nfoo\n# End TR-300\n\n# TR-300 Machine Report\nbar\n# End TR-300\n";
        assert!(check_marker_balance(content, "# TR-300 Machine Report", "# End TR-300").is_ok());
    }

    #[test]
    fn marker_balance_missing_end_is_refused() {
        // The actual data-loss scenario: user removed `# End TR-300` line.
        let content = "export FOO=bar\n\n# TR-300 Machine Report\nalias report='tr300'\nthe rest of my bashrc...\n";
        let err =
            check_marker_balance(content, "# TR-300 Machine Report", "# End TR-300").unwrap_err();
        assert!(err.contains("mutilated"), "error: {}", err);
        assert!(err.contains("# End TR-300"), "error: {}", err);
    }

    #[test]
    fn marker_balance_missing_start_is_refused() {
        // The inverse: an orphan `# End TR-300` line on its own. Less
        // common (user would have to delete the start without the end),
        // but refused for symmetry.
        let content = "alias foo=bar\n# End TR-300\nmore stuff\n";
        let err =
            check_marker_balance(content, "# TR-300 Machine Report", "# End TR-300").unwrap_err();
        assert!(err.contains("mutilated"), "error: {}", err);
    }

    /// Test scratch dir under the system temp dir. Each test gets its
    /// own dir keyed by pid+thread-id so parallel test runs don't
    /// collide.
    fn tempdir_in_target() -> std::path::PathBuf {
        let dir = std::env::temp_dir().join(format!(
            "tr300-install-tests-{}-{:?}",
            std::process::id(),
            std::thread::current().id(),
        ));
        let _ = fs::remove_dir_all(&dir);
        fs::create_dir_all(&dir).unwrap();
        dir
    }
}