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_line(comp_line: &str) -> Option<String> {
126    let tokens: Vec<&str> = comp_line.split_whitespace().collect();
127    let pos = tokens
128        .iter()
129        .position(|&t| t == "--root" || t.starts_with("--root="))?;
130
131    if let Some(tok) = tokens.get(pos)
132        && let Some(val) = tok.strip_prefix("--root=")
133    {
134        return Some(val.to_string());
135    }
136
137    // `--root <value>` form
138    tokens.get(pos + 1).map(|s| s.to_string())
139}
140
141fn extract_root_from_comp_line() -> Option<String> {
142    let comp_line = std::env::var("COMP_LINE").ok()?;
143    extract_root_from_line(&comp_line)
144}
145
146// ── public completer functions ─────────────────────────────────────────────────
147//
148// Each function matches the signature required by `ArgValueCompleter::new()`:
149//   fn(&OsStr) -> Vec<CompletionCandidate>
150//
151// The `current` parameter is the partial value the user has typed so far.
152// clap_complete performs prefix filtering itself, so returning all candidates
153// unconditionally is correct.
154
155/// Complete all job IDs regardless of state.
156/// Used by: `status`, `tail`, `tag set`, `notify set`.
157pub fn complete_all_jobs(_current: &std::ffi::OsStr) -> Vec<CompletionCandidate> {
158    list_job_candidates(&resolve_root_for_completion(), None)
159}
160
161/// Complete only jobs in `created` state.
162/// Used by: `start` (only un-started jobs can be started).
163pub fn complete_created_jobs(_current: &std::ffi::OsStr) -> Vec<CompletionCandidate> {
164    list_job_candidates(&resolve_root_for_completion(), Some(&["created"]))
165}
166
167/// Complete only jobs in `running` state.
168/// Used by: `kill` (only running jobs can be killed).
169pub fn complete_running_jobs(_current: &std::ffi::OsStr) -> Vec<CompletionCandidate> {
170    list_job_candidates(&resolve_root_for_completion(), Some(&["running"]))
171}
172
173/// Complete only jobs in terminal states (`exited`, `killed`, `failed`).
174/// Used by: `delete` (only finished jobs can be deleted).
175pub fn complete_terminal_jobs(_current: &std::ffi::OsStr) -> Vec<CompletionCandidate> {
176    list_job_candidates(
177        &resolve_root_for_completion(),
178        Some(&["exited", "killed", "failed"]),
179    )
180}
181
182/// Complete jobs in non-terminal states (`created`, `running`).
183/// Used by: `wait` (waiting on a terminal job is a no-op).
184pub fn complete_waitable_jobs(_current: &std::ffi::OsStr) -> Vec<CompletionCandidate> {
185    list_job_candidates(
186        &resolve_root_for_completion(),
187        Some(&["created", "running"]),
188    )
189}
190
191// ── unit tests ────────────────────────────────────────────────────────────────
192
193#[cfg(test)]
194mod tests {
195    use super::*;
196    use std::fs;
197    use tempfile::tempdir;
198
199    fn make_job(root: &std::path::Path, id: &str, state: &str) {
200        let dir = root.join(id);
201        fs::create_dir_all(&dir).unwrap();
202        let state_json = serde_json::json!({ "state": state, "job_id": id });
203        fs::write(dir.join("state.json"), state_json.to_string()).unwrap();
204    }
205
206    #[test]
207    fn test_list_all_jobs_returns_all_dirs() {
208        let tmp = tempdir().unwrap();
209        make_job(tmp.path(), "01AAA", "running");
210        make_job(tmp.path(), "01BBB", "exited");
211
212        let candidates = list_job_candidates(tmp.path(), None);
213        let names: Vec<_> = candidates
214            .iter()
215            .map(|c| c.get_value().to_string_lossy().to_string())
216            .collect();
217        assert!(names.contains(&"01AAA".to_string()));
218        assert!(names.contains(&"01BBB".to_string()));
219        assert_eq!(candidates.len(), 2);
220    }
221
222    #[test]
223    fn test_list_with_state_filter() {
224        let tmp = tempdir().unwrap();
225        make_job(tmp.path(), "01AAA", "running");
226        make_job(tmp.path(), "01BBB", "exited");
227        make_job(tmp.path(), "01CCC", "running");
228
229        let candidates = list_job_candidates(tmp.path(), Some(&["running"]));
230        let names: Vec<_> = candidates
231            .iter()
232            .map(|c| c.get_value().to_string_lossy().to_string())
233            .collect();
234        assert!(names.contains(&"01AAA".to_string()));
235        assert!(names.contains(&"01CCC".to_string()));
236        assert!(!names.contains(&"01BBB".to_string()));
237        assert_eq!(candidates.len(), 2);
238    }
239
240    #[test]
241    fn test_nonexistent_root_returns_empty() {
242        let candidates = list_job_candidates(std::path::Path::new("/nonexistent/path"), None);
243        assert!(candidates.is_empty());
244    }
245
246    #[test]
247    fn test_description_includes_state() {
248        let tmp = tempdir().unwrap();
249        make_job(tmp.path(), "01AAA", "running");
250
251        let candidates = list_job_candidates(tmp.path(), None);
252        assert_eq!(candidates.len(), 1);
253        let help = candidates[0].get_help();
254        assert!(help.is_some());
255        assert!(help.unwrap().to_string().contains("running"));
256    }
257
258    #[test]
259    fn test_missing_state_json_included_without_filter() {
260        let tmp = tempdir().unwrap();
261        // Job dir without state.json
262        fs::create_dir_all(tmp.path().join("01NOSTATE")).unwrap();
263        make_job(tmp.path(), "01AAA", "running");
264
265        let candidates = list_job_candidates(tmp.path(), None);
266        let names: Vec<_> = candidates
267            .iter()
268            .map(|c| c.get_value().to_string_lossy().to_string())
269            .collect();
270        assert!(names.contains(&"01NOSTATE".to_string()));
271        assert_eq!(candidates.len(), 2);
272    }
273
274    #[test]
275    fn test_missing_state_json_excluded_with_filter() {
276        let tmp = tempdir().unwrap();
277        // Job dir without state.json — should be excluded when filtering
278        fs::create_dir_all(tmp.path().join("01NOSTATE")).unwrap();
279        make_job(tmp.path(), "01AAA", "running");
280
281        let candidates = list_job_candidates(tmp.path(), Some(&["running"]));
282        let names: Vec<_> = candidates
283            .iter()
284            .map(|c| c.get_value().to_string_lossy().to_string())
285            .collect();
286        assert!(!names.contains(&"01NOSTATE".to_string()));
287        assert!(names.contains(&"01AAA".to_string()));
288        assert_eq!(candidates.len(), 1);
289    }
290
291    #[test]
292    fn test_terminal_jobs_filter() {
293        let tmp = tempdir().unwrap();
294        make_job(tmp.path(), "01EXITED", "exited");
295        make_job(tmp.path(), "01KILLED", "killed");
296        make_job(tmp.path(), "01FAILED", "failed");
297        make_job(tmp.path(), "01RUNNING", "running");
298
299        let candidates = list_job_candidates(tmp.path(), Some(&["exited", "killed", "failed"]));
300        assert_eq!(candidates.len(), 3);
301    }
302
303    #[test]
304    fn test_waitable_jobs_filter() {
305        let tmp = tempdir().unwrap();
306        make_job(tmp.path(), "01CREATED", "created");
307        make_job(tmp.path(), "01RUNNING", "running");
308        make_job(tmp.path(), "01EXITED", "exited");
309
310        let candidates = list_job_candidates(tmp.path(), Some(&["created", "running"]));
311        assert_eq!(candidates.len(), 2);
312    }
313
314    #[test]
315    fn test_explicit_root_via_env_var() {
316        let tmp = tempdir().unwrap();
317        make_job(tmp.path(), "01AAA", "running");
318
319        // Simulate --root by setting AGENT_EXEC_ROOT
320        // SAFETY: single-threaded test; no other threads read AGENT_EXEC_ROOT here.
321        unsafe {
322            std::env::set_var("AGENT_EXEC_ROOT", tmp.path().to_str().unwrap());
323        }
324        let root = resolve_root_for_completion();
325        unsafe {
326            std::env::remove_var("AGENT_EXEC_ROOT");
327        }
328
329        let candidates = list_job_candidates(&root, None);
330        assert_eq!(candidates.len(), 1);
331    }
332
333    #[test]
334    fn test_extract_root_from_comp_line() {
335        let root = extract_root_from_line("agent-exec --root /tmp/myjobs status ");
336        assert_eq!(root, Some("/tmp/myjobs".to_string()));
337    }
338
339    #[test]
340    fn test_extract_root_from_comp_line_equals_form() {
341        let root = extract_root_from_line("agent-exec --root=/tmp/myjobs status ");
342        assert_eq!(root, Some("/tmp/myjobs".to_string()));
343    }
344
345    #[test]
346    fn test_list_job_candidates_with_explicit_root_path() {
347        // Passing an explicit root path directly (not via env) should work.
348        let tmp = tempdir().unwrap();
349        make_job(tmp.path(), "01CUSTOM", "running");
350
351        let other_tmp = tempdir().unwrap();
352        make_job(other_tmp.path(), "01OTHER", "running");
353
354        // list_job_candidates with the explicit root must only return jobs
355        // from that root, not from any other location.
356        let candidates = list_job_candidates(tmp.path(), None);
357        let names: Vec<_> = candidates
358            .iter()
359            .map(|c| c.get_value().to_string_lossy().to_string())
360            .collect();
361        assert!(names.contains(&"01CUSTOM".to_string()));
362        assert!(!names.contains(&"01OTHER".to_string()));
363        assert_eq!(candidates.len(), 1);
364    }
365}