1use anyhow::Result;
30use tracing::debug;
31
32use crate::jobstore::resolve_root;
33use crate::jobstore::short_job_id;
34use crate::run::resolve_effective_cwd;
35use crate::schema::{JobSummary, ListData, Response};
36use crate::tag::{matches_all_patterns, validate_filter_pattern};
37
38#[derive(Debug)]
40pub struct ListOpts<'a> {
41 pub root: Option<&'a str>,
42 pub limit: u64,
44 pub state: Option<&'a str>,
46 pub cwd: Option<&'a str>,
49 pub all: bool,
52 pub tags: Vec<String>,
54}
55
56pub fn execute(opts: ListOpts) -> Result<()> {
58 let root = resolve_root(opts.root);
59 let root_str = root.display().to_string();
60
61 for pattern in &opts.tags {
63 validate_filter_pattern(pattern).map_err(anyhow::Error::from)?;
64 }
65
66 let cwd_filter: Option<String> = if opts.all {
69 None
71 } else if let Some(cwd_arg) = opts.cwd {
72 Some(resolve_effective_cwd(Some(cwd_arg)))
74 } else {
75 Some(resolve_effective_cwd(None))
77 };
78
79 debug!(
80 cwd_filter = ?cwd_filter,
81 all = opts.all,
82 "list: cwd filter determined"
83 );
84
85 if !root.exists() {
87 debug!(root = %root_str, "root does not exist; returning empty list");
88 let response = Response::new(
89 "list",
90 ListData {
91 root: root_str,
92 jobs: vec![],
93 truncated: false,
94 skipped: 0,
95 },
96 );
97 response.print();
98 return Ok(());
99 }
100
101 let read_dir = std::fs::read_dir(&root)
103 .map_err(|e| anyhow::anyhow!("failed to read root directory {}: {}", root_str, e))?;
104
105 let mut jobs: Vec<JobSummary> = Vec::new();
106 let mut skipped: u64 = 0;
107
108 for entry in read_dir {
109 let entry = match entry {
110 Ok(e) => e,
111 Err(e) => {
112 debug!(error = %e, "failed to read directory entry; skipping");
113 skipped += 1;
114 continue;
115 }
116 };
117
118 let path = entry.path();
119 if !path.is_dir() {
120 continue;
122 }
123
124 let meta_path = path.join("meta.json");
126 let meta_bytes = match std::fs::read(&meta_path) {
127 Ok(b) => b,
128 Err(_) => {
129 debug!(path = %path.display(), "meta.json missing or unreadable; skipping");
130 skipped += 1;
131 continue;
132 }
133 };
134 let meta: crate::schema::JobMeta = match serde_json::from_slice(&meta_bytes) {
135 Ok(m) => m,
136 Err(e) => {
137 debug!(path = %path.display(), error = %e, "meta.json parse error; skipping");
138 skipped += 1;
139 continue;
140 }
141 };
142
143 if let Some(ref filter_cwd) = cwd_filter {
145 match meta.cwd.as_deref() {
146 Some(job_cwd) if job_cwd == filter_cwd => {
147 }
149 _ => {
150 debug!(
152 path = %path.display(),
153 job_cwd = ?meta.cwd,
154 filter_cwd = %filter_cwd,
155 "list: skipping job (cwd mismatch)"
156 );
157 continue;
158 }
159 }
160 }
161
162 if !opts.tags.is_empty() && !matches_all_patterns(&meta.tags, &opts.tags) {
164 debug!(
165 path = %path.display(),
166 job_tags = ?meta.tags,
167 patterns = ?opts.tags,
168 "list: skipping job (tag mismatch)"
169 );
170 continue;
171 }
172
173 let state_opt: Option<crate::schema::JobState> = {
175 let state_path = path.join("state.json");
176 match std::fs::read(&state_path) {
177 Ok(b) => serde_json::from_slice(&b).ok(),
178 Err(_) => None,
179 }
180 };
181
182 let (state_str, exit_code, finished_at, updated_at) = if let Some(ref s) = state_opt {
183 (
184 s.status().as_str().to_string(),
185 s.exit_code(),
186 s.finished_at.clone(),
187 Some(s.updated_at.clone()),
188 )
189 } else {
190 ("unknown".to_string(), None, None, None)
191 };
192
193 let job_started_at = state_opt
194 .as_ref()
195 .and_then(|s| s.started_at().map(|t| t.to_string()));
196 jobs.push(JobSummary {
197 job_id: meta.job.id.clone(),
198 short_job_id: short_job_id(&meta.job.id),
199 state: state_str,
200 exit_code,
201 created_at: meta.created_at.clone(),
202 started_at: job_started_at,
203 finished_at,
204 updated_at,
205 tags: meta.tags.clone(),
206 });
207 }
208
209 if let Some(filter_state) = opts.state {
211 jobs.retain(|j| j.state == filter_state);
212 }
213
214 jobs.sort_by(|a, b| {
216 b.started_at
217 .cmp(&a.started_at)
218 .then_with(|| b.job_id.cmp(&a.job_id))
219 });
220
221 let truncated = opts.limit > 0 && jobs.len() as u64 > opts.limit;
223 if truncated {
224 jobs.truncate(opts.limit as usize);
225 }
226
227 debug!(
228 root = %root_str,
229 count = jobs.len(),
230 skipped,
231 truncated,
232 "list complete"
233 );
234
235 let response = Response::new(
236 "list",
237 ListData {
238 root: root_str,
239 jobs,
240 truncated,
241 skipped,
242 },
243 );
244 response.print();
245 Ok(())
246}