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
);
}
}