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.get("state")?.as_str().map(str::to_string)
35}
36
37/// List completion candidates under `root`, optionally filtered by job state.
38///
39/// - If `root` does not exist or is unreadable, returns an empty list.
40/// - If `state_filter` is `Some(slice)`, only jobs whose state appears in
41///   the slice are included.  Jobs whose `state.json` is unreadable are
42///   excluded when a filter is active (safe default: don't offer jobs we
43///   can't categorise).
44/// - Each candidate includes the state as a help annotation when available.
45pub fn list_job_candidates(
46    root: &std::path::Path,
47    state_filter: Option<&[&str]>,
48) -> Vec<CompletionCandidate> {
49    let entries = match std::fs::read_dir(root) {
50        Ok(e) => e,
51        Err(_) => return vec![],
52    };
53    entries
54        .filter_map(|e| e.ok())
55        .filter(|e| e.file_type().map(|t| t.is_dir()).unwrap_or(false))
56        .filter_map(|e| {
57            let name = e.file_name().to_string_lossy().to_string();
58            let state = read_job_state(&e.path());
59
60            // Apply state filter when specified.
61            if let Some(filter) = state_filter {
62                match &state {
63                    Some(s) if filter.contains(&s.as_str()) => {}
64                    _ => return None,
65                }
66            }
67
68            let candidate = CompletionCandidate::new(name);
69            Some(match state {
70                Some(s) => candidate.help(Some(s.into())),
71                None => candidate,
72            })
73        })
74        .collect()
75}
76
77/// Resolve the root directory to use during a completion invocation.
78///
79/// Tries, in order:
80/// 1. `--root <value>` extracted from `COMP_LINE` (bash/zsh).
81/// 2. `--root <value>` extracted from the process argv after the `--`
82///    separator (covers fish and other shells that pass words as argv).
83/// 3. `AGENT_EXEC_ROOT` environment variable (via `resolve_root(None)`).
84/// 4. XDG / platform default (via `resolve_root(None)`).
85pub fn resolve_root_for_completion() -> PathBuf {
86    // Try to extract --root from the partial command line that the shell
87    // provides in COMP_LINE (bash/zsh) during completion invocations.
88    if let Some(root) = extract_root_from_comp_line() {
89        return PathBuf::from(root);
90    }
91    // Fallback: parse the argv for --root (covers fish and other shells that
92    // don't set COMP_LINE but pass completion words as process argv after `--`).
93    if let Some(root) = extract_root_from_argv() {
94        return PathBuf::from(root);
95    }
96    crate::jobstore::resolve_root(None)
97}
98
99/// Parse `--root <value>` from the process argv (words after the `--` separator).
100///
101/// In CompleteEnv mode, `clap_complete` invokes the binary as:
102///   `<binary> <completer_path> -- <program_name> [args…]`
103/// This function looks for `--root` in the words that follow `--` so that
104/// shells (e.g. fish) that do not set `COMP_LINE` can still trigger root
105/// resolution from an explicit `--root` flag.
106fn extract_root_from_argv() -> Option<String> {
107    let args: Vec<String> = std::env::args().collect();
108    let sep_pos = args.iter().position(|a| a == "--")?;
109    let words = &args[sep_pos + 1..];
110    let pos = words
111        .iter()
112        .position(|t| t == "--root" || t.starts_with("--root="))?;
113
114    if let Some(val) = words[pos].strip_prefix("--root=") {
115        return Some(val.to_string());
116    }
117    // `--root <value>` form
118    words.get(pos + 1).map(|s| s.to_string())
119}
120
121/// Parse `--root <value>` from the `COMP_LINE` environment variable.
122///
123/// `COMP_LINE` is set by bash/zsh to the full command line being completed.
124/// Returns `None` if the variable is absent, malformed, or `--root` is not found.
125fn extract_root_from_comp_line() -> Option<String> {
126    let comp_line = std::env::var("COMP_LINE").ok()?;
127    let tokens: Vec<&str> = comp_line.split_whitespace().collect();
128    let pos = tokens
129        .iter()
130        .position(|&t| t == "--root" || t.starts_with("--root="))?;
131
132    if let Some(tok) = tokens.get(pos)
133        && let Some(val) = tok.strip_prefix("--root=")
134    {
135        return Some(val.to_string());
136    }
137    // `--root <value>` form
138    tokens.get(pos + 1).map(|s| s.to_string())
139}
140
141// ── public completer functions ─────────────────────────────────────────────────
142//
143// Each function matches the signature required by `ArgValueCompleter::new()`:
144//   fn(&OsStr) -> Vec<CompletionCandidate>
145//
146// The `current` parameter is the partial value the user has typed so far.
147// clap_complete performs prefix filtering itself, so returning all candidates
148// unconditionally is correct.
149
150/// Complete all job IDs regardless of state.
151/// Used by: `status`, `tail`, `tag set`, `notify set`.
152pub fn complete_all_jobs(_current: &std::ffi::OsStr) -> Vec<CompletionCandidate> {
153    list_job_candidates(&resolve_root_for_completion(), None)
154}
155
156/// Complete only jobs in `created` state.
157/// Used by: `start` (only un-started jobs can be started).
158pub fn complete_created_jobs(_current: &std::ffi::OsStr) -> Vec<CompletionCandidate> {
159    list_job_candidates(&resolve_root_for_completion(), Some(&["created"]))
160}
161
162/// Complete only jobs in `running` state.
163/// Used by: `kill` (only running jobs can be killed).
164pub fn complete_running_jobs(_current: &std::ffi::OsStr) -> Vec<CompletionCandidate> {
165    list_job_candidates(&resolve_root_for_completion(), Some(&["running"]))
166}
167
168/// Complete only jobs in terminal states (`exited`, `killed`, `failed`).
169/// Used by: `delete` (only finished jobs can be deleted).
170pub fn complete_terminal_jobs(_current: &std::ffi::OsStr) -> Vec<CompletionCandidate> {
171    list_job_candidates(
172        &resolve_root_for_completion(),
173        Some(&["exited", "killed", "failed"]),
174    )
175}
176
177/// Complete jobs in non-terminal states (`created`, `running`).
178/// Used by: `wait` (waiting on a terminal job is a no-op).
179pub fn complete_waitable_jobs(_current: &std::ffi::OsStr) -> Vec<CompletionCandidate> {
180    list_job_candidates(
181        &resolve_root_for_completion(),
182        Some(&["created", "running"]),
183    )
184}
185
186// ── unit tests ────────────────────────────────────────────────────────────────
187
188#[cfg(test)]
189mod tests {
190    use super::*;
191    use std::fs;
192    use tempfile::tempdir;
193
194    fn make_job(root: &std::path::Path, id: &str, state: &str) {
195        let dir = root.join(id);
196        fs::create_dir_all(&dir).unwrap();
197        let state_json = serde_json::json!({ "state": state, "job_id": id });
198        fs::write(dir.join("state.json"), state_json.to_string()).unwrap();
199    }
200
201    #[test]
202    fn test_list_all_jobs_returns_all_dirs() {
203        let tmp = tempdir().unwrap();
204        make_job(tmp.path(), "01AAA", "running");
205        make_job(tmp.path(), "01BBB", "exited");
206
207        let candidates = list_job_candidates(tmp.path(), None);
208        let names: Vec<_> = candidates
209            .iter()
210            .map(|c| c.get_value().to_string_lossy().to_string())
211            .collect();
212        assert!(names.contains(&"01AAA".to_string()));
213        assert!(names.contains(&"01BBB".to_string()));
214        assert_eq!(candidates.len(), 2);
215    }
216
217    #[test]
218    fn test_list_with_state_filter() {
219        let tmp = tempdir().unwrap();
220        make_job(tmp.path(), "01AAA", "running");
221        make_job(tmp.path(), "01BBB", "exited");
222        make_job(tmp.path(), "01CCC", "running");
223
224        let candidates = list_job_candidates(tmp.path(), Some(&["running"]));
225        let names: Vec<_> = candidates
226            .iter()
227            .map(|c| c.get_value().to_string_lossy().to_string())
228            .collect();
229        assert!(names.contains(&"01AAA".to_string()));
230        assert!(names.contains(&"01CCC".to_string()));
231        assert!(!names.contains(&"01BBB".to_string()));
232        assert_eq!(candidates.len(), 2);
233    }
234
235    #[test]
236    fn test_nonexistent_root_returns_empty() {
237        let candidates = list_job_candidates(std::path::Path::new("/nonexistent/path"), None);
238        assert!(candidates.is_empty());
239    }
240
241    #[test]
242    fn test_description_includes_state() {
243        let tmp = tempdir().unwrap();
244        make_job(tmp.path(), "01AAA", "running");
245
246        let candidates = list_job_candidates(tmp.path(), None);
247        assert_eq!(candidates.len(), 1);
248        let help = candidates[0].get_help();
249        assert!(help.is_some());
250        assert!(help.unwrap().to_string().contains("running"));
251    }
252
253    #[test]
254    fn test_missing_state_json_included_without_filter() {
255        let tmp = tempdir().unwrap();
256        // Job dir without state.json
257        fs::create_dir_all(tmp.path().join("01NOSTATE")).unwrap();
258        make_job(tmp.path(), "01AAA", "running");
259
260        let candidates = list_job_candidates(tmp.path(), None);
261        let names: Vec<_> = candidates
262            .iter()
263            .map(|c| c.get_value().to_string_lossy().to_string())
264            .collect();
265        assert!(names.contains(&"01NOSTATE".to_string()));
266        assert_eq!(candidates.len(), 2);
267    }
268
269    #[test]
270    fn test_missing_state_json_excluded_with_filter() {
271        let tmp = tempdir().unwrap();
272        // Job dir without state.json — should be excluded when filtering
273        fs::create_dir_all(tmp.path().join("01NOSTATE")).unwrap();
274        make_job(tmp.path(), "01AAA", "running");
275
276        let candidates = list_job_candidates(tmp.path(), Some(&["running"]));
277        let names: Vec<_> = candidates
278            .iter()
279            .map(|c| c.get_value().to_string_lossy().to_string())
280            .collect();
281        assert!(!names.contains(&"01NOSTATE".to_string()));
282        assert!(names.contains(&"01AAA".to_string()));
283        assert_eq!(candidates.len(), 1);
284    }
285
286    #[test]
287    fn test_terminal_jobs_filter() {
288        let tmp = tempdir().unwrap();
289        make_job(tmp.path(), "01EXITED", "exited");
290        make_job(tmp.path(), "01KILLED", "killed");
291        make_job(tmp.path(), "01FAILED", "failed");
292        make_job(tmp.path(), "01RUNNING", "running");
293
294        let candidates = list_job_candidates(tmp.path(), Some(&["exited", "killed", "failed"]));
295        assert_eq!(candidates.len(), 3);
296    }
297
298    #[test]
299    fn test_waitable_jobs_filter() {
300        let tmp = tempdir().unwrap();
301        make_job(tmp.path(), "01CREATED", "created");
302        make_job(tmp.path(), "01RUNNING", "running");
303        make_job(tmp.path(), "01EXITED", "exited");
304
305        let candidates = list_job_candidates(tmp.path(), Some(&["created", "running"]));
306        assert_eq!(candidates.len(), 2);
307    }
308
309    #[test]
310    fn test_explicit_root_via_env_var() {
311        let tmp = tempdir().unwrap();
312        make_job(tmp.path(), "01AAA", "running");
313
314        // Simulate --root by setting AGENT_EXEC_ROOT
315        // SAFETY: single-threaded test; no other threads read AGENT_EXEC_ROOT here.
316        unsafe {
317            std::env::set_var("AGENT_EXEC_ROOT", tmp.path().to_str().unwrap());
318        }
319        let root = resolve_root_for_completion();
320        unsafe {
321            std::env::remove_var("AGENT_EXEC_ROOT");
322        }
323
324        let candidates = list_job_candidates(&root, None);
325        assert_eq!(candidates.len(), 1);
326    }
327
328    #[test]
329    fn test_extract_root_from_comp_line() {
330        // SAFETY: single-threaded test.
331        unsafe {
332            std::env::set_var("COMP_LINE", "agent-exec --root /tmp/myjobs status ");
333        }
334        let root = extract_root_from_comp_line();
335        unsafe {
336            std::env::remove_var("COMP_LINE");
337        }
338        assert_eq!(root, Some("/tmp/myjobs".to_string()));
339    }
340
341    #[test]
342    fn test_extract_root_from_comp_line_equals_form() {
343        // SAFETY: single-threaded test.
344        unsafe {
345            std::env::set_var("COMP_LINE", "agent-exec --root=/tmp/myjobs status ");
346        }
347        let root = extract_root_from_comp_line();
348        unsafe {
349            std::env::remove_var("COMP_LINE");
350        }
351        assert_eq!(root, Some("/tmp/myjobs".to_string()));
352    }
353
354    #[test]
355    fn test_list_job_candidates_with_explicit_root_path() {
356        // Passing an explicit root path directly (not via env) should work.
357        let tmp = tempdir().unwrap();
358        make_job(tmp.path(), "01CUSTOM", "running");
359
360        let other_tmp = tempdir().unwrap();
361        make_job(other_tmp.path(), "01OTHER", "running");
362
363        // list_job_candidates with the explicit root must only return jobs
364        // from that root, not from any other location.
365        let candidates = list_job_candidates(tmp.path(), None);
366        let names: Vec<_> = candidates
367            .iter()
368            .map(|c| c.get_value().to_string_lossy().to_string())
369            .collect();
370        assert!(names.contains(&"01CUSTOM".to_string()));
371        assert!(!names.contains(&"01OTHER".to_string()));
372        assert_eq!(candidates.len(), 1);
373    }
374}