pub fn resolve_task(
arg: Option<&str>,
agent_name: &str,
description: &str,
stdin_is_terminal: &dyn Fn() -> bool,
) -> anyhow::Result<String> {
resolve_task_with(arg, agent_name, description, stdin_is_terminal)
}
fn looks_like_path(s: &str) -> bool {
!s.is_empty()
&& !s.chars().any(char::is_whitespace)
&& (s.contains('/') || s.contains('\\') || s.starts_with('~'))
}
fn resolve_task_with(
arg: Option<&str>,
agent_name: &str,
description: &str,
stdin_is_terminal: &dyn Fn() -> bool,
) -> anyhow::Result<String> {
resolve_task_with_editor(
arg,
agent_name,
description,
stdin_is_terminal,
&leviath_sys::editor::launch,
&std::env::temp_dir,
)
}
pub fn read_region_value(raw: &str) -> anyhow::Result<String> {
match raw.strip_prefix('@') {
Some(path) => {
let content = std::fs::read_to_string(path)
.map_err(|e| anyhow::anyhow!("Failed to read region file '{}': {}", path, e))?;
let trimmed = content.trim().to_string();
if trimmed.is_empty() {
anyhow::bail!("Region file '{}' is empty.", path);
}
Ok(trimmed)
}
None => Ok(raw.to_string()),
}
}
fn resolve_task_with_editor(
arg: Option<&str>,
agent_name: &str,
description: &str,
stdin_is_terminal: &dyn Fn() -> bool,
launch_editor_fn: &dyn Fn(&std::path::Path) -> std::io::Result<()>,
tmp_dir_fn: &dyn Fn() -> std::path::PathBuf,
) -> anyhow::Result<String> {
match arg {
Some(s) => {
let p = std::path::Path::new(s);
if p.is_file() {
let content = std::fs::read_to_string(p)
.map_err(|e| anyhow::anyhow!("Failed to read task file '{}': {}", s, e))?;
let trimmed = content.trim().to_string();
if trimmed.is_empty() {
anyhow::bail!("Task file '{}' is empty.", s);
}
return Ok(trimmed);
}
if p.is_dir() {
anyhow::bail!("Task file '{}' is a directory.", s);
}
if looks_like_path(s) {
anyhow::bail!(
"No task file '{}'. Pass the prompt itself, or point --task at a file that exists.",
s
);
}
Ok(s.to_string())
}
None => {
if !stdin_is_terminal() {
anyhow::bail!(
"No task provided. Pass --task \"<prompt>\" or --task <file>.\n\
(stdin is not a TTY, so the interactive editor cannot be used)"
);
}
let template = build_task_template(agent_name, description);
let tmp = write_task_template(&tmp_dir_fn(), &template)?;
let tmp = tmp.into_temp_path();
let tmp_path = tmp.to_path_buf();
let result = launch_editor_fn(&tmp_path);
let content = std::fs::read_to_string(&tmp_path).unwrap_or_default();
let _ = std::fs::remove_file(&tmp_path);
result.map_err(|e| anyhow::anyhow!("{e}"))?;
let task: String = content
.lines()
.filter(|l| !l.trim_start().starts_with('#'))
.collect::<Vec<_>>()
.join("\n")
.trim()
.to_string();
if task.is_empty() {
anyhow::bail!("Aborting run: empty task.");
}
Ok(task)
}
}
}
fn build_task_template(agent_name: &str, description: &str) -> String {
let mut template = format!("# Task for agent: {}\n", agent_name);
if !description.is_empty() {
template.push_str(&format!("# {}\n", description));
}
template.push_str("#\n# Describe your task below. Lines starting with '#' are ignored.\n\n");
template
}
fn write_task_template(
dir: &std::path::Path,
content: &str,
) -> anyhow::Result<tempfile::NamedTempFile> {
use std::io::Write as _;
tempfile::Builder::new()
.prefix("lev-task-")
.suffix(".txt")
.tempfile_in(dir)
.and_then(|mut file| {
file.as_file_mut()
.write_all(content.as_bytes())
.and_then(|()| file.as_file_mut().flush())
.map(|()| file)
})
.map_err(|e| anyhow::anyhow!("Failed to create task temp file: {}", e))
}
#[cfg(test)]
mod tests {
use super::*;
fn never_a_tty() -> bool {
false
}
#[test]
fn read_region_value_literal_passthrough() {
assert_eq!(read_region_value("just text").unwrap(), "just text");
}
#[test]
fn read_region_value_at_path_reads_and_trims() {
let dir = std::env::temp_dir().join("lev-test-region-value");
std::fs::create_dir_all(&dir).unwrap();
let file = dir.join("r.md");
std::fs::write(&file, " hello region \n").unwrap();
let raw = format!("@{}", file.to_string_lossy());
assert_eq!(read_region_value(&raw).unwrap(), "hello region");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn read_region_value_at_missing_file_errors() {
let err = read_region_value("@/no/such/region/file.md").unwrap_err();
assert!(err.to_string().contains("Failed to read region file"));
}
#[test]
fn read_region_value_at_empty_file_errors() {
let dir = std::env::temp_dir().join("lev-test-region-empty");
std::fs::create_dir_all(&dir).unwrap();
let file = dir.join("empty.md");
std::fs::write(&file, " \n").unwrap();
let raw = format!("@{}", file.to_string_lossy());
let err = read_region_value(&raw).unwrap_err();
assert!(err.to_string().contains("is empty"));
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn resolve_task_with_literal_string() {
let result = resolve_task(Some("do something"), "test", "", &never_a_tty);
assert_eq!(result.unwrap(), "do something");
}
#[test]
fn resolve_task_with_file_path() {
let dir = std::env::temp_dir().join("lev-test-resolve-task");
let _ = std::fs::create_dir_all(&dir);
let file = dir.join("task.txt");
std::fs::write(&file, "task from file\n").unwrap();
let result = resolve_task(Some(file.to_str().unwrap()), "test", "", &never_a_tty);
assert_eq!(result.unwrap(), "task from file");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn resolve_task_with_empty_file_errors() {
let dir = std::env::temp_dir().join("lev-test-resolve-empty");
let _ = std::fs::create_dir_all(&dir);
let file = dir.join("empty.txt");
std::fs::write(&file, " \n ").unwrap();
let result = resolve_task(Some(file.to_str().unwrap()), "test", "", &never_a_tty);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("empty"));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn resolve_task_bare_word_that_is_not_a_file_is_literal() {
let result = resolve_task(Some("do_something"), "test", "", &never_a_tty);
assert_eq!(result.unwrap(), "do_something");
}
#[test]
fn resolve_task_path_shaped_value_that_names_nothing_errors() {
let err = resolve_task(
Some("/nonexistent/path/do_something"),
"test",
"",
&never_a_tty,
)
.unwrap_err();
assert!(
err.to_string()
.starts_with("No task file '/nonexistent/path/do_something'."),
"{err}"
);
}
#[test]
fn resolve_task_directory_argument_errors() {
let dir = tempfile::tempdir().unwrap();
let err =
resolve_task(Some(dir.path().to_str().unwrap()), "test", "", &never_a_tty).unwrap_err();
assert!(err.to_string().ends_with("is a directory."), "{err}");
}
#[test]
fn looks_like_path_accepts_separators_and_a_home_prefix() {
assert!(looks_like_path("./a"));
assert!(looks_like_path("a/b"));
assert!(looks_like_path("a\\b"));
assert!(looks_like_path("~/tasks/x.md"));
}
#[test]
fn looks_like_path_rejects_prose_and_bare_words() {
assert!(!looks_like_path("fix src/main.rs"));
assert!(!looks_like_path("refactor"));
assert!(!looks_like_path(""));
}
#[test]
fn resolve_task_file_with_whitespace_only_errors() {
let dir = std::env::temp_dir().join("lev-test-resolve-ws");
let _ = std::fs::create_dir_all(&dir);
let file = dir.join("whitespace.txt");
std::fs::write(&file, " \n\t\n \n").unwrap();
let result = resolve_task(Some(file.to_str().unwrap()), "test", "", &never_a_tty);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("empty"));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn resolve_task_file_trims_content() {
let dir = std::env::temp_dir().join("lev-test-resolve-trim");
let _ = std::fs::create_dir_all(&dir);
let file = dir.join("trimme.txt");
std::fs::write(&file, " hello world \n\n").unwrap();
let result = resolve_task(Some(file.to_str().unwrap()), "test", "", &never_a_tty);
assert_eq!(result.unwrap(), "hello world");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn resolve_task_preserves_literal_string_as_is() {
let result = resolve_task(Some(" spaces around "), "test", "", &never_a_tty);
assert_eq!(result.unwrap(), " spaces around ");
}
#[test]
fn resolve_task_multiline_file() {
let dir = std::env::temp_dir().join("lev-test-resolve-multiline");
let _ = std::fs::create_dir_all(&dir);
let file = dir.join("multi.txt");
std::fs::write(&file, "line one\nline two\nline three\n").unwrap();
let result = resolve_task(Some(file.to_str().unwrap()), "test", "", &never_a_tty);
let task = result.unwrap();
assert!(task.contains("line one"));
assert!(task.contains("line two"));
assert!(task.contains("line three"));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn resolve_task_literal_with_special_chars() {
let result = resolve_task(
Some("Write a function that does X & Y <html>"),
"test",
"",
&never_a_tty,
);
assert_eq!(result.unwrap(), "Write a function that does X & Y <html>");
}
#[test]
fn resolve_task_literal_empty_string() {
let result = resolve_task(Some(""), "test", "", &never_a_tty);
assert_eq!(result.unwrap(), "");
}
#[test]
fn resolve_task_file_with_multiple_trailing_newlines() {
let dir = std::env::temp_dir().join("lev-test-resolve-trail");
let _ = std::fs::create_dir_all(&dir);
let file = dir.join("trail.txt");
std::fs::write(&file, "task content\n\n\n\n").unwrap();
let result = resolve_task(Some(file.to_str().unwrap()), "test", "", &never_a_tty);
assert_eq!(result.unwrap(), "task content");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn resolve_task_file_with_real_content() {
let dir = std::env::temp_dir().join("lev-test-resolve-real");
let _ = std::fs::create_dir_all(&dir);
let file = dir.join("real.txt");
std::fs::write(&file, "Implement a REST API server\nwith authentication\n").unwrap();
let result = resolve_task(
Some(file.to_str().unwrap()),
"api-agent",
"API agent",
&never_a_tty,
);
let task = result.unwrap();
assert!(task.contains("Implement a REST API server"));
assert!(task.contains("with authentication"));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn resolve_task_none_arg_errors_when_stdin_not_tty() {
let result = resolve_task_with(None, "test-agent", "", &|| false);
assert!(result.is_err());
let msg = result.unwrap_err().to_string();
assert!(msg.contains("No task provided"));
assert!(msg.contains("stdin is not a TTY"));
}
#[test]
fn resolve_task_none_arg_uses_injected_probe_via_public_wrapper() {
let result = resolve_task(Some("literal task"), "test-agent", "", &never_a_tty);
assert_eq!(result.unwrap(), "literal task");
}
#[test]
fn resolve_task_none_arg_errors_via_public_wrapper_when_not_a_tty() {
let result = resolve_task(None, "test-agent", "", &never_a_tty);
assert!(result.is_err());
}
#[cfg(unix)]
#[test]
fn resolve_task_with_editor_path_happy_case() {
use std::os::unix::fs::PermissionsExt;
let dir = std::env::temp_dir().join("lev-test-resolve-task-editor-happy");
let _ = std::fs::create_dir_all(&dir);
let script = dir.join("fake-editor.sh");
std::fs::write(
&script,
"#!/bin/sh\necho \"task body from editor\" >> \"$1\"\n",
)
.unwrap();
let mut perms = std::fs::metadata(&script).unwrap().permissions();
perms.set_mode(0o700);
std::fs::set_permissions(&script, perms).unwrap();
temp_env::with_vars([("VISUAL", Some(&script)), ("EDITOR", None)], || {
let result = resolve_task_with(None, "test-agent", "a non-empty description", &|| true);
assert_eq!(result.unwrap(), "task body from editor");
let _ = std::fs::remove_dir_all(&dir);
});
}
#[cfg(unix)]
#[test]
fn resolve_task_with_editor_path_empty_after_stripping_comments_errors() {
temp_env::with_vars(
[("VISUAL", Some("/usr/bin/true")), ("EDITOR", None)],
|| {
let result = resolve_task_with(None, "test-agent", "", &|| true);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("Aborting run"));
},
);
}
#[cfg(unix)]
#[test]
fn resolve_task_with_editor_path_propagates_launch_editor_error() {
temp_env::with_vars(
[
("VISUAL", None),
("EDITOR", None),
("PATH", Some("/lev-definitely-empty-path-dir")),
],
|| {
let result = resolve_task_with(None, "test-agent", "", &|| true);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("No editor found"));
},
);
}
fn stub_editor_returns_no_editor_found(_path: &std::path::Path) -> std::io::Result<()> {
Err(std::io::Error::new(
std::io::ErrorKind::NotFound,
"No editor found. Set $VISUAL or $EDITOR, or install vim, nano, or edit.",
))
}
#[test]
fn resolve_task_with_editor_injected_editor_failure_propagates() {
let result = resolve_task_with_editor(
None,
"test-agent",
"",
&|| true,
&stub_editor_returns_no_editor_found,
&std::env::temp_dir,
);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("No editor found"));
}
#[test]
fn resolve_task_with_editor_tmp_file_write_failure_propagates() {
let bad_tmp_dir = std::env::temp_dir()
.join("lev-definitely-nonexistent-parent-dir-for-task-template-xyz")
.join("nested");
let result = resolve_task_with_editor(
None,
"test-agent",
"",
&|| true,
&stub_editor_returns_no_editor_found,
&move || bad_tmp_dir.clone(),
);
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("Failed to create task temp file")
);
}
#[cfg(windows)]
fn write_bat(path: &std::path::Path, body: &str) {
std::fs::write(path, format!("@echo off\r\n{}\r\n", body)).unwrap();
}
#[cfg(windows)]
#[test]
fn resolve_task_with_editor_path_happy_case() {
let dir = std::env::temp_dir().join("lev-test-resolve-task-editor-happy-win");
let _ = std::fs::create_dir_all(&dir);
let script = dir.join("fake-editor.bat");
write_bat(&script, "echo task body from editor>>\"%~1\"");
temp_env::with_vars([("VISUAL", Some(&script)), ("EDITOR", None)], || {
let result = resolve_task_with(None, "test-agent", "a non-empty description", &|| true);
assert_eq!(result.unwrap(), "task body from editor");
let _ = std::fs::remove_dir_all(&dir);
});
}
#[cfg(windows)]
#[test]
fn resolve_task_with_editor_path_empty_after_stripping_comments_errors() {
let dir = std::env::temp_dir().join("lev-test-resolve-task-editor-empty-win");
let _ = std::fs::create_dir_all(&dir);
let ok_bat = dir.join("ok.bat");
write_bat(&ok_bat, "exit /b 0");
temp_env::with_vars([("VISUAL", Some(&ok_bat)), ("EDITOR", None)], || {
let result = resolve_task_with(None, "test-agent", "", &|| true);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("Aborting run"));
let _ = std::fs::remove_dir_all(&dir);
});
}
#[test]
fn build_task_template_with_empty_description_skips_desc_line() {
let t = build_task_template("agent", "");
assert!(!t.contains("# \n"));
assert!(t.contains("# Task for agent: agent\n"));
assert!(t.contains("Describe your task below"));
}
#[test]
fn build_task_template_with_non_empty_description_adds_desc_line() {
let t = build_task_template("my-agent", "Build a web server");
assert!(t.contains("# Task for agent: my-agent\n"));
assert!(t.contains("# Build a web server\n"));
}
#[test]
fn write_task_template_error_on_bad_path() {
let dir = tempfile::tempdir().unwrap();
let blocker = dir.path().join("blocker");
std::fs::write(&blocker, b"x").unwrap();
let result = write_task_template(&blocker, "content");
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("Failed to create task temp file")
);
}
#[cfg(unix)]
#[test]
fn resolve_task_unreadable_file_returns_error() {
use std::os::unix::fs::PermissionsExt;
let dir = std::env::temp_dir().join("lev-test-resolve-unreadable");
let _ = std::fs::create_dir_all(&dir);
let file = dir.join("secret.txt");
std::fs::write(&file, "secret content").unwrap();
let mut perms = std::fs::metadata(&file).unwrap().permissions();
perms.set_mode(0o000);
std::fs::set_permissions(&file, perms).unwrap();
let result = resolve_task_with(Some(file.to_str().unwrap()), "test-agent", "", &|| false);
let mut perms2 = std::fs::metadata(&file).unwrap().permissions();
perms2.set_mode(0o644);
std::fs::set_permissions(&file, perms2).ok();
let _ = std::fs::remove_dir_all(&dir);
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("Failed to read task file")
);
}
#[cfg(windows)]
#[test]
fn resolve_task_unreadable_file_returns_error() {
use std::fs::OpenOptions;
use std::os::windows::fs::OpenOptionsExt;
let dir = std::env::temp_dir().join("lev-test-resolve-unreadable-win");
let _ = std::fs::create_dir_all(&dir);
let file = dir.join("secret.txt");
std::fs::write(&file, "secret content").unwrap();
let _locked = OpenOptions::new()
.write(true)
.share_mode(0)
.open(&file)
.unwrap();
let result = resolve_task_with(Some(file.to_str().unwrap()), "test-agent", "", &|| false);
drop(_locked);
let _ = std::fs::remove_dir_all(&dir);
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("Failed to read task file")
);
}
}