agent_exec/
completions.rs1use clap_complete::engine::CompletionCandidate;
24use std::path::PathBuf;
25
26fn 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
37pub 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 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
77pub fn resolve_root_for_completion() -> PathBuf {
86 if let Some(root) = extract_root_from_comp_line() {
89 return PathBuf::from(root);
90 }
91 if let Some(root) = extract_root_from_argv() {
94 return PathBuf::from(root);
95 }
96 crate::jobstore::resolve_root(None)
97}
98
99fn 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 words.get(pos + 1).map(|s| s.to_string())
119}
120
121fn 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 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
146pub fn complete_all_jobs(_current: &std::ffi::OsStr) -> Vec<CompletionCandidate> {
158 list_job_candidates(&resolve_root_for_completion(), None)
159}
160
161pub fn complete_created_jobs(_current: &std::ffi::OsStr) -> Vec<CompletionCandidate> {
164 list_job_candidates(&resolve_root_for_completion(), Some(&["created"]))
165}
166
167pub fn complete_running_jobs(_current: &std::ffi::OsStr) -> Vec<CompletionCandidate> {
170 list_job_candidates(&resolve_root_for_completion(), Some(&["running"]))
171}
172
173pub 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
182pub 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#[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 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 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 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 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 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}