a73x

src/editor.rs

Ref:   Size: 8.9 KiB

use std::process::Command;

use crate::error::Error;

/// Resolve the user's preferred editor by checking $VISUAL, then $EDITOR.
/// Returns `None` if neither is set or both are empty.
pub fn resolve_editor() -> Option<String> {
    resolve_editor_from(
        std::env::var("VISUAL").ok().as_deref(),
        std::env::var("EDITOR").ok().as_deref(),
    )
}

/// Inner helper: resolve editor from explicit values (testable without env mutation).
fn resolve_editor_from(visual: Option<&str>, editor: Option<&str>) -> Option<String> {
    for val in [visual, editor].into_iter().flatten() {
        let trimmed = val.trim();
        if !trimmed.is_empty() {
            return Some(trimmed.to_string());
        }
    }
    None
}

/// Launch the editor at a specific file and line number.
///
/// The editor string is split on whitespace to support editors like `code --wait`.
/// The command is invoked as: `<editor...> +{line} {file}`.
pub fn open_editor_at(file: &str, line: u32) -> Result<(), Error> {
    let editor_str = resolve_editor().ok_or_else(|| {
        Error::Cmd("No editor configured. Set $EDITOR or $VISUAL.".to_string())
    })?;

    open_editor_at_with(file, line, &editor_str)
}

/// Inner helper: launch an editor command at a specific file and line.
/// Separated from `open_editor_at` so tests can pass an explicit editor string
/// without mutating environment variables.
fn open_editor_at_with(file: &str, line: u32, editor_str: &str) -> Result<(), Error> {
    if !std::path::Path::new(file).exists() {
        return Err(Error::Cmd(format!("File not found: {}", file)));
    }

    let parts: Vec<&str> = editor_str.split_whitespace().collect();
    if parts.is_empty() {
        return Err(Error::Cmd("Editor command is empty".to_string()));
    }

    let program = parts[0];
    let extra_args = &parts[1..];

    let status = Command::new(program)
        .args(extra_args)
        .arg(format!("+{}", line))
        .arg(file)
        .status()
        .map_err(|e| Error::Cmd(format!("Failed to launch editor '{}': {}", program, e)))?;

    if !status.success() {
        let code = status.code().unwrap_or(-1);
        return Err(Error::Cmd(format!("Editor exited with status: {}", code)));
    }

    Ok(())
}

