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}