Skip to main content

agent_exec/
completions.rs

1//! Dynamic shell completion support for job ID arguments.
2//!
3//! # API choice
4//!
5//! Uses `clap_complete`'s `unstable-dynamic` engine with `ArgValueCompleter`.
6//! Each job-ID argument is annotated with a context-specific completer function.
7//! At runtime, when the shell calls the binary with `COMPLETE=<shell>` set,
8//! `CompleteEnv::complete()` intercepts the invocation and returns candidates.
9//!
10//! ## Root resolution
11//!
12//! Completers use `resolve_root(None)` as the primary path, which respects
13//! the `AGENT_EXEC_ROOT` environment variable.  For `--root` flag awareness,
14//! `resolve_root_for_completion` additionally parses `COMP_LINE` / `COMP_WORDS`
15//! (bash) and `_CLAP_COMPLETE_ARGS` to extract a `--root` value when present.
16//!
17//! ## Resilience
18//!
19//! All completers are best-effort: if the root is unreadable, if `state.json`
20//! is missing or malformed, or if any directory entry fails to read, the
21//! offending entry is silently skipped and the remaining candidates are returned.
22
23use clap_complete::engine::CompletionCandidate;
24use std::path::PathBuf;
25
26// ── internal helpers ───────────────────────────────────────────────────────────
27
28/// Read the `state` field from `<job_dir>/state.json`.
29/// Returns `None` on any I/O or parse failure so callers can treat it as
30/// "state unknown" rather than an error.
31fn read_job_state(job_dir: &std::path::Path) -> Option<String> {
32    let content = std::fs::read_to_string(job_dir.join("state.json")).ok()?;
33    let value: serde_json::Value = serde_json::from_str(&content).ok()?;
34    value
35        .get("job")?
36        .get("status")?
37        .as_str()
38        .map(str::to_string)
39}
40
41/// Read the persisted `cwd` from `<job_dir>/meta.json`.
42/// Returns `None` on any I/O or parse failure so callers can treat missing or
43/// legacy metadata as a non-match for the default cwd filter.
44fn read_job_cwd(job_dir: &std::path::Path) -> Option<String> {
45    let content = std::fs::read_to_string(job_dir.join("meta.json")).ok()?;
46    let value: serde_json::Value = serde_json::from_str(&content).ok()?;
47    value.get("cwd")?.as_str().map(str::to_string)
48}
49
50/// List completion candidates under `root`, optionally filtered by job state.
51///
52/// - If `root` does not exist or is unreadable, returns an empty list.
53/// - If `state_filter` is `Some(slice)`, only jobs whose state appears in
54///   the slice are included.  Jobs whose `state.json` is unreadable are
55///   excluded when a filter is active (safe default: don't offer jobs we
56///   can't categorise).
57/// - Each candidate includes the state as a help annotation when available.
58pub fn list_job_candidates(
59    root: &std::path::Path,
60    state_filter: Option<&[&str]>,
61) -> Vec<CompletionCandidate> {
62    let cwd_filter = crate::run::resolve_effective_cwd(None);
63    let entries = match std::fs::read_dir(root) {
64        Ok(e) => e,
65        Err(_) => return vec![],
66    };
67    entries
68        .filter_map(|e| e.ok())
69        .filter(|e| e.file_type().map(|t| t.is_dir()).unwrap_or(false))
70        .filter_map(|e| {
71            let name = e.file_name().to_string_lossy().to_string();
72            let cwd = read_job_cwd(&e.path());
73            let state = read_job_state(&e.path());
74
75            // Match `list` default behavior: only show jobs created from the
76            // caller's current working directory unless the CLI later grows an
77            // explicit completion override.
78            match cwd.as_deref() {
79                Some(job_cwd) if job_cwd == cwd_filter => {}
80                _ => return None,
81            }
82
83            // Apply state filter when specified.
84            if let Some(filter) = state_filter {
85                match &state {
86                    Some(s) if filter.contains(&s.as_str()) => {}
87                    _ => return None,
88                }
89            }
90
91            let candidate = CompletionCandidate::new(name);
92            Some(match state {
93                Some(s) => candidate.help(Some(s.into())),
94                None => candidate,
95            })
96        })
97        .collect()
98}
99
100/// Resolve the root directory to use during a completion invocation.
101///
102/// Tries, in order:
103/// 1. `--root <value>` extracted from `COMP_LINE` (bash/zsh).
104/// 2. `--root <value>` extracted from the process argv after the `--`
105///    separator (covers fish and other shells that pass words as argv).
106/// 3. `AGENT_EXEC_ROOT` environment variable (via `resolve_root(None)`).
107/// 4. XDG / platform default (via `resolve_root(None)`).
108pub fn resolve_root_for_completion() -> PathBuf {
109    // Try to extract --root from the partial command line that the shell
110    // provides in COMP_LINE (bash/zsh) during completion invocations.
111    if let Some(root) = extract_root_from_comp_line() {
112        return PathBuf::from(root);
113    }
114    // Fallback: parse the argv for --root (covers fish and other shells that
115    // don't set COMP_LINE but pass completion words as process argv after `--`).
116    if let Some(root) = extract_root_from_argv() {
117        return PathBuf::from(root);
118    }
119    crate::jobstore::resolve_root(None)
120}
121
122/// Parse `--root <value>` from the process argv (words after the `--` separator).
123///
124/// In CompleteEnv mode, `clap_complete` invokes the binary as:
125///   `<binary> <completer_path> -- <program_name> [args…]`
126/// This function looks for `--root` in the words that follow `--` so that
127/// shells (e.g. fish) that do not set `COMP_LINE` can still trigger root
128/// resolution from an explicit `--root` flag.
129fn extract_root_from_argv() -> Option<String> {
130    let args: Vec<String> = std::env::args().collect();
131    let sep_pos = args.iter().position(|a| a == "--")?;
132    let words = &args[sep_pos + 1..];
133    let pos = words
134        .iter()
135        .position(|t| t == "--root" || t.starts_with("--root="))?;
136
137    if let Some(val) = words[pos].strip_prefix("--root=") {
138        return Some(val.to_string());
139    }
140    // `--root <value>` form
141    words.get(pos + 1).map(|s| s.to_string())
142}
143
144/// Parse `--root <value>` from the `COMP_LINE` environment variable.
145///
146/// `COMP_LINE` is set by bash/zsh to the full command line being completed.
147/// Returns `None` if the variable is absent, malformed, or `--root` is not found.
148fn extract_root_from_line(comp_line: &str) -> Option<String> {
149    let tokens: Vec<&str> = comp_line.split_whitespace().collect();
150    let pos = tokens
151        .iter()
152        .position(|&t| t == "--root" || t.starts_with("--root="))?;
153
154    if let Some(tok) = tokens.get(pos)
155        && let Some(val) = tok.strip_prefix("--root=")
156    {
157        return Some(val.to_string());
158    }
159
160    // `--root <value>` form
161    tokens.get(pos + 1).map(|s| s.to_string())
162}
163
164fn extract_root_from_comp_line() -> Option<String> {
165    let comp_line = std::env::var("COMP_LINE").ok()?;
166    extract_root_from_line(&comp_line)
167}
168
169// ── public completer functions ─────────────────────────────────────────────────
170//
171// Each function matches the signature required by `ArgValueCompleter::new()`:
172//   fn(&OsStr) -> Vec<CompletionCandidate>
173//
174// The `current` parameter is the partial value the user has typed so far.
175// clap_complete performs prefix filtering itself, so returning all candidates
176// unconditionally is correct.
177
178/// Complete all job IDs regardless of state.
179/// Used by: `status`, `tail`, `tag set`, `notify set`.
180pub fn complete_all_jobs(_current: &std::ffi::OsStr) -> Vec<CompletionCandidate> {
181    list_job_candidates(&resolve_root_for_completion(), None)
182}
183
184/// Complete only jobs in `created` state.
185/// Used by: `start` (only un-started jobs can be started).
186pub fn complete_created_jobs(_current: &std::ffi::OsStr) -> Vec<CompletionCandidate> {
187    list_job_candidates(&resolve_root_for_completion(), Some(&["created"]))
188}
189
190/// Complete only jobs in `running` state.
191/// Used by: `kill` (only running jobs can be killed).
192pub fn complete_running_jobs(_current: &std::ffi::OsStr) -> Vec<CompletionCandidate> {
193    list_job_candidates(&resolve_root_for_completion(), Some(&["running"]))
194}
195
196/// Complete only jobs in terminal states (`exited`, `killed`, `failed`).
197/// Used by: `delete` (only finished jobs can be deleted).
198pub fn complete_terminal_jobs(_current: &std::ffi::OsStr) -> Vec<CompletionCandidate> {
199    list_job_candidates(
200        &resolve_root_for_completion(),
201        Some(&["exited", "killed", "failed"]),
202    )
203}
204
205/// Complete jobs in non-terminal states (`created`, `running`).
206/// Used by: `wait` (waiting on a terminal job is a no-op).
207pub fn complete_waitable_jobs(_current: &std::ffi::OsStr) -> Vec<CompletionCandidate> {
208    list_job_candidates(
209        &resolve_root_for_completion(),
210        Some(&["created", "running"]),
211    )
212}
213
214// ── unit tests ────────────────────────────────────────────────────────────────
215
216#[cfg(test)]
217mod tests {
218    use super::*;
219    use std::fs;
220    use tempfile::tempdir;
221
222    fn make_job(root: &std::path::Path, id: &str, state: &str) {
223        let dir = root.join(id);
224        fs::create_dir_all(&dir).unwrap();
225        let cwd = std::env::current_dir().unwrap().display().to_string();
226        let meta_json = serde_json::json!({
227            "job": { "id": id },
228            "schema_version": "0.1",
229            "command": ["true"],
230            "created_at": "2026-01-01T00:00:00Z",
231            "root": root.display().to_string(),
232            "env_keys": [],
233            "cwd": cwd,
234            "tags": []
235        });
236        fs::write(dir.join("meta.json"), meta_json.to_string()).unwrap();
237        let state_json = serde_json::json!({
238            "job": { "id": id, "status": state },
239            "result": { "exit_code": null, "signal": null, "duration_ms": null },
240            "updated_at": "2026-01-01T00:00:00Z"
241        });
242        fs::write(dir.join("state.json"), state_json.to_string()).unwrap();
243    }
244
245    #[test]
246    fn test_list_all_jobs_returns_all_dirs() {
247        let tmp = tempdir().unwrap();
248        make_job(tmp.path(), "01AAA", "running");
249        make_job(tmp.path(), "01BBB", "exited");
250
251        let candidates = list_job_candidates(tmp.path(), None);
252        let names: Vec<_> = candidates
253            .iter()
254            .map(|c| c.get_value().to_string_lossy().to_string())
255            .collect();
256        assert!(names.contains(&"01AAA".to_string()));
257        assert!(names.contains(&"01BBB".to_string()));
258        assert_eq!(candidates.len(), 2);
259    }
260
261    #[test]
262    fn test_list_with_state_filter() {
263        let tmp = tempdir().unwrap();
264        make_job(tmp.path(), "01AAA", "running");
265        make_job(tmp.path(), "01BBB", "exited");
266        make_job(tmp.path(), "01CCC", "running");
267
268        let candidates = list_job_candidates(tmp.path(), Some(&["running"]));
269        let names: Vec<_> = candidates
270            .iter()
271            .map(|c| c.get_value().to_string_lossy().to_string())
272            .collect();
273        assert!(names.contains(&"01AAA".to_string()));
274        assert!(names.contains(&"01CCC".to_string()));
275        assert!(!names.contains(&"01BBB".to_string()));
276        assert_eq!(candidates.len(), 2);
277    }
278
279    #[test]
280    fn test_nonexistent_root_returns_empty() {
281        let candidates = list_job_candidates(std::path::Path::new("/nonexistent/path"), None);
282        assert!(candidates.is_empty());
283    }
284
285    #[test]
286    fn test_description_includes_state() {
287        let tmp = tempdir().unwrap();
288        make_job(tmp.path(), "01AAA", "running");
289
290        let candidates = list_job_candidates(tmp.path(), None);
291        assert_eq!(candidates.len(), 1);
292        let help = candidates[0].get_help();
293        assert!(help.is_some());
294        assert!(help.unwrap().to_string().contains("running"));
295    }
296
297    #[test]
298    fn test_missing_state_json_included_without_filter() {
299        let tmp = tempdir().unwrap();
300        // Job dir without state.json
301        let dir = tmp.path().join("01NOSTATE");
302        fs::create_dir_all(&dir).unwrap();
303        let cwd = std::env::current_dir().unwrap().display().to_string();
304        let meta_json = serde_json::json!({
305            "job": { "id": "01NOSTATE" },
306            "schema_version": "0.1",
307            "command": ["true"],
308            "created_at": "2026-01-01T00:00:00Z",
309            "root": tmp.path().display().to_string(),
310            "env_keys": [],
311            "cwd": cwd,
312            "tags": []
313        });
314        fs::write(dir.join("meta.json"), meta_json.to_string()).unwrap();
315        make_job(tmp.path(), "01AAA", "running");
316
317        let candidates = list_job_candidates(tmp.path(), None);
318        let names: Vec<_> = candidates
319            .iter()
320            .map(|c| c.get_value().to_string_lossy().to_string())
321            .collect();
322        assert!(names.contains(&"01NOSTATE".to_string()));
323        assert_eq!(candidates.len(), 2);
324    }
325
326    #[test]
327    fn test_missing_state_json_excluded_with_filter() {
328        let tmp = tempdir().unwrap();
329        // Job dir without state.json — should be excluded when filtering
330        let dir = tmp.path().join("01NOSTATE");
331        fs::create_dir_all(&dir).unwrap();
332        let cwd = std::env::current_dir().unwrap().display().to_string();
333        let meta_json = serde_json::json!({
334            "job": { "id": "01NOSTATE" },
335            "schema_version": "0.1",
336            "command": ["true"],
337            "created_at": "2026-01-01T00:00:00Z",
338            "root": tmp.path().display().to_string(),
339            "env_keys": [],
340            "cwd": cwd,
341            "tags": []
342        });
343        fs::write(dir.join("meta.json"), meta_json.to_string()).unwrap();
344        make_job(tmp.path(), "01AAA", "running");
345
346        let candidates = list_job_candidates(tmp.path(), Some(&["running"]));
347        let names: Vec<_> = candidates
348            .iter()
349            .map(|c| c.get_value().to_string_lossy().to_string())
350            .collect();
351        assert!(!names.contains(&"01NOSTATE".to_string()));
352        assert!(names.contains(&"01AAA".to_string()));
353        assert_eq!(candidates.len(), 1);
354    }
355
356    #[test]
357    fn test_terminal_jobs_filter() {
358        let tmp = tempdir().unwrap();
359        make_job(tmp.path(), "01EXITED", "exited");
360        make_job(tmp.path(), "01KILLED", "killed");
361        make_job(tmp.path(), "01FAILED", "failed");
362        make_job(tmp.path(), "01RUNNING", "running");
363
364        let candidates = list_job_candidates(tmp.path(), Some(&["exited", "killed", "failed"]));
365        assert_eq!(candidates.len(), 3);
366    }
367
368    #[test]
369    fn test_waitable_jobs_filter() {
370        let tmp = tempdir().unwrap();
371        make_job(tmp.path(), "01CREATED", "created");
372        make_job(tmp.path(), "01RUNNING", "running");
373        make_job(tmp.path(), "01EXITED", "exited");
374
375        let candidates = list_job_candidates(tmp.path(), Some(&["created", "running"]));
376        assert_eq!(candidates.len(), 2);
377    }
378
379    #[test]
380    fn test_explicit_root_via_env_var() {
381        let tmp = tempdir().unwrap();
382        make_job(tmp.path(), "01AAA", "running");
383
384        // Simulate --root by setting AGENT_EXEC_ROOT
385        // SAFETY: single-threaded test; no other threads read AGENT_EXEC_ROOT here.
386        unsafe {
387            std::env::set_var("AGENT_EXEC_ROOT", tmp.path().to_str().unwrap());
388        }
389        let root = resolve_root_for_completion();
390        unsafe {
391            std::env::remove_var("AGENT_EXEC_ROOT");
392        }
393
394        let candidates = list_job_candidates(&root, None);
395        assert_eq!(candidates.len(), 1);
396    }
397
398    #[test]
399    fn test_extract_root_from_comp_line() {
400        let root = extract_root_from_line("agent-exec --root /tmp/myjobs status ");
401        assert_eq!(root, Some("/tmp/myjobs".to_string()));
402    }
403
404    #[test]
405    fn test_extract_root_from_comp_line_equals_form() {
406        let root = extract_root_from_line("agent-exec --root=/tmp/myjobs status ");
407        assert_eq!(root, Some("/tmp/myjobs".to_string()));
408    }
409
410    #[test]
411    fn test_list_job_candidates_with_explicit_root_path() {
412        // Passing an explicit root path directly (not via env) should work.
413        let tmp = tempdir().unwrap();
414        make_job(tmp.path(), "01CUSTOM", "running");
415
416        let other_tmp = tempdir().unwrap();
417        make_job(other_tmp.path(), "01OTHER", "running");
418
419        // list_job_candidates with the explicit root must only return jobs
420        // from that root, not from any other location.
421        let candidates = list_job_candidates(tmp.path(), None);
422        let names: Vec<_> = candidates
423            .iter()
424            .map(|c| c.get_value().to_string_lossy().to_string())
425            .collect();
426        assert!(names.contains(&"01CUSTOM".to_string()));
427        assert!(!names.contains(&"01OTHER".to_string()));
428        assert_eq!(candidates.len(), 1);
429    }
430
431    #[test]
432    fn test_cwd_filter_excludes_jobs_from_other_directories() {
433        let tmp = tempdir().unwrap();
434        make_job(tmp.path(), "01MATCH", "running");
435
436        let dir = tmp.path().join("01OTHER");
437        fs::create_dir_all(&dir).unwrap();
438        let meta_json = serde_json::json!({
439            "job": { "id": "01OTHER" },
440            "schema_version": "0.1",
441            "command": ["true"],
442            "created_at": "2026-01-01T00:00:00Z",
443            "root": tmp.path().display().to_string(),
444            "env_keys": [],
445            "cwd": "/tmp/somewhere-else",
446            "tags": []
447        });
448        fs::write(dir.join("meta.json"), meta_json.to_string()).unwrap();
449        let state_json = serde_json::json!({ "state": "running", "job_id": "01OTHER" });
450        fs::write(dir.join("state.json"), state_json.to_string()).unwrap();
451
452        let candidates = list_job_candidates(tmp.path(), None);
453        let names: Vec<_> = candidates
454            .iter()
455            .map(|c| c.get_value().to_string_lossy().to_string())
456            .collect();
457        assert!(names.contains(&"01MATCH".to_string()));
458        assert!(!names.contains(&"01OTHER".to_string()));
459    }
460}