From 560d365eb84772806632d6d10ad2a6254f5cbc5b Mon Sep 17 00:00:00 2001 From: Raman Date: Thu, 23 Jul 2026 15:20:08 +0200 Subject: [PATCH 1/3] Pick combats to delete, auto-load on startup, and refresh only on change - Replace "Clear Log File" with a dialog listing every combat with checkboxes (select all / none; all but the newest selected by default); deletion keeps the chosen combats' byte ranges and rewrites the log atomically, aborting if any kept combat can't be read. - Show ~15 combats in the dropdown before it scrolls. - Load the combats list on startup and push the latest combat to the overlay as soon as it is enabled, instead of only after Refresh Now. - Only notify handlers when the log actually changed (tracked by size), so auto refresh no longer rebuilds the view (collapsing expanded trees) for no-op events, and re-create the auto-refresh watcher after a delete. - On Linux, watch the log folder while consolidating so the game's writes to a rotating file still drive the live view. Co-Authored-By: Claude Opus 4.8 --- src/analyzer/mod.rs | 58 ++++++++++ src/app/analysis_handling.rs | 210 +++++++++++++++++++++++++++++------ src/app/mod.rs | 11 +- src/app/settings/general.rs | 110 ++++++++++++++---- src/app/settings/mod.rs | 12 +- 5 files changed, 337 insertions(+), 64 deletions(-) diff --git a/src/analyzer/mod.rs b/src/analyzer/mod.rs index 04ab495..88da784 100644 --- a/src/analyzer/mod.rs +++ b/src/analyzer/mod.rs @@ -645,6 +645,64 @@ impl CombatName { mod tests { use super::*; + /// Real-data check for the "delete combats" flow: keep a subset of combats + /// by concatenating their byte ranges, then re-analyze and assert exactly + /// those combats survive, in order (removing a middle combat must not merge + /// its neighbours). Run with: + /// `cargo test keep_subset_of_combats -- --ignored --nocapture`. + #[test] + #[ignore = "reads a real STO log"] + fn keep_subset_of_combats() { + let src = "/home/raman/Games/steamapps/common/Star Trek Online/Star Trek Online/Live/logs/GameClient/combatlog.log"; + let dir = std::env::temp_dir().join("cla-keep-combats-test"); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let work = dir.join("combatlog.log"); + std::fs::copy(src, &work).unwrap(); + + let analyze = |file: &Path| { + let mut analyzer = Analyzer::new(AnalysisSettings { + combatlog_file: file.to_string_lossy().into_owned(), + ..Default::default() + }) + .unwrap(); + analyzer.update(); + analyzer + }; + + let analyzer = analyze(&work); + let combats = analyzer.result(); + let n = combats.len(); + assert!(n >= 3, "need several combats to test with, got {n}"); + + // Keep the newest plus every other combat (drops several middle ones). + let keep: Vec = (0..n).filter(|i| *i == n - 1 || i % 2 == 0).collect(); + let kept_ids: Vec = keep.iter().map(|&i| combats[i].identifier()).collect(); + + let mut data = Vec::new(); + for &i in &keep { + let bytes = combats[i] + .read_log_combat_data(&work) + .expect("combat byte range"); + data.extend_from_slice(&bytes); + } + let rewritten = dir.join("kept.log"); + std::fs::write(&rewritten, &data).unwrap(); + + let new_ids: Vec = analyze(&rewritten) + .result() + .iter() + .map(|c| c.identifier()) + .collect(); + println!("kept {} of {} combats", keep.len(), n); + assert_eq!( + new_ids, kept_ids, + "rewritten log must contain exactly the kept combats, in order" + ); + + let _ = std::fs::remove_dir_all(&dir); + } + #[test] #[ignore = "manual test"] fn analyze_log() { diff --git a/src/app/analysis_handling.rs b/src/app/analysis_handling.rs index d264c94..9e94911 100644 --- a/src/app/analysis_handling.rs +++ b/src/app/analysis_handling.rs @@ -38,6 +38,10 @@ struct AnalysisContext { is_busy: Arc, auto_refresh_interval: Duration, auto_refresh: Option, + // Size of the log at the last refresh we notified about; used to skip + // no-op refreshes so auto refresh doesn't rebuild the view when nothing + // changed. Reset whenever the analyzer is (re)built. + last_file_size: Option, } #[derive(Debug)] @@ -66,7 +70,7 @@ enum Instruction { Refresh(bool), AutoRefresh, GetCombat(usize, u32), - ClearLog, + KeepCombats(Vec), SaveCombat(usize, PathBuf), EnableAutoRefresh(bool, u32), SetAutoRefreshInterval(f64), @@ -142,8 +146,9 @@ impl AnalysisHandler { .unwrap(); } - pub fn clear_log(&self) { - self.tx.send(Instruction::ClearLog).unwrap(); + /// Rewrites the log to keep only the combats at `keep` (dropping the rest). + pub fn keep_combats(&self, keep: Vec) { + self.tx.send(Instruction::KeepCombats(keep)).unwrap(); } pub fn save_combat(&self, combat_index: usize, file: PathBuf) { @@ -215,8 +220,15 @@ impl AnalysisContext { is_busy, auto_refresh_interval: AutoRefreshContext::interval(auto_refresh_interval_seconds), auto_refresh: None, + last_file_size: None, }; _self.update_auto_refresh(); + // Load the combats list on startup without waiting for the user to hit + // Refresh. It is queued so it runs on the analysis thread, keeping the + // window responsive while the initial parse happens. + if _self.analyzer.is_some() { + let _ = _self.instruction_tx.send(Instruction::Refresh(false)); + } _self } @@ -233,11 +245,19 @@ impl AnalysisContext { Instruction::GetCombat(combat_index, handler) => { self.get_combat(combat_index, handler); } - Instruction::ClearLog => self.clear_log(), + Instruction::KeepCombats(keep) => self.keep_combats(keep), Instruction::SaveCombat(combat_index, file) => self.save_combat(combat_index, file), Instruction::EnableAutoRefresh(enable, handler) => { self.handler_mut(handler, |h| h.auto_refresh = enable); self.update_auto_refresh(); + if enable { + // Show the latest combat right away on the just-enabled + // handler (e.g. the overlay), instead of only after the + // next refresh. + if let info @ AnalysisInfo::Refreshed { .. } = self.latest_info() { + self.send_info(info, handler); + } + } } Instruction::SetAutoRefreshInterval(refresh_interval) => { self.set_auto_refresh_interval(refresh_interval) @@ -267,13 +287,14 @@ impl AnalysisContext { fn refresh(&mut self, only_when_auto_refresh: bool) { Self::set_is_busy(&self.is_busy, true); - let info = self.try_refresh(); - if only_when_auto_refresh { - for handler in self.handlers.iter().filter(|h| h.auto_refresh) { - handler.send(info.clone(), &self.ctx); + if let Some(info) = self.try_refresh() { + if only_when_auto_refresh { + for handler in self.handlers.iter().filter(|h| h.auto_refresh) { + handler.send(info.clone(), &self.ctx); + } + } else { + self.send_info_all(info); } - } else { - self.send_info_all(info); } if let Some(ctx) = &mut self.auto_refresh { ctx.state = AutoRefreshState::Idle; @@ -281,24 +302,49 @@ impl AnalysisContext { } } - fn try_refresh(&mut self) -> AnalysisInfo { - let analyzer = match self.analyzer.as_mut() { + /// Re-parses the log and returns `None` when it has not changed since the + /// last notified refresh, so callers do not rebuild the view (which would + /// collapse expanded damage trees) for nothing. + fn try_refresh(&mut self) -> Option { + match self.analyzer.as_mut() { + Some(a) => a.update(), + None => { + self.last_file_size = None; + return Some(AnalysisInfo::RefreshError); + } + } + let size = self + .analyzer + .as_ref() + .and_then(|a| std::fs::metadata(&a.settings().combatlog_file).ok()) + .map(|m| m.len()); + // Nothing new since the last notified refresh: skip so the view is not + // rebuilt (which would collapse any expanded tree the user opened). + if size.is_some() && size == self.last_file_size { + return None; + } + self.last_file_size = size; + Some(self.latest_info()) + } + + /// Builds a `Refreshed` from the analyzer's current results without + /// re-reading the log; `RefreshError` if nothing is loaded yet. + fn latest_info(&self) -> AnalysisInfo { + let analyzer = match self.analyzer.as_ref() { Some(a) => a, None => return AnalysisInfo::RefreshError, }; - analyzer.update(); let latest_combat = match analyzer.result().last() { Some(c) => c.clone(), None => return AnalysisInfo::RefreshError, }; - let info = AnalysisInfo::Refreshed { + AnalysisInfo::Refreshed { latest_combat: latest_combat.into(), combats: analyzer.result().iter().map(|c| c.identifier()).collect(), file_size: std::fs::metadata(&analyzer.settings().combatlog_file) .ok() .map(|m| m.len()), - }; - info + } } fn auto_refresh(&mut self) { @@ -344,36 +390,45 @@ impl AnalysisContext { self.send_info(AnalysisInfo::Combat(combat.into()), handler); } - fn clear_log(&mut self) { + /// Rewrites the log so it keeps only the combats at `keep` (their byte + /// ranges), dropping the rest. + fn keep_combats(&mut self, mut keep: Vec) { let analyzer = match &self.analyzer { Some(a) => a, None => return, }; let settings = analyzer.settings().clone(); - let last_combat = analyzer.result().last(); - let last_combat_data = last_combat - .map(|c| c.read_log_combat_data(settings.combatlog_file())) - .flatten(); + keep.sort_unstable(); + keep.dedup(); + let mut data = Vec::new(); + for &index in &keep { + match analyzer + .result() + .get(index) + .and_then(|combat| combat.read_log_combat_data(settings.combatlog_file())) + { + Some(bytes) => data.extend_from_slice(&bytes), + None => { + // Abort rather than risk dropping a combat the user wanted + // to keep; leave the log untouched. + log::error!( + "aborting combat deletion: could not read combat {index} from the log" + ); + return; + } + } + } self.analyzer = None; - - let mut file = match File::options() - .write(true) - .truncate(true) - .create(false) - .open(settings.combatlog_file()) - { - Ok(f) => f, - Err(_) => return, - }; - - if let Some(last_combat_data) = last_combat_data { - let _ = file.write_all(last_combat_data.as_slice()); + if let Err(e) = rewrite_file(settings.combatlog_file(), &data) { + log::error!("failed to rewrite combat log while deleting combats: {e}"); } - - drop(file); self.analyzer = Analyzer::new(settings); + self.last_file_size = None; + // The rewrite replaces the file (new inode), so the auto-refresh watcher + // must be re-created or it would keep watching the old, deleted file. + self.update_auto_refresh(); self.refresh(false); } @@ -479,3 +534,86 @@ impl HandlerContext { } } } + +/// Atomically replaces `path`'s contents with `data` (write to a sibling temp +/// file, flush and sync, then rename over the target) so a crash mid-write +/// cannot leave a truncated log behind. +fn rewrite_file(path: &Path, data: &[u8]) -> std::io::Result<()> { + let dir = path.parent().unwrap_or_else(|| Path::new(".")); + let tmp = dir.join(".cla-log-rewrite.tmp"); + { + let mut file = File::create(&tmp)?; + file.write_all(data)?; + file.flush()?; + file.sync_data()?; + } + std::fs::rename(&tmp, path) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The app should populate the combats list on startup on its own (no manual + /// Refresh). Creates a handler exactly like the app does and waits for the + /// automatic first refresh to deliver the combats. + #[test] + #[ignore = "reads a real STO log"] + fn loads_combats_on_startup() { + // Work on a copy in a scratch dir rather than pointing the handler at + // the real log. + let src = "/home/raman/Games/steamapps/common/Star Trek Online/Star Trek Online/Live/logs/GameClient/combatlog.log"; + let dir = std::env::temp_dir().join(format!("cla-startup-test-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let work = dir.join("combatlog.log"); + std::fs::copy(src, &work).unwrap(); + + let settings = AnalysisSettings { + combatlog_file: work.to_string_lossy().into_owned(), + ..Default::default() + }; + let handler = AnalysisHandler::new(settings, Context::default(), 1.0, false); + + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30); + loop { + if let Some(info) = handler.check_for_info().last() { + match info { + AnalysisInfo::Refreshed { combats, .. } => { + assert!(!combats.is_empty(), "startup refresh produced no combats"); + println!("startup loaded {} combats", combats.len()); + let _ = std::fs::remove_dir_all(&dir); + return; + } + AnalysisInfo::RefreshError => panic!("startup refresh errored"), + AnalysisInfo::Combat(_) => {} + } + } + assert!( + std::time::Instant::now() <= deadline, + "no startup refresh within timeout" + ); + std::thread::sleep(std::time::Duration::from_millis(50)); + } + } + + #[test] + fn rewrite_file_replaces_contents_atomically() { + let dir = std::env::temp_dir().join(format!("cla-rewrite-test-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let target = dir.join("combatlog.log"); + + std::fs::write(&target, b"old contents that are longer").unwrap(); + rewrite_file(&target, b"new").unwrap(); + assert_eq!(std::fs::read(&target).unwrap(), b"new"); + // No temp file is left behind. + assert!(!dir.join(".cla-log-rewrite.tmp").exists()); + + // Rewriting to empty truncates the file. + rewrite_file(&target, b"").unwrap(); + assert_eq!(std::fs::read(&target).unwrap(), b""); + + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/src/app/mod.rs b/src/app/mod.rs index ff801d5..ecd5539 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -66,6 +66,7 @@ impl eframe::App for App { self.settings_window.show( &mut self.state, self.selected_combat.as_deref(), + &self.combats, ui, frame, ); @@ -79,6 +80,9 @@ impl eframe::App for App { ComboBox::new("combat list", "Combats") .width(400.0) + // Show around 15 combats before the list starts to + // scroll (the default only fits a few). + .height(360.0) .selected_text(self.main_tabs.identifier.as_str()) .show_ui(ui, |ui| { for (i, combat) in self.combats.iter().enumerate().rev() { @@ -101,8 +105,11 @@ impl eframe::App for App { self.state.analysis_handler.refresh(); } - self.settings_window - .show_clear_log_dialog(&self.state.analysis_handler, ui); + self.settings_window.show_clear_log_dialog( + &self.state.analysis_handler, + &self.combats, + ui, + ); if ui .checkbox( diff --git a/src/app/settings/general.rs b/src/app/settings/general.rs index a7d8559..a970394 100644 --- a/src/app/settings/general.rs +++ b/src/app/settings/general.rs @@ -16,6 +16,9 @@ pub struct GeneralTab { #[derive(Default)] pub struct ClearLogDialog { is_open: bool, + // Per-combat "delete" flags, indexed like the combats list; rebuilt when the + // dialog opens (default: every combat except the newest). + to_delete: Vec, } impl GeneralTab { @@ -23,6 +26,7 @@ impl GeneralTab { &mut self, analysis_handler: &AnalysisHandler, modified_settings: &mut Settings, + combats: &[String], ui: &mut Ui, frame: &Frame, ) { @@ -40,7 +44,7 @@ impl GeneralTab { } } - self.clear_log_dialog.show(analysis_handler, ui); + self.clear_log_dialog.show(analysis_handler, combats, ui); }); TextEdit::singleline(&mut modified_settings.analysis.combatlog_file) .desired_width(f32::MAX) @@ -90,8 +94,13 @@ impl GeneralTab { ); } - pub fn show_clear_log_dialog(&mut self, analysis_handler: &AnalysisHandler, ui: &mut Ui) { - self.clear_log_dialog.show(analysis_handler, ui); + pub fn show_clear_log_dialog( + &mut self, + analysis_handler: &AnalysisHandler, + combats: &[String], + ui: &mut Ui, + ) { + self.clear_log_dialog.show(analysis_handler, combats, ui); } pub fn initialize(&mut self) { @@ -100,45 +109,98 @@ impl GeneralTab { } impl ClearLogDialog { - fn show(&mut self, analysis_handler: &AnalysisHandler, ui: &mut Ui) { - let clear_response = ui.button("Clear Log File"); + fn show(&mut self, analysis_handler: &AnalysisHandler, combats: &[String], ui: &mut Ui) { + let open_response = ui.button("Clear Log File"); let mut newly_opened = false; - if clear_response.clicked() { + if open_response.clicked() { self.is_open = true; newly_opened = true; + self.reset_selection(combats.len()); } if !self.is_open { return; } - let mut window = Window::new("Clear Log File") + // Keep the flags aligned with the combats list if it changed while the + // dialog was open (e.g. an auto refresh appended a new combat). + if self.to_delete.len() != combats.len() { + self.reset_selection(combats.len()); + } + + let mut window = Window::new("Delete Combats") .collapsible(false) - .default_size([400.0, 400.0]) - .resizable(false); + .default_size([460.0, 480.0]) + .resizable(true); if newly_opened { - window = window.current_pos(clear_response.rect.min); + window = window.current_pos(open_response.rect.min); } window.show(ui.ctx(), |ui| { - ui.label("Clearing the log will delete all combats from log file except for the newest one."); - ui.label("Note that for this to work properly all data from the log must have been analyzed."); - ui.label("Make sure you refreshed before proceeding."); - ui.add_space(20.0); - ui.label("Do you wish to proceed?"); - - ui.horizontal(|ui| { - if ui.button("Clear Log").clicked() { - self.is_open = false; - analysis_handler.clear_log() - } + ui.label("Select the combats to delete. Everything left unchecked is kept in the log."); + ui.label("Make sure you refreshed first, so the list is up to date."); + ui.add_space(6.0); + + ui.horizontal(|ui| { + if ui.button("Select all").clicked() { + self.to_delete.iter_mut().for_each(|d| *d = true); + } + if ui.button("Unselect all").clicked() { + self.to_delete.iter_mut().for_each(|d| *d = false); + } + }); - if ui.button("Cancel").clicked() { - self.is_open = false; + ui.separator(); + ScrollArea::vertical().max_height(320.0).show(ui, |ui| { + // Newest first, matching the combats dropdown. + let newest = combats.len().wrapping_sub(1); + for i in (0..combats.len()).rev() { + if let Some(flag) = self.to_delete.get_mut(i) { + let label = if i == newest { + format!("{} (newest)", combats[i]) + } else { + combats[i].clone() + }; + ui.checkbox(flag, label); } - }); + } + }); + ui.separator(); + + let delete_count = self.to_delete.iter().filter(|&&d| d).count(); + ui.horizontal(|ui| { + if ui + .add_enabled( + delete_count > 0, + Button::new(format!("Delete {delete_count} selected")), + ) + .clicked() + { + let keep: Vec = self + .to_delete + .iter() + .enumerate() + .filter(|&(_, &delete)| !delete) + .map(|(i, _)| i) + .collect(); + analysis_handler.keep_combats(keep); + self.is_open = false; + } + + if ui.button("Cancel").clicked() { + self.is_open = false; + } }); + }); + } + + /// Default selection: delete every combat except the newest. + fn reset_selection(&mut self, count: usize) { + self.to_delete = vec![true; count]; + if let Some(newest) = self.to_delete.last_mut() { + *newest = false; + } } fn initialize(&mut self) { diff --git a/src/app/settings/mod.rs b/src/app/settings/mod.rs index 0e0fc3b..8afb31e 100644 --- a/src/app/settings/mod.rs +++ b/src/app/settings/mod.rs @@ -61,6 +61,7 @@ impl SettingsWindow { &mut self, state: &mut AppState, selected_combat: Option<&Combat>, + combats: &[String], ui: &mut Ui, frame: &Frame, ) { @@ -91,6 +92,7 @@ impl SettingsWindow { SettingsTab::General => self.general_tab.show( &state.analysis_handler, &mut self.modified_settings, + combats, ui, frame, ), @@ -117,8 +119,14 @@ impl SettingsWindow { }); } - pub fn show_clear_log_dialog(&mut self, analysis_handler: &AnalysisHandler, ui: &mut Ui) { - self.general_tab.show_clear_log_dialog(analysis_handler, ui); + pub fn show_clear_log_dialog( + &mut self, + analysis_handler: &AnalysisHandler, + combats: &[String], + ui: &mut Ui, + ) { + self.general_tab + .show_clear_log_dialog(analysis_handler, combats, ui); } fn handle_dropped_file(&mut self, ui: &mut Ui, state: &mut AppState) { From 07a8da9518b70cda68d15ab0f3e3924ff6a4ea7a Mon Sep 17 00:00:00 2001 From: Raman Date: Thu, 23 Jul 2026 21:29:22 +0200 Subject: [PATCH 2/3] Refresh the combats list when the delete dialog opens Opening "Clear Log File" now triggers a manual refresh so the dialog always lists every combat in the log, instead of relying on the user having refreshed first. Drops the now-obsolete "refresh first" hint. Co-Authored-By: Claude Opus 4.8 --- src/app/settings/general.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/app/settings/general.rs b/src/app/settings/general.rs index a970394..b8c5e69 100644 --- a/src/app/settings/general.rs +++ b/src/app/settings/general.rs @@ -117,6 +117,8 @@ impl ClearLogDialog { self.is_open = true; newly_opened = true; self.reset_selection(combats.len()); + // Rebuild the list from the log on open so it always shows every combat. + analysis_handler.refresh(); } if !self.is_open { @@ -139,7 +141,6 @@ impl ClearLogDialog { window.show(ui.ctx(), |ui| { ui.label("Select the combats to delete. Everything left unchecked is kept in the log."); - ui.label("Make sure you refreshed first, so the list is up to date."); ui.add_space(6.0); ui.horizontal(|ui| { From cace9acc3be430be0d3cf62516e5864373896830 Mon Sep 17 00:00:00 2001 From: Raman Date: Sun, 26 Jul 2026 20:41:40 +0200 Subject: [PATCH 3/3] Take the log path for the manual tests from the environment The two ignored tests that need a real combat log had a developer's absolute path baked in, which is useless to anyone else. They now read CLA_TEST_COMBATLOG and skip with a note when it is not set. Co-Authored-By: Claude Opus 5 --- src/analyzer/mod.rs | 10 +++++++--- src/app/analysis_handling.rs | 8 ++++++-- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/src/analyzer/mod.rs b/src/analyzer/mod.rs index 88da784..1d21e02 100644 --- a/src/analyzer/mod.rs +++ b/src/analyzer/mod.rs @@ -648,12 +648,16 @@ mod tests { /// Real-data check for the "delete combats" flow: keep a subset of combats /// by concatenating their byte ranges, then re-analyze and assert exactly /// those combats survive, in order (removing a middle combat must not merge - /// its neighbours). Run with: - /// `cargo test keep_subset_of_combats -- --ignored --nocapture`. + /// its neighbours). Point `CLA_TEST_COMBATLOG` at a real combatlog.log and + /// run with: + /// `CLA_TEST_COMBATLOG= cargo test keep_subset_of_combats -- --ignored --nocapture`. #[test] #[ignore = "reads a real STO log"] fn keep_subset_of_combats() { - let src = "/home/raman/Games/steamapps/common/Star Trek Online/Star Trek Online/Live/logs/GameClient/combatlog.log"; + let Some(src) = std::env::var_os("CLA_TEST_COMBATLOG") else { + println!("set CLA_TEST_COMBATLOG to a combatlog.log to run this test"); + return; + }; let dir = std::env::temp_dir().join("cla-keep-combats-test"); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap(); diff --git a/src/app/analysis_handling.rs b/src/app/analysis_handling.rs index 9e94911..320e1d0 100644 --- a/src/app/analysis_handling.rs +++ b/src/app/analysis_handling.rs @@ -556,13 +556,17 @@ mod tests { /// The app should populate the combats list on startup on its own (no manual /// Refresh). Creates a handler exactly like the app does and waits for the - /// automatic first refresh to deliver the combats. + /// automatic first refresh to deliver the combats. Point + /// `CLA_TEST_COMBATLOG` at a real combatlog.log to run it. #[test] #[ignore = "reads a real STO log"] fn loads_combats_on_startup() { // Work on a copy in a scratch dir rather than pointing the handler at // the real log. - let src = "/home/raman/Games/steamapps/common/Star Trek Online/Star Trek Online/Live/logs/GameClient/combatlog.log"; + let Some(src) = std::env::var_os("CLA_TEST_COMBATLOG") else { + println!("set CLA_TEST_COMBATLOG to a combatlog.log to run this test"); + return; + }; let dir = std::env::temp_dir().join(format!("cla-startup-test-{}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap();