1use crate::config::Config;
4use leviath_runtime::ProviderRegistry;
5
6pub use leviath_runtime::provider_creds::{ProviderCreds, build_provider_registry};
13
14pub fn resolve_task(
26 arg: &Option<String>,
27 agent_name: &str,
28 description: Option<&str>,
29 stdin_is_terminal: &dyn Fn() -> bool,
30) -> anyhow::Result<String> {
31 resolve_task_with(arg, agent_name, description, stdin_is_terminal)
32}
33
34fn resolve_task_with(
53 arg: &Option<String>,
54 agent_name: &str,
55 description: Option<&str>,
56 stdin_is_terminal: &dyn Fn() -> bool,
57) -> anyhow::Result<String> {
58 resolve_task_with_editor(
59 arg,
60 agent_name,
61 description,
62 stdin_is_terminal,
63 &launch_editor,
64 &std::env::temp_dir,
65 )
66}
67
68pub fn read_region_value(raw: &str) -> anyhow::Result<String> {
73 match raw.strip_prefix('@') {
74 Some(path) => {
75 let content = std::fs::read_to_string(path)
76 .map_err(|e| anyhow::anyhow!("Failed to read region file '{}': {}", path, e))?;
77 let trimmed = content.trim().to_string();
78 if trimmed.is_empty() {
79 anyhow::bail!("Region file '{}' is empty.", path);
80 }
81 Ok(trimmed)
82 }
83 None => Ok(raw.to_string()),
84 }
85}
86
87fn resolve_task_with_editor(
107 arg: &Option<String>,
108 agent_name: &str,
109 description: Option<&str>,
110 stdin_is_terminal: &dyn Fn() -> bool,
111 launch_editor_fn: &dyn Fn(&std::path::Path) -> anyhow::Result<()>,
112 tmp_dir_fn: &dyn Fn() -> std::path::PathBuf,
113) -> anyhow::Result<String> {
114 match arg {
115 Some(s) => {
116 let p = std::path::Path::new(s);
117 if p.is_file() {
118 let content = std::fs::read_to_string(p)
119 .map_err(|e| anyhow::anyhow!("Failed to read task file '{}': {}", s, e))?;
120 let trimmed = content.trim().to_string();
121 if trimmed.is_empty() {
122 anyhow::bail!("Task file '{}' is empty.", s);
123 }
124 return Ok(trimmed);
125 }
126 Ok(s.clone())
127 }
128 None => {
129 if !stdin_is_terminal() {
130 anyhow::bail!(
131 "No task provided. Pass --task \"<prompt>\" or --task <file>.\n\
132 (stdin is not a TTY, so the interactive editor cannot be used)"
133 );
134 }
135
136 let template = build_task_template(agent_name, description);
138
139 let tmp = write_task_template(&tmp_dir_fn(), &template)?;
149 let tmp = tmp.into_temp_path();
153 let tmp_path = tmp.to_path_buf();
154
155 let result = launch_editor_fn(&tmp_path);
157 let content = std::fs::read_to_string(&tmp_path).unwrap_or_default();
158 let _ = std::fs::remove_file(&tmp_path);
159 result?;
160
161 let task: String = content
163 .lines()
164 .filter(|l| !l.trim_start().starts_with('#'))
165 .collect::<Vec<_>>()
166 .join("\n")
167 .trim()
168 .to_string();
169
170 if task.is_empty() {
171 anyhow::bail!("Aborting run: empty task.");
172 }
173 Ok(task)
174 }
175 }
176}
177
178fn build_task_template(agent_name: &str, description: Option<&str>) -> String {
179 let mut template = format!("# Task for agent: {}\n", agent_name);
180 if let Some(desc) = description
181 && !desc.is_empty()
182 {
183 template.push_str(&format!("# {}\n", desc));
184 }
185 template.push_str("#\n# Describe your task below. Lines starting with '#' are ignored.\n\n");
186 template
187}
188
189fn write_task_template(
190 dir: &std::path::Path,
191 content: &str,
192) -> anyhow::Result<tempfile::NamedTempFile> {
193 use std::io::Write as _;
194
195 tempfile::Builder::new()
204 .prefix("lev-task-")
205 .suffix(".txt")
206 .tempfile_in(dir)
207 .and_then(|mut file| {
208 file.as_file_mut()
209 .write_all(content.as_bytes())
210 .and_then(|()| file.as_file_mut().flush())
211 .map(|()| file)
212 })
213 .map_err(|e| anyhow::anyhow!("Failed to create task temp file: {}", e))
214}
215
216fn platform_default_editors() -> Vec<String> {
225 #[cfg(unix)]
226 {
227 vec!["vim".to_string(), "nano".to_string(), "vi".to_string()]
228 }
229 #[cfg(windows)]
230 {
231 vec!["notepad".to_string()]
232 }
233}
234
235#[derive(Debug, Clone, Copy, PartialEq, Eq)]
247enum EditorRunOutcome {
248 Completed,
251 Aborted,
254}
255
256fn classify_editor_exit(success: bool, code: Option<i32>) -> EditorRunOutcome {
261 if success || code.is_some() {
262 EditorRunOutcome::Completed
263 } else {
264 EditorRunOutcome::Aborted
265 }
266}
267
268fn launch_editor(path: &std::path::Path) -> anyhow::Result<()> {
269 launch_editor_with(path, &mut |cmd| {
270 cmd.status()
271 .map(|s| classify_editor_exit(s.success(), s.code()))
272 })
273}
274
275fn launch_editor_with(
298 path: &std::path::Path,
299 run: &mut dyn FnMut(&mut std::process::Command) -> std::io::Result<EditorRunOutcome>,
300) -> anyhow::Result<()> {
301 use std::process::Command;
302
303 let mut candidates: Vec<String> = Vec::new();
305 if let Ok(v) = std::env::var("VISUAL")
306 && !v.is_empty()
307 {
308 candidates.push(v);
309 }
310 if let Ok(e) = std::env::var("EDITOR")
311 && !e.is_empty()
312 {
313 candidates.push(e);
314 }
315
316 candidates.extend(platform_default_editors());
317 let path_str = path.to_string_lossy();
318
319 for editor in &candidates {
320 let parts: Vec<&str> = editor.split_whitespace().collect();
322 if parts.is_empty() {
323 continue;
324 }
325
326 let mut cmd = Command::new(parts[0]);
327 for arg in &parts[1..] {
328 cmd.arg(arg);
329 }
330 cmd.arg(path_str.as_ref());
331
332 match run(&mut cmd) {
333 Ok(EditorRunOutcome::Completed) => {
335 return Ok(());
336 }
337 Ok(EditorRunOutcome::Aborted) => {
338 }
341 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
342 continue;
344 }
345 Err(e) => {
346 return Err(anyhow::anyhow!(
347 "Failed to launch editor '{}': {}",
348 editor,
349 e
350 ));
351 }
352 }
353 }
354
355 anyhow::bail!("No editor found. Set $VISUAL or $EDITOR, or install vim/nano/notepad.")
356}
357
358pub fn provider_creds_from_config(config: &Config) -> Vec<ProviderCreds> {
363 let caps = &config.model_capabilities;
364 let timeout = config.request_timeout_secs;
365 let mut creds = Vec::new();
366
367 let keyed = [
368 ("anthropic", config.providers.anthropic_api_key.as_deref()),
369 ("openai", config.providers.openai_api_key.as_deref()),
370 ("google", config.providers.google_api_key.as_deref()),
371 ("openrouter", config.openrouter_api_key.as_deref()),
372 ];
373 for (name, key) in keyed {
374 if let Some(key) = key.map(str::trim).filter(|k| !k.is_empty()) {
378 creds.push(ProviderCreds {
379 name: name.to_string(),
380 api_key: Some(key.to_string()),
381 base_url: None,
382 model_capabilities: caps.clone(),
383 request_timeout_secs: timeout,
384 rate_limit: config.rate_limits.get(name).cloned(),
385 options: std::collections::HashMap::new(),
386 });
387 }
388 }
389
390 creds.push(ProviderCreds {
392 name: "ollama".to_string(),
393 api_key: None,
394 base_url: Some(
395 config
396 .ollama_base_url
397 .as_deref()
398 .unwrap_or("http://localhost:11434")
399 .to_string(),
400 ),
401 model_capabilities: caps.clone(),
402 request_timeout_secs: timeout,
403 rate_limit: None,
404 options: std::collections::HashMap::new(),
405 });
406
407 if config.providers.claude_code_enabled {
413 let mut options = std::collections::HashMap::new();
414 if let Some(binary) = &config.providers.claude_code_binary {
415 options.insert("binary".to_string(), binary.clone());
416 }
417 if let Some(effort) = &config.providers.claude_code_effort {
418 options.insert("effort".to_string(), effort.clone());
419 }
420 creds.push(ProviderCreds {
421 name: "claude-code".to_string(),
422 api_key: None,
423 base_url: None,
424 model_capabilities: caps.clone(),
425 request_timeout_secs: None,
426 rate_limit: None,
427 options,
428 });
429 }
430
431 creds
432}
433
434pub fn build_provider_registry_from_config(config: &Config) -> ProviderRegistry {
444 let registry = build_provider_registry(&provider_creds_from_config(config));
445 attach_script_layer(registry, crate::config::providers_dir(), config)
446}
447
448fn attach_script_layer(
453 registry: ProviderRegistry,
454 dir: Option<std::path::PathBuf>,
455 config: &Config,
456) -> ProviderRegistry {
457 let Some(dir) = dir else {
458 return registry;
459 };
460 let overrides = config
461 .model_providers
462 .iter()
463 .map(|(name, mp)| (name.clone(), script_provider_spec(mp)))
464 .collect();
465 let layer = leviath_runtime::script_provider::ScriptProviderLayer::new(
466 dir,
467 overrides,
468 config.model_capabilities.clone(),
469 config.request_timeout_secs,
470 config.security.allow_env_vars.clone(),
471 );
472 registry.with_script_layer(std::sync::Arc::new(layer))
473}
474
475fn script_provider_spec(
480 mp: &crate::config::ModelProviderConfig,
481) -> leviath_runtime::script_provider::ScriptProviderSpec {
482 let mut cfg = serde_json::Map::new();
483 if let Some(b) = &mp.base_url {
484 cfg.insert("base_url".to_string(), serde_json::Value::String(b.clone()));
485 }
486 if let Some(k) = &mp.api_key {
487 cfg.insert("api_key".to_string(), serde_json::Value::String(k.clone()));
488 }
489 for (k, v) in &mp.extra {
490 cfg.insert(
491 k.clone(),
492 serde_json::to_value(v).unwrap_or(serde_json::Value::Null),
493 );
494 }
495 leviath_runtime::script_provider::ScriptProviderSpec {
496 script: mp.script.clone(),
497 rate_limit: mp.rate_limit.clone(),
498 init_config: serde_json::Value::Object(cfg),
499 }
500}
501
502#[cfg(test)]
503mod tests {
504 use super::*;
505
506 fn never_a_tty() -> bool {
512 false
513 }
514
515 fn assert_launch_ok(result: &anyhow::Result<()>) {
523 assert!(result.is_ok(), "expected Ok, got {:?}", result);
524 }
525
526 #[test]
527 #[should_panic(expected = "expected Ok, got Err(boom)")]
528 fn assert_launch_ok_panics_when_err() {
529 assert_launch_ok(&Err(anyhow::anyhow!("boom")));
530 }
531
532 #[test]
535 fn read_region_value_literal_passthrough() {
536 assert_eq!(read_region_value("just text").unwrap(), "just text");
537 }
538
539 #[test]
540 fn read_region_value_at_path_reads_and_trims() {
541 let dir = std::env::temp_dir().join("lev-test-region-value");
542 std::fs::create_dir_all(&dir).unwrap();
543 let file = dir.join("r.md");
544 std::fs::write(&file, " hello region \n").unwrap();
545 let raw = format!("@{}", file.to_string_lossy());
546 assert_eq!(read_region_value(&raw).unwrap(), "hello region");
547 std::fs::remove_dir_all(&dir).ok();
548 }
549
550 #[test]
551 fn read_region_value_at_missing_file_errors() {
552 let err = read_region_value("@/no/such/region/file.md").unwrap_err();
553 assert!(err.to_string().contains("Failed to read region file"));
554 }
555
556 #[test]
557 fn read_region_value_at_empty_file_errors() {
558 let dir = std::env::temp_dir().join("lev-test-region-empty");
559 std::fs::create_dir_all(&dir).unwrap();
560 let file = dir.join("empty.md");
561 std::fs::write(&file, " \n").unwrap();
562 let raw = format!("@{}", file.to_string_lossy());
563 let err = read_region_value(&raw).unwrap_err();
564 assert!(err.to_string().contains("is empty"));
565 std::fs::remove_dir_all(&dir).ok();
566 }
567
568 #[cfg(windows)]
571 #[test]
572 fn platform_default_editors_includes_notepad() {
573 assert_eq!(platform_default_editors(), vec!["notepad".to_string()]);
574 }
575
576 #[cfg(unix)]
577 #[test]
578 fn platform_default_editors_includes_vim_nano_vi() {
579 assert_eq!(
580 platform_default_editors(),
581 vec!["vim".to_string(), "nano".to_string(), "vi".to_string()]
582 );
583 }
584
585 #[test]
586 fn resolve_task_with_literal_string() {
587 let result = resolve_task(
588 &Some("do something".to_string()),
589 "test",
590 None,
591 &never_a_tty,
592 );
593 assert_eq!(result.unwrap(), "do something");
594 }
595
596 #[test]
597 fn resolve_task_with_file_path() {
598 let dir = std::env::temp_dir().join("lev-test-resolve-task");
599 let _ = std::fs::create_dir_all(&dir);
600 let file = dir.join("task.txt");
601 std::fs::write(&file, "task from file\n").unwrap();
602
603 let result = resolve_task(
604 &Some(file.to_str().unwrap().to_string()),
605 "test",
606 None,
607 &never_a_tty,
608 );
609 assert_eq!(result.unwrap(), "task from file");
610
611 let _ = std::fs::remove_dir_all(&dir);
612 }
613
614 #[test]
615 fn resolve_task_with_empty_file_errors() {
616 let dir = std::env::temp_dir().join("lev-test-resolve-empty");
617 let _ = std::fs::create_dir_all(&dir);
618 let file = dir.join("empty.txt");
619 std::fs::write(&file, " \n ").unwrap();
620
621 let result = resolve_task(
622 &Some(file.to_str().unwrap().to_string()),
623 "test",
624 None,
625 &never_a_tty,
626 );
627 assert!(result.is_err());
628 assert!(result.unwrap_err().to_string().contains("empty"));
629
630 let _ = std::fs::remove_dir_all(&dir);
631 }
632
633 #[test]
634 fn resolve_task_nonexistent_file_used_as_literal() {
635 let result = resolve_task(
636 &Some("/nonexistent/path/do_something".to_string()),
637 "test",
638 None,
639 &never_a_tty,
640 );
641 assert_eq!(result.unwrap(), "/nonexistent/path/do_something");
643 }
644
645 #[test]
646 fn resolve_task_file_with_whitespace_only_errors() {
647 let dir = std::env::temp_dir().join("lev-test-resolve-ws");
648 let _ = std::fs::create_dir_all(&dir);
649 let file = dir.join("whitespace.txt");
650 std::fs::write(&file, " \n\t\n \n").unwrap();
651
652 let result = resolve_task(
653 &Some(file.to_str().unwrap().to_string()),
654 "test",
655 None,
656 &never_a_tty,
657 );
658 assert!(result.is_err());
659 assert!(result.unwrap_err().to_string().contains("empty"));
660
661 let _ = std::fs::remove_dir_all(&dir);
662 }
663
664 #[test]
665 fn resolve_task_file_trims_content() {
666 let dir = std::env::temp_dir().join("lev-test-resolve-trim");
667 let _ = std::fs::create_dir_all(&dir);
668 let file = dir.join("trimme.txt");
669 std::fs::write(&file, " hello world \n\n").unwrap();
670
671 let result = resolve_task(
672 &Some(file.to_str().unwrap().to_string()),
673 "test",
674 None,
675 &never_a_tty,
676 );
677 assert_eq!(result.unwrap(), "hello world");
678
679 let _ = std::fs::remove_dir_all(&dir);
680 }
681
682 #[test]
683 fn resolve_task_preserves_literal_string_as_is() {
684 let result = resolve_task(
685 &Some(" spaces around ".to_string()),
686 "test",
687 None,
688 &never_a_tty,
689 );
690 assert_eq!(result.unwrap(), " spaces around ");
691 }
692
693 #[test]
694 fn build_provider_registry_with_empty_config() {
695 let config = Config::default();
696 let registry = build_provider_registry_from_config(&config);
697 assert!(registry.has("ollama"));
699 assert!(!registry.has("claude-code"));
703 assert!(!registry.has("anthropic"));
705 assert!(!registry.has("openai"));
706 assert!(!registry.has("google"));
707 }
708
709 #[test]
710 fn build_provider_registry_with_anthropic_key() {
711 let config = Config {
712 providers: crate::config::ProviderConfig {
713 anthropic_api_key: Some("sk-ant-test-key-12345".to_string()),
714 ..Config::default().providers
715 },
716 ..Config::default()
717 };
718 let registry = build_provider_registry_from_config(&config);
719 assert!(registry.has("anthropic"));
720 }
721
722 #[test]
723 fn build_provider_registry_with_openai_key() {
724 let config = Config {
725 providers: crate::config::ProviderConfig {
726 openai_api_key: Some("sk-test-key-12345".to_string()),
727 ..Config::default().providers
728 },
729 ..Config::default()
730 };
731 let registry = build_provider_registry_from_config(&config);
732 assert!(registry.has("openai"));
733 }
734
735 #[test]
736 fn build_provider_registry_with_google_key() {
737 let config = Config {
738 providers: crate::config::ProviderConfig {
739 google_api_key: Some("AIzatest12345".to_string()),
740 claude_code_enabled: false,
741 claude_code_binary: None,
742 claude_code_effort: None,
743 ..Config::default().providers
744 },
745 ..Config::default()
746 };
747 let registry = build_provider_registry_from_config(&config);
748 assert!(registry.has("google"));
749 }
750
751 #[test]
752 fn build_provider_registry_with_openrouter_key() {
753 let config = Config {
754 openrouter_api_key: Some("sk-or-test-12345".to_string()),
755 ..Config::default()
756 };
757 let registry = build_provider_registry_from_config(&config);
758 assert!(registry.has("openrouter"));
759 }
760
761 #[test]
762 fn build_provider_registry_custom_ollama_url() {
763 let config = Config {
764 ollama_base_url: Some("http://my-server:11434".to_string()),
765 ..Config::default()
766 };
767 let registry = build_provider_registry_from_config(&config);
768 assert!(registry.has("ollama"));
769 }
770
771 #[test]
772 fn script_provider_spec_assembles_init_config() {
773 let mut extra = std::collections::HashMap::new();
774 extra.insert("region".to_string(), toml::Value::String("us".to_string()));
775 let mp = crate::config::ModelProviderConfig {
776 script: Some("groq".to_string()),
777 api_key: Some("k".to_string()),
778 base_url: Some("http://api".to_string()),
779 rate_limit: Some(leviath_providers::RateLimitConfig {
780 requests_per_minute: 30,
781 tokens_per_minute: 1000,
782 }),
783 extra,
784 };
785 let spec = script_provider_spec(&mp);
786 assert_eq!(spec.script.as_deref(), Some("groq"));
787 assert!(spec.rate_limit.is_some());
788 assert_eq!(spec.init_config["base_url"], "http://api");
789 assert_eq!(spec.init_config["api_key"], "k");
790 assert_eq!(spec.init_config["region"], "us");
791 }
792
793 #[test]
794 fn attach_script_layer_without_home_is_a_noop() {
795 let registry = attach_script_layer(ProviderRegistry::new(), None, &Config::default());
798 assert!(!registry.has("groq"));
799 }
800
801 #[test]
802 fn build_registry_resolves_a_configured_script_provider() {
803 let home = tempfile::tempdir().unwrap();
804 let providers = home.path().join(".leviath").join("providers");
805 std::fs::create_dir_all(&providers).unwrap();
806 std::fs::write(
807 providers.join("groq.rhai"),
808 "fn initialize(config) { #{} }\nfn inference(state, request) { #{ content: \"ok\" } }",
809 )
810 .unwrap();
811
812 let mut model_providers = std::collections::HashMap::new();
813 model_providers.insert(
814 "groq".to_string(),
815 crate::config::ModelProviderConfig::default(),
816 );
817 let config = Config {
818 model_providers,
819 ..Config::default()
820 };
821 temp_env::with_var("LEVIATH_HOME", Some(home.path().as_os_str()), || {
822 let registry = build_provider_registry_from_config(&config);
823 assert!(registry.has("groq"));
824 assert!(registry.get("groq").is_some());
825 });
826 }
827
828 #[test]
831 fn build_provider_registry_all_keys_set() {
832 let config = Config {
833 providers: crate::config::ProviderConfig {
834 anthropic_api_key: Some("sk-ant-test".to_string()),
835 openai_api_key: Some("sk-test".to_string()),
836 google_api_key: Some("AIza-test".to_string()),
837 claude_code_enabled: false,
838 claude_code_binary: None,
839 claude_code_effort: None,
840 },
841 openrouter_api_key: Some("sk-or-test".to_string()),
842 ollama_base_url: Some("http://custom:11434".to_string()),
843 ..Config::default()
844 };
845 let registry = build_provider_registry_from_config(&config);
846 assert!(registry.has("anthropic"));
847 assert!(registry.has("openai"));
848 assert!(registry.has("google"));
849 assert!(registry.has("openrouter"));
850 assert!(registry.has("ollama"));
851 assert!(!registry.has("claude-code"));
853 }
854
855 #[test]
858 fn provider_creds_from_config_includes_defaults_and_keyed() {
859 let config = Config {
860 providers: crate::config::ProviderConfig {
861 anthropic_api_key: Some("sk-ant".to_string()),
862 ..Config::default().providers
863 },
864 ollama_base_url: Some("http://custom:11434".to_string()),
865 ..Config::default()
866 };
867 let creds = provider_creds_from_config(&config);
868 let names: Vec<&str> = creds.iter().map(|c| c.name.as_str()).collect();
869 assert!(names.contains(&"anthropic"));
872 assert!(names.contains(&"ollama"));
873 assert!(!names.contains(&"claude-code"));
874 assert!(!names.contains(&"openai"));
875 assert!(!names.contains(&"google"));
876 assert!(!names.contains(&"openrouter"));
877 let ollama = creds.iter().find(|c| c.name == "ollama").unwrap();
879 assert_eq!(ollama.base_url.as_deref(), Some("http://custom:11434"));
880 assert!(ollama.api_key.is_none());
881 }
882
883 #[test]
888 fn provider_creds_from_config_ignores_blank_keys() {
889 let config = Config {
890 providers: crate::config::ProviderConfig {
891 anthropic_api_key: Some(String::new()),
892 openai_api_key: Some(" ".to_string()),
893 google_api_key: Some("AIza-real".to_string()),
894 ..Config::default().providers
895 },
896 ..Config::default()
897 };
898 let creds = provider_creds_from_config(&config);
899 let names: Vec<&str> = creds.iter().map(|c| c.name.as_str()).collect();
900 assert!(
901 names.contains(&"google"),
902 "the configured provider must register: {names:?}"
903 );
904 assert!(!names.contains(&"anthropic"), "empty key must not register");
905 assert!(
906 !names.contains(&"openai"),
907 "whitespace-only key must not register"
908 );
909 }
910
911 #[test]
912 fn provider_creds_from_config_carries_rate_limits() {
913 let config = Config {
914 providers: crate::config::ProviderConfig {
915 anthropic_api_key: Some("sk-ant".to_string()),
916 openai_api_key: Some("sk-oa".to_string()),
917 ..Config::default().providers
918 },
919 rate_limits: std::collections::HashMap::from([(
920 "anthropic".to_string(),
921 leviath_providers::RateLimitConfig {
922 requests_per_minute: 50,
923 tokens_per_minute: 40_000,
924 },
925 )]),
926 ..Config::default()
927 };
928 let creds = provider_creds_from_config(&config);
929 let anthropic = creds.iter().find(|c| c.name == "anthropic").unwrap();
930 assert_eq!(
931 anthropic.rate_limit.as_ref().map(|r| r.requests_per_minute),
932 Some(50)
933 );
934 let openai = creds.iter().find(|c| c.name == "openai").unwrap();
936 assert!(openai.rate_limit.is_none());
937 }
938
939 #[test]
942 fn resolve_task_multiline_file() {
943 let dir = std::env::temp_dir().join("lev-test-resolve-multiline");
944 let _ = std::fs::create_dir_all(&dir);
945 let file = dir.join("multi.txt");
946 std::fs::write(&file, "line one\nline two\nline three\n").unwrap();
947
948 let result = resolve_task(
949 &Some(file.to_str().unwrap().to_string()),
950 "test",
951 None,
952 &never_a_tty,
953 );
954 let task = result.unwrap();
955 assert!(task.contains("line one"));
956 assert!(task.contains("line two"));
957 assert!(task.contains("line three"));
958
959 let _ = std::fs::remove_dir_all(&dir);
960 }
961
962 #[test]
965 fn resolve_task_literal_with_special_chars() {
966 let result = resolve_task(
967 &Some("Write a function that does X & Y <html>".to_string()),
968 "test",
969 None,
970 &never_a_tty,
971 );
972 assert_eq!(result.unwrap(), "Write a function that does X & Y <html>");
973 }
974
975 #[test]
978 fn build_provider_registry_defaults_have_ollama_only() {
979 let config = Config::default();
980 let registry = build_provider_registry_from_config(&config);
981 assert!(registry.has("ollama"));
984 assert!(!registry.has("claude-code"));
985 }
986
987 #[test]
988 fn enabling_claude_code_registers_it_with_its_options() {
989 let config = Config {
990 providers: crate::config::ProviderConfig {
991 claude_code_enabled: true,
992 claude_code_binary: Some("/opt/bin/claude".to_string()),
993 claude_code_effort: Some("low".to_string()),
994 ..Config::default().providers
995 },
996 ..Config::default()
997 };
998 let creds = provider_creds_from_config(&config);
999 let cc = creds
1000 .iter()
1001 .find(|c| c.name == "claude-code")
1002 .expect("enabled ⇒ present");
1003 assert_eq!(
1004 cc.options.get("binary").map(String::as_str),
1005 Some("/opt/bin/claude")
1006 );
1007 assert_eq!(cc.options.get("effort").map(String::as_str), Some("low"));
1008 assert!(cc.api_key.is_none());
1009 assert!(build_provider_registry_from_config(&config).has("claude-code"));
1010 }
1011
1012 #[test]
1013 fn enabling_claude_code_without_options_carries_none() {
1014 let config = Config {
1015 providers: crate::config::ProviderConfig {
1016 claude_code_enabled: true,
1017 ..Config::default().providers
1018 },
1019 ..Config::default()
1020 };
1021 let creds = provider_creds_from_config(&config);
1022 let cc = creds.iter().find(|c| c.name == "claude-code").unwrap();
1023 assert!(cc.options.is_empty());
1026 }
1027
1028 #[test]
1031 fn resolve_task_literal_empty_string() {
1032 let result = resolve_task(&Some("".to_string()), "test", None, &never_a_tty);
1034 assert_eq!(result.unwrap(), "");
1035 }
1036
1037 #[test]
1040 fn resolve_task_file_with_multiple_trailing_newlines() {
1041 let dir = std::env::temp_dir().join("lev-test-resolve-trail");
1042 let _ = std::fs::create_dir_all(&dir);
1043 let file = dir.join("trail.txt");
1044 std::fs::write(&file, "task content\n\n\n\n").unwrap();
1045
1046 let result = resolve_task(
1047 &Some(file.to_str().unwrap().to_string()),
1048 "test",
1049 None,
1050 &never_a_tty,
1051 );
1052 assert_eq!(result.unwrap(), "task content");
1053
1054 let _ = std::fs::remove_dir_all(&dir);
1055 }
1056
1057 #[test]
1060 fn build_provider_registry_propagates_model_capabilities() {
1061 use leviath_providers::ModelCapabilities;
1062 let mut caps = std::collections::HashMap::new();
1063 caps.insert(
1064 "custom-model".to_string(),
1065 ModelCapabilities {
1066 supports_temperature: true,
1067 supports_streaming: true,
1068 supports_tools: true,
1069 supports_system_prompt: true,
1070 max_context_tokens: 9999,
1071 max_output_tokens: 999,
1072 },
1073 );
1074 let config = crate::config::Config {
1075 model_capabilities: caps,
1076 providers: crate::config::ProviderConfig {
1077 anthropic_api_key: Some("sk-ant-test".to_string()),
1078 openai_api_key: None,
1079 google_api_key: None,
1080 claude_code_enabled: false,
1081 claude_code_binary: None,
1082 claude_code_effort: None,
1083 },
1084 ..crate::config::Config::default()
1085 };
1086 let registry = build_provider_registry_from_config(&config);
1087 assert!(registry.has("anthropic"));
1089 assert!(registry.has("ollama"));
1091 }
1092
1093 #[test]
1096 fn resolve_task_file_with_real_content() {
1097 let dir = std::env::temp_dir().join("lev-test-resolve-real");
1098 let _ = std::fs::create_dir_all(&dir);
1099 let file = dir.join("real.txt");
1100 std::fs::write(&file, "Implement a REST API server\nwith authentication\n").unwrap();
1101
1102 let result = resolve_task(
1103 &Some(file.to_str().unwrap().to_string()),
1104 "api-agent",
1105 Some("API agent"),
1106 &never_a_tty,
1107 );
1108 let task = result.unwrap();
1109 assert!(task.contains("Implement a REST API server"));
1110 assert!(task.contains("with authentication"));
1111
1112 let _ = std::fs::remove_dir_all(&dir);
1113 }
1114
1115 #[test]
1118 fn build_provider_registry_ollama_with_custom_url_propagates_caps() {
1119 use leviath_providers::ModelCapabilities;
1120 let mut caps = std::collections::HashMap::new();
1121 caps.insert(
1122 "llama3-8b".to_string(),
1123 ModelCapabilities {
1124 supports_temperature: false,
1125 supports_streaming: false,
1126 supports_tools: false,
1127 supports_system_prompt: false,
1128 max_context_tokens: 99,
1129 max_output_tokens: 99,
1130 },
1131 );
1132 let config = crate::config::Config {
1133 ollama_base_url: Some("http://custom-ollama:11434".to_string()),
1134 model_capabilities: caps,
1135 ..crate::config::Config::default()
1136 };
1137 let registry = build_provider_registry_from_config(&config);
1138 assert!(registry.has("ollama"));
1139 }
1140
1141 #[test]
1144 fn resolve_task_none_arg_errors_when_stdin_not_tty() {
1145 let result = resolve_task_with(&None, "test-agent", None, &|| false);
1151 assert!(result.is_err());
1152 let msg = result.unwrap_err().to_string();
1153 assert!(msg.contains("No task provided"));
1154 assert!(msg.contains("stdin is not a TTY"));
1155 }
1156
1157 #[test]
1158 fn resolve_task_none_arg_uses_injected_probe_via_public_wrapper() {
1159 let result = resolve_task(
1164 &Some("literal task".to_string()),
1165 "test-agent",
1166 None,
1167 &never_a_tty,
1168 );
1169 assert_eq!(result.unwrap(), "literal task");
1170 }
1171
1172 #[test]
1173 fn resolve_task_none_arg_errors_via_public_wrapper_when_not_a_tty() {
1174 let result = resolve_task(&None, "test-agent", None, &never_a_tty);
1178 assert!(result.is_err());
1179 }
1180
1181 #[cfg(unix)]
1192 #[test]
1193 fn launch_editor_visual_env_success() {
1194 temp_env::with_vars(
1195 [("VISUAL", Some("/usr/bin/true")), ("EDITOR", None)],
1196 || {
1197 let dir = std::env::temp_dir().join("lev-test-launch-editor-visual");
1198 let _ = std::fs::create_dir_all(&dir);
1199 let file = dir.join("edit.txt");
1200 std::fs::write(&file, "content").unwrap();
1201
1202 let result = launch_editor(&file);
1203 assert_launch_ok(&result);
1204
1205 let _ = std::fs::remove_dir_all(&dir);
1206 },
1207 );
1208 }
1209
1210 #[cfg(unix)]
1213 #[test]
1214 fn launch_editor_editor_env_success() {
1215 temp_env::with_vars(
1216 [("VISUAL", None), ("EDITOR", Some("/usr/bin/true"))],
1217 || {
1218 let dir = std::env::temp_dir().join("lev-test-launch-editor-editor");
1219 let _ = std::fs::create_dir_all(&dir);
1220 let file = dir.join("edit.txt");
1221 std::fs::write(&file, "content").unwrap();
1222
1223 let result = launch_editor(&file);
1224 assert_launch_ok(&result);
1225
1226 let _ = std::fs::remove_dir_all(&dir);
1227 },
1228 );
1229 }
1230
1231 #[cfg(unix)]
1234 #[test]
1235 fn launch_editor_nonzero_exit_still_ok() {
1236 temp_env::with_vars(
1237 [("VISUAL", Some("/usr/bin/false")), ("EDITOR", None)],
1238 || {
1239 let dir = std::env::temp_dir().join("lev-test-launch-editor-nonzero");
1240 let _ = std::fs::create_dir_all(&dir);
1241 let file = dir.join("edit.txt");
1242 std::fs::write(&file, "content").unwrap();
1243
1244 let result = launch_editor(&file);
1247 assert_launch_ok(&result);
1248
1249 let _ = std::fs::remove_dir_all(&dir);
1250 },
1251 );
1252 }
1253
1254 #[test]
1257 fn classify_editor_exit_success_is_completed() {
1258 assert_eq!(
1259 classify_editor_exit(true, Some(0)),
1260 EditorRunOutcome::Completed
1261 );
1262 }
1263
1264 #[test]
1265 fn classify_editor_exit_nonzero_code_is_completed() {
1266 assert_eq!(
1268 classify_editor_exit(false, Some(1)),
1269 EditorRunOutcome::Completed
1270 );
1271 }
1272
1273 #[test]
1274 fn classify_editor_exit_no_code_is_aborted() {
1275 assert_eq!(classify_editor_exit(false, None), EditorRunOutcome::Aborted);
1279 }
1280
1281 #[test]
1282 fn launch_editor_with_aborted_candidate_falls_through_to_next() {
1283 temp_env::with_vars(
1288 [("VISUAL", Some("editor-a")), ("EDITOR", Some("editor-b"))],
1289 || {
1290 let dir = std::env::temp_dir().join("lev-test-launch-editor-aborted");
1291 let _ = std::fs::create_dir_all(&dir);
1292 let file = dir.join("edit.txt");
1293 std::fs::write(&file, "content").unwrap();
1294
1295 let mut calls = 0;
1296 let result = launch_editor_with(&file, &mut |_cmd| {
1297 calls += 1;
1298 Ok(EditorRunOutcome::Aborted)
1299 });
1300 assert!(result.is_err());
1302 assert!(calls >= 2, "expected multiple candidates to be tried");
1303
1304 let _ = std::fs::remove_dir_all(&dir);
1305 },
1306 );
1307 }
1308
1309 #[cfg(unix)]
1312 #[test]
1313 fn launch_editor_command_with_flags_splits_correctly() {
1314 temp_env::with_vars(
1319 [
1320 ("VISUAL", Some("/usr/bin/true --some-flag")),
1321 ("EDITOR", None),
1322 ],
1323 || {
1324 let dir = std::env::temp_dir().join("lev-test-launch-editor-flags");
1325 let _ = std::fs::create_dir_all(&dir);
1326 let file = dir.join("edit.txt");
1327 std::fs::write(&file, "content").unwrap();
1328
1329 let result = launch_editor(&file);
1330 assert_launch_ok(&result);
1331
1332 let _ = std::fs::remove_dir_all(&dir);
1333 },
1334 );
1335 }
1336
1337 #[cfg(unix)]
1340 #[test]
1341 fn launch_editor_whitespace_only_visual_falls_through_to_editor() {
1342 temp_env::with_vars(
1346 [("VISUAL", Some(" ")), ("EDITOR", Some("/usr/bin/true"))],
1347 || {
1348 let dir = std::env::temp_dir().join("lev-test-launch-editor-ws-visual");
1349 let _ = std::fs::create_dir_all(&dir);
1350 let file = dir.join("edit.txt");
1351 std::fs::write(&file, "content").unwrap();
1352
1353 let result = launch_editor(&file);
1354 assert_launch_ok(&result);
1355
1356 let _ = std::fs::remove_dir_all(&dir);
1357 },
1358 );
1359 }
1360
1361 #[test]
1381 fn launch_editor_with_empty_visual_and_editor_are_skipped() {
1382 temp_env::with_vars([("VISUAL", Some("")), ("EDITOR", Some(""))], || {
1383 let dir = std::env::temp_dir().join("lev-test-launch-editor-with-empty-skip");
1384 let _ = std::fs::create_dir_all(&dir);
1385 let file = dir.join("edit.txt");
1386 std::fs::write(&file, "content").unwrap();
1387
1388 let result = launch_editor_with(&file, &mut |_cmd| {
1389 std::process::Command::new(std::env::current_exe().unwrap())
1395 .arg("--this-flag-does-not-exist")
1396 .status()
1397 .map(|s| classify_editor_exit(s.success(), s.code()))
1398 });
1399 assert_launch_ok(&result);
1400
1401 let _ = std::fs::remove_dir_all(&dir);
1402 });
1403 }
1404
1405 #[cfg(unix)]
1420 #[test]
1421 fn launch_editor_empty_visual_and_editor_are_skipped() {
1422 temp_env::with_vars(
1423 [
1424 ("VISUAL", Some("")),
1425 ("EDITOR", Some("")),
1426 ("PATH", Some("/lev-definitely-empty-path-dir")),
1427 ],
1428 || {
1429 let dir = std::env::temp_dir().join("lev-test-launch-editor-empty-visual-editor");
1430 let _ = std::fs::create_dir_all(&dir);
1431 let file = dir.join("edit.txt");
1432 std::fs::write(&file, "content").unwrap();
1433
1434 let result = launch_editor(&file);
1439 assert!(result.is_err());
1440 assert!(result.unwrap_err().to_string().contains("No editor found"));
1441
1442 let _ = std::fs::remove_dir_all(&dir);
1443 },
1444 );
1445 }
1446
1447 #[cfg(unix)]
1450 #[test]
1451 fn launch_editor_not_found_candidate_falls_through_to_next() {
1452 temp_env::with_vars(
1455 [
1456 ("VISUAL", Some("lev-definitely-not-a-real-binary-xyz")),
1457 ("EDITOR", Some("/usr/bin/true")),
1458 ],
1459 || {
1460 let dir = std::env::temp_dir().join("lev-test-launch-editor-notfound");
1461 let _ = std::fs::create_dir_all(&dir);
1462 let file = dir.join("edit.txt");
1463 std::fs::write(&file, "content").unwrap();
1464
1465 let result = launch_editor(&file);
1466 assert_launch_ok(&result);
1467
1468 let _ = std::fs::remove_dir_all(&dir);
1469 },
1470 );
1471 }
1472
1473 #[cfg(unix)]
1476 #[test]
1477 fn launch_editor_permission_denied_returns_error() {
1478 use std::os::unix::fs::PermissionsExt;
1479
1480 let dir = std::env::temp_dir().join("lev-test-launch-editor-perm-denied");
1481 let _ = std::fs::create_dir_all(&dir);
1482 let not_executable = dir.join("not-executable");
1487 std::fs::write(¬_executable, "not a script").unwrap();
1488 let mut perms = std::fs::metadata(¬_executable).unwrap().permissions();
1489 perms.set_mode(0o600);
1490 std::fs::set_permissions(¬_executable, perms).unwrap();
1491
1492 temp_env::with_vars(
1493 [("VISUAL", Some(¬_executable)), ("EDITOR", None)],
1494 || {
1495 let file = dir.join("edit.txt");
1496 std::fs::write(&file, "content").unwrap();
1497
1498 let result = launch_editor(&file);
1499 assert!(result.is_err());
1500 assert!(
1501 result
1502 .unwrap_err()
1503 .to_string()
1504 .contains("Failed to launch editor")
1505 );
1506
1507 let _ = std::fs::remove_dir_all(&dir);
1508 },
1509 );
1510 }
1511
1512 #[test]
1528 fn launch_editor_with_no_editor_found_when_every_candidate_not_found() {
1529 let dir = std::env::temp_dir().join("lev-test-launch-editor-with-no-editor");
1530 let _ = std::fs::create_dir_all(&dir);
1531 let file = dir.join("edit.txt");
1532 std::fs::write(&file, "content").unwrap();
1533
1534 let result = launch_editor_with(&file, &mut |_cmd| {
1535 Err(std::io::Error::from(std::io::ErrorKind::NotFound))
1536 });
1537 assert!(result.is_err());
1538 assert!(result.unwrap_err().to_string().contains("No editor found"));
1539
1540 let _ = std::fs::remove_dir_all(&dir);
1541 }
1542
1543 #[cfg(unix)]
1554 #[test]
1555 fn launch_editor_no_editor_found_when_path_has_no_candidates() {
1556 temp_env::with_vars(
1560 [
1561 ("VISUAL", None),
1562 ("EDITOR", None),
1563 ("PATH", Some("/lev-definitely-empty-path-dir")),
1564 ],
1565 || {
1566 let dir = std::env::temp_dir().join("lev-test-launch-editor-no-editor");
1567 let _ = std::fs::create_dir_all(&dir);
1568 let file = dir.join("edit.txt");
1569 std::fs::write(&file, "content").unwrap();
1570
1571 let result = launch_editor(&file);
1572 assert!(result.is_err());
1573 assert!(result.unwrap_err().to_string().contains("No editor found"));
1574
1575 let _ = std::fs::remove_dir_all(&dir);
1576 },
1577 );
1578 }
1579
1580 #[cfg(windows)]
1616 fn write_bat(path: &std::path::Path, body: &str) {
1617 std::fs::write(path, format!("@echo off\r\n{}\r\n", body)).unwrap();
1618 }
1619
1620 #[cfg(windows)]
1621 #[test]
1622 fn launch_editor_visual_env_success() {
1623 let dir = std::env::temp_dir().join("lev-test-launch-editor-visual-win");
1624 let _ = std::fs::create_dir_all(&dir);
1625 let ok_bat = dir.join("ok.bat");
1626 write_bat(&ok_bat, "exit /b 0");
1627
1628 temp_env::with_vars([("VISUAL", Some(&ok_bat)), ("EDITOR", None)], || {
1629 let file = dir.join("edit.txt");
1630 std::fs::write(&file, "content").unwrap();
1631
1632 let result = launch_editor(&file);
1633 assert_launch_ok(&result);
1634
1635 let _ = std::fs::remove_dir_all(&dir);
1636 });
1637 }
1638
1639 #[cfg(windows)]
1640 #[test]
1641 fn launch_editor_editor_env_success() {
1642 let dir = std::env::temp_dir().join("lev-test-launch-editor-editor-win");
1643 let _ = std::fs::create_dir_all(&dir);
1644 let ok_bat = dir.join("ok.bat");
1645 write_bat(&ok_bat, "exit /b 0");
1646
1647 temp_env::with_vars([("VISUAL", None), ("EDITOR", Some(&ok_bat))], || {
1648 let file = dir.join("edit.txt");
1649 std::fs::write(&file, "content").unwrap();
1650
1651 let result = launch_editor(&file);
1652 assert_launch_ok(&result);
1653
1654 let _ = std::fs::remove_dir_all(&dir);
1655 });
1656 }
1657
1658 #[cfg(windows)]
1659 #[test]
1660 fn launch_editor_nonzero_exit_still_ok() {
1661 let dir = std::env::temp_dir().join("lev-test-launch-editor-nonzero-win");
1662 let _ = std::fs::create_dir_all(&dir);
1663 let fail_bat = dir.join("fail.bat");
1664 write_bat(&fail_bat, "exit /b 1");
1665
1666 temp_env::with_vars([("VISUAL", Some(&fail_bat)), ("EDITOR", None)], || {
1667 let file = dir.join("edit.txt");
1668 std::fs::write(&file, "content").unwrap();
1669
1670 let result = launch_editor(&file);
1673 assert_launch_ok(&result);
1674
1675 let _ = std::fs::remove_dir_all(&dir);
1676 });
1677 }
1678
1679 #[cfg(windows)]
1680 #[test]
1681 fn launch_editor_command_with_flags_splits_correctly() {
1682 let dir = std::env::temp_dir().join("lev-test-launch-editor-flags-win");
1683 let _ = std::fs::create_dir_all(&dir);
1684 let ok_bat = dir.join("ok.bat");
1685 write_bat(&ok_bat, "exit /b 0");
1686
1687 temp_env::with_vars(
1692 [
1693 ("VISUAL", Some(format!("{} --some-flag", ok_bat.display()))),
1694 ("EDITOR", None),
1695 ],
1696 || {
1697 let file = dir.join("edit.txt");
1698 std::fs::write(&file, "content").unwrap();
1699
1700 let result = launch_editor(&file);
1701 assert_launch_ok(&result);
1702
1703 let _ = std::fs::remove_dir_all(&dir);
1704 },
1705 );
1706 }
1707
1708 #[cfg(windows)]
1709 #[test]
1710 fn launch_editor_whitespace_only_visual_falls_through_to_editor() {
1711 let dir = std::env::temp_dir().join("lev-test-launch-editor-ws-visual-win");
1712 let _ = std::fs::create_dir_all(&dir);
1713 let ok_bat = dir.join("ok.bat");
1714 write_bat(&ok_bat, "exit /b 0");
1715
1716 temp_env::with_vars(
1720 [
1721 ("VISUAL", Some(std::ffi::OsString::from(" "))),
1722 ("EDITOR", Some(ok_bat.clone().into_os_string())),
1723 ],
1724 || {
1725 let file = dir.join("edit.txt");
1726 std::fs::write(&file, "content").unwrap();
1727
1728 let result = launch_editor(&file);
1729 assert_launch_ok(&result);
1730
1731 let _ = std::fs::remove_dir_all(&dir);
1732 },
1733 );
1734 }
1735
1736 #[cfg(windows)]
1737 #[test]
1738 fn launch_editor_not_found_candidate_falls_through_to_next() {
1739 let dir = std::env::temp_dir().join("lev-test-launch-editor-notfound-win");
1740 let _ = std::fs::create_dir_all(&dir);
1741 let ok_bat = dir.join("ok.bat");
1742 write_bat(&ok_bat, "exit /b 0");
1743
1744 temp_env::with_vars(
1747 [
1748 (
1749 "VISUAL",
1750 Some(std::ffi::OsString::from(
1751 "lev-definitely-not-a-real-binary-xyz",
1752 )),
1753 ),
1754 ("EDITOR", Some(ok_bat.clone().into_os_string())),
1755 ],
1756 || {
1757 let file = dir.join("edit.txt");
1758 std::fs::write(&file, "content").unwrap();
1759
1760 let result = launch_editor(&file);
1761 assert_launch_ok(&result);
1762
1763 let _ = std::fs::remove_dir_all(&dir);
1764 },
1765 );
1766 }
1767
1768 #[cfg(windows)]
1769 #[test]
1770 fn launch_editor_permission_denied_returns_error() {
1771 let dir = std::env::temp_dir().join("lev-test-launch-editor-perm-denied-win");
1772 let _ = std::fs::create_dir_all(&dir);
1773 let not_executable = dir.join("not-executable.txt");
1779 std::fs::write(¬_executable, "not a script").unwrap();
1780
1781 temp_env::with_vars(
1782 [("VISUAL", Some(¬_executable)), ("EDITOR", None)],
1783 || {
1784 let file = dir.join("edit.txt");
1785 std::fs::write(&file, "content").unwrap();
1786
1787 let result = launch_editor(&file);
1788 assert!(result.is_err());
1789 assert!(
1790 result
1791 .unwrap_err()
1792 .to_string()
1793 .contains("Failed to launch editor")
1794 );
1795
1796 let _ = std::fs::remove_dir_all(&dir);
1797 },
1798 );
1799 }
1800
1801 #[cfg(unix)]
1804 #[test]
1805 fn resolve_task_with_editor_path_happy_case() {
1806 use std::os::unix::fs::PermissionsExt;
1807
1808 let dir = std::env::temp_dir().join("lev-test-resolve-task-editor-happy");
1812 let _ = std::fs::create_dir_all(&dir);
1813 let script = dir.join("fake-editor.sh");
1814 std::fs::write(
1815 &script,
1816 "#!/bin/sh\necho \"task body from editor\" >> \"$1\"\n",
1817 )
1818 .unwrap();
1819 let mut perms = std::fs::metadata(&script).unwrap().permissions();
1820 perms.set_mode(0o700);
1821 std::fs::set_permissions(&script, perms).unwrap();
1822
1823 temp_env::with_vars([("VISUAL", Some(&script)), ("EDITOR", None)], || {
1824 let result = resolve_task_with(
1825 &None,
1826 "test-agent",
1827 Some("a non-empty description"),
1828 &|| true,
1829 );
1830 assert_eq!(result.unwrap(), "task body from editor");
1831
1832 let _ = std::fs::remove_dir_all(&dir);
1833 });
1834 }
1835
1836 #[cfg(unix)]
1837 #[test]
1838 fn resolve_task_with_editor_path_empty_after_stripping_comments_errors() {
1839 temp_env::with_vars(
1842 [("VISUAL", Some("/usr/bin/true")), ("EDITOR", None)],
1843 || {
1844 let result = resolve_task_with(&None, "test-agent", None, &|| true);
1845 assert!(result.is_err());
1846 assert!(result.unwrap_err().to_string().contains("Aborting run"));
1847 },
1848 );
1849 }
1850
1851 #[cfg(unix)]
1856 #[test]
1857 fn resolve_task_with_editor_path_propagates_launch_editor_error() {
1858 temp_env::with_vars(
1862 [
1863 ("VISUAL", None),
1864 ("EDITOR", None),
1865 ("PATH", Some("/lev-definitely-empty-path-dir")),
1866 ],
1867 || {
1868 let result = resolve_task_with(&None, "test-agent", None, &|| true);
1869 assert!(result.is_err());
1870 assert!(result.unwrap_err().to_string().contains("No editor found"));
1871 },
1872 );
1873 }
1874
1875 fn stub_editor_returns_no_editor_found(_path: &std::path::Path) -> anyhow::Result<()> {
1888 Err(anyhow::anyhow!(
1889 "No editor found. Set $VISUAL or $EDITOR, or install vim/nano/notepad."
1890 ))
1891 }
1892
1893 #[test]
1901 fn resolve_task_with_editor_injected_editor_failure_propagates() {
1902 let result = resolve_task_with_editor(
1903 &None,
1904 "test-agent",
1905 None,
1906 &|| true,
1907 &stub_editor_returns_no_editor_found,
1908 &std::env::temp_dir,
1909 );
1910 assert!(result.is_err());
1911 assert!(result.unwrap_err().to_string().contains("No editor found"));
1912 }
1913
1914 #[test]
1926 fn resolve_task_with_editor_tmp_file_write_failure_propagates() {
1927 let bad_tmp_dir = std::env::temp_dir()
1928 .join("lev-definitely-nonexistent-parent-dir-for-task-template-xyz")
1929 .join("nested");
1930 let result = resolve_task_with_editor(
1931 &None,
1932 "test-agent",
1933 None,
1934 &|| true,
1935 &stub_editor_returns_no_editor_found,
1936 &move || bad_tmp_dir.clone(),
1937 );
1938 assert!(result.is_err());
1939 assert!(
1940 result
1941 .unwrap_err()
1942 .to_string()
1943 .contains("Failed to create task temp file")
1944 );
1945 }
1946
1947 #[cfg(windows)]
1950 #[test]
1951 fn resolve_task_with_editor_path_happy_case() {
1952 let dir = std::env::temp_dir().join("lev-test-resolve-task-editor-happy-win");
1957 let _ = std::fs::create_dir_all(&dir);
1958 let script = dir.join("fake-editor.bat");
1959 write_bat(&script, "echo task body from editor>>\"%~1\"");
1960
1961 temp_env::with_vars([("VISUAL", Some(&script)), ("EDITOR", None)], || {
1962 let result = resolve_task_with(
1963 &None,
1964 "test-agent",
1965 Some("a non-empty description"),
1966 &|| true,
1967 );
1968 assert_eq!(result.unwrap(), "task body from editor");
1969
1970 let _ = std::fs::remove_dir_all(&dir);
1971 });
1972 }
1973
1974 #[cfg(windows)]
1975 #[test]
1976 fn resolve_task_with_editor_path_empty_after_stripping_comments_errors() {
1977 let dir = std::env::temp_dir().join("lev-test-resolve-task-editor-empty-win");
1981 let _ = std::fs::create_dir_all(&dir);
1982 let ok_bat = dir.join("ok.bat");
1983 write_bat(&ok_bat, "exit /b 0");
1984
1985 temp_env::with_vars([("VISUAL", Some(&ok_bat)), ("EDITOR", None)], || {
1986 let result = resolve_task_with(&None, "test-agent", None, &|| true);
1987 assert!(result.is_err());
1988 assert!(result.unwrap_err().to_string().contains("Aborting run"));
1989
1990 let _ = std::fs::remove_dir_all(&dir);
1991 });
1992 }
1993
1994 #[test]
1997 fn build_task_template_with_empty_description_skips_desc_line() {
1998 let t = build_task_template("agent", Some(""));
1999 assert!(!t.contains("# \n"));
2000 assert!(t.contains("# Task for agent: agent\n"));
2001 }
2002
2003 #[test]
2004 fn build_task_template_with_non_empty_description_adds_desc_line() {
2005 let t = build_task_template("my-agent", Some("Build a web server"));
2006 assert!(t.contains("# Task for agent: my-agent\n"));
2007 assert!(t.contains("# Build a web server\n"));
2008 }
2009
2010 #[test]
2011 fn build_task_template_with_no_description() {
2012 let t = build_task_template("my-agent", None);
2013 assert!(t.contains("# Task for agent: my-agent\n"));
2014 assert!(t.contains("Describe your task below"));
2015 }
2016
2017 #[test]
2021 fn write_task_template_error_on_bad_path() {
2022 let dir = tempfile::tempdir().unwrap();
2025 let blocker = dir.path().join("blocker");
2026 std::fs::write(&blocker, b"x").unwrap();
2027 let result = write_task_template(&blocker, "content");
2028 assert!(result.is_err());
2029 assert!(
2030 result
2031 .unwrap_err()
2032 .to_string()
2033 .contains("Failed to create task temp file")
2034 );
2035 }
2036
2037 #[cfg(unix)]
2040 #[test]
2041 fn resolve_task_unreadable_file_returns_error() {
2042 use std::os::unix::fs::PermissionsExt;
2043 let dir = std::env::temp_dir().join("lev-test-resolve-unreadable");
2044 let _ = std::fs::create_dir_all(&dir);
2045 let file = dir.join("secret.txt");
2046 std::fs::write(&file, "secret content").unwrap();
2047 let mut perms = std::fs::metadata(&file).unwrap().permissions();
2048 perms.set_mode(0o000);
2049 std::fs::set_permissions(&file, perms).unwrap();
2050
2051 let result = resolve_task_with(
2052 &Some(file.to_str().unwrap().to_string()),
2053 "test-agent",
2054 None,
2055 &|| false,
2056 );
2057 let mut perms2 = std::fs::metadata(&file).unwrap().permissions();
2059 perms2.set_mode(0o644);
2060 std::fs::set_permissions(&file, perms2).ok();
2061 let _ = std::fs::remove_dir_all(&dir);
2062
2063 assert!(result.is_err());
2064 assert!(
2065 result
2066 .unwrap_err()
2067 .to_string()
2068 .contains("Failed to read task file")
2069 );
2070 }
2071
2072 #[cfg(windows)]
2079 #[test]
2080 fn resolve_task_unreadable_file_returns_error() {
2081 use std::fs::OpenOptions;
2082 use std::os::windows::fs::OpenOptionsExt;
2083
2084 let dir = std::env::temp_dir().join("lev-test-resolve-unreadable-win");
2085 let _ = std::fs::create_dir_all(&dir);
2086 let file = dir.join("secret.txt");
2087 std::fs::write(&file, "secret content").unwrap();
2088
2089 let _locked = OpenOptions::new()
2092 .write(true)
2093 .share_mode(0)
2094 .open(&file)
2095 .unwrap();
2096
2097 let result = resolve_task_with(
2098 &Some(file.to_str().unwrap().to_string()),
2099 "test-agent",
2100 None,
2101 &|| false,
2102 );
2103
2104 drop(_locked);
2105 let _ = std::fs::remove_dir_all(&dir);
2106
2107 assert!(result.is_err());
2108 assert!(
2109 result
2110 .unwrap_err()
2111 .to_string()
2112 .contains("Failed to read task file")
2113 );
2114 }
2115}