use clap_complete::engine::CompletionCandidate;
use std::path::PathBuf;
fn read_job_state(job_dir: &std::path::Path) -> Option<String> {
let content = std::fs::read_to_string(job_dir.join("state.json")).ok()?;
let value: serde_json::Value = serde_json::from_str(&content).ok()?;
value.get("state")?.as_str().map(str::to_string)
}
pub fn list_job_candidates(
root: &std::path::Path,
state_filter: Option<&[&str]>,
) -> Vec<CompletionCandidate> {
let entries = match std::fs::read_dir(root) {
Ok(e) => e,
Err(_) => return vec![],
};
entries
.filter_map(|e| e.ok())
.filter(|e| e.file_type().map(|t| t.is_dir()).unwrap_or(false))
.filter_map(|e| {
let name = e.file_name().to_string_lossy().to_string();
let state = read_job_state(&e.path());
if let Some(filter) = state_filter {
match &state {
Some(s) if filter.contains(&s.as_str()) => {}
_ => return None,
}
}
let candidate = CompletionCandidate::new(name);
Some(match state {
Some(s) => candidate.help(Some(s.into())),
None => candidate,
})
})
.collect()
}
pub fn resolve_root_for_completion() -> PathBuf {
if let Some(root) = extract_root_from_comp_line() {
return PathBuf::from(root);
}
if let Some(root) = extract_root_from_argv() {
return PathBuf::from(root);
}
crate::jobstore::resolve_root(None)
}
fn extract_root_from_argv() -> Option<String> {
let args: Vec<String> = std::env::args().collect();
let sep_pos = args.iter().position(|a| a == "--")?;
let words = &args[sep_pos + 1..];
let pos = words
.iter()
.position(|t| t == "--root" || t.starts_with("--root="))?;
if let Some(val) = words[pos].strip_prefix("--root=") {
return Some(val.to_string());
}
words.get(pos + 1).map(|s| s.to_string())
}
fn extract_root_from_line(comp_line: &str) -> Option<String> {
let tokens: Vec<&str> = comp_line.split_whitespace().collect();
let pos = tokens
.iter()
.position(|&t| t == "--root" || t.starts_with("--root="))?;
if let Some(tok) = tokens.get(pos)
&& let Some(val) = tok.strip_prefix("--root=")
{
return Some(val.to_string());
}
tokens.get(pos + 1).map(|s| s.to_string())
}
fn extract_root_from_comp_line() -> Option<String> {
let comp_line = std::env::var("COMP_LINE").ok()?;
extract_root_from_line(&comp_line)
}
pub fn complete_all_jobs(_current: &std::ffi::OsStr) -> Vec<CompletionCandidate> {
list_job_candidates(&resolve_root_for_completion(), None)
}
pub fn complete_created_jobs(_current: &std::ffi::OsStr) -> Vec<CompletionCandidate> {
list_job_candidates(&resolve_root_for_completion(), Some(&["created"]))
}
pub fn complete_running_jobs(_current: &std::ffi::OsStr) -> Vec<CompletionCandidate> {
list_job_candidates(&resolve_root_for_completion(), Some(&["running"]))
}
pub fn complete_terminal_jobs(_current: &std::ffi::OsStr) -> Vec<CompletionCandidate> {
list_job_candidates(
&resolve_root_for_completion(),
Some(&["exited", "killed", "failed"]),
)
}
pub fn complete_waitable_jobs(_current: &std::ffi::OsStr) -> Vec<CompletionCandidate> {
list_job_candidates(
&resolve_root_for_completion(),
Some(&["created", "running"]),
)
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::tempdir;
fn make_job(root: &std::path::Path, id: &str, state: &str) {
let dir = root.join(id);
fs::create_dir_all(&dir).unwrap();
let state_json = serde_json::json!({ "state": state, "job_id": id });
fs::write(dir.join("state.json"), state_json.to_string()).unwrap();
}
#[test]
fn test_list_all_jobs_returns_all_dirs() {
let tmp = tempdir().unwrap();
make_job(tmp.path(), "01AAA", "running");
make_job(tmp.path(), "01BBB", "exited");
let candidates = list_job_candidates(tmp.path(), None);
let names: Vec<_> = candidates
.iter()
.map(|c| c.get_value().to_string_lossy().to_string())
.collect();
assert!(names.contains(&"01AAA".to_string()));
assert!(names.contains(&"01BBB".to_string()));
assert_eq!(candidates.len(), 2);
}
#[test]
fn test_list_with_state_filter() {
let tmp = tempdir().unwrap();
make_job(tmp.path(), "01AAA", "running");
make_job(tmp.path(), "01BBB", "exited");
make_job(tmp.path(), "01CCC", "running");
let candidates = list_job_candidates(tmp.path(), Some(&["running"]));
let names: Vec<_> = candidates
.iter()
.map(|c| c.get_value().to_string_lossy().to_string())
.collect();
assert!(names.contains(&"01AAA".to_string()));
assert!(names.contains(&"01CCC".to_string()));
assert!(!names.contains(&"01BBB".to_string()));
assert_eq!(candidates.len(), 2);
}
#[test]
fn test_nonexistent_root_returns_empty() {
let candidates = list_job_candidates(std::path::Path::new("/nonexistent/path"), None);
assert!(candidates.is_empty());
}
#[test]
fn test_description_includes_state() {
let tmp = tempdir().unwrap();
make_job(tmp.path(), "01AAA", "running");
let candidates = list_job_candidates(tmp.path(), None);
assert_eq!(candidates.len(), 1);
let help = candidates[0].get_help();
assert!(help.is_some());
assert!(help.unwrap().to_string().contains("running"));
}
#[test]
fn test_missing_state_json_included_without_filter() {
let tmp = tempdir().unwrap();
fs::create_dir_all(tmp.path().join("01NOSTATE")).unwrap();
make_job(tmp.path(), "01AAA", "running");
let candidates = list_job_candidates(tmp.path(), None);
let names: Vec<_> = candidates
.iter()
.map(|c| c.get_value().to_string_lossy().to_string())
.collect();
assert!(names.contains(&"01NOSTATE".to_string()));
assert_eq!(candidates.len(), 2);
}
#[test]
fn test_missing_state_json_excluded_with_filter() {
let tmp = tempdir().unwrap();
fs::create_dir_all(tmp.path().join("01NOSTATE")).unwrap();
make_job(tmp.path(), "01AAA", "running");
let candidates = list_job_candidates(tmp.path(), Some(&["running"]));
let names: Vec<_> = candidates
.iter()
.map(|c| c.get_value().to_string_lossy().to_string())
.collect();
assert!(!names.contains(&"01NOSTATE".to_string()));
assert!(names.contains(&"01AAA".to_string()));
assert_eq!(candidates.len(), 1);
}
#[test]
fn test_terminal_jobs_filter() {
let tmp = tempdir().unwrap();
make_job(tmp.path(), "01EXITED", "exited");
make_job(tmp.path(), "01KILLED", "killed");
make_job(tmp.path(), "01FAILED", "failed");
make_job(tmp.path(), "01RUNNING", "running");
let candidates = list_job_candidates(tmp.path(), Some(&["exited", "killed", "failed"]));
assert_eq!(candidates.len(), 3);
}
#[test]
fn test_waitable_jobs_filter() {
let tmp = tempdir().unwrap();
make_job(tmp.path(), "01CREATED", "created");
make_job(tmp.path(), "01RUNNING", "running");
make_job(tmp.path(), "01EXITED", "exited");
let candidates = list_job_candidates(tmp.path(), Some(&["created", "running"]));
assert_eq!(candidates.len(), 2);
}
#[test]
fn test_explicit_root_via_env_var() {
let tmp = tempdir().unwrap();
make_job(tmp.path(), "01AAA", "running");
unsafe {
std::env::set_var("AGENT_EXEC_ROOT", tmp.path().to_str().unwrap());
}
let root = resolve_root_for_completion();
unsafe {
std::env::remove_var("AGENT_EXEC_ROOT");
}
let candidates = list_job_candidates(&root, None);
assert_eq!(candidates.len(), 1);
}
#[test]
fn test_extract_root_from_comp_line() {
let root = extract_root_from_line("agent-exec --root /tmp/myjobs status ");
assert_eq!(root, Some("/tmp/myjobs".to_string()));
}
#[test]
fn test_extract_root_from_comp_line_equals_form() {
let root = extract_root_from_line("agent-exec --root=/tmp/myjobs status ");
assert_eq!(root, Some("/tmp/myjobs".to_string()));
}
#[test]
fn test_list_job_candidates_with_explicit_root_path() {
let tmp = tempdir().unwrap();
make_job(tmp.path(), "01CUSTOM", "running");
let other_tmp = tempdir().unwrap();
make_job(other_tmp.path(), "01OTHER", "running");
let candidates = list_job_candidates(tmp.path(), None);
let names: Vec<_> = candidates
.iter()
.map(|c| c.get_value().to_string_lossy().to_string())
.collect();
assert!(names.contains(&"01CUSTOM".to_string()));
assert!(!names.contains(&"01OTHER".to_string()));
assert_eq!(candidates.len(), 1);
}
}