Skip to main content

leviath_cli/commands/run/
session.rs

1//! Session setup: task resolution, editor launching, engine setup.
2
3use crate::config::Config;
4use leviath_runtime::ProviderRegistry;
5
6// `ProviderCreds` + `build_provider_registry(&[ProviderCreds])` live in
7// `leviath-runtime` (plain data + provider instantiation, no `Config`
8// dependency). Re-exported here so `commands::run`'s public re-export and all
9// existing call sites keep resolving. The `Config`-based translators
10// (`provider_creds_from_config` / `build_provider_registry_from_config`) stay
11// below because they need the CLI's `Config`.
12pub use leviath_runtime::provider_creds::{ProviderCreds, build_provider_registry};
13
14/// Resolve the task string from a CLI argument.
15///
16/// - `Some(s)` where `s` is an existing file path → read file contents.
17/// - `Some(s)` otherwise → use `s` as a literal prompt.
18/// - `None` when stdin is not a TTY → error.
19/// - `None` when stdin is a TTY → launch the user's editor on a temp prompt file.
20///
21/// `stdin_is_terminal` is injected (a `&dyn Fn() -> bool`) rather than probing
22/// the real process stdin here, so the library core stays free of direct
23/// `std::io::stdin()` access and is fully testable. In production the binary
24/// passes `&|| std::io::stdin().is_terminal()`.
25pub 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
34/// Same as [`resolve_task`], but with the stdin-is-a-TTY check injected
35/// instead of hardcoded - lets tests deterministically exercise both the
36/// "not a TTY" error path and the "is a TTY" editor-launch path regardless
37/// of whether the test runner's own stdin happens to be a real terminal
38/// (e.g. a human running `cargo test` interactively vs. CI).
39///
40/// `stdin_is_terminal` is a trait-object reference (`&dyn Fn`) rather than
41/// `impl FnOnce` deliberately: this function is called from many test sites,
42/// each passing a distinct closure *type* even when the closures are
43/// behaviorally identical (e.g. multiple `|| true`s are still different
44/// anonymous types). A generic `impl Trait` parameter gives `rustc` one
45/// monomorphization per call site, and `cargo llvm-cov` sometimes reports a
46/// region as uncovered for one instantiation even though the union of all
47/// instantiations covers every source position - a confirmed llvm-cov
48/// limitation (see `xtask/src/coverage.rs`'s doc comment on generic-function
49/// monomorphization). Erasing the closure type with `&dyn Fn` collapses
50/// every call site back down to a single instantiation, avoiding that noise
51/// entirely.
52fn 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
68/// Resolve one CLI region-flag value: `@path` reads (and trims) that file's
69/// contents; anything else is literal text. Unlike `--task`, the `@` is an
70/// explicit file marker, so a missing `@file` is an error (the user meant a
71/// file), not a literal fallback.
72pub 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
87/// Same as [`resolve_task_with`], but with the editor launch itself injected
88/// too - lets tests deterministically exercise `launch_editor`'s error
89/// propagating out of `resolve_task_with` (the `result?` a few lines down)
90/// without needing a real failing subprocess/PATH setup. On Windows there is
91/// no safe way to make the real `launch_editor`'s platform-default candidate
92/// (`notepad`, resolved via `System32` unconditionally) fail without
93/// mutating the real system directory, so `resolve_task_with`'s own
94/// `#[cfg(unix)]`-only real-PATH-starvation test for this can't be mirrored
95/// there - injecting the editor launcher closes that gap on every platform.
96///
97/// Also takes the temp-directory provider (`tmp_dir_fn`) as an injectable
98/// closure so tests can point the task-template write at a guaranteed-
99/// unwritable directory (e.g. one whose parent doesn't exist) and
100/// deterministically exercise `write_task_template`'s `?` propagating out of
101/// this function - the real OS temp directory used in production is
102/// essentially always writable, so that error path is otherwise untestable.
103///
104/// All closures are `&dyn Fn` for the same monomorphization-noise reason
105/// documented on [`resolve_task_with`].
106fn 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            // Build a commented template file for the editor
137            let template = build_task_template(agent_name, description);
138
139            // A randomly named file created `O_EXCL`, not `lev-task-<pid>.txt`.
140            // A predictable name is an attack surface because `fs::write`
141            // follows symlinks: on a shared host another user pre-creates that
142            // path as a link to `~/.leviath/config.toml` or
143            // `~/.ssh/authorized_keys`, and the next `lev run` writes the
144            // template - and then everything the user types into their editor -
145            // straight through it. `tempfile`
146            // also creates it owner-only, so the task prompt is not world
147            // readable while the editor holds it open.
148            let tmp = write_task_template(&tmp_dir_fn(), &template)?;
149            // Close our own handle before the editor opens the file: Windows
150            // refuses a second writer while the first still holds it, so the
151            // editor could not save. `TempPath` keeps the delete-on-drop.
152            let tmp = tmp.into_temp_path();
153            let tmp_path = tmp.to_path_buf();
154
155            // Launch the editor (exits only when the user closes it)
156            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            // Strip comment lines and trim
162            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    // Creating and writing in one fallible step, through the handle the builder
196    // opened. Two steps would mean re-opening by path between them - a window in
197    // which the name could be swapped - and a second error arm that a freshly
198    // created, writable handle can never actually take.
199    // A combinator chain rather than `?`s: each `?` would be an error arm that
200    // a freshly created, writable handle can never take, and the whole point of
201    // reporting here is the one failure that is real - the file could not be
202    // created at all.
203    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
216/// Platform-specific fallback editor candidates, appended after any
217/// $VISUAL/$EDITOR candidates.
218///
219/// Extracted into its own pure, injectable function (rather than inlined
220/// directly in [`launch_editor`]) so tests can assert on the Windows
221/// candidate list containing `notepad` without ever having to actually
222/// spawn it - launching a real, blocking, interactive GUI text editor with
223/// no timeout would hang CI indefinitely.
224fn 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/// Launch the user's preferred editor on `path` and wait for it to exit.
236///
237/// Editor resolution order: $VISUAL → $EDITOR → platform default.
238/// Platform defaults: Unix tries `vim` then `nano`; Windows uses `notepad`.
239/// Outcome of running one editor candidate, abstracting over the raw
240/// `ExitStatus`. This exists so the "ran but ended with no exit code" case (a
241/// signal kill on Unix) is injectable in tests on *every* platform: on Windows
242/// an `ExitStatus` always carries a code (even via `ExitStatusExt::from_raw`),
243/// so that case cannot be fabricated from a status directly. The injected `run`
244/// seam of [`launch_editor_with`] therefore yields this enum rather than an
245/// `ExitStatus`.
246#[derive(Debug, Clone, Copy, PartialEq, Eq)]
247enum EditorRunOutcome {
248    /// Process finished (success, or any explicit exit code) - treat as the
249    /// user having closed the editor.
250    Completed,
251    /// Process ended with no exit code (e.g. killed by a signal) - try the next
252    /// candidate.
253    Aborted,
254}
255
256/// Classify an editor subprocess's exit. `code == None` means it ended without
257/// an exit code (a signal kill). A pure function so both arms are unit-testable
258/// on every platform, independent of whether a real process can produce a
259/// code-less status there.
260fn 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
275/// Core of [`launch_editor`], with the actual "run this candidate and get its
276/// exit status" step injected as `run` instead of hardcoded to
277/// `Command::status()`.
278///
279/// This seam exists specifically so the final "no editor found" `bail!` below
280/// can be exercised deterministically on every platform. On Unix that branch
281/// is reachable by starving `$PATH` so even the real `vim`/`nano`/`vi`
282/// fallbacks fail to resolve (see the real-subprocess tests below), but there
283/// is no safe real-subprocess equivalent on Windows: `Command::new("notepad")`
284/// resolves via the `System32` search path that `CreateProcess` consults
285/// *before* `$PATH`, so it can't be made to fail short of tampering with a
286/// real system directory. Injecting `run` lets a single, platform-independent
287/// test force every candidate to fail with `NotFound` without spawning any
288/// process at all - proving the `bail!` is reachable production code on
289/// every platform, not a permanent gap.
290///
291/// `run` is `&mut dyn FnMut` rather than `impl FnMut` for the same
292/// monomorphization-noise reason documented on
293/// [`resolve_task_with`](super::session::resolve_task_with): several test
294/// call sites below pass distinct closure literals directly to this
295/// function, and a generic parameter would give each one its own
296/// instantiation.
297fn 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    // Resolve editor candidates in priority order
304    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        // Handle editor strings that may include flags (e.g. "code --wait")
321        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            // Exited (even non-zero means the user closed it - treat as OK).
334            Ok(EditorRunOutcome::Completed) => {
335                return Ok(());
336            }
337            Ok(EditorRunOutcome::Aborted) => {
338                // Ended with no exit code (e.g. killed by signal on Unix) -
339                // try the next candidate.
340            }
341            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
342                // Try next candidate
343                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
358/// Build the list of [`ProviderCreds`] a [`Config`] implies. `ollama` is always
359/// present (it needs no key); the API-key providers are included only when their
360/// key is configured, and `claude-code` only when explicitly enabled. This is the
361/// sole point that reads provider settings out of `Config`.
362pub 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        // A blank key is not a key: `lev setup` writes empty strings for
375        // providers the user skipped, and registering one produces a provider
376        // that authenticates as nobody and fails at the first call.
377        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    // Ollama is always available (no key); carry any configured base URL.
391    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    // Claude Code needs no API key, but it is opt-in rather than always-on: the
408    // CLI puts the user's account email address into every call and that cannot
409    // be turned off. Leaving it unregistered is also how it stays out of an
410    // agent's model fallback chain - `resolve_stage_model` skips any provider
411    // the registry doesn't have.
412    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
434/// Convenience wrapper: build a [`ProviderRegistry`] straight from a [`Config`].
435///
436/// Kept as a `fn(&Config) -> ProviderRegistry` so it can be passed as the
437/// registry-builder seam that `run`/`models`/`dashboard` inject for tests.
438///
439/// Native providers are registered eagerly from [`provider_creds_from_config`];
440/// a [`ScriptProviderLayer`](leviath_runtime::script_provider::ScriptProviderLayer)
441/// is then attached so Rhai *script providers* resolve lazily and
442/// hot-reload from `~/.leviath/providers/`.
443pub 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
448/// Attach a [`ScriptProviderLayer`](leviath_runtime::script_provider::ScriptProviderLayer)
449/// over `dir` (the providers directory) when one is available; otherwise return
450/// the registry unchanged. Split out so both the with-dir and no-home paths are
451/// unit-testable.
452fn 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
475/// Translate a CLI [`ModelProviderConfig`](crate::config::ModelProviderConfig)
476/// into the runtime's plain-data
477/// [`ScriptProviderSpec`](leviath_runtime::script_provider::ScriptProviderSpec):
478/// `base_url`/`api_key`/extra keys become the `initialize(config)` map.
479fn 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    /// Shared "stdin is never a TTY" probe for the `resolve_task` tests whose
507    /// argument is `Some(..)` (so the probe is never consulted) or that
508    /// explicitly want the non-TTY error path. A single named `fn` (rather than
509    /// a fresh `|| false` closure per call site) keeps every call site sharing
510    /// one instantiation and one covered region.
511    fn never_a_tty() -> bool {
512        false
513    }
514
515    /// Shared `assert!`-with-dynamic-message helper: several `launch_editor`
516    /// success tests assert `result.is_ok()` while formatting the actual
517    /// result into the panic message for diagnostics if the assertion ever
518    /// fails. The panic-message formatting is only evaluated on failure,
519    /// which otherwise leaves it permanently uncovered by `cargo llvm-cov`.
520    /// Extracted once here (rather than per call site) and exercised below
521    /// via `#[should_panic]`.
522    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    // ─── read_region_value ────────────────────────────────────────────────
533
534    #[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    // ─── platform_default_editors ─────────────────────────────────────────
569
570    #[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        // Path doesn't exist as a file, so it's treated as a literal string
642        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        // Ollama needs no key and is always on.
698        assert!(registry.has("ollama"));
699        // Claude Code needs no key either, but is opt-in - a default config
700        // must not reach the user's Claude subscription (or send their account
701        // email to it) without them having said yes.
702        assert!(!registry.has("claude-code"));
703        // Should NOT have anthropic, openai, google without keys
704        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        // No providers directory (no resolvable home) → registry unchanged, no
796        // script provider resolves.
797        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    // ─── build_provider_registry with all keys ──────────────────────────
829
830    #[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        // Every key in the world doesn't enable Claude Code - only opting in does.
852        assert!(!registry.has("claude-code"));
853    }
854
855    // ─── ProviderCreds seam ─────────────────────────────────────────────
856
857    #[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        // anthropic (keyed) + ollama, but not openai/google/openrouter, and not
870        // claude-code (opt-in, not enabled here).
871        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        // The ollama base URL is carried through.
878        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    /// `lev setup` writes an empty string for a provider the user skipped, so
884    /// a blank key must not register one: doing so produced a provider that
885    /// authenticates as nobody and fails at the first call, and it crowded out
886    /// the provider the user actually configured.
887    #[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        // A provider without a [rate_limits.<name>] entry stays unthrottled.
935        let openai = creds.iter().find(|c| c.name == "openai").unwrap();
936        assert!(openai.rate_limit.is_none());
937    }
938
939    // ─── resolve_task: multiline file content ───────────────────────────
940
941    #[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    // ─── resolve_task: literal string with special chars ────────────────
963
964    #[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    // ─── build_provider_registry: no providers except defaults ──────────
976
977    #[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        // Ollama is present regardless of key configuration; claude-code is not,
982        // until the user opts in.
983        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        // Absent settings stay absent so the provider applies its own defaults
1024        // (the `claude` binary on PATH, DEFAULT_EFFORT).
1025        assert!(cc.options.is_empty());
1026    }
1027
1028    // ─── resolve_task: file with only comments in editor-like format ────
1029
1030    #[test]
1031    fn resolve_task_literal_empty_string() {
1032        // Empty string is treated as literal, returns as-is
1033        let result = resolve_task(&Some("".to_string()), "test", None, &never_a_tty);
1034        assert_eq!(result.unwrap(), "");
1035    }
1036
1037    // ─── resolve_task: file with multiple lines and trailing whitespace ──
1038
1039    #[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    // ─── build_provider_registry: model_capabilities propagated ──────────
1058
1059    #[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        // Verify anthropic provider was registered
1088        assert!(registry.has("anthropic"));
1089        // Verify ollama always registered
1090        assert!(registry.has("ollama"));
1091    }
1092
1093    // ─── launch_editor: candidates exhausted when no editors available ────
1094
1095    #[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    // ─── build_provider_registry: all providers registered ───────────────
1116
1117    #[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    // ─── resolve_task: None arg, non-TTY stdin ───────────────────────────
1142
1143    #[test]
1144    fn resolve_task_none_arg_errors_when_stdin_not_tty() {
1145        // The TTY check is injected (not the real std::io::stdin()) so this
1146        // is deterministic regardless of whether the test runner's own
1147        // stdin happens to be a real terminal - a human running `cargo test`
1148        // interactively has a real TTY on stdin, unlike CI, so hardcoding
1149        // "stdin is never a TTY under cargo test" was a false assumption.
1150        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        // Smoke test that the public `resolve_task()` wrapper still compiles
1160        // and delegates to `resolve_task_with` with the caller-supplied probe.
1161        // A literal task never consults the probe, so the outcome is
1162        // deterministic regardless of environment.
1163        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        // Drives `resolve_task`'s public wrapper with an injected "never a TTY"
1175        // probe: no task + not-a-TTY hits the "no task provided" error,
1176        // cross-platform, without touching real stdin or launching an editor.
1177        let result = resolve_task(&None, "test-agent", None, &never_a_tty);
1178        assert!(result.is_err());
1179    }
1180
1181    // ─── launch_editor: VISUAL takes priority and succeeds ───────────────
1182
1183    // These `launch_editor` tests point VISUAL/EDITOR at `/usr/bin/true` (or
1184    // rely on PATH-starvation to prevent any editor being found) - both
1185    // assumptions are Unix-only. On Windows, `/usr/bin/true` doesn't exist,
1186    // so the NotFound-branch falls through to the windows-only "notepad"
1187    // candidate, which Windows resolves via its System32 search path
1188    // *regardless* of $PATH - so PATH-starvation doesn't stop it either.
1189    // Either way that means launching a real, blocking GUI text editor with
1190    // no timeout, which hung a Windows CI run indefinitely. Gated to `unix`.
1191    #[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    // ─── launch_editor: EDITOR used when VISUAL unset ────────────────────
1211
1212    #[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    // ─── launch_editor: exit code (even non-zero) is treated as success ──
1232
1233    #[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                // A non-zero-but-present exit code is treated as the user having
1245                // closed the editor - not an error.
1246                let result = launch_editor(&file);
1247                assert_launch_ok(&result);
1248
1249                let _ = std::fs::remove_dir_all(&dir);
1250            },
1251        );
1252    }
1253
1254    // ─── launch_editor: terminated by signal (no exit code) tries next ───
1255
1256    #[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        // Non-zero but present exit code = user closed the editor = done.
1267        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        // No exit code (e.g. killed by a Unix signal) = try the next candidate.
1276        // Exercised here as a pure function so it's covered on every platform,
1277        // including Windows where a real `ExitStatus` always carries a code.
1278        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        // An injected `run` reporting `Aborted` exercises the `Ok(Aborted) => {}`
1284        // arm (try the next candidate) on every platform, without needing a real
1285        // signal-killed subprocess (which can't be fabricated on Windows). With
1286        // every candidate aborting, the loop exhausts them and bails.
1287        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                // Every candidate "ran" but aborted, so it tried them all then bailed.
1301                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    // ─── launch_editor: command with flags is split correctly ────────────
1310
1311    #[cfg(unix)]
1312    #[test]
1313    fn launch_editor_command_with_flags_splits_correctly() {
1314        // `/usr/bin/true` ignores all arguments, so appending a flag
1315        // and the file path is harmless; this exercises the
1316        // whitespace-splitting logic for editor strings like
1317        // "code --wait".
1318        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    // ─── launch_editor: whitespace-only VISUAL falls through, EDITOR used ─
1338
1339    #[cfg(unix)]
1340    #[test]
1341    fn launch_editor_whitespace_only_visual_falls_through_to_editor() {
1342        // Whitespace-only string is non-empty so it IS pushed as a
1343        // candidate, but splitting on whitespace yields an empty parts
1344        // vec, which triggers the `continue` branch.
1345        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    // ─── launch_editor_with: truly-empty VISUAL/EDITOR, injected (cross-platform) ─
1362
1363    /// Exercises the `!v.is_empty()`/`!e.is_empty()` false arm (empty-string
1364    /// `VISUAL`/`EDITOR` never gets pushed onto the candidates list) the same
1365    /// way the "no editor found" test above closes the Windows gap: via the
1366    /// injected `run` seam instead of PATH-starvation.
1367    ///
1368    /// The Unix test below needs PATH-starvation only to keep an
1369    /// empty-VISUAL/EDITOR fallthrough to the real `vim`/`nano`/`vi`
1370    /// platform defaults from actually launching a real, blocking editor --
1371    /// it isn't inherent to the branch itself. That real-editor risk doesn't
1372    /// exist here: `run` never spawns anything real regardless of which
1373    /// candidate string `launch_editor_with` resolved to, so this needs no
1374    /// PATH manipulation (and thus no `PATH_ENV_LOCK`) at all. Re-spawning
1375    /// the current test binary (rather than fabricating a `std::process::
1376    /// ExitStatus` directly, which has no portable stable constructor) gives
1377    /// a real, immediate, always-terminates `ExitStatus` on every platform --
1378    /// same technique `commands::serve::agents` uses to get a real child
1379    /// process without depending on what it actually does.
1380    #[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                // Ignores the actual candidate `launch_editor_with` resolved to
1390                // (the platform default, since VISUAL/EDITOR are both empty) and
1391                // spawns the current test binary instead - any exit status it
1392                // produces (even a nonzero "unrecognized option" error) classifies
1393                // as `Completed`.
1394                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    // ─── launch_editor: truly-empty VISUAL/EDITOR are skipped entirely ────
1406
1407    // Unlike the whitespace-only case above (non-empty string, pushed as a
1408    // candidate that then fails to split into any usable parts), a truly
1409    // empty `VISUAL`/`EDITOR` value never even gets pushed onto the
1410    // candidates list - exercising the `!v.is_empty()`/`!e.is_empty()`
1411    // false arm for both. With both vars empty, resolution falls through to
1412    // the unix platform defaults (vim/nano/vi) unless PATH is also starved --
1413    // so this test combines the empty-string case with the same
1414    // PATH-starvation trick as `launch_editor_no_editor_found_when_path_has_no_candidates`
1415    // below, guaranteeing a deterministic `Err` instead of ever risking a
1416    // real, blocking, interactive editor launch. Kept as extra real-PATH
1417    // insurance on Unix alongside the injected-seam version above, which is
1418    // what actually closes the Windows gap for this branch.
1419    #[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                // Neither empty var is pushed as a candidate, and PATH starvation
1435                // means even the unix platform defaults (vim/nano/vi) fail to
1436                // resolve - so this deterministically reaches "no editor found"
1437                // rather than ever spawning a real editor.
1438                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    // ─── launch_editor: NotFound candidate is skipped, next one used ─────
1448
1449    #[cfg(unix)]
1450    #[test]
1451    fn launch_editor_not_found_candidate_falls_through_to_next() {
1452        // VISUAL points at a nonexistent binary, which should be
1453        // skipped (NotFound branch, `continue`) in favor of EDITOR.
1454        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    // ─── launch_editor: non-NotFound spawn error propagates ──────────────
1474
1475    #[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        // A regular, non-executable file: spawning it directly fails with
1483        // `PermissionDenied`, not `NotFound` - exercising the generic
1484        // `Err(e)` arm (as opposed to the `NotFound` "try next candidate"
1485        // arm already covered above).
1486        let not_executable = dir.join("not-executable");
1487        std::fs::write(&not_executable, "not a script").unwrap();
1488        let mut perms = std::fs::metadata(&not_executable).unwrap().permissions();
1489        perms.set_mode(0o600);
1490        std::fs::set_permissions(&not_executable, perms).unwrap();
1491
1492        temp_env::with_vars(
1493            [("VISUAL", Some(&not_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    // ─── launch_editor_with: no candidate resolves (injected, cross-platform) ─
1513
1514    /// Exercises the final `bail!("No editor found...")` in
1515    /// [`launch_editor_with`] via the injected `run` seam rather than real
1516    /// PATH/filesystem state - see the doc comment on `launch_editor_with`
1517    /// for why that matters on Windows specifically (real PATH-starvation
1518    /// can't fail `Command::new("notepad")`, which resolves via `System32`
1519    /// unconditionally). Forcing every candidate to fail with `NotFound`
1520    /// here doesn't depend on the platform at all: no real process is ever
1521    /// spawned, so this runs identically - and actually proves the `bail!`
1522    /// line is reachable production code - on Unix, Windows, and macOS
1523    /// alike. Doesn't need `ENV_LOCK`/`PATH_ENV_LOCK`: whatever `$VISUAL`/
1524    /// `$EDITOR` happen to be set to by a concurrently-running test is
1525    /// irrelevant, since the injected closure fails every candidate the same
1526    /// way regardless of its name.
1527    #[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    // ─── launch_editor: no candidate resolves anywhere on PATH ────────────
1544
1545    // Windows resolves "notepad" via the System32 search path regardless of
1546    // $PATH, so PATH-starvation can't produce a "no editor found" outcome
1547    // there the way it does on Unix (breaking PATH so vim/nano/vi can't
1548    // resolve) - gated to `unix` for the same real-blocking-editor-hang
1549    // reason as the tests above. Kept alongside
1550    // `launch_editor_with_no_editor_found_when_every_candidate_not_found`
1551    // above as extra real-subprocess insurance on Unix; the injected-seam
1552    // test is what actually closes the Windows gap.
1553    #[cfg(unix)]
1554    #[test]
1555    fn launch_editor_no_editor_found_when_path_has_no_candidates() {
1556        // No VISUAL/EDITOR (both unset), and PATH points nowhere - so even
1557        // the unix platform-default candidates (vim/nano/vi) all fail to
1558        // resolve.
1559        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    // ─── launch_editor: Windows twin suite ────────────────────────────────
1581    //
1582    // Windows can't reuse the Unix tests above verbatim: `/usr/bin/true` /
1583    // `/usr/bin/false` don't exist, shebang scripts can't execute (`os error
1584    // 193`), and Unix permission bits (`PermissionsExt`) don't apply. Batch
1585    // (`.bat`) files stand in for the shebang scripts - they're directly
1586    // executable via `Command::new(path)` on Windows, exit instantly, and
1587    // never touch a real interactive editor.
1588    //
1589    // The "editor ended with no exit code" case (killed by a Unix signal) is a
1590    // Windows testability challenge: on Windows `ExitStatus::code()` is always
1591    // `Some(_)` (even via `ExitStatusExt::from_raw`), so no real or fabricated
1592    // status reaches the "try next candidate" arm there. The injected `run`
1593    // seam returns `EditorRunOutcome` rather than `ExitStatus`: the
1594    // status-to-outcome decision lives in the pure `classify_editor_exit`
1595    // (unit-tested for the code-less case on every platform), and the
1596    // "outcome == Aborted, try next" arm is driven directly via injection in
1597    // `launch_editor_with_aborted_candidate_falls_through_to_next` - both
1598    // cross-platform, no code-less `ExitStatus` required.
1599    //
1600    // Three other Unix tests rely on PATH-starvation --
1601    // `launch_editor_empty_visual_and_editor_are_skipped`,
1602    // `launch_editor_no_editor_found_when_path_has_no_candidates`, and
1603    // `resolve_task_with_editor_path_propagates_launch_editor_error`.
1604    // PATH-starvation has no safe Windows equivalent: `Command::new("notepad")`
1605    // resolves via `System32` unconditionally before consulting `$PATH`, so
1606    // PATH-starvation can't make it fail there. Instead, injecting the "run
1607    // this candidate" step itself (`launch_editor_with`'s `run` parameter) or
1608    // the "launch the editor" step (`resolve_task_with_editor`'s
1609    // `launch_editor_fn` parameter) sidesteps real process resolution
1610    // entirely, closing all three gaps on every platform - see
1611    // `launch_editor_with_no_editor_found_when_every_candidate_not_found`,
1612    // `launch_editor_with_empty_visual_and_editor_are_skipped`, and
1613    // `resolve_task_with_editor_injected_editor_failure_propagates` above.
1614
1615    #[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            // A non-zero-but-present exit code is treated as the user having
1671            // closed the editor - not an error.
1672            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        // The batch file ignores all arguments, so appending a flag and
1688        // the file path is harmless; this exercises the
1689        // whitespace-splitting logic for editor strings like
1690        // "code --wait".
1691        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        // Whitespace-only string is non-empty so it IS pushed as a
1717        // candidate, but splitting on whitespace yields an empty parts
1718        // vec, which triggers the `continue` branch.
1719        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        // VISUAL points at a nonexistent binary, which should be
1745        // skipped (NotFound branch, `continue`) in favor of EDITOR.
1746        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        // A plain, non-executable text file: Windows' `CreateProcess` can't
1774        // recognize it as an executable image and fails with
1775        // `ERROR_BAD_EXE_FORMAT` (os error 193), not `NotFound` - exercising
1776        // the generic `Err(e)` arm (as opposed to the `NotFound` "try next
1777        // candidate" arm already covered above).
1778        let not_executable = dir.join("not-executable.txt");
1779        std::fs::write(&not_executable, "not a script").unwrap();
1780
1781        temp_env::with_vars(
1782            [("VISUAL", Some(&not_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    // ─── resolve_task_with: editor path (stdin is a TTY) ──────────────────
1802
1803    #[cfg(unix)]
1804    #[test]
1805    fn resolve_task_with_editor_path_happy_case() {
1806        use std::os::unix::fs::PermissionsExt;
1807
1808        // A tiny "editor" script that appends a non-comment line to
1809        // whatever file it's invoked on ($1) - standing in for a real
1810        // interactive editor session.
1811        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        // /usr/bin/true "opens" the file and does nothing to it, so only the
1840        // commented-out template remains - stripped down to an empty task.
1841        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    // Same Windows PATH-starvation caveat as
1852    // `launch_editor_no_editor_found_when_path_has_no_candidates` above. Kept
1853    // as extra real-PATH insurance on Unix alongside the injected-seam
1854    // version below, which is what actually closes the Windows gap.
1855    #[cfg(unix)]
1856    #[test]
1857    fn resolve_task_with_editor_path_propagates_launch_editor_error() {
1858        // No VISUAL/EDITOR (both unset), and PATH points nowhere - so even
1859        // the unix platform-default candidates (vim/nano/vi) all fail to
1860        // resolve, propagating the "no editor found" error.
1861        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    /// Shared stub editor-launcher used by both
1876    /// `resolve_task_with_editor_injected_editor_failure_propagates` (where
1877    /// it's actually invoked) and
1878    /// `resolve_task_with_editor_tmp_file_write_failure_propagates` (where,
1879    /// by design, `write_task_template`'s earlier `?` should short-circuit
1880    /// before this is ever reached). Extracted into a single named `fn`
1881    /// rather than an inline closure per call site so that if the latter
1882    /// test's control flow ever regresses and this stub *does* get called,
1883    /// llvm-cov's function-level coverage for it is still merged from the
1884    /// former test - an inline closure unique to the latter test would
1885    /// otherwise show up as a brand new "0 calls" function purely because
1886    /// that particular test is designed to never reach it.
1887    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    /// Cross-platform twin of
1894    /// `resolve_task_with_editor_path_propagates_launch_editor_error` via
1895    /// `resolve_task_with_editor`'s injected editor launcher - see that
1896    /// function's doc comment for why real PATH-starvation can't be mirrored
1897    /// on Windows here. Doesn't touch `PATH`/`VISUAL`/`EDITOR` at all (no
1898    /// `ENV_LOCK`/`PATH_ENV_LOCK` needed): the injected closure fails
1899    /// unconditionally regardless of environment state.
1900    #[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    /// Exercises `write_task_template(&tmp_path, &template)?`'s error path at
1915    /// its actual call site inside `resolve_task_with_editor` (as opposed to
1916    /// `write_task_template_error_on_bad_path` below, which calls
1917    /// `write_task_template` directly). The real OS temp directory used in
1918    /// production is essentially always writable, so this is only reachable
1919    /// at all via the injected `tmp_dir_fn` - pointed here at a directory
1920    /// whose parent doesn't exist, so the write fails deterministically on
1921    /// both Unix (ENOENT) and Windows (ERROR_PATH_NOT_FOUND) before the
1922    /// editor launcher is ever reached (if it *were* reached, the assertion
1923    /// below on the error message would fail, since
1924    /// `stub_editor_returns_no_editor_found`'s error text differs).
1925    #[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    // ─── resolve_task_with: editor path (stdin is a TTY) - Windows twins ──
1948
1949    #[cfg(windows)]
1950    #[test]
1951    fn resolve_task_with_editor_path_happy_case() {
1952        // A tiny batch "editor" that appends a non-comment line to whatever
1953        // file it's invoked on (%~1) - standing in for a real interactive
1954        // editor session. `%~1` strips any surrounding quotes Windows adds
1955        // around a path containing spaces.
1956        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        // A no-op batch file "opens" the file and does nothing to it, so
1978        // only the commented-out template remains - stripped down to an
1979        // empty task.
1980        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    // ─── build_task_template: description branch ──────────────────────────
1995
1996    #[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    // ─── write_task_template: error path ─────────────────────────────────
2018
2019    /// A temp file that cannot be created is reported rather than swallowed.
2020    #[test]
2021    fn write_task_template_error_on_bad_path() {
2022        // A directory that is not one: creation fails, which is the single
2023        // error this reports.
2024        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    // ─── resolve_task: unreadable file errors ────────────────────────────
2038
2039    #[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        // Restore perms before asserting (so cleanup works)
2058        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    // Windows has no chmod-style permission bits; instead, opening the file
2073    // for writing with a zero share mode (no `FILE_SHARE_READ`) makes any
2074    // concurrent read attempt fail with a sharing violation for as long as
2075    // the handle stays open - a deterministic Windows-native way to force
2076    // the same "file exists but can't be read" outcome the Unix test above
2077    // produces via `chmod 000`.
2078    #[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        // Hold an exclusive (no-share) handle open for the duration of the
2090        // read attempt below.
2091        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}