Skip to content

Commit e12d447

Browse files
Merge branch 'main' into max/add-dataset-snapshots
2 parents bb15342 + 29191c0 commit e12d447

5 files changed

Lines changed: 95 additions & 19 deletions

File tree

src/config/mod.rs

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -202,17 +202,15 @@ pub(crate) fn trimmed_option(value: Option<&str>) -> Option<&str> {
202202
}
203203

204204
pub fn save_file(path: &Path, config: &Config) -> Result<()> {
205-
if let Some(parent) = path.parent() {
206-
fs::create_dir_all(parent)?;
207-
}
205+
let parent = path.parent().unwrap_or_else(|| Path::new("."));
206+
fs::create_dir_all(parent)?;
208207

209208
let json = serde_json::to_string_pretty(config)?;
210-
let temp_path = path.with_extension("tmp");
211-
let mut file = fs::File::create(&temp_path)?;
209+
let mut file = tempfile::NamedTempFile::new_in(parent)?;
212210
file.write_all(json.as_bytes())?;
213211
file.write_all(b"\n")?;
214-
file.sync_all()?;
215-
fs::rename(&temp_path, path)?;
212+
file.as_file().sync_all()?;
213+
file.persist(path)?;
216214

217215
Ok(())
218216
}

src/sync.rs

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ use crate::experiments::api::create_experiment;
2626
use crate::http::ApiClient;
2727
use crate::projects::api::{create_project, list_projects, Project};
2828
use crate::ui::{animations_enabled, fuzzy_select, is_quiet};
29+
use crate::utils::parse_duration_to_seconds;
2930

3031
const STATE_SCHEMA_VERSION: u32 = 1;
3132
const DEFAULT_PULL_LIMIT: usize = 100;
@@ -2615,16 +2616,20 @@ fn parse_duration_to_seconds(input: &str) -> Result<u64> {
26152616
return Ok(seconds);
26162617
}
26172618

