use crate::config::Config;
use leviath_runtime::ProviderRegistry;
pub use leviath_runtime::provider_creds::{ProviderCreds, build_provider_registry};
pub fn resolve_task(
arg: &Option<String>,
agent_name: &str,
description: Option<&str>,
stdin_is_terminal: &dyn Fn() -> bool,
) -> anyhow::Result<String> {
resolve_task_with(arg, agent_name, description, stdin_is_terminal)
}
fn resolve_task_with(
arg: &Option<String>,
agent_name: &str,
description: Option<&str>,
stdin_is_terminal: &dyn Fn() -> bool,
) -> anyhow::Result<String> {
resolve_task_with_editor(
arg,
agent_name,
description,
stdin_is_terminal,
&launch_editor,
&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<String>,
agent_name: &str,
description: Option<&str>,
stdin_is_terminal: &dyn Fn() -> bool,
launch_editor_fn: &dyn Fn(&std::path::Path) -> anyhow::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);
}
Ok(s.clone())
}
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?;
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: Option<&str>) -> String {
let mut template = format!("# Task for agent: {}\n", agent_name);
if let Some(desc) = description
&& !desc.is_empty()
{
template.push_str(&format!("# {}\n", desc));
}
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))
}
fn platform_default_editors() -> Vec<String> {
#[cfg(unix)]
{
vec!["vim".to_string(), "nano".to_string(), "vi".to_string()]
}
#[cfg(windows)]
{
vec!["notepad".to_string()]
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum EditorRunOutcome {
Completed,
Aborted,
}
fn classify_editor_exit(success: bool, code: Option<i32>) -> EditorRunOutcome {
if success || code.is_some() {
EditorRunOutcome::Completed
} else {
EditorRunOutcome::Aborted
}
}
fn launch_editor(path: &std::path::Path) -> anyhow::Result<()> {
launch_editor_with(path, &mut |cmd| {
cmd.status()
.map(|s| classify_editor_exit(s.success(), s.code()))
})
}
fn launch_editor_with(
path: &std::path::Path,
run: &mut dyn FnMut(&mut std::process::Command) -> std::io::Result<EditorRunOutcome>,
) -> anyhow::Result<()> {
use std::process::Command;
let mut candidates: Vec<String> = Vec::new();
if let Ok(v) = std::env::var("VISUAL")
&& !v.is_empty()
{
candidates.push(v);
}
if let Ok(e) = std::env::var("EDITOR")
&& !e.is_empty()
{
candidates.push(e);
}
candidates.extend(platform_default_editors());
let path_str = path.to_string_lossy();
for editor in &candidates {
let parts: Vec<&str> = editor.split_whitespace().collect();
if parts.is_empty() {
continue;
}
let mut cmd = Command::new(parts[0]);
for arg in &parts[1..] {
cmd.arg(arg);
}
cmd.arg(path_str.as_ref());
match run(&mut cmd) {
Ok(EditorRunOutcome::Completed) => {
return Ok(());
}
Ok(EditorRunOutcome::Aborted) => {
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
continue;
}
Err(e) => {
return Err(anyhow::anyhow!(
"Failed to launch editor '{}': {}",
editor,
e
));
}
}
}
anyhow::bail!("No editor found. Set $VISUAL or $EDITOR, or install vim/nano/notepad.")
}
pub fn provider_creds_from_config(config: &Config) -> Vec<ProviderCreds> {
let caps = &config.model_capabilities;
let timeout = config.request_timeout_secs;
let mut creds = Vec::new();
let keyed = [
("anthropic", config.providers.anthropic_api_key.as_deref()),
("openai", config.providers.openai_api_key.as_deref()),
("google", config.providers.google_api_key.as_deref()),
("openrouter", config.openrouter_api_key.as_deref()),
];
for (name, key) in keyed {
if let Some(key) = key.map(str::trim).filter(|k| !k.is_empty()) {
creds.push(ProviderCreds {
name: name.to_string(),
api_key: Some(key.to_string()),
base_url: None,
model_capabilities: caps.clone(),
request_timeout_secs: timeout,
rate_limit: config.rate_limits.get(name).cloned(),
options: std::collections::HashMap::new(),
});
}
}
creds.push(ProviderCreds {
name: "ollama".to_string(),
api_key: None,
base_url: Some(
config
.ollama_base_url
.as_deref()
.unwrap_or("http://localhost:11434")
.to_string(),
),
model_capabilities: caps.clone(),
request_timeout_secs: timeout,
rate_limit: None,
options: std::collections::HashMap::new(),
});
if config.providers.claude_code_enabled {
let mut options = std::collections::HashMap::new();
if let Some(binary) = &config.providers.claude_code_binary {
options.insert("binary".to_string(), binary.clone());
}
if let Some(effort) = &config.providers.claude_code_effort {
options.insert("effort".to_string(), effort.clone());
}
creds.push(ProviderCreds {
name: "claude-code".to_string(),
api_key: None,
base_url: None,
model_capabilities: caps.clone(),
request_timeout_secs: None,
rate_limit: None,
options,
});
}
creds
}
pub fn build_provider_registry_from_config(config: &Config) -> ProviderRegistry {
let registry = build_provider_registry(&provider_creds_from_config(config));
attach_script_layer(registry, crate::config::providers_dir(), config)
}
fn attach_script_layer(
registry: ProviderRegistry,
dir: Option<std::path::PathBuf>,
config: &Config,
) -> ProviderRegistry {
let Some(dir) = dir else {
return registry;
};
let overrides = config
.model_providers
.iter()
.map(|(name, mp)| (name.clone(), script_provider_spec(mp)))
.collect();
let layer = leviath_runtime::script_provider::ScriptProviderLayer::new(
dir,
overrides,
config.model_capabilities.clone(),
config.request_timeout_secs,
config.security.allow_env_vars.clone(),
);
registry.with_script_layer(std::sync::Arc::new(layer))
}
fn script_provider_spec(
mp: &crate::config::ModelProviderConfig,
) -> leviath_runtime::script_provider::ScriptProviderSpec {
let mut cfg = serde_json::Map::new();
if let Some(b) = &mp.base_url {
cfg.insert("base_url".to_string(), serde_json::Value::String(b.clone()));
}
if let Some(k) = &mp.api_key {
cfg.insert("api_key".to_string(), serde_json::Value::String(k.clone()));
}
for (k, v) in &mp.extra {
cfg.insert(
k.clone(),
serde_json::to_value(v).unwrap_or(serde_json::Value::Null),
);
}
leviath_runtime::script_provider::ScriptProviderSpec {
script: mp.script.clone(),
rate_limit: mp.rate_limit.clone(),
init_config: serde_json::Value::Object(cfg),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn never_a_tty() -> bool {
false
}
fn assert_launch_ok(result: &anyhow::Result<()>) {
assert!(result.is_ok(), "expected Ok, got {:?}", result);
}
#[test]
#[should_panic(expected = "expected Ok, got Err(boom)")]
fn assert_launch_ok_panics_when_err() {
assert_launch_ok(&Err(anyhow::anyhow!("boom")));
}
#[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();
}
#[cfg(windows)]
#[test]
fn platform_default_editors_includes_notepad() {
assert_eq!(platform_default_editors(), vec!["notepad".to_string()]);
}
#[cfg(unix)]
#[test]
fn platform_default_editors_includes_vim_nano_vi() {
assert_eq!(
platform_default_editors(),
vec!["vim".to_string(), "nano".to_string(), "vi".to_string()]
);
}
#[test]
fn resolve_task_with_literal_string() {
let result = resolve_task(
&Some("do something".to_string()),
"test",
None,
&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().to_string()),
"test",
None,
&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().to_string()),
"test",
None,
&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_nonexistent_file_used_as_literal() {
let result = resolve_task(
&Some("/nonexistent/path/do_something".to_string()),
"test",
None,
&never_a_tty,
);
assert_eq!(result.unwrap(), "/nonexistent/path/do_something");
}
#[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().to_string()),
"test",
None,
&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().to_string()),
"test",
None,
&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 ".to_string()),
"test",
None,
&never_a_tty,
);
assert_eq!(result.unwrap(), " spaces around ");
}
#[test]
fn build_provider_registry_with_empty_config() {
let config = Config::default();
let registry = build_provider_registry_from_config(&config);
assert!(registry.has("ollama"));
assert!(!registry.has("claude-code"));
assert!(!registry.has("anthropic"));
assert!(!registry.has("openai"));
assert!(!registry.has("google"));
}
#[test]
fn build_provider_registry_with_anthropic_key() {
let config = Config {
providers: crate::config::ProviderConfig {
anthropic_api_key: Some("sk-ant-test-key-12345".to_string()),
..Config::default().providers
},
..Config::default()
};
let registry = build_provider_registry_from_config(&config);
assert!(registry.has("anthropic"));
}
#[test]
fn build_provider_registry_with_openai_key() {
let config = Config {
providers: crate::config::ProviderConfig {
openai_api_key: Some("sk-test-key-12345".to_string()),
..Config::default().providers
},
..Config::default()
};
let registry = build_provider_registry_from_config(&config);
assert!(registry.has("openai"));
}
#[test]
fn build_provider_registry_with_google_key() {
let config = Config {
providers: crate::config::ProviderConfig {
google_api_key: Some("AIzatest12345".to_string()),
claude_code_enabled: false,
claude_code_binary: None,
claude_code_effort: None,
..Config::default().providers
},
..Config::default()
};
let registry = build_provider_registry_from_config(&config);
assert!(registry.has("google"));
}
#[test]
fn build_provider_registry_with_openrouter_key() {
let config = Config {
openrouter_api_key: Some("sk-or-test-12345".to_string()),
..Config::default()
};
let registry = build_provider_registry_from_config(&config);
assert!(registry.has("openrouter"));
}
#[test]
fn build_provider_registry_custom_ollama_url() {
let config = Config {
ollama_base_url: Some("http://my-server:11434".to_string()),
..Config::default()
};
let registry = build_provider_registry_from_config(&config);
assert!(registry.has("ollama"));
}
#[test]
fn script_provider_spec_assembles_init_config() {
let mut extra = std::collections::HashMap::new();
extra.insert("region".to_string(), toml::Value::String("us".to_string()));
let mp = crate::config::ModelProviderConfig {
script: Some("groq".to_string()),
api_key: Some("k".to_string()),
base_url: Some("http://api".to_string()),
rate_limit: Some(leviath_providers::RateLimitConfig {
requests_per_minute: 30,
tokens_per_minute: 1000,
}),
extra,
};
let spec = script_provider_spec(&mp);
assert_eq!(spec.script.as_deref(), Some("groq"));
assert!(spec.rate_limit.is_some());
assert_eq!(spec.init_config["base_url"], "http://api");
assert_eq!(spec.init_config["api_key"], "k");
assert_eq!(spec.init_config["region"], "us");
}
#[test]
fn attach_script_layer_without_home_is_a_noop() {
let registry = attach_script_layer(ProviderRegistry::new(), None, &Config::default());
assert!(!registry.has("groq"));
}
#[test]
fn build_registry_resolves_a_configured_script_provider() {
let home = tempfile::tempdir().unwrap();
let providers = home.path().join(".leviath").join("providers");
std::fs::create_dir_all(&providers).unwrap();
std::fs::write(
providers.join("groq.rhai"),
"fn initialize(config) { #{} }\nfn inference(state, request) { #{ content: \"ok\" } }",
)
.unwrap();
let mut model_providers = std::collections::HashMap::new();
model_providers.insert(
"groq".to_string(),
crate::config::ModelProviderConfig::default(),
);
let config = Config {
model_providers,
..Config::default()
};
temp_env::with_var("LEVIATH_HOME", Some(home.path().as_os_str()), || {
let registry = build_provider_registry_from_config(&config);
assert!(registry.has("groq"));
assert!(registry.get("groq").is_some());
});
}
#[test]
fn build_provider_registry_all_keys_set() {
let config = Config {
providers: crate::config::ProviderConfig {
anthropic_api_key: Some("sk-ant-test".to_string()),
openai_api_key: Some("sk-test".to_string()),
google_api_key: Some("AIza-test".to_string()),
claude_code_enabled: false,
claude_code_binary: None,
claude_code_effort: None,
},
openrouter_api_key: Some("sk-or-test".to_string()),
ollama_base_url: Some("http://custom:11434".to_string()),
..Config::default()
};
let registry = build_provider_registry_from_config(&config);
assert!(registry.has("anthropic"));
assert!(registry.has("openai"));
assert!(registry.has("google"));
assert!(registry.has("openrouter"));
assert!(registry.has("ollama"));
assert!(!registry.has("claude-code"));
}
#[test]
fn provider_creds_from_config_includes_defaults_and_keyed() {
let config = Config {
providers: crate::config::ProviderConfig {
anthropic_api_key: Some("sk-ant".to_string()),
..Config::default().providers
},
ollama_base_url: Some("http://custom:11434".to_string()),
..Config::default()
};
let creds = provider_creds_from_config(&config);
let names: Vec<&str> = creds.iter().map(|c| c.name.as_str()).collect();
assert!(names.contains(&"anthropic"));
assert!(names.contains(&"ollama"));
assert!(!names.contains(&"claude-code"));
assert!(!names.contains(&"openai"));
assert!(!names.contains(&"google"));
assert!(!names.contains(&"openrouter"));
let ollama = creds.iter().find(|c| c.name == "ollama").unwrap();
assert_eq!(ollama.base_url.as_deref(), Some("http://custom:11434"));
assert!(ollama.api_key.is_none());
}
#[test]
fn provider_creds_from_config_ignores_blank_keys() {
let config = Config {
providers: crate::config::ProviderConfig {
anthropic_api_key: Some(String::new()),
openai_api_key: Some(" ".to_string()),
google_api_key: Some("AIza-real".to_string()),
..Config::default().providers
},
..Config::default()
};
let creds = provider_creds_from_config(&config);
let names: Vec<&str> = creds.iter().map(|c| c.name.as_str()).collect();
assert!(
names.contains(&"google"),
"the configured provider must register: {names:?}"
);
assert!(!names.contains(&"anthropic"), "empty key must not register");
assert!(
!names.contains(&"openai"),
"whitespace-only key must not register"
);
}
#[test]
fn provider_creds_from_config_carries_rate_limits() {
let config = Config {
providers: crate::config::ProviderConfig {
anthropic_api_key: Some("sk-ant".to_string()),
openai_api_key: Some("sk-oa".to_string()),
..Config::default().providers
},
rate_limits: std::collections::HashMap::from([(
"anthropic".to_string(),
leviath_providers::RateLimitConfig {
requests_per_minute: 50,
tokens_per_minute: 40_000,
},
)]),
..Config::default()
};
let creds = provider_creds_from_config(&config);
let anthropic = creds.iter().find(|c| c.name == "anthropic").unwrap();
assert_eq!(
anthropic.rate_limit.as_ref().map(|r| r.requests_per_minute),
Some(50)
);
let openai = creds.iter().find(|c| c.name == "openai").unwrap();
assert!(openai.rate_limit.is_none());
}
#[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().to_string()),
"test",
None,
&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>".to_string()),
"test",
None,
&never_a_tty,
);
assert_eq!(result.unwrap(), "Write a function that does X & Y <html>");
}
#[test]
fn build_provider_registry_defaults_have_ollama_only() {
let config = Config::default();
let registry = build_provider_registry_from_config(&config);
assert!(registry.has("ollama"));
assert!(!registry.has("claude-code"));
}
#[test]
fn enabling_claude_code_registers_it_with_its_options() {
let config = Config {
providers: crate::config::ProviderConfig {
claude_code_enabled: true,
claude_code_binary: Some("/opt/bin/claude".to_string()),
claude_code_effort: Some("low".to_string()),
..Config::default().providers
},
..Config::default()
};
let creds = provider_creds_from_config(&config);
let cc = creds
.iter()
.find(|c| c.name == "claude-code")
.expect("enabled ⇒ present");
assert_eq!(
cc.options.get("binary").map(String::as_str),
Some("/opt/bin/claude")
);
assert_eq!(cc.options.get("effort").map(String::as_str), Some("low"));
assert!(cc.api_key.is_none());
assert!(build_provider_registry_from_config(&config).has("claude-code"));
}
#[test]
fn enabling_claude_code_without_options_carries_none() {
let config = Config {
providers: crate::config::ProviderConfig {
claude_code_enabled: true,
..Config::default().providers
},
..Config::default()
};
let creds = provider_creds_from_config(&config);
let cc = creds.iter().find(|c| c.name == "claude-code").unwrap();
assert!(cc.options.is_empty());
}
#[test]
fn resolve_task_literal_empty_string() {
let result = resolve_task(&Some("".to_string()), "test", None, &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().to_string()),
"test",
None,
&never_a_tty,
);
assert_eq!(result.unwrap(), "task content");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn build_provider_registry_propagates_model_capabilities() {
use leviath_providers::ModelCapabilities;
let mut caps = std::collections::HashMap::new();
caps.insert(
"custom-model".to_string(),
ModelCapabilities {
supports_temperature: true,
supports_streaming: true,
supports_tools: true,
supports_system_prompt: true,
max_context_tokens: 9999,
max_output_tokens: 999,
},
);
let config = crate::config::Config {
model_capabilities: caps,
providers: crate::config::ProviderConfig {
anthropic_api_key: Some("sk-ant-test".to_string()),
openai_api_key: None,
google_api_key: None,
claude_code_enabled: false,
claude_code_binary: None,
claude_code_effort: None,
},
..crate::config::Config::default()
};
let registry = build_provider_registry_from_config(&config);
assert!(registry.has("anthropic"));
assert!(registry.has("ollama"));
}
#[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().to_string()),
"api-agent",
Some("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 build_provider_registry_ollama_with_custom_url_propagates_caps() {
use leviath_providers::ModelCapabilities;
let mut caps = std::collections::HashMap::new();
caps.insert(
"llama3-8b".to_string(),
ModelCapabilities {
supports_temperature: false,
supports_streaming: false,
supports_tools: false,
supports_system_prompt: false,
max_context_tokens: 99,
max_output_tokens: 99,
},
);
let config = crate::config::Config {
ollama_base_url: Some("http://custom-ollama:11434".to_string()),
model_capabilities: caps,
..crate::config::Config::default()
};
let registry = build_provider_registry_from_config(&config);
assert!(registry.has("ollama"));
}
#[test]
fn resolve_task_none_arg_errors_when_stdin_not_tty() {
let result = resolve_task_with(&None, "test-agent", None, &|| 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".to_string()),
"test-agent",
None,
&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", None, &never_a_tty);
assert!(result.is_err());
}
#[cfg(unix)]
#[test]
fn launch_editor_visual_env_success() {
temp_env::with_vars(
[("VISUAL", Some("/usr/bin/true")), ("EDITOR", None)],
|| {
let dir = std::env::temp_dir().join("lev-test-launch-editor-visual");
let _ = std::fs::create_dir_all(&dir);
let file = dir.join("edit.txt");
std::fs::write(&file, "content").unwrap();
let result = launch_editor(&file);
assert_launch_ok(&result);
let _ = std::fs::remove_dir_all(&dir);
},
);
}
#[cfg(unix)]
#[test]
fn launch_editor_editor_env_success() {
temp_env::with_vars(
[("VISUAL", None), ("EDITOR", Some("/usr/bin/true"))],
|| {
let dir = std::env::temp_dir().join("lev-test-launch-editor-editor");
let _ = std::fs::create_dir_all(&dir);
let file = dir.join("edit.txt");
std::fs::write(&file, "content").unwrap();
let result = launch_editor(&file);
assert_launch_ok(&result);
let _ = std::fs::remove_dir_all(&dir);
},
);
}
#[cfg(unix)]
#[test]
fn launch_editor_nonzero_exit_still_ok() {
temp_env::with_vars(
[("VISUAL", Some("/usr/bin/false")), ("EDITOR", None)],
|| {
let dir = std::env::temp_dir().join("lev-test-launch-editor-nonzero");
let _ = std::fs::create_dir_all(&dir);
let file = dir.join("edit.txt");
std::fs::write(&file, "content").unwrap();
let result = launch_editor(&file);
assert_launch_ok(&result);
let _ = std::fs::remove_dir_all(&dir);
},
);
}
#[test]
fn classify_editor_exit_success_is_completed() {
assert_eq!(
classify_editor_exit(true, Some(0)),
EditorRunOutcome::Completed
);
}
#[test]
fn classify_editor_exit_nonzero_code_is_completed() {
assert_eq!(
classify_editor_exit(false, Some(1)),
EditorRunOutcome::Completed
);
}
#[test]
fn classify_editor_exit_no_code_is_aborted() {
assert_eq!(classify_editor_exit(false, None), EditorRunOutcome::Aborted);
}
#[test]
fn launch_editor_with_aborted_candidate_falls_through_to_next() {
temp_env::with_vars(
[("VISUAL", Some("editor-a")), ("EDITOR", Some("editor-b"))],
|| {
let dir = std::env::temp_dir().join("lev-test-launch-editor-aborted");
let _ = std::fs::create_dir_all(&dir);
let file = dir.join("edit.txt");
std::fs::write(&file, "content").unwrap();
let mut calls = 0;
let result = launch_editor_with(&file, &mut |_cmd| {
calls += 1;
Ok(EditorRunOutcome::Aborted)
});
assert!(result.is_err());
assert!(calls >= 2, "expected multiple candidates to be tried");
let _ = std::fs::remove_dir_all(&dir);
},
);
}
#[cfg(unix)]
#[test]
fn launch_editor_command_with_flags_splits_correctly() {
temp_env::with_vars(
[
("VISUAL", Some("/usr/bin/true --some-flag")),
("EDITOR", None),
],
|| {
let dir = std::env::temp_dir().join("lev-test-launch-editor-flags");
let _ = std::fs::create_dir_all(&dir);
let file = dir.join("edit.txt");
std::fs::write(&file, "content").unwrap();
let result = launch_editor(&file);
assert_launch_ok(&result);
let _ = std::fs::remove_dir_all(&dir);
},
);
}
#[cfg(unix)]
#[test]
fn launch_editor_whitespace_only_visual_falls_through_to_editor() {
temp_env::with_vars(
[("VISUAL", Some(" ")), ("EDITOR", Some("/usr/bin/true"))],
|| {
let dir = std::env::temp_dir().join("lev-test-launch-editor-ws-visual");
let _ = std::fs::create_dir_all(&dir);
let file = dir.join("edit.txt");
std::fs::write(&file, "content").unwrap();
let result = launch_editor(&file);
assert_launch_ok(&result);
let _ = std::fs::remove_dir_all(&dir);
},
);
}
#[test]
fn launch_editor_with_empty_visual_and_editor_are_skipped() {
temp_env::with_vars([("VISUAL", Some("")), ("EDITOR", Some(""))], || {
let dir = std::env::temp_dir().join("lev-test-launch-editor-with-empty-skip");
let _ = std::fs::create_dir_all(&dir);
let file = dir.join("edit.txt");
std::fs::write(&file, "content").unwrap();
let result = launch_editor_with(&file, &mut |_cmd| {
std::process::Command::new(std::env::current_exe().unwrap())
.arg("--this-flag-does-not-exist")
.status()
.map(|s| classify_editor_exit(s.success(), s.code()))
});
assert_launch_ok(&result);
let _ = std::fs::remove_dir_all(&dir);
});
}
#[cfg(unix)]
#[test]
fn launch_editor_empty_visual_and_editor_are_skipped() {
temp_env::with_vars(
[
("VISUAL", Some("")),
("EDITOR", Some("")),
("PATH", Some("/lev-definitely-empty-path-dir")),
],
|| {
let dir = std::env::temp_dir().join("lev-test-launch-editor-empty-visual-editor");
let _ = std::fs::create_dir_all(&dir);
let file = dir.join("edit.txt");
std::fs::write(&file, "content").unwrap();
let result = launch_editor(&file);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("No editor found"));
let _ = std::fs::remove_dir_all(&dir);
},
);
}
#[cfg(unix)]
#[test]
fn launch_editor_not_found_candidate_falls_through_to_next() {
temp_env::with_vars(
[
("VISUAL", Some("lev-definitely-not-a-real-binary-xyz")),
("EDITOR", Some("/usr/bin/true")),
],
|| {
let dir = std::env::temp_dir().join("lev-test-launch-editor-notfound");
let _ = std::fs::create_dir_all(&dir);
let file = dir.join("edit.txt");
std::fs::write(&file, "content").unwrap();
let result = launch_editor(&file);
assert_launch_ok(&result);
let _ = std::fs::remove_dir_all(&dir);
},
);
}
#[cfg(unix)]
#[test]
fn launch_editor_permission_denied_returns_error() {
use std::os::unix::fs::PermissionsExt;
let dir = std::env::temp_dir().join("lev-test-launch-editor-perm-denied");
let _ = std::fs::create_dir_all(&dir);
let not_executable = dir.join("not-executable");
std::fs::write(¬_executable, "not a script").unwrap();
let mut perms = std::fs::metadata(¬_executable).unwrap().permissions();
perms.set_mode(0o600);
std::fs::set_permissions(¬_executable, perms).unwrap();
temp_env::with_vars(
[("VISUAL", Some(¬_executable)), ("EDITOR", None)],
|| {
let file = dir.join("edit.txt");
std::fs::write(&file, "content").unwrap();
let result = launch_editor(&file);
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("Failed to launch editor")
);
let _ = std::fs::remove_dir_all(&dir);
},
);
}
#[test]
fn launch_editor_with_no_editor_found_when_every_candidate_not_found() {
let dir = std::env::temp_dir().join("lev-test-launch-editor-with-no-editor");
let _ = std::fs::create_dir_all(&dir);
let file = dir.join("edit.txt");
std::fs::write(&file, "content").unwrap();
let result = launch_editor_with(&file, &mut |_cmd| {
Err(std::io::Error::from(std::io::ErrorKind::NotFound))
});
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("No editor found"));
let _ = std::fs::remove_dir_all(&dir);
}
#[cfg(unix)]
#[test]
fn launch_editor_no_editor_found_when_path_has_no_candidates() {
temp_env::with_vars(
[
("VISUAL", None),
("EDITOR", None),
("PATH", Some("/lev-definitely-empty-path-dir")),
],
|| {
let dir = std::env::temp_dir().join("lev-test-launch-editor-no-editor");
let _ = std::fs::create_dir_all(&dir);
let file = dir.join("edit.txt");
std::fs::write(&file, "content").unwrap();
let result = launch_editor(&file);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("No editor found"));
let _ = std::fs::remove_dir_all(&dir);
},
);
}
#[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 launch_editor_visual_env_success() {
let dir = std::env::temp_dir().join("lev-test-launch-editor-visual-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 file = dir.join("edit.txt");
std::fs::write(&file, "content").unwrap();
let result = launch_editor(&file);
assert_launch_ok(&result);
let _ = std::fs::remove_dir_all(&dir);
});
}
#[cfg(windows)]
#[test]
fn launch_editor_editor_env_success() {
let dir = std::env::temp_dir().join("lev-test-launch-editor-editor-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", None), ("EDITOR", Some(&ok_bat))], || {
let file = dir.join("edit.txt");
std::fs::write(&file, "content").unwrap();
let result = launch_editor(&file);
assert_launch_ok(&result);
let _ = std::fs::remove_dir_all(&dir);
});
}
#[cfg(windows)]
#[test]
fn launch_editor_nonzero_exit_still_ok() {
let dir = std::env::temp_dir().join("lev-test-launch-editor-nonzero-win");
let _ = std::fs::create_dir_all(&dir);
let fail_bat = dir.join("fail.bat");
write_bat(&fail_bat, "exit /b 1");
temp_env::with_vars([("VISUAL", Some(&fail_bat)), ("EDITOR", None)], || {
let file = dir.join("edit.txt");
std::fs::write(&file, "content").unwrap();
let result = launch_editor(&file);
assert_launch_ok(&result);
let _ = std::fs::remove_dir_all(&dir);
});
}
#[cfg(windows)]
#[test]
fn launch_editor_command_with_flags_splits_correctly() {
let dir = std::env::temp_dir().join("lev-test-launch-editor-flags-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(format!("{} --some-flag", ok_bat.display()))),
("EDITOR", None),
],
|| {
let file = dir.join("edit.txt");
std::fs::write(&file, "content").unwrap();
let result = launch_editor(&file);
assert_launch_ok(&result);
let _ = std::fs::remove_dir_all(&dir);
},
);
}
#[cfg(windows)]
#[test]
fn launch_editor_whitespace_only_visual_falls_through_to_editor() {
let dir = std::env::temp_dir().join("lev-test-launch-editor-ws-visual-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(std::ffi::OsString::from(" "))),
("EDITOR", Some(ok_bat.clone().into_os_string())),
],
|| {
let file = dir.join("edit.txt");
std::fs::write(&file, "content").unwrap();
let result = launch_editor(&file);
assert_launch_ok(&result);
let _ = std::fs::remove_dir_all(&dir);
},
);
}
#[cfg(windows)]
#[test]
fn launch_editor_not_found_candidate_falls_through_to_next() {
let dir = std::env::temp_dir().join("lev-test-launch-editor-notfound-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(std::ffi::OsString::from(
"lev-definitely-not-a-real-binary-xyz",
)),
),
("EDITOR", Some(ok_bat.clone().into_os_string())),
],
|| {
let file = dir.join("edit.txt");
std::fs::write(&file, "content").unwrap();
let result = launch_editor(&file);
assert_launch_ok(&result);
let _ = std::fs::remove_dir_all(&dir);
},
);
}
#[cfg(windows)]
#[test]
fn launch_editor_permission_denied_returns_error() {
let dir = std::env::temp_dir().join("lev-test-launch-editor-perm-denied-win");
let _ = std::fs::create_dir_all(&dir);
let not_executable = dir.join("not-executable.txt");
std::fs::write(¬_executable, "not a script").unwrap();
temp_env::with_vars(
[("VISUAL", Some(¬_executable)), ("EDITOR", None)],
|| {
let file = dir.join("edit.txt");
std::fs::write(&file, "content").unwrap();
let result = launch_editor(&file);
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("Failed to launch editor")
);
let _ = std::fs::remove_dir_all(&dir);
},
);
}
#[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",
Some("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", None, &|| 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", None, &|| 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) -> anyhow::Result<()> {
Err(anyhow::anyhow!(
"No editor found. Set $VISUAL or $EDITOR, or install vim/nano/notepad."
))
}
#[test]
fn resolve_task_with_editor_injected_editor_failure_propagates() {
let result = resolve_task_with_editor(
&None,
"test-agent",
None,
&|| 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",
None,
&|| 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)]
#[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",
Some("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", None, &|| 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", Some(""));
assert!(!t.contains("# \n"));
assert!(t.contains("# Task for agent: agent\n"));
}
#[test]
fn build_task_template_with_non_empty_description_adds_desc_line() {
let t = build_task_template("my-agent", Some("Build a web server"));
assert!(t.contains("# Task for agent: my-agent\n"));
assert!(t.contains("# Build a web server\n"));
}
#[test]
fn build_task_template_with_no_description() {
let t = build_task_template("my-agent", None);
assert!(t.contains("# Task for agent: my-agent\n"));
assert!(t.contains("Describe your task below"));
}
#[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().to_string()),
"test-agent",
None,
&|| 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().to_string()),
"test-agent",
None,
&|| 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")
);
}
}