Skip to main content

leviath_cli/commands/run/
task.rs

1//! Turning what the caller typed into the text an agent actually runs on.
2//!
3//! Two inputs land here: `--task`, which is either a prompt or the path of a
4//! file holding one, and the dynamic `--<region>` flags, whose values take an
5//! explicit `@` before a path. Left off entirely, `--task` opens the user's
6//! editor on a commented template.
7//!
8//! Everything in this module is Leviath policy rather than OS behavior, and
9//! none of it carries a `#[cfg]`. Finding and running the editor itself is the
10//! OS's business and lives in [`leviath_sys::editor`].
11
12/// Resolve the task text from what `--task` was given, if anything.
13///
14/// - `Some(s)` naming an existing file → its contents, trimmed.
15/// - `Some(s)` naming an existing directory → error.
16/// - `Some(s)` naming nothing, but shaped like a path (no whitespace, and a
17///   separator or a `~` prefix) → error, so a mistyped filename does not
18///   quietly become the prompt.
19/// - `Some(s)` otherwise → `s` as a literal prompt.
20/// - `None` when stdin is not a TTY → error.
21/// - `None` when stdin is a TTY → the user's editor on a commented template.
22///
23/// `description` comes from the blueprint and is `""` when the manifest sets
24/// none; it only ever reaches the editor template.
25///
26/// `stdin_is_terminal` is injected (a `&dyn Fn() -> bool`) rather than probing
27/// the real process stdin here, so the library core stays free of direct
28/// `std::io::stdin()` access and is fully testable. In production the binary
29/// passes `&|| std::io::stdin().is_terminal()`.
30pub fn resolve_task(
31    arg: Option<&str>,
32    agent_name: &str,
33    description: &str,
34    stdin_is_terminal: &dyn Fn() -> bool,
35) -> anyhow::Result<String> {
36    resolve_task_with(arg, agent_name, description, stdin_is_terminal)
37}
38
39/// Whether `s` reads as a filesystem path rather than prompt text: no
40/// whitespace anywhere, and either a path separator or a `~` home prefix.
41///
42/// This exists so a mistyped filename fails instead of silently becoming the
43/// agent's entire task, which is what `lev run coder -t ./promt.md` used to do:
44/// a real run, real tokens, and a transcript whose only instruction was an
45/// eleven-character string. Prompt text that happens to mention a path ("fix
46/// src/main.rs") always has spaces, so it never trips this.
47///
48/// `\` counts on every platform, not just Windows. Keeping the rule uniform
49/// keeps the function pure and testable everywhere, and a whitespace-free
50/// literal prompt containing a backslash is not a real prompt.
51fn looks_like_path(s: &str) -> bool {
52    !s.is_empty()
53        && !s.chars().any(char::is_whitespace)
54        && (s.contains('/') || s.contains('\\') || s.starts_with('~'))
55}
56
57/// Same as [`resolve_task`], but with the stdin-is-a-TTY check injected
58/// instead of hardcoded - lets tests deterministically exercise both the
59/// "not a TTY" error path and the "is a TTY" editor-launch path regardless
60/// of whether the test runner's own stdin happens to be a real terminal
61/// (e.g. a human running `cargo test` interactively vs. CI).
62///
63/// `stdin_is_terminal` is a trait-object reference (`&dyn Fn`) rather than
64/// `impl FnOnce` deliberately: this function is called from many test sites,
65/// each passing a distinct closure *type* even when the closures are
66/// behaviorally identical (e.g. multiple `|| true`s are still different
67/// anonymous types). A generic `impl Trait` parameter gives `rustc` one
68/// monomorphization per call site, and `cargo llvm-cov` sometimes reports a
69/// region as uncovered for one instantiation even though the union of all
70/// instantiations covers every source position - a confirmed llvm-cov
71/// limitation (see `xtask/src/coverage.rs`'s doc comment on generic-function
72/// monomorphization). Erasing the closure type with `&dyn Fn` collapses
73/// every call site back down to a single instantiation, avoiding that noise
74/// entirely.
75fn resolve_task_with(
76    arg: Option<&str>,
77    agent_name: &str,
78    description: &str,
79    stdin_is_terminal: &dyn Fn() -> bool,
80) -> anyhow::Result<String> {
81    resolve_task_with_editor(
82        arg,
83        agent_name,
84        description,
85        stdin_is_terminal,
86        &leviath_sys::editor::launch,
87        &std::env::temp_dir,
88    )
89}
90
91/// Resolve one CLI region-flag value: `@path` reads (and trims) that file's
92/// contents; anything else is literal text. Unlike `--task`, the `@` is an
93/// explicit file marker, so a missing `@file` is an error (the user meant a
94/// file), not a literal fallback.
95pub fn read_region_value(raw: &str) -> anyhow::Result<String> {
96    match raw.strip_prefix('@') {
97        Some(path) => {
98            let content = std::fs::read_to_string(path)
99                .map_err(|e| anyhow::anyhow!("Failed to read region file '{}': {}", path, e))?;
100            let trimmed = content.trim().to_string();
101            if trimmed.is_empty() {
102                anyhow::bail!("Region file '{}' is empty.", path);
103            }
104            Ok(trimmed)
105        }
106        None => Ok(raw.to_string()),
107    }
108}
109
110/// Same as [`resolve_task_with`], but with the editor launch itself injected
111/// too - lets tests deterministically exercise the editor's error propagating
112/// out of `resolve_task_with` (the `result?` a few lines down) without needing
113/// a real failing subprocess or PATH setup. On Windows there is no safe way to
114/// make the real launcher's platform default fail (`notepad` resolves via
115/// `System32` unconditionally) short of mutating a real system directory, so
116/// injecting the launcher is what closes that gap on every platform.
117///
118/// Also takes the temp-directory provider (`tmp_dir_fn`) as an injectable
119/// closure so tests can point the task-template write at a guaranteed-
120/// unwritable directory (e.g. one whose parent doesn't exist) and
121/// deterministically exercise `write_task_template`'s `?` propagating out of
122/// this function - the real OS temp directory used in production is
123/// essentially always writable, so that error path is otherwise untestable.
124///
125/// All closures are `&dyn Fn` for the same monomorphization-noise reason
126/// documented on [`resolve_task_with`].
127fn resolve_task_with_editor(
128    arg: Option<&str>,
129    agent_name: &str,
130    description: &str,
131    stdin_is_terminal: &dyn Fn() -> bool,
132    launch_editor_fn: &dyn Fn(&std::path::Path) -> std::io::Result<()>,
133    tmp_dir_fn: &dyn Fn() -> std::path::PathBuf,
134) -> anyhow::Result<String> {
135    match arg {
136        Some(s) => {
137            let p = std::path::Path::new(s);
138            if p.is_file() {
139                let content = std::fs::read_to_string(p)
140                    .map_err(|e| anyhow::anyhow!("Failed to read task file '{}': {}", s, e))?;
141                let trimmed = content.trim().to_string();
142                if trimmed.is_empty() {
143                    anyhow::bail!("Task file '{}' is empty.", s);
144                }
145                return Ok(trimmed);
146            }
147            // A directory is unambiguously an attempt to name a file, so say so
148            // rather than sending the path itself to the agent as the prompt.
149            if p.is_dir() {
150                anyhow::bail!("Task file '{}' is a directory.", s);
151            }
152            if looks_like_path(s) {
153                anyhow::bail!(
154                    "No task file '{}'. Pass the prompt itself, or point --task at a file that exists.",
155                    s
156                );
157            }
158            Ok(s.to_string())
159        }
160        None => {
161            if !stdin_is_terminal() {
162                anyhow::bail!(
163                    "No task provided. Pass --task \"<prompt>\" or --task <file>.\n\
164                     (stdin is not a TTY, so the interactive editor cannot be used)"
165                );
166            }
167
168            // Build a commented template file for the editor
169            let template = build_task_template(agent_name, description);
170
171            // A randomly named file created `O_EXCL`, not `lev-task-<pid>.txt`.
172            // A predictable name is an attack surface because `fs::write`
173            // follows symlinks: on a shared host another user pre-creates that
174            // path as a link to `~/.leviath/config.toml` or
175            // `~/.ssh/authorized_keys`, and the next `lev run` writes the
176            // template - and then everything the user types into their editor -
177            // straight through it. `tempfile`
178            // also creates it owner-only, so the task prompt is not world
179            // readable while the editor holds it open.
180            let tmp = write_task_template(&tmp_dir_fn(), &template)?;
181            // Close our own handle before the editor opens the file: Windows
182            // refuses a second writer while the first still holds it, so the
183            // editor could not save. `TempPath` keeps the delete-on-drop.
184            let tmp = tmp.into_temp_path();
185            let tmp_path = tmp.to_path_buf();
186
187            // Launch the editor (exits only when the user closes it)
188            let result = launch_editor_fn(&tmp_path);
189            let content = std::fs::read_to_string(&tmp_path).unwrap_or_default();
190            let _ = std::fs::remove_file(&tmp_path);
191            // The launcher speaks `io::Error` because it lives in `leviath-sys`,
192            // which carries no `anyhow`. Its messages are already user-facing.
193            result.map_err(|e| anyhow::anyhow!("{e}"))?;
194
195            // Strip comment lines and trim
196            let task: String = content
197                .lines()
198                .filter(|l| !l.trim_start().starts_with('#'))
199                .collect::<Vec<_>>()
200                .join("\n")
201                .trim()
202                .to_string();
203
204            if task.is_empty() {
205                anyhow::bail!("Aborting run: empty task.");
206            }
207            Ok(task)
208        }
209    }
210}
211
212fn build_task_template(agent_name: &str, description: &str) -> String {
213    let mut template = format!("# Task for agent: {}\n", agent_name);
214    if !description.is_empty() {
215        template.push_str(&format!("# {}\n", description));
216    }
217    template.push_str("#\n# Describe your task below. Lines starting with '#' are ignored.\n\n");
218    template
219}
220
221fn write_task_template(
222    dir: &std::path::Path,
223    content: &str,
224) -> anyhow::Result<tempfile::NamedTempFile> {
225    use std::io::Write as _;
226
227    // Creating and writing in one fallible step, through the handle the builder
228    // opened. Two steps would mean re-opening by path between them - a window in
229    // which the name could be swapped - and a second error arm that a freshly
230    // created, writable handle can never actually take.
231    // A combinator chain rather than `?`s: each `?` would be an error arm that
232    // a freshly created, writable handle can never take, and the whole point of
233    // reporting here is the one failure that is real - the file could not be
234    // created at all.
235    tempfile::Builder::new()
236        .prefix("lev-task-")
237        .suffix(".txt")
238        .tempfile_in(dir)
239        .and_then(|mut file| {
240            file.as_file_mut()
241                .write_all(content.as_bytes())
242                .and_then(|()| file.as_file_mut().flush())
243                .map(|()| file)
244        })
245        .map_err(|e| anyhow::anyhow!("Failed to create task temp file: {}", e))
246}
247
248#[cfg(test)]
249mod tests {
250    use super::*;
251
252    /// Shared "stdin is never a TTY" probe for the `resolve_task` tests whose
253    /// argument is `Some(..)` (so the probe is never consulted) or that
254    /// explicitly want the non-TTY error path. A single named `fn` (rather than
255    /// a fresh `|| false` closure per call site) keeps every call site sharing
256    /// one instantiation and one covered region.
257    fn never_a_tty() -> bool {
258        false
259    }
260    // ─── read_region_value ────────────────────────────────────────────────
261
262    #[test]
263    fn read_region_value_literal_passthrough() {
264        assert_eq!(read_region_value("just text").unwrap(), "just text");
265    }
266
267    #[test]
268    fn read_region_value_at_path_reads_and_trims() {
269        let dir = std::env::temp_dir().join("lev-test-region-value");
270        std::fs::create_dir_all(&dir).unwrap();
271        let file = dir.join("r.md");
272        std::fs::write(&file, "  hello region  \n").unwrap();
273        let raw = format!("@{}", file.to_string_lossy());
274        assert_eq!(read_region_value(&raw).unwrap(), "hello region");
275        std::fs::remove_dir_all(&dir).ok();
276    }
277
278    #[test]
279    fn read_region_value_at_missing_file_errors() {
280        let err = read_region_value("@/no/such/region/file.md").unwrap_err();
281        assert!(err.to_string().contains("Failed to read region file"));
282    }
283
284    #[test]
285    fn read_region_value_at_empty_file_errors() {
286        let dir = std::env::temp_dir().join("lev-test-region-empty");
287        std::fs::create_dir_all(&dir).unwrap();
288        let file = dir.join("empty.md");
289        std::fs::write(&file, "   \n").unwrap();
290        let raw = format!("@{}", file.to_string_lossy());
291        let err = read_region_value(&raw).unwrap_err();
292        assert!(err.to_string().contains("is empty"));
293        std::fs::remove_dir_all(&dir).ok();
294    }
295
296    #[test]
297    fn resolve_task_with_literal_string() {
298        let result = resolve_task(Some("do something"), "test", "", &never_a_tty);
299        assert_eq!(result.unwrap(), "do something");
300    }
301
302    #[test]
303    fn resolve_task_with_file_path() {
304        let dir = std::env::temp_dir().join("lev-test-resolve-task");
305        let _ = std::fs::create_dir_all(&dir);
306        let file = dir.join("task.txt");
307        std::fs::write(&file, "task from file\n").unwrap();
308
309        let result = resolve_task(Some(file.to_str().unwrap()), "test", "", &never_a_tty);
310        assert_eq!(result.unwrap(), "task from file");
311
312        let _ = std::fs::remove_dir_all(&dir);
313    }
314
315    #[test]
316    fn resolve_task_with_empty_file_errors() {
317        let dir = std::env::temp_dir().join("lev-test-resolve-empty");
318        let _ = std::fs::create_dir_all(&dir);
319        let file = dir.join("empty.txt");
320        std::fs::write(&file, "   \n  ").unwrap();
321
322        let result = resolve_task(Some(file.to_str().unwrap()), "test", "", &never_a_tty);
323        assert!(result.is_err());
324        assert!(result.unwrap_err().to_string().contains("empty"));
325
326        let _ = std::fs::remove_dir_all(&dir);
327    }
328
329    /// A bare word that names no file is prompt text. It has no separator, so
330    /// nobody could have meant it as a path.
331    #[test]
332    fn resolve_task_bare_word_that_is_not_a_file_is_literal() {
333        let result = resolve_task(Some("do_something"), "test", "", &never_a_tty);
334        assert_eq!(result.unwrap(), "do_something");
335    }
336
337    /// The mistyped-filename case. Before this guard the agent was spawned with
338    /// the path itself as its entire task.
339    #[test]
340    fn resolve_task_path_shaped_value_that_names_nothing_errors() {
341        let err = resolve_task(
342            Some("/nonexistent/path/do_something"),
343            "test",
344            "",
345            &never_a_tty,
346        )
347        .unwrap_err();
348        assert!(
349            err.to_string()
350                .starts_with("No task file '/nonexistent/path/do_something'."),
351            "{err}"
352        );
353    }
354
355    /// A directory is unmistakably an attempt to name a file.
356    #[test]
357    fn resolve_task_directory_argument_errors() {
358        let dir = tempfile::tempdir().unwrap();
359        let err =
360            resolve_task(Some(dir.path().to_str().unwrap()), "test", "", &never_a_tty).unwrap_err();
361        assert!(err.to_string().ends_with("is a directory."), "{err}");
362    }
363
364    #[test]
365    fn looks_like_path_accepts_separators_and_a_home_prefix() {
366        assert!(looks_like_path("./a"));
367        assert!(looks_like_path("a/b"));
368        // Backslashes count on every platform, so the rule stays uniform.
369        assert!(looks_like_path("a\\b"));
370        assert!(looks_like_path("~/tasks/x.md"));
371    }
372
373    #[test]
374    fn looks_like_path_rejects_prose_and_bare_words() {
375        // Real prompts that mention a path always have spaces around it.
376        assert!(!looks_like_path("fix src/main.rs"));
377        // No separator at all.
378        assert!(!looks_like_path("refactor"));
379        assert!(!looks_like_path(""));
380    }
381
382    #[test]
383    fn resolve_task_file_with_whitespace_only_errors() {
384        let dir = std::env::temp_dir().join("lev-test-resolve-ws");
385        let _ = std::fs::create_dir_all(&dir);
386        let file = dir.join("whitespace.txt");
387        std::fs::write(&file, "   \n\t\n  \n").unwrap();
388
389        let result = resolve_task(Some(file.to_str().unwrap()), "test", "", &never_a_tty);
390        assert!(result.is_err());
391        assert!(result.unwrap_err().to_string().contains("empty"));
392
393        let _ = std::fs::remove_dir_all(&dir);
394    }
395
396    #[test]
397    fn resolve_task_file_trims_content() {
398        let dir = std::env::temp_dir().join("lev-test-resolve-trim");
399        let _ = std::fs::create_dir_all(&dir);
400        let file = dir.join("trimme.txt");
401        std::fs::write(&file, "  hello world  \n\n").unwrap();
402
403        let result = resolve_task(Some(file.to_str().unwrap()), "test", "", &never_a_tty);
404        assert_eq!(result.unwrap(), "hello world");
405
406        let _ = std::fs::remove_dir_all(&dir);
407    }
408
409    #[test]
410    fn resolve_task_preserves_literal_string_as_is() {
411        let result = resolve_task(Some("  spaces around  "), "test", "", &never_a_tty);
412        assert_eq!(result.unwrap(), "  spaces around  ");
413    }
414
415    #[test]
416    fn resolve_task_multiline_file() {
417        let dir = std::env::temp_dir().join("lev-test-resolve-multiline");
418        let _ = std::fs::create_dir_all(&dir);
419        let file = dir.join("multi.txt");
420        std::fs::write(&file, "line one\nline two\nline three\n").unwrap();
421
422        let result = resolve_task(Some(file.to_str().unwrap()), "test", "", &never_a_tty);
423        let task = result.unwrap();
424        assert!(task.contains("line one"));
425        assert!(task.contains("line two"));
426        assert!(task.contains("line three"));
427
428        let _ = std::fs::remove_dir_all(&dir);
429    }
430
431    // ─── resolve_task: literal string with special chars ────────────────
432
433    #[test]
434    fn resolve_task_literal_with_special_chars() {
435        let result = resolve_task(
436            Some("Write a function that does X & Y <html>"),
437            "test",
438            "",
439            &never_a_tty,
440        );
441        assert_eq!(result.unwrap(), "Write a function that does X & Y <html>");
442    }
443
444    // ─── build_provider_registry: no providers except defaults ──────────
445
446    #[test]
447    fn resolve_task_literal_empty_string() {
448        // Empty string is treated as literal, returns as-is
449        let result = resolve_task(Some(""), "test", "", &never_a_tty);
450        assert_eq!(result.unwrap(), "");
451    }
452
453    // ─── resolve_task: file with multiple lines and trailing whitespace ──
454
455    #[test]
456    fn resolve_task_file_with_multiple_trailing_newlines() {
457        let dir = std::env::temp_dir().join("lev-test-resolve-trail");
458        let _ = std::fs::create_dir_all(&dir);
459        let file = dir.join("trail.txt");
460        std::fs::write(&file, "task content\n\n\n\n").unwrap();
461
462        let result = resolve_task(Some(file.to_str().unwrap()), "test", "", &never_a_tty);
463        assert_eq!(result.unwrap(), "task content");
464
465        let _ = std::fs::remove_dir_all(&dir);
466    }
467
468    // ─── build_provider_registry: model_capabilities propagated ──────────
469
470    #[test]
471    fn resolve_task_file_with_real_content() {
472        let dir = std::env::temp_dir().join("lev-test-resolve-real");
473        let _ = std::fs::create_dir_all(&dir);
474        let file = dir.join("real.txt");
475        std::fs::write(&file, "Implement a REST API server\nwith authentication\n").unwrap();
476
477        let result = resolve_task(
478            Some(file.to_str().unwrap()),
479            "api-agent",
480            "API agent",
481            &never_a_tty,
482        );
483        let task = result.unwrap();
484        assert!(task.contains("Implement a REST API server"));
485        assert!(task.contains("with authentication"));
486
487        let _ = std::fs::remove_dir_all(&dir);
488    }
489
490    // ─── build_provider_registry: all providers registered ───────────────
491
492    #[test]
493    fn resolve_task_none_arg_errors_when_stdin_not_tty() {
494        // The TTY check is injected (not the real std::io::stdin()) so this
495        // is deterministic regardless of whether the test runner's own
496        // stdin happens to be a real terminal - a human running `cargo test`
497        // interactively has a real TTY on stdin, unlike CI, so hardcoding
498        // "stdin is never a TTY under cargo test" was a false assumption.
499        let result = resolve_task_with(None, "test-agent", "", &|| false);
500        assert!(result.is_err());
501        let msg = result.unwrap_err().to_string();
502        assert!(msg.contains("No task provided"));
503        assert!(msg.contains("stdin is not a TTY"));
504    }
505
506    #[test]
507    fn resolve_task_none_arg_uses_injected_probe_via_public_wrapper() {
508        // Smoke test that the public `resolve_task()` wrapper still compiles
509        // and delegates to `resolve_task_with` with the caller-supplied probe.
510        // A literal task never consults the probe, so the outcome is
511        // deterministic regardless of environment.
512        let result = resolve_task(Some("literal task"), "test-agent", "", &never_a_tty);
513        assert_eq!(result.unwrap(), "literal task");
514    }
515
516    #[test]
517    fn resolve_task_none_arg_errors_via_public_wrapper_when_not_a_tty() {
518        // Drives `resolve_task`'s public wrapper with an injected "never a TTY"
519        // probe: no task + not-a-TTY hits the "no task provided" error,
520        // cross-platform, without touching real stdin or launching an editor.
521        let result = resolve_task(None, "test-agent", "", &never_a_tty);
522        assert!(result.is_err());
523    }
524
525    // ─── launch_editor: VISUAL takes priority and succeeds ───────────────
526
527    // These `launch_editor` tests point VISUAL/EDITOR at `/usr/bin/true` (or
528    // rely on PATH-starvation to prevent any editor being found) - both
529    // assumptions are Unix-only. On Windows, `/usr/bin/true` doesn't exist,
530    // so the NotFound-branch falls through to the windows-only "notepad"
531    // candidate, which Windows resolves via its System32 search path
532    // *regardless* of $PATH - so PATH-starvation doesn't stop it either.
533    // Either way that means launching a real, blocking GUI text editor with
534    // no timeout, which hung a Windows CI run indefinitely. Gated to `unix`.
535    #[cfg(unix)]
536    #[test]
537    fn resolve_task_with_editor_path_happy_case() {
538        use std::os::unix::fs::PermissionsExt;
539
540        // A tiny "editor" script that appends a non-comment line to
541        // whatever file it's invoked on ($1) - standing in for a real
542        // interactive editor session.
543        let dir = std::env::temp_dir().join("lev-test-resolve-task-editor-happy");
544        let _ = std::fs::create_dir_all(&dir);
545        let script = dir.join("fake-editor.sh");
546        std::fs::write(
547            &script,
548            "#!/bin/sh\necho \"task body from editor\" >> \"$1\"\n",
549        )
550        .unwrap();
551        let mut perms = std::fs::metadata(&script).unwrap().permissions();
552        perms.set_mode(0o700);
553        std::fs::set_permissions(&script, perms).unwrap();
554
555        temp_env::with_vars([("VISUAL", Some(&script)), ("EDITOR", None)], || {
556            let result = resolve_task_with(None, "test-agent", "a non-empty description", &|| true);
557            assert_eq!(result.unwrap(), "task body from editor");
558
559            let _ = std::fs::remove_dir_all(&dir);
560        });
561    }
562
563    #[cfg(unix)]
564    #[test]
565    fn resolve_task_with_editor_path_empty_after_stripping_comments_errors() {
566        // /usr/bin/true "opens" the file and does nothing to it, so only the
567        // commented-out template remains - stripped down to an empty task.
568        temp_env::with_vars(
569            [("VISUAL", Some("/usr/bin/true")), ("EDITOR", None)],
570            || {
571                let result = resolve_task_with(None, "test-agent", "", &|| true);
572                assert!(result.is_err());
573                assert!(result.unwrap_err().to_string().contains("Aborting run"));
574            },
575        );
576    }
577
578    // Same Windows PATH-starvation caveat as
579    // `launch_editor_no_editor_found_when_path_has_no_candidates` above. Kept
580    // as extra real-PATH insurance on Unix alongside the injected-seam
581    // version below, which is what actually closes the Windows gap.
582    #[cfg(unix)]
583    #[test]
584    fn resolve_task_with_editor_path_propagates_launch_editor_error() {
585        // No VISUAL/EDITOR (both unset), and PATH points nowhere - so even
586        // the unix platform-default candidates (vim/nano/vi) all fail to
587        // resolve, propagating the "no editor found" error.
588        temp_env::with_vars(
589            [
590                ("VISUAL", None),
591                ("EDITOR", None),
592                ("PATH", Some("/lev-definitely-empty-path-dir")),
593            ],
594            || {
595                let result = resolve_task_with(None, "test-agent", "", &|| true);
596                assert!(result.is_err());
597                assert!(result.unwrap_err().to_string().contains("No editor found"));
598            },
599        );
600    }
601
602    /// Shared stub editor-launcher used by both
603    /// `resolve_task_with_editor_injected_editor_failure_propagates` (where
604    /// it's actually invoked) and
605    /// `resolve_task_with_editor_tmp_file_write_failure_propagates` (where,
606    /// by design, `write_task_template`'s earlier `?` should short-circuit
607    /// before this is ever reached). Extracted into a single named `fn`
608    /// rather than an inline closure per call site so that if the latter
609    /// test's control flow ever regresses and this stub *does* get called,
610    /// llvm-cov's function-level coverage for it is still merged from the
611    /// former test - an inline closure unique to the latter test would
612    /// otherwise show up as a brand new "0 calls" function purely because
613    /// that particular test is designed to never reach it.
614    fn stub_editor_returns_no_editor_found(_path: &std::path::Path) -> std::io::Result<()> {
615        Err(std::io::Error::new(
616            std::io::ErrorKind::NotFound,
617            "No editor found. Set $VISUAL or $EDITOR, or install vim, nano, or edit.",
618        ))
619    }
620
621    /// Cross-platform twin of
622    /// `resolve_task_with_editor_path_propagates_launch_editor_error` via
623    /// `resolve_task_with_editor`'s injected editor launcher - see that
624    /// function's doc comment for why real PATH-starvation can't be mirrored
625    /// on Windows here. Doesn't touch `PATH`/`VISUAL`/`EDITOR` at all (no
626    /// `ENV_LOCK`/`PATH_ENV_LOCK` needed): the injected closure fails
627    /// unconditionally regardless of environment state.
628    #[test]
629    fn resolve_task_with_editor_injected_editor_failure_propagates() {
630        let result = resolve_task_with_editor(
631            None,
632            "test-agent",
633            "",
634            &|| true,
635            &stub_editor_returns_no_editor_found,
636            &std::env::temp_dir,
637        );
638        assert!(result.is_err());
639        assert!(result.unwrap_err().to_string().contains("No editor found"));
640    }
641
642    /// Exercises `write_task_template(&tmp_path, &template)?`'s error path at
643    /// its actual call site inside `resolve_task_with_editor` (as opposed to
644    /// `write_task_template_error_on_bad_path` below, which calls
645    /// `write_task_template` directly). The real OS temp directory used in
646    /// production is essentially always writable, so this is only reachable
647    /// at all via the injected `tmp_dir_fn` - pointed here at a directory
648    /// whose parent doesn't exist, so the write fails deterministically on
649    /// both Unix (ENOENT) and Windows (ERROR_PATH_NOT_FOUND) before the
650    /// editor launcher is ever reached (if it *were* reached, the assertion
651    /// below on the error message would fail, since
652    /// `stub_editor_returns_no_editor_found`'s error text differs).
653    #[test]
654    fn resolve_task_with_editor_tmp_file_write_failure_propagates() {
655        let bad_tmp_dir = std::env::temp_dir()
656            .join("lev-definitely-nonexistent-parent-dir-for-task-template-xyz")
657            .join("nested");
658        let result = resolve_task_with_editor(
659            None,
660            "test-agent",
661            "",
662            &|| true,
663            &stub_editor_returns_no_editor_found,
664            &move || bad_tmp_dir.clone(),
665        );
666        assert!(result.is_err());
667        assert!(
668            result
669                .unwrap_err()
670                .to_string()
671                .contains("Failed to create task temp file")
672        );
673    }
674
675    // ─── resolve_task_with: editor path (stdin is a TTY) - Windows twins ──
676
677    /// Write a `.bat` stand-in editor. CRLF and `@echo off` because `cmd`
678    /// wants both.
679    #[cfg(windows)]
680    fn write_bat(path: &std::path::Path, body: &str) {
681        std::fs::write(path, format!("@echo off\r\n{}\r\n", body)).unwrap();
682    }
683
684    #[cfg(windows)]
685    #[test]
686    fn resolve_task_with_editor_path_happy_case() {
687        // A tiny batch "editor" that appends a non-comment line to whatever
688        // file it's invoked on (%~1) - standing in for a real interactive
689        // editor session. `%~1` strips any surrounding quotes Windows adds
690        // around a path containing spaces.
691        let dir = std::env::temp_dir().join("lev-test-resolve-task-editor-happy-win");
692        let _ = std::fs::create_dir_all(&dir);
693        let script = dir.join("fake-editor.bat");
694        write_bat(&script, "echo task body from editor>>\"%~1\"");
695
696        temp_env::with_vars([("VISUAL", Some(&script)), ("EDITOR", None)], || {
697            let result = resolve_task_with(None, "test-agent", "a non-empty description", &|| true);
698            assert_eq!(result.unwrap(), "task body from editor");
699
700            let _ = std::fs::remove_dir_all(&dir);
701        });
702    }
703
704    #[cfg(windows)]
705    #[test]
706    fn resolve_task_with_editor_path_empty_after_stripping_comments_errors() {
707        // A no-op batch file "opens" the file and does nothing to it, so
708        // only the commented-out template remains - stripped down to an
709        // empty task.
710        let dir = std::env::temp_dir().join("lev-test-resolve-task-editor-empty-win");
711        let _ = std::fs::create_dir_all(&dir);
712        let ok_bat = dir.join("ok.bat");
713        write_bat(&ok_bat, "exit /b 0");
714
715        temp_env::with_vars([("VISUAL", Some(&ok_bat)), ("EDITOR", None)], || {
716            let result = resolve_task_with(None, "test-agent", "", &|| true);
717            assert!(result.is_err());
718            assert!(result.unwrap_err().to_string().contains("Aborting run"));
719
720            let _ = std::fs::remove_dir_all(&dir);
721        });
722    }
723
724    // ─── build_task_template: description branch ──────────────────────────
725
726    /// A blueprint with no `description` gets `""` from the manifest parser,
727    /// which must not become a bare `# ` line in the template.
728    #[test]
729    fn build_task_template_with_empty_description_skips_desc_line() {
730        let t = build_task_template("agent", "");
731        assert!(!t.contains("# \n"));
732        assert!(t.contains("# Task for agent: agent\n"));
733        assert!(t.contains("Describe your task below"));
734    }
735
736    #[test]
737    fn build_task_template_with_non_empty_description_adds_desc_line() {
738        let t = build_task_template("my-agent", "Build a web server");
739        assert!(t.contains("# Task for agent: my-agent\n"));
740        assert!(t.contains("# Build a web server\n"));
741    }
742
743    // ─── write_task_template: error path ─────────────────────────────────
744
745    /// A temp file that cannot be created is reported rather than swallowed.
746    #[test]
747    fn write_task_template_error_on_bad_path() {
748        // A directory that is not one: creation fails, which is the single
749        // error this reports.
750        let dir = tempfile::tempdir().unwrap();
751        let blocker = dir.path().join("blocker");
752        std::fs::write(&blocker, b"x").unwrap();
753        let result = write_task_template(&blocker, "content");
754        assert!(result.is_err());
755        assert!(
756            result
757                .unwrap_err()
758                .to_string()
759                .contains("Failed to create task temp file")
760        );
761    }
762
763    // ─── resolve_task: unreadable file errors ────────────────────────────
764
765    #[cfg(unix)]
766    #[test]
767    fn resolve_task_unreadable_file_returns_error() {
768        use std::os::unix::fs::PermissionsExt;
769        let dir = std::env::temp_dir().join("lev-test-resolve-unreadable");
770        let _ = std::fs::create_dir_all(&dir);
771        let file = dir.join("secret.txt");
772        std::fs::write(&file, "secret content").unwrap();
773        let mut perms = std::fs::metadata(&file).unwrap().permissions();
774        perms.set_mode(0o000);
775        std::fs::set_permissions(&file, perms).unwrap();
776
777        let result = resolve_task_with(Some(file.to_str().unwrap()), "test-agent", "", &|| false);
778        // Restore perms before asserting (so cleanup works)
779        let mut perms2 = std::fs::metadata(&file).unwrap().permissions();
780        perms2.set_mode(0o644);
781        std::fs::set_permissions(&file, perms2).ok();
782        let _ = std::fs::remove_dir_all(&dir);
783
784        assert!(result.is_err());
785        assert!(
786            result
787                .unwrap_err()
788                .to_string()
789                .contains("Failed to read task file")
790        );
791    }
792
793    // Windows has no chmod-style permission bits; instead, opening the file
794    // for writing with a zero share mode (no `FILE_SHARE_READ`) makes any
795    // concurrent read attempt fail with a sharing violation for as long as
796    // the handle stays open - a deterministic Windows-native way to force
797    // the same "file exists but can't be read" outcome the Unix test above
798    // produces via `chmod 000`.
799    #[cfg(windows)]
800    #[test]
801    fn resolve_task_unreadable_file_returns_error() {
802        use std::fs::OpenOptions;
803        use std::os::windows::fs::OpenOptionsExt;
804
805        let dir = std::env::temp_dir().join("lev-test-resolve-unreadable-win");
806        let _ = std::fs::create_dir_all(&dir);
807        let file = dir.join("secret.txt");
808        std::fs::write(&file, "secret content").unwrap();
809
810        // Hold an exclusive (no-share) handle open for the duration of the
811        // read attempt below.
812        let _locked = OpenOptions::new()
813            .write(true)
814            .share_mode(0)
815            .open(&file)
816            .unwrap();
817
818        let result = resolve_task_with(Some(file.to_str().unwrap()), "test-agent", "", &|| false);
819
820        drop(_locked);
821        let _ = std::fs::remove_dir_all(&dir);
822
823        assert!(result.is_err());
824        assert!(
825            result
826                .unwrap_err()
827                .to_string()
828                .contains("Failed to read task file")
829        );
830    }
831}