2618-
let (num_str, unit) = trimmed.split_at(trimmed.len().saturating_sub(1));
2619+
let suffix = trimmed.chars().last().filter(|ch| ch.is_ascii_alphabetic());
2620+
let (num_str, unit) = match suffix {
2621+
Some(unit) => (&trimmed[..trimmed.len() - unit.len_utf8()], unit),
2622+
None => (trimmed, 's'),
2623+
};
26192624
let value: u64 = num_str
26202625
.trim()
26212626
.parse()
26222627
.with_context(|| format!("invalid duration '{input}'"))?;
2623-
let multiplier = match unit.to_ascii_lowercase().as_str() {
2624-
"s" => 1,
2625-
"m" => 60,
2626-
"h" => 60 * 60,
2627-
"d" => 60 * 60 * 24,
2628+
let multiplier = match unit.to_ascii_lowercase() {
2629+
's' => 1,
2630+
'm' => 60,
2631+
'h' => 60 * 60,
2632+
'd' => 60 * 60 * 24,
26282633
_ => bail!("invalid duration '{input}'. expected suffix s/m/h/d"),
26292634
};
26302635
Ok(value.saturating_mul(multiplier))
@@ -4291,6 +4296,14 @@ fn spinner_bar(message: &str) -> ProgressBar {
42914296
mod tests {
42924297
use super::*;
42934298

4299+
#[test]
4300+
fn parse_duration_to_seconds_rejects_non_ascii_suffix_without_panicking() {
4301+
for input in ["1–", "1é", "1🙂"] {
4302+
let err = parse_duration_to_seconds(input).expect_err("invalid unicode suffix");
4303+
assert!(err.to_string().contains("invalid duration"));
4304+
}
4305+
}
4306+
42944307
#[test]
42954308
fn push_checkpoint_line_offset_advances_only_after_commit() {
42964309
let mut state =

src/traces.rs

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ use crate::args::BaseArgs;
3636
use crate::auth::{self, login};
3737
use crate::http::ApiClient;
3838
use crate::ui::{fuzzy_select, is_interactive, with_spinner};
39+
use crate::utils::parse_duration_to_seconds;
3940

4041
const MAX_TRACE_SPANS: usize = 5000;
4142
const MAX_BTQL_PAGE_LIMIT: usize = 1000;
@@ -5039,16 +5040,20 @@ fn parse_duration_to_seconds(input: &str) -> Result<u64> {
50395040
return Ok(seconds);
50405041
}
50415042

5042-
let (num_str, unit) = trimmed.split_at(trimmed.len().saturating_sub(1));
5043+
let suffix = trimmed.chars().last().filter(|ch| ch.is_ascii_alphabetic());
5044+
let (num_str, unit) = match suffix {
5045+
Some(unit) => (&trimmed[..trimmed.len() - unit.len_utf8()], unit),
5046+
None => (trimmed, 's'),
5047+
};
50435048
let value: u64 = num_str
50445049
.trim()
50455050
.parse()
50465051
.with_context(|| format!("invalid duration '{input}'"))?;
5047-
let multiplier = match unit.to_ascii_lowercase().as_str() {
5048-
"s" => 1,
5049-
"m" => 60,
5050-
"h" => 60 * 60,
5051-
"d" => 60 * 60 * 24,
5052+
let multiplier = match unit.to_ascii_lowercase() {
5053+
's' => 1,
5054+
'm' => 60,
5055+
'h' => 60 * 60,
5056+
'd' => 60 * 60 * 24,
50525057
_ => bail!("invalid duration '{input}'. expected suffix s/m/h/d"),
50535058
};
50545059
Ok(value.saturating_mul(multiplier))
@@ -6187,6 +6192,14 @@ mod tests {
61876192
assert_eq!(parse_duration_to_seconds("1d").expect("days"), 86_400);
61886193
}
61896194

6195+
#[test]
6196+
fn parse_duration_to_seconds_rejects_non_ascii_suffix_without_panicking() {
6197+
for input in ["1–", "1é", "1🙂"] {
6198+
let err = parse_duration_to_seconds(input).expect_err("invalid unicode suffix");
6199+
assert!(err.to_string().contains("invalid duration"));
6200+
}
6201+
}
6202+
61906203
#[test]
61916204
fn build_base_filter_clause_uses_window_or_since() {
61926205
let from_window = build_base_filter_clause(None, "1h", Some("metadata.model IS NOT NULL"))

src/utils/duration.rs

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
use anyhow::{bail, Context, Result};
2+
3+
pub fn parse_duration_to_seconds(input: &str) -> Result<u64> {
4+
let trimmed = input.trim();
5+
if trimmed.is_empty() {
6+
bail!("duration cannot be empty");
7+
}
8+
if let Ok(seconds) = trimmed.parse::<u64>() {
9+
return Ok(seconds);
10+
}
11+
12+
let suffix = trimmed.chars().last().filter(|ch| ch.is_ascii_alphabetic());
13+
let (num_str, unit) = match suffix {
14+
Some(unit) => (&trimmed[..trimmed.len() - unit.len_utf8()], unit),
15+
None => (trimmed, 's'),
16+
};
17+
let value: u64 = num_str
18+
.trim()
19+
.parse()
20+
.with_context(|| format!("invalid duration '{input}'"))?;
21+
let multiplier = match unit.to_ascii_lowercase() {
22+
's' => 1,
23+
'm' => 60,
24+
'h' => 60 * 60,
25+
'd' => 60 * 60 * 24,
26+
_ => bail!("invalid duration '{input}'. expected suffix s/m/h/d"),
27+
};
28+
Ok(value.saturating_mul(multiplier))
29+
}
30+
31+
#[cfg(test)]
32+
mod tests {
33+
use super::*;
34+
35+
#[test]
36+
fn supports_units() {
37+
assert_eq!(parse_duration_to_seconds("90").expect("seconds"), 90);
38+
assert_eq!(parse_duration_to_seconds("15m").expect("minutes"), 900);
39+
assert_eq!(parse_duration_to_seconds("2h").expect("hours"), 7_200);
40+
assert_eq!(parse_duration_to_seconds("1d").expect("days"), 86_400);
41+
}
42+
43+
#[test]
44+
fn rejects_non_ascii_suffix_without_panicking() {
45+
for input in ["1–", "1é", "1🙂"] {
46+
let err = parse_duration_to_seconds(input).expect_err("invalid unicode suffix");
47+
assert!(err.to_string().contains("invalid duration"));
48+
}
49+
}
50+
}

src/utils/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
1+
mod duration;
12
mod fs_atomic;
23
mod git;
34
mod ids;
45
mod json_object;
56
mod plurals;
67
mod profile;
78

9+
pub use duration::parse_duration_to_seconds;
810
pub use fs_atomic::write_text_atomic;
911
pub use git::GitRepo;
1012
pub(crate) use ids::new_uuid_id;

0 commit comments

Comments
 (0)