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_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 tokens.get(pos + 1).map(|s| s.to_string())
139}
140
141pub fn complete_all_jobs(_current: &std::ffi::OsStr) -> Vec<CompletionCandidate> {
153 list_job_candidates(&resolve_root_for_completion(), None)
154}
155
156pub fn complete_created_jobs(_current: &std::ffi::OsStr) -> Vec<CompletionCandidate> {
159 list_job_candidates(&resolve_root_for_completion(), Some(&["created"]))
160}
161
162pub fn complete_running_jobs(_current: &std::ffi::OsStr) -> Vec<CompletionCandidate> {
165 list_job_candidates(&resolve_root_for_completion(), Some(&["running"]))
166}
167
168pub 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
177pub 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#[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 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 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 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 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 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 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 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}