1use 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
35 .get("job")?
36 .get("status")?
37 .as_str()
38 .map(str::to_string)
39}
40
41fn 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
50pub 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 cwd.as_deref() {
79 Some(job_cwd) if job_cwd == cwd_filter => {}
80 _ => return None,
81 }
82
83 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
100pub fn resolve_root_for_completion() -> PathBuf {
109 if let Some(root) = extract_root_from_comp_line() {
112 return PathBuf::from(root);
113 }
114 if let Some(root) = extract_root_from_argv() {
117 return PathBuf::from(root);
118 }
119 crate::jobstore::resolve_root(None)
120}
121
122fn 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 words.get(pos + 1).map(|s| s.to_string())
142}
143
144fn 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 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
169pub fn complete_all_jobs(_current: &std::ffi::OsStr) -> Vec<CompletionCandidate> {
181 list_job_candidates(&resolve_root_for_completion(), None)
182}
183
184pub fn complete_created_jobs(_current: &std::ffi::OsStr) -> Vec<CompletionCandidate> {
187 list_job_candidates(&resolve_root_for_completion(), Some(&["created"]))
188}
189
190pub fn complete_running_jobs(_current: &std::ffi::OsStr) -> Vec<CompletionCandidate> {
193 list_job_candidates(&resolve_root_for_completion(), Some(&["running"]))
194}
195
196pub 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
205pub 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#[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 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 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 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 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 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}