/// Given a list of comment positions `(file, line, rendered_row)` and the
/// current scroll position, find the comment whose rendered row is closest to
/// and at or above the scroll position. Returns `(file, line)` if found.
///
/// The `rendered_position` (third tuple element) is the row index within the
/// rendered diff text where this inline comment appears.
pub fn find_comment_at_scroll(
    comments: &[(String, u32, usize)],
    scroll_pos: u16,
) -> Option<(String, u32)> {
    let scroll = scroll_pos as usize;
    let mut best: Option<&(String, u32, usize)> = None;

    for entry in comments {
        let rendered = entry.2;
        if rendered <= scroll {
            match best {
                Some(prev) if rendered > prev.2 => best = Some(entry),
                None => best = Some(entry),
                _ => {}
            }
        }
    }

    best.map(|e| (e.0.clone(), e.1))
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::io::Write;

    // ---- resolve_editor tests (pure, no env mutation) ----

    #[test]
    fn test_resolve_editor_visual_takes_precedence() {
        let result = resolve_editor_from(Some("nvim"), Some("vi"));
        assert_eq!(result, Some("nvim".to_string()));
    }

    #[test]
    fn test_resolve_editor_falls_back_to_editor() {
        let result = resolve_editor_from(None, Some("nano"));
        assert_eq!(result, Some("nano".to_string()));
    }

    #[test]
    fn test_resolve_editor_none_when_unset() {
        let result = resolve_editor_from(None, None);
        assert_eq!(result, None);
    }

    #[test]
    fn test_resolve_editor_skips_empty_visual() {
        let result = resolve_editor_from(Some("  "), Some("vim"));
        assert_eq!(result, Some("vim".to_string()));
    }

    #[test]
    fn test_resolve_editor_both_empty() {
        let result = resolve_editor_from(Some(""), Some(""));
        assert_eq!(result, None);
    }

    #[test]
    fn test_resolve_editor_trims_whitespace() {
        let result = resolve_editor_from(None, Some("  vim  "));
        assert_eq!(result, Some("vim".to_string()));
    }

    // ---- find_comment_at_scroll tests ----

    #[test]
    fn test_find_comment_at_scroll_empty() {
        let comments: Vec<(String, u32, usize)> = vec![];
        assert_eq!(find_comment_at_scroll(&comments, 10), None);
    }

    #[test]
    fn test_find_comment_at_scroll_exact_match() {
        let comments = vec![
            ("src/main.rs".to_string(), 42, 10),
            ("src/lib.rs".to_string(), 15, 20),
        ];
        let result = find_comment_at_scroll(&comments, 10);
        assert_eq!(result, Some(("src/main.rs".to_string(), 42)));
    }

    #[test]
    fn test_find_comment_at_scroll_picks_closest_above() {
        let comments = vec![
            ("a.rs".to_string(), 1, 5),
            ("b.rs".to_string(), 2, 15),
            ("c.rs".to_string(), 3, 25),
        ];
        // Scroll is at 20, closest at-or-above is rendered_pos=15
        let result = find_comment_at_scroll(&comments, 20);
        assert_eq!(result, Some(("b.rs".to_string(), 2)));
    }

    #[test]
    fn test_find_comment_at_scroll_all_below() {
        let comments = vec![
            ("a.rs".to_string(), 1, 30),
            ("b.rs".to_string(), 2, 40),
        ];
        // Scroll at 10, all comments are below
        let result = find_comment_at_scroll(&comments, 10);
        assert_eq!(result, None);
    }

    #[test]
    fn test_find_comment_at_scroll_first_wins_on_tie() {
        // Two comments at same rendered position -- first in list wins
        // because our > comparison doesn't replace when equal.
        let comments = vec![
            ("a.rs".to_string(), 1, 10),
            ("b.rs".to_string(), 2, 10),
        ];
        let result = find_comment_at_scroll(&comments, 10);
        assert_eq!(result, Some(("a.rs".to_string(), 1)));
    }

    #[test]
    fn test_find_comment_at_scroll_scroll_at_zero() {
        let comments = vec![
            ("a.rs".to_string(), 1, 0),
            ("b.rs".to_string(), 2, 5),
        ];
        let result = find_comment_at_scroll(&comments, 0);
        assert_eq!(result, Some(("a.rs".to_string(), 1)));
    }

    #[test]
    fn test_find_comment_at_scroll_single_comment_above() {
        let comments = vec![("only.rs".to_string(), 99, 3)];
        let result = find_comment_at_scroll(&comments, 100);
        assert_eq!(result, Some(("only.rs".to_string(), 99)));
    }

    // ---- open_editor_at tests (using inner helper, no env mutation) ----

    #[test]
    fn test_open_editor_at_success_with_true() {
        let mut tmp = tempfile::NamedTempFile::new().unwrap();
        writeln!(tmp, "hello").unwrap();
        let path = tmp.path().to_str().unwrap().to_string();

        let result = open_editor_at_with(&path, 1, "true");
        assert!(result.is_ok(), "Expected Ok, got {:?}", result);
    }

    #[test]
    fn test_open_editor_at_file_not_found() {
        let result =
            open_editor_at_with("/tmp/nonexistent_file_for_test_abc123xyz.txt", 1, "true");
        assert!(result.is_err());
        let err_msg = format!("{}", result.unwrap_err());
        assert!(
            err_msg.contains("File not found"),
            "Unexpected error: {}",
            err_msg
        );
    }

    #[test]
    fn test_open_editor_at_nonzero_exit() {
        let mut tmp = tempfile::NamedTempFile::new().unwrap();
        writeln!(tmp, "hello").unwrap();
        let path = tmp.path().to_str().unwrap().to_string();

        let result = open_editor_at_with(&path, 1, "false");
        assert!(result.is_err());
        let err_msg = format!("{}", result.unwrap_err());
        assert!(
            err_msg.contains("Editor exited with status"),
            "Unexpected error: {}",
            err_msg
        );
    }

    #[test]
    fn test_open_editor_at_with_args_in_editor_string() {
        // `true` ignores all arguments, so "true --wait" should succeed
        let mut tmp = tempfile::NamedTempFile::new().unwrap();
        writeln!(tmp, "hello").unwrap();
        let path = tmp.path().to_str().unwrap().to_string();

        let result = open_editor_at_with(&path, 42, "true --wait");
        assert!(result.is_ok(), "Expected Ok, got {:?}", result);
    }

    #[test]
    fn test_open_editor_at_bad_command() {
        let mut tmp = tempfile::NamedTempFile::new().unwrap();
        writeln!(tmp, "hello").unwrap();
        let path = tmp.path().to_str().unwrap().to_string();

        let result = open_editor_at_with(&path, 1, "nonexistent_editor_binary_xyz");
        assert!(result.is_err());
        let err_msg = format!("{}", result.unwrap_err());
        assert!(
            err_msg.contains("Failed to launch editor"),
            "Unexpected error: {}",
            err_msg
        );
    }
}