Skip to main content

nsg_cli/
models.rs

1use anyhow::Result;
2use quick_xml::events::Event;
3use quick_xml::Reader;
4use std::path::PathBuf;
5
6#[derive(Debug, Clone)]
7pub struct JobSummary {
8    pub job_id: String,
9    pub url: String,
10    pub tool: Option<String>,
11    pub job_stage: Option<String>,
12    pub failed: bool,
13    pub date_submitted: Option<String>,
14    pub date_completed: Option<String>,
15}
16
17#[derive(Debug, Clone)]
18pub struct JobStatus {
19    pub job_id: String,
20    pub job_stage: String,
21    pub failed: bool,
22    pub date_submitted: Option<String>,
23    pub date_completed: Option<String>,
24    pub tool_id: Option<String>,
25    pub self_uri: String,
26    pub results_uri: Option<String>,
27    pub messages: Vec<JobMessage>,
28}
29
30#[derive(Debug, Clone)]
31pub struct JobMessage {
32    pub stage: String,
33    pub text: String,
34    pub timestamp: Option<String>,
35}
36
37#[derive(Debug, Clone)]
38pub struct OutputFile {
39    pub filename: String,
40    pub download_uri: String,
41    pub size: u64,
42}
43
44#[derive(Debug, Clone)]
45pub struct DownloadedFile {
46    pub filename: String,
47    pub path: PathBuf,
48    pub size: u64,
49}
50
51pub fn parse_job_list(xml: &str) -> Result<Vec<JobSummary>> {
52    let mut reader = Reader::from_str(xml);
53    reader.config_mut().trim_text(true);
54
55    let mut jobs = Vec::new();
56    let mut buf = Vec::new();
57
58    // Current job being parsed
59    let mut current_url = None;
60    let mut current_title = None;
61    let mut current_tool = None;
62    let mut current_stage = None;
63    let mut current_failed = false;
64    let mut current_date_submitted = None;
65    let mut current_date_completed = None;
66
67    let mut in_self_uri = false;
68    let mut in_job = false;
69    let mut current_tag = String::new();
70
71    loop {
72        match reader.read_event_into(&mut buf) {
73            Ok(Event::Start(e)) => {
74                let tag = String::from_utf8_lossy(e.name().as_ref()).to_string();
75                current_tag = tag.clone();
76
77                match tag.as_str() {
78                    "jobstatus" => {
79                        in_job = true;
80                        // Reset all fields for new job
81                        current_url = None;
82                        current_title = None;
83                        current_tool = None;
84                        current_stage = None;
85                        current_failed = false;
86                        current_date_submitted = None;
87                        current_date_completed = None;
88                    }
89                    "selfUri" => in_self_uri = true,
90                    _ => {}
91                }
92            }
93            Ok(Event::End(e)) => {
94                let tag = String::from_utf8_lossy(e.name().as_ref()).to_string();
95
96                match tag.as_str() {
97                    "selfUri" => in_self_uri = false,
98                    "jobstatus" => {
99                        in_job = false;
100                        if let (Some(url), Some(title)) = (current_url.take(), current_title.take())
101                        {
102                            jobs.push(JobSummary {
103                                job_id: title,
104                                url,
105                                tool: current_tool.take(),
106                                job_stage: current_stage.take(),
107                                failed: current_failed,
108                                date_submitted: current_date_submitted.take(),
109                                date_completed: current_date_completed.take(),
110                            });
111                        }
112                        current_failed = false;
113                    }
114                    _ => {}
115                }
116                current_tag.clear();
117            }
118            Ok(Event::Text(e)) => {
119                let text = reader
120                    .decoder()
121                    .decode(e.as_ref())
122                    .ok()
123                    .map(|s| s.to_string());
124
125                if let Some(text) = text {
126                    match current_tag.as_str() {
127                        "url" if in_self_uri => current_url = Some(text),
128                        "title" if in_self_uri => current_title = Some(text),
129                        "toolId" if in_job => current_tool = Some(text),
130                        "jobStage" if in_job => current_stage = Some(text),
131                        "failed" if in_job => current_failed = text == "true",
132                        "dateSubmitted" if in_job => current_date_submitted = Some(text),
133                        "dateTerminated" if in_job => current_date_completed = Some(text),
134                        _ => {}
135                    }
136                }
137            }
138            Ok(Event::Eof) => break,
139            Err(e) => {
140                return Err(anyhow::anyhow!(
141                    "XML parse error at position {}: {}",
142                    reader.buffer_position(),
143                    e
144                ))
145            }
146            _ => {}
147        }
148        buf.clear();
149    }
150
151    Ok(jobs)
152}
153
154pub fn parse_job_status(xml: &str) -> Result<JobStatus> {
155    let mut reader = Reader::from_str(xml);
156    reader.config_mut().trim_text(true);
157
158    let mut buf = Vec::new();
159    let mut job_id = String::new();
160    let mut job_stage = String::new();
161    let mut failed = false;
162    let mut terminal_stage = false;
163    let mut date_submitted: Option<String> = None;
164    let mut date_completed: Option<String> = None;
165    let mut self_uri = String::new();
166    let mut results_uri: Option<String> = None;
167    let mut messages = Vec::new();
168
169    let mut current_tag = String::new();
170    let mut in_results_uri = false;
171    let mut in_message = false;
172    let mut current_message_stage = String::new();
173    let mut current_message_text = String::new();
174    let mut current_message_timestamp = None;
175
176    loop {
177        match reader.read_event_into(&mut buf) {
178            Ok(Event::Start(e)) => {
179                current_tag = String::from_utf8_lossy(e.name().as_ref()).to_string();
180                match current_tag.as_str() {
181                    "resultsUri" => in_results_uri = true,
182                    "message" => {
183                        in_message = true;
184                        current_message_stage.clear();
185                        current_message_text.clear();
186                        current_message_timestamp = None;
187                    }
188                    _ => {}
189                }
190            }
191            Ok(Event::End(e)) => {
192                let tag = String::from_utf8_lossy(e.name().as_ref()).to_string();
193                match tag.as_str() {
194                    "resultsUri" => in_results_uri = false,
195                    "message" => {
196                        if in_message {
197                            messages.push(JobMessage {
198                                stage: current_message_stage.clone(),
199                                text: current_message_text.clone(),
200                                timestamp: current_message_timestamp.clone(),
201                            });
202                            in_message = false;
203                        }
204                    }
205                    _ => {}
206                }
207                current_tag.clear();
208            }
209            Ok(Event::Text(e)) => {
210                let text = reader
211                    .decoder()
212                    .decode(e.as_ref())
213                    .map(|s| s.to_string())
214                    .unwrap_or_default();
215                match current_tag.as_str() {
216                    "jobHandle" => job_id = text,
217                    "jobStage" => job_stage = text,
218                    "failed" => failed = text == "true",
219                    "terminalStage" => terminal_stage = text == "true",
220                    "dateSubmitted" => date_submitted = Some(text),
221                    "url" if in_results_uri => results_uri = Some(text),
222                    "url" if !in_results_uri && self_uri.is_empty() => self_uri = text,
223                    "stage" if in_message => current_message_stage = text,
224                    "text" if in_message => current_message_text = text,
225                    "timestamp" if in_message => current_message_timestamp = Some(text),
226                    _ => {}
227                }
228            }
229            Ok(Event::Eof) => break,
230            Err(e) => return Err(anyhow::anyhow!("XML parse error: {}", e)),
231            _ => {}
232        }
233        buf.clear();
234    }
235
236    if job_id.is_empty() {
237        anyhow::bail!("Failed to parse job status: missing job ID");
238    }
239
240    // Extract tool from job ID (e.g., "NGBW-JOB-PY_EXPANSE-..." -> "PY_EXPANSE")
241    let extracted_tool = extract_tool_from_job_id(&job_id);
242
243    // Use last message timestamp as completion date if job is in terminal stage
244    if terminal_stage && date_completed.is_none() && !messages.is_empty() {
245        date_completed = messages.last().and_then(|m| m.timestamp.clone());
246    }
247
248    Ok(JobStatus {
249        job_id,
250        job_stage,
251        failed,
252        date_submitted,
253        date_completed,
254        tool_id: extracted_tool,
255        self_uri,
256        results_uri,
257        messages,
258    })
259}
260
261fn extract_tool_from_job_id(job_id: &str) -> Option<String> {
262    // Job IDs follow pattern: NGBW-JOB-{TOOL}-{HASH}
263    // where HASH is a 32-character hex string
264    // TOOL may contain dashes (e.g., TOOL-NAME)
265    let parts: Vec<&str> = job_id.split('-').collect();
266    if parts.len() >= 4 && parts[0] == "NGBW" && parts[1] == "JOB" {
267        let last_part = parts[parts.len() - 1];
268        // Check if last part is a 32-char hex hash
269        if last_part.len() == 32 && last_part.chars().all(|c| c.is_ascii_hexdigit()) {
270            // Tool is everything between "JOB" and the hash
271            let tool_parts = &parts[2..parts.len() - 1];
272            Some(tool_parts.join("-"))
273        } else {
274            // Fallback: assume tool is just the third part
275            Some(parts[2].to_string())
276        }
277    } else {
278        None
279    }
280}
281
282pub fn parse_output_files(xml: &str) -> Result<Vec<OutputFile>> {
283    let mut reader = Reader::from_str(xml);
284    reader.config_mut().trim_text(true);
285
286    let mut files = Vec::new();
287    let mut buf = Vec::new();
288
289    let mut in_jobfile = false;
290    let mut in_download_uri = false;
291    let mut current_filename = None;
292    let mut current_download_uri = None;
293    let mut current_size = None;
294    let mut current_tag = String::new();
295
296    loop {
297        match reader.read_event_into(&mut buf) {
298            Ok(Event::Start(e)) => {
299                let tag = String::from_utf8_lossy(e.name().as_ref()).to_string();
300                match tag.as_str() {
301                    "jobfile" => in_jobfile = true,
302                    "downloadUri" => in_download_uri = true,
303                    _ => current_tag = tag,
304                }
305            }
306            Ok(Event::End(e)) => {
307                let tag = String::from_utf8_lossy(e.name().as_ref()).to_string();
308                match tag.as_str() {
309                    "jobfile" => {
310                        if let (Some(filename), Some(download_uri), Some(size)) = (
311                            current_filename.take(),
312                            current_download_uri.take(),
313                            current_size.take(),
314                        ) {
315                            files.push(OutputFile {
316                                filename,
317                                download_uri,
318                                size,
319                            });
320                        }
321                        in_jobfile = false;
322                    }
323                    "downloadUri" => in_download_uri = false,
324                    _ => {}
325                }
326                current_tag.clear();
327            }
328            Ok(Event::Text(e)) => {
329                let text = reader
330                    .decoder()
331                    .decode(e.as_ref())
332                    .map(|s| s.to_string())
333                    .unwrap_or_default();
334                if in_jobfile {
335                    match current_tag.as_str() {
336                        "filename" => current_filename = Some(text),
337                        "length" => current_size = text.parse().ok(),
338                        "url" if in_download_uri => current_download_uri = Some(text),
339                        _ => {}
340                    }
341                }
342            }
343            Ok(Event::Eof) => break,
344            Err(e) => return Err(anyhow::anyhow!("XML parse error: {}", e)),
345            _ => {}
346        }
347        buf.clear();
348    }
349
350    Ok(files)
351}