Skip to content

Commit 347ebfd

Browse files
refactor: improve code quality and reduce duplication
- Rename slurm_job_id to scheduler_job_id for scheduler-agnostic naming - Add tracing crate and replace eprintln! with structured logging - Use once_cell::Lazy for pre-compiled regex patterns in types.rs - Extract shared duration formatting functions to charmer-parsers - Remove redundant parser implementations in failure modules - Consolidate time parsing with parse_duration_secs() 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 9c6e8f2 commit 347ebfd

File tree

22 files changed

+185
-310
lines changed

22 files changed

+185
-310
lines changed

Cargo.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ camino = { version = "1.1", features = ["serde1"] }
3535

3636
# Regex for parsing
3737
regex = "1.11"
38+
once_cell = "1.20"
3839

3940
# Base64 for snakemake filenames
4041
base64 = "0.22"
@@ -43,6 +44,9 @@ base64 = "0.22"
4344
thiserror = "2.0"
4445
miette = { version = "7.4", features = ["fancy"] }
4546

47+
# Logging
48+
tracing = "0.1"
49+
4650
# Clipboard
4751
arboard = "3"
4852

crates/charmer-core/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,4 @@ base64.workspace = true
1111
thiserror.workspace = true
1212
camino.workspace = true
1313
chrono.workspace = true
14+
tracing.workspace = true

crates/charmer-core/src/metadata.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -138,7 +138,7 @@ pub fn scan_metadata_dir(working_dir: &Utf8Path) -> Result<Vec<SnakemakeJob>, Me
138138
Ok(job) => jobs.push(job),
139139
Err(e) => {
140140
// Log but continue on parse errors
141-
eprintln!("Warning: Failed to parse metadata file {}: {}", path, e);
141+
tracing::warn!("Failed to parse metadata file {}: {}", path, e);
142142
}
143143
}
144144
}

crates/charmer-lsf/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,4 @@ charmer-parsers.workspace = true
99
chrono.workspace = true
1010
thiserror.workspace = true
1111
tokio.workspace = true
12+
tracing.workspace = true

crates/charmer-lsf/src/bjobs.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@ pub async fn query_bjobs(job_name_filter: Option<&str>) -> Result<Vec<LsfJob>, B
8787
}
8888
match parse_bjobs_line(line) {
8989
Ok(job) => jobs.push(job),
90-
Err(e) => eprintln!("Warning: {}", e),
90+
Err(e) => tracing::warn!("Failed to parse bjobs line: {}", e),
9191
}
9292
}
9393

crates/charmer-lsf/src/failure.rs

Lines changed: 34 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,10 @@
22
//!
33
//! Query detailed failure information and provide actionable suggestions.
44
5-
use charmer_parsers::run_command_allow_failure;
5+
use charmer_parsers::{
6+
format_duration, format_duration_lsf, parse_duration_secs, parse_memory_mb,
7+
run_command_allow_failure, MemoryFormat,
8+
};
69
use thiserror::Error;
710
use tokio::process::Command;
811

@@ -193,19 +196,19 @@ fn parse_bhist_output(job_id: &str, output: &str) -> Result<FailureAnalysis, Fai
193196

194197
// Look for memory info
195198
if line.contains("MAX MEM:") {
196-
max_mem_mb = parse_lsf_memory(line, "MAX MEM:");
199+
max_mem_mb = parse_lsf_memory_from_line(line, "MAX MEM:");
197200
}
198201
if line.contains("MEMLIMIT") || line.contains("MEM LIMIT:") {
199-
mem_limit_mb =
200-
parse_lsf_memory(line, "MEMLIMIT").or_else(|| parse_lsf_memory(line, "MEM LIMIT:"));
202+
mem_limit_mb = parse_lsf_memory_from_line(line, "MEMLIMIT")
203+
.or_else(|| parse_lsf_memory_from_line(line, "MEM LIMIT:"));
201204
}
202205

203206
// Look for runtime info
204207
if line.contains("Run time:") || line.contains("RUN_TIME:") {
205-
run_time_seconds = parse_lsf_time(line);
208+
run_time_seconds = parse_lsf_time_from_line(line);
206209
}
207210
if line.contains("RUNLIMIT") || line.contains("RUN LIMIT:") {
208-
run_limit_seconds = parse_lsf_time(line);
211+
run_limit_seconds = parse_lsf_time_from_line(line);
209212
}
210213
}
211214

@@ -285,99 +288,61 @@ fn extract_number(s: &str, prefix: &str) -> Option<u64> {
285288
}
286289
}
287290

