pub fn build_editor_open_argv(template: &str, abs_path: &str, line: Option<usize>) -> Vec<String> {
let template = template.trim();
if template.is_empty() {
return Vec::new();
}
let line_str = line.map(|n| n.to_string()).unwrap_or_default();
template
.split_whitespace()
.map(|token| {
token
.replace("{path}", abs_path)
.replace("{line}", &line_str)
})
.collect()
}
pub fn file_target_for_tool(
name: &str,
args: &serde_json::Value,
) -> Option<(String, Option<usize>)> {
match name {
"read" => {
let path = args.get("path")?.as_str()?.to_string();
let line = args
.get("offset")
.and_then(|v| v.as_u64())
.map(|n| n as usize);
Some((path, line))
}
"write" => {
let path = args.get("path")?.as_str()?.to_string();
Some((path, None))
}
"edit" => {
let path = args.get("path")?.as_str()?.to_string();
Some((path, None))
}
"edit_lines" => {
let path = args.get("path")?.as_str()?.to_string();
let line = args
.get("start_line")
.and_then(|v| v.as_u64())
.map(|n| n as usize);
Some((path, line))
}
"edit_minified" => {
let path = args.get("path")?.as_str()?.to_string();
Some((path, None))
}
"apply_patch" => {
let ops = args.get("operations")?.as_array()?;
let first_with_path = ops.iter().find_map(|op| op.get("path")?.as_str())?;
Some((first_with_path.to_string(), None))
}
_ => None,
}
}
pub fn spawn_editor_follow(argv: &[String]) {
if argv.is_empty() {
return;
}
let mut cmd = std::process::Command::new(&argv[0]);
cmd.args(&argv[1..]);
cmd.stdin(std::process::Stdio::null());
cmd.stdout(std::process::Stdio::null());
cmd.stderr(std::process::Stdio::null());
#[cfg(unix)]
{
use std::os::unix::process::CommandExt;
unsafe {
cmd.pre_exec(|| {
if libc::setsid() == -1 {
return Err(std::io::Error::last_os_error());
}
Ok(())
});
}
}
match cmd.spawn() {
Ok(mut child) => {
std::thread::spawn(move || {
let _ = child.wait();
});
}
Err(e) => {
tracing::warn!(
target: "dirge::editor_follow",
"failed to spawn editor follow-along: {e}"
);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn argv_zed_with_line() {
let argv = build_editor_open_argv("zed {path}:{line}", "/foo/bar.rs", Some(42));
assert_eq!(argv, vec!["zed", "/foo/bar.rs:42"]);
}
#[test]
fn argv_code_goto_with_line() {
let argv = build_editor_open_argv("code --goto {path}:{line}", "/a/b.rs", Some(99));
assert_eq!(argv, vec!["code", "--goto", "/a/b.rs:99"]);
}
#[test]
fn argv_short_flag() {
let argv = build_editor_open_argv("code -g {path}:{line}", "/x/y.rs", Some(7));
assert_eq!(argv, vec!["code", "-g", "/x/y.rs:7"]);
}
#[test]
fn argv_line_none_becomes_empty() {
let argv = build_editor_open_argv("zed {path}:{line}", "/foo/bar.rs", None);
assert_eq!(argv, vec!["zed", "/foo/bar.rs:"]);
}
#[test]
fn argv_no_line_placeholder() {
let argv = build_editor_open_argv("hx {path}", "/a/b.rs", Some(10));
assert_eq!(argv, vec!["hx", "/a/b.rs"]);
}
#[test]
fn argv_blank_template() {
let argv = build_editor_open_argv(" ", "/a/b.rs", Some(1));
assert!(argv.is_empty());
}
#[test]
fn argv_empty_template() {
let argv = build_editor_open_argv("", "/a/b.rs", Some(1));
assert!(argv.is_empty());
}
#[test]
fn argv_path_with_spaces() {
let argv = build_editor_open_argv("code -g {path}:{line}", "/path/no spaces.rs", Some(5));
assert_eq!(argv, vec!["code", "-g", "/path/no spaces.rs:5"]);
}
fn args_json(json: &str) -> serde_json::Value {
serde_json::from_str(json).unwrap()
}
#[test]
fn target_read() {
let args = args_json(r#"{"path": "/f.rs", "offset": 10}"#);
let (path, line) = file_target_for_tool("read", &args).unwrap();
assert_eq!(path, "/f.rs");
assert_eq!(line, Some(10));
}
#[test]
fn target_read_no_offset() {
let args = args_json(r#"{"path": "/f.rs"}"#);
let (path, line) = file_target_for_tool("read", &args).unwrap();
assert_eq!(path, "/f.rs");
assert_eq!(line, None);
}
#[test]
fn target_write() {
let args = args_json(r#"{"path": "/w.rs"}"#);
let (path, line) = file_target_for_tool("write", &args).unwrap();
assert_eq!(path, "/w.rs");
assert_eq!(line, None);
}
#[test]
fn target_edit() {
let args = args_json(r#"{"path": "/e.rs", "old_text": "x", "new_text": "y"}"#);
let (path, line) = file_target_for_tool("edit", &args).unwrap();
assert_eq!(path, "/e.rs");
assert_eq!(line, None);
}
#[test]
fn target_edit_lines() {
let args = args_json(r#"{"path": "/el.rs", "start_line": 15, "end_line": 20}"#);
let (path, line) = file_target_for_tool("edit_lines", &args).unwrap();
assert_eq!(path, "/el.rs");
assert_eq!(line, Some(15));
}
#[test]
fn target_edit_lines_no_start_line() {
let args = args_json(r#"{"path": "/el.rs", "end_line": 20}"#);
let (path, line) = file_target_for_tool("edit_lines", &args).unwrap();
assert_eq!(path, "/el.rs");
assert_eq!(line, None);
}
#[test]
fn target_edit_minified() {
let args = args_json(r#"{"path": "/em.rs", "old_text": "x", "new_text": "y"}"#);
let (path, line) = file_target_for_tool("edit_minified", &args).unwrap();
assert_eq!(path, "/em.rs");
assert_eq!(line, None);
}
#[test]
fn target_apply_patch() {
let args = args_json(
r#"{"operations": [{"action": "update", "path": "/p.rs", "old_text": "a", "new_text": "b"}]}"#,
);
let (path, line) = file_target_for_tool("apply_patch", &args).unwrap();
assert_eq!(path, "/p.rs");
assert_eq!(line, None);
}
#[test]
fn target_apply_patch_no_ops() {
let args = args_json(r#"{"operations": []}"#);
assert!(file_target_for_tool("apply_patch", &args).is_none());
}
#[test]
fn target_unknown_tool() {
let args = args_json(r#"{"path": "/x.rs"}"#);
assert!(file_target_for_tool("bash", &args).is_none());
}
#[test]
fn target_missing_path() {
let args = args_json(r#"{"offset": 10}"#);
assert!(file_target_for_tool("read", &args).is_none());
}
#[test]
fn target_non_string_path() {
let args = args_json(r#"{"path": 42}"#);
assert!(file_target_for_tool("read", &args).is_none());
}
#[test]
fn target_question_tool() {
let args = args_json(r#"{"questions": [{"question": "q", "options": []}]}"#);
assert!(file_target_for_tool("question", &args).is_none());
}
#[test]
fn target_task_tool() {
let args = args_json(r#"{"prompt": "do something"}"#);
assert!(file_target_for_tool("task", &args).is_none());
}
}