diff --git a/src/jobs/inbox_process.rs b/src/jobs/inbox_process.rs index cb792a7..c58ed39 100644 --- a/src/jobs/inbox_process.rs +++ b/src/jobs/inbox_process.rs @@ -262,7 +262,7 @@ async fn process_folder_batch( format!("batch({})", file_count) } else { let short = truncate_path(folder_rel, 20); - format!("{short}({})", file_count) + truncate_utf8_bytes(&format!("{short}({})", file_count), 32) }; let mut run = match JobRun::create_running(db, "file_process", &trigger_label).await { Ok(r) => r, @@ -963,9 +963,37 @@ fn truncate_path(path: &str, max_len: usize) -> String { } } +fn truncate_utf8_bytes(value: &str, max_bytes: usize) -> String { + if value.len() <= max_bytes { + return value.to_owned(); + } + + if max_bytes <= 3 { + return ".".repeat(max_bytes); + } + + let suffix_budget = max_bytes - 3; + let mut suffix = Vec::new(); + let mut suffix_len = 0; + for ch in value.chars().rev() { + let ch_len = ch.len_utf8(); + if suffix_len + ch_len > suffix_budget { + break; + } + suffix.push(ch); + suffix_len += ch_len; + } + + let mut result = String::from("..."); + for ch in suffix.iter().rev() { + result.push(*ch); + } + result +} + #[cfg(test)] mod tests { - use super::truncate_path; + use super::{truncate_path, truncate_utf8_bytes}; #[test] fn truncate_path_handles_unicode_boundaries() { @@ -978,4 +1006,12 @@ mod tests { "...еНазвание" ); } + + #[test] + fn truncate_utf8_bytes_handles_limited_string_boundaries() { + let value = truncate_utf8_bytes("KUNTEYNIR/Блёвбургер(1)", 32); + assert!(value.len() <= 32); + assert!(value.is_char_boundary(value.len())); + assert!(value.ends_with("Блёвбургер(1)")); + } } diff --git a/src/scheduler/mod.rs b/src/scheduler/mod.rs index c8e8fe5..1a7eb8a 100644 --- a/src/scheduler/mod.rs +++ b/src/scheduler/mod.rs @@ -30,6 +30,41 @@ fn now_iso() -> LimitedString<32> { LimitedString::new(&chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string()).unwrap() } +fn truncate_utf8_bytes(value: &str, max_bytes: usize) -> String { + if value.len() <= max_bytes { + return value.to_owned(); + } + + if max_bytes <= 3 { + return ".".repeat(max_bytes); + } + + let suffix_budget = max_bytes - 3; + let mut suffix = Vec::new(); + let mut suffix_len = 0; + for ch in value.chars().rev() { + let ch_len = ch.len_utf8(); + if suffix_len + ch_len > suffix_budget { + break; + } + suffix.push(ch); + suffix_len += ch_len; + } + + let mut result = String::from("..."); + for ch in suffix.iter().rev() { + result.push(*ch); + } + result +} + +fn limited_string(value: &str) -> LimitedString { + LimitedString::new(value).unwrap_or_else(|_| { + let truncated = truncate_utf8_bytes(value, N as usize); + LimitedString::new(&truncated).unwrap() + }) +} + impl ScheduledJob { pub async fn list_all(db: &Database) -> cot::db::Result> { Self::objects().all(db).await @@ -138,14 +173,14 @@ impl JobRun { pub async fn create_running(db: &Database, job_name: &str, trigger: &str) -> cot::db::Result { let mut run = Self { id: Auto::auto(), - job_name: LimitedString::new(job_name).unwrap(), + job_name: limited_string(job_name), status: LimitedString::new("running").unwrap(), started_at: now_iso(), finished_at: None, duration_ms: None, log_output: None, error_message: None, - trigger: LimitedString::new(trigger).unwrap(), + trigger: limited_string(trigger), }; run.insert(db).await?; Ok(run) @@ -273,14 +308,14 @@ impl JobRunRow { fn into_model(self) -> JobRun { JobRun { id: Auto::Fixed(self.id), - job_name: LimitedString::new(&self.job_name).unwrap(), - status: LimitedString::new(&self.status).unwrap(), - started_at: LimitedString::new(&self.started_at).unwrap(), + job_name: limited_string(&self.job_name), + status: limited_string(&self.status), + started_at: limited_string(&self.started_at), finished_at: self.finished_at, duration_ms: self.duration_ms, log_output: self.log_output, error_message: self.error_message, - trigger: LimitedString::new(&self.trigger).unwrap(), + trigger: limited_string(&self.trigger), } } }