288-
/// Parse LSF memory value (e.g., "4.5 Gbytes", "1024 Mbytes").
289-
fn parse_lsf_memory(line: &str, prefix: &str) -> Option<u64> {
291+
/// Parse LSF memory value from a line with a prefix (e.g., "MAX MEM: 4.5 Gbytes").
292+
fn parse_lsf_memory_from_line(line: &str, prefix: &str) -> Option<u64> {
290293
if let Some(idx) = line.find(prefix) {
291-
let after = &line[idx + prefix.len()..];
292-
// Find the number
293-
let parts: Vec<&str> = after.split_whitespace().collect();
294-
if parts.len() >= 2 {
295-
let value: f64 = parts[0].parse().ok()?;
296-
let unit = parts[1].to_lowercase();
297-
return Some(if unit.starts_with('g') {
298-
(value * 1024.0) as u64
299-
} else if unit.starts_with('m') {
300-
value as u64
301-
} else if unit.starts_with('k') {
302-
(value / 1024.0) as u64
303-
} else {
304-
value as u64
305-
});
306-
}
294+
let after = &line[idx + prefix.len()..].trim();
295+
// Extract just "4.5 Gbytes" part for the shared parser
296+
let mem_str: String = after
297+
.split_whitespace()
298+
.take(2)
299+
.collect::<Vec<_>>()
300+
.join(" ");
301+
parse_memory_mb(&mem_str, MemoryFormat::Lsf)
302+
} else {
303+
None
307304
}
308-
None
309305
}
310306

311-
/// Parse LSF time value (e.g., "01:30:00", "1800 seconds").
312-
fn parse_lsf_time(line: &str) -> Option<u64> {
313-
// Look for HH:MM:SS pattern
307+
/// Parse LSF time value from a line (e.g., "Run time: 01:30:00").
308+
fn parse_lsf_time_from_line(line: &str) -> Option<u64> {
309+
// Look for HH:MM:SS pattern in the line
314310
for word in line.split_whitespace() {
315-
if word.contains(':') {
316-
let parts: Vec<&str> = word.split(':').collect();
317-
if parts.len() == 3 {
318-
let hours: u64 = parts[0].parse().ok()?;
319-
let mins: u64 = parts[1].parse().ok()?;
320-
let secs: u64 = parts[2].parse().ok()?;
321-
return Some(hours * 3600 + mins * 60 + secs);
322-
}
311+
if word.contains(':') && word.chars().filter(|c| *c == ':').count() == 2 {
312+
return parse_duration_secs(word);
323313
}
324314
}
325315
// Look for "N seconds" pattern
326316
if let Some(idx) = line.find("seconds") {
327317
let before = line[..idx].trim();
328-
// Get the last number token before "seconds"
329318
if let Some(num_str) = before.split_whitespace().last() {
330-
if let Ok(secs) = num_str.parse::<f64>() {
331-
return Some(secs as u64);
319+
if let Ok(secs) = num_str.parse::<u64>() {
320+
return Some(secs);
332321
}
333322
}
334323
}
335324
None
336325
}
337326

338-
/// Format seconds as human-readable duration.
339-
fn format_duration(seconds: u64) -> String {
340-
let hours = seconds / 3600;
341-
let mins = (seconds % 3600) / 60;
342-
let secs = seconds % 60;
343-
344-
if hours > 24 {
345-
let days = hours / 24;
346-
let hours = hours % 24;
347-
format!("{}d {:02}:{:02}:{:02}", days, hours, mins, secs)
348-
} else if hours > 0 {
349-
format!("{:02}:{:02}:{:02}", hours, mins, secs)
350-
} else {
351-
format!("{:02}:{:02}", mins, secs)
352-
}
353-
}
354-
355-
/// Format seconds as LSF duration format (HH:MM).
356-
fn format_duration_lsf(seconds: u64) -> String {
357-
let hours = seconds / 3600;
358-
let mins = (seconds % 3600) / 60;
359-
format!("{}:{:02}", hours, mins)
360-
}
361-
362327
#[cfg(test)]
363328
mod tests {
364329
use super::*;
365330

366331
#[test]
367-
fn test_parse_lsf_memory() {
332+
fn test_parse_lsf_memory_from_line() {
368333
assert_eq!(
369-
parse_lsf_memory("MAX MEM: 4.5 Gbytes", "MAX MEM:"),
334+
parse_lsf_memory_from_line("MAX MEM: 4.5 GB", "MAX MEM:"),
370335
Some(4608)
371336
);
372337
assert_eq!(
373-
parse_lsf_memory("MEMLIMIT 8192 Mbytes", "MEMLIMIT"),
338+
parse_lsf_memory_from_line("MEMLIMIT 8192 MB", "MEMLIMIT"),
374339
Some(8192)
375340
);
376341
}
377342

378343
#[test]
379-
fn test_parse_lsf_time() {
380-
assert_eq!(parse_lsf_time("Run time: 01:30:00"), Some(5400));
381-
assert_eq!(parse_lsf_time("1800 seconds"), Some(1800));
344+
fn test_parse_lsf_time_from_line() {
345+
assert_eq!(parse_lsf_time_from_line("Run time: 01:30:00"), Some(5400));
346+
assert_eq!(parse_lsf_time_from_line("1800 seconds"), Some(1800));
382347
}
383348
}

crates/charmer-monitor/src/app.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -353,8 +353,8 @@ impl App {
353353
}
354354
}
355355

356-
// Try SLURM log path format: .snakemake/slurm_logs/rule_{rule}/{slurm_job_id}.log
357-
if let Some(ref slurm_id) = job.slurm_job_id {
356+
// Try SLURM log path format: .snakemake/slurm_logs/rule_{rule}/{scheduler_job_id}.log
357+
if let Some(ref slurm_id) = job.scheduler_job_id {
358358
let slurm_log = working_dir
359359
.join(".snakemake")
360360
.join("slurm_logs")

crates/charmer-monitor/src/components/job_detail.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -319,8 +319,8 @@ fn build_detail_lines(job: &Job, command_expanded: bool) -> Vec<Line<'static>> {
319319
),
320320
]));
321321

322-
// SLURM/LSF Job ID
323-
if let Some(ref slurm_id) = job.slurm_job_id {
322+
// Scheduler Job ID (SLURM/LSF)
323+
if let Some(ref slurm_id) = job.scheduler_job_id {
324324
lines.push(Line::from(vec![
325325
Span::styled("Job ID: ", Style::default().fg(Color::Gray)),
326326
Span::styled(slurm_id.clone(), Style::default().fg(Color::Cyan)),

crates/charmer-parsers/src/lib.rs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,10 @@ pub mod time;
99

1010
pub use command::{run_command, run_command_allow_failure, CommandError};
1111
pub use memory::{parse_memory_mb, MemoryFormat};
12-
pub use time::{parse_duration, parse_exit_code, parse_lsf_timestamp, parse_slurm_timestamp};
12+
pub use time::{
13+
format_duration, format_duration_lsf, format_duration_slurm, parse_duration,
14+
parse_duration_secs, parse_exit_code, parse_lsf_timestamp, parse_slurm_timestamp,
15+
};
1316

1417
/// Filter helper for optional string fields.
1518
/// Returns None if the string is empty or a placeholder value.

crates/charmer-parsers/src/time.rs

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,51 @@ pub fn parse_exit_code(s: &str) -> i32 {
8787
.unwrap_or(0)
8888
}
8989

90+
/// Parse duration to seconds from various formats.
91+
///
92+
/// Like `parse_duration` but returns seconds as u64 instead of Duration.
93+
pub fn parse_duration_secs(s: &str) -> Option<u64> {
94+
parse_duration(s).map(|d| d.as_secs())
95+
}
96+
97+
/// Format seconds as human-readable duration (e.g., "1d 02:30:00", "01:30:00", "05:30").
98+
pub fn format_duration(seconds: u64) -> String {
99+
let hours = seconds / 3600;
100+
let mins = (seconds % 3600) / 60;
101+
let secs = seconds % 60;
102+
103+
if hours > 24 {
104+
let days = hours / 24;
105+
let hours = hours % 24;
106+
format!("{}d {:02}:{:02}:{:02}", days, hours, mins, secs)
107+
} else if hours > 0 {
108+
format!("{:02}:{:02}:{:02}", hours, mins, secs)
109+
} else {
110+
format!("{:02}:{:02}", mins, secs)
111+
}
112+
}
113+
114+
/// Format seconds as LSF duration format (HH:MM for resource limits).
115+
pub fn format_duration_lsf(seconds: u64) -> String {
116+
let hours = seconds / 3600;
117+
let mins = (seconds % 3600) / 60;
118+
format!("{}:{:02}", hours, mins)
119+
}
120+
121+
/// Format seconds as SLURM duration format (D-HH:MM:SS).
122+
pub fn format_duration_slurm(seconds: u64) -> String {
123+
let days = seconds / 86400;
124+
let hours = (seconds % 86400) / 3600;
125+
let mins = (seconds % 3600) / 60;
126+
let secs = seconds % 60;
127+
128+
if days > 0 {
129+
format!("{}-{:02}:{:02}:{:02}", days, hours, mins, secs)
130+
} else {
131+
format!("{:02}:{:02}:{:02}", hours, mins, secs)
132+
}
133+
}
134+
90135
#[cfg(test)]
91136
mod tests {
92137
use super::*;

0 commit comments

Comments
 (0)