use std::path::PathBuf;
use std::time::Duration;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
pub enum SignalPolicy {
Off,
#[default]
CtrlC,
CtrlCAndTerm,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct ExportFormatSet(u8);
impl ExportFormatSet {
pub const HTML: Self = Self(0b01);
pub const JSON: Self = Self(0b10);
pub const HTML_JSON: Self = Self(0b11);
pub const fn from_bits(bits: u8) -> Self {
Self(bits)
}
pub const fn to_bits(self) -> u8 {
self.0
}
pub const fn is_empty(self) -> bool {
self.0 == 0
}
pub const fn contains_html(self) -> bool {
(self.0 & Self::HTML.0) != 0
}
pub const fn contains_json(self) -> bool {
(self.0 & Self::JSON.0) != 0
}
pub const fn insert(self, other: Self) -> Self {
Self(self.0 | other.0)
}
pub const fn remove(self, other: Self) -> Self {
Self(self.0 & !other.0)
}
}
impl Default for ExportFormatSet {
fn default() -> Self {
Self::HTML_JSON
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct AutoExportConfig {
pub output_path: PathBuf,
pub formats: ExportFormatSet,
pub on_exit: bool,
pub on_panic: bool,
pub on_signal: SignalPolicy,
pub flush_interval: Option<Duration>,
pub exit_timeout: Duration,
}
impl Default for AutoExportConfig {
fn default() -> Self {
Self {
output_path: PathBuf::from("./memscope-report"),
formats: ExportFormatSet::default(),
on_exit: true,
on_panic: true,
on_signal: SignalPolicy::default(),
flush_interval: None,
exit_timeout: Duration::from_secs(5),
}
}
}
impl AutoExportConfig {
pub fn with_output_path(mut self, path: impl Into<PathBuf>) -> Self {
self.output_path = path.into();
self
}
pub fn with_formats(mut self, formats: ExportFormatSet) -> Self {
self.formats = formats;
self
}
pub fn with_flush_interval(mut self, interval: Duration) -> Self {
self.flush_interval = Some(interval);
self
}
pub fn with_signal_policy(mut self, policy: SignalPolicy) -> Self {
self.on_signal = policy;
self
}
pub fn with_on_exit(mut self, on_exit: bool) -> Self {
self.on_exit = on_exit;
self
}
pub fn with_on_panic(mut self, on_panic: bool) -> Self {
self.on_panic = on_panic;
self
}
pub fn with_exit_timeout(mut self, timeout: Duration) -> Self {
self.exit_timeout = timeout;
self
}
pub fn is_auto_export_enabled(&self) -> bool {
self.on_exit || self.on_panic || self.on_signal != SignalPolicy::Off
}
pub fn wants_html(&self) -> bool {
self.formats.contains_html()
}
pub fn wants_json(&self) -> bool {
self.formats.contains_json()
}
}
#[derive(Debug, Clone, Default)]
pub struct MemScopeConfig {
pub auto_export: AutoExportConfig,
pub tracker: crate::capture::backends::global_tracking::GlobalTrackerConfig,
}
impl MemScopeConfig {
pub fn with_auto_export(mut self, cfg: AutoExportConfig) -> Self {
self.auto_export = cfg;
self
}
pub fn with_tracker(
mut self,
cfg: crate::capture::backends::global_tracking::GlobalTrackerConfig,
) -> Self {
self.tracker = cfg;
self
}
}
#[cfg(test)]
mod tests {
use super::*;
use proptest::prelude::*;
use std::sync::Arc;
use std::thread;
#[test]
fn default_config_has_all_documented_defaults() {
let cfg = AutoExportConfig::default();
assert_eq!(
cfg.output_path,
PathBuf::from("./memscope-report"),
"default output_path must be ./memscope-report per the field doc"
);
assert_eq!(
cfg.formats,
ExportFormatSet::HTML_JSON,
"default formats must be HTML+JSON (the recommended default)"
);
assert!(
cfg.on_exit,
"on_exit defaults to true so the Drop-guard export fires"
);
assert!(
cfg.on_panic,
"on_panic defaults to true to cover panic=abort release builds"
);
assert_eq!(
cfg.on_signal,
SignalPolicy::CtrlC,
"default signal policy is CtrlC per SignalPolicy::default"
);
assert_eq!(
cfg.flush_interval, None,
"no background flushing by default; export only on exit"
);
assert_eq!(
cfg.exit_timeout,
Duration::from_secs(5),
"shutdown path waits up to 5s for an in-flight export"
);
}
#[test]
fn html_json_format_contains_both_html_and_json() {
let both = ExportFormatSet::HTML_JSON;
assert!(
both.contains_html(),
"HTML_JSON must select the HTML format"
);
assert!(
both.contains_json(),
"HTML_JSON must select the JSON format"
);
assert!(!both.is_empty(), "HTML_JSON must not be empty");
}
#[test]
fn builder_methods_chain_correctly() {
let cfg = AutoExportConfig::default()
.with_output_path("/tmp/x")
.with_flush_interval(Duration::from_secs(10));
assert_eq!(
cfg.output_path,
PathBuf::from("/tmp/x"),
"with_output_path must override the default directory"
);
assert_eq!(
cfg.flush_interval,
Some(Duration::from_secs(10)),
"with_flush_interval must enable periodic flushing at 10s"
);
assert_eq!(
cfg.formats,
ExportFormatSet::HTML_JSON,
"chaining must not reset previously-defaulted formats"
);
assert!(cfg.on_exit, "chaining must not reset on_exit");
assert_eq!(
cfg.on_signal,
SignalPolicy::CtrlC,
"chaining must not reset on_signal"
);
}
#[test]
fn memscope_config_with_auto_export_round_trips() {
let inner = AutoExportConfig::default().with_output_path("/tmp/round");
let outer = MemScopeConfig::default().with_auto_export(inner.clone());
assert_eq!(
outer.auto_export.output_path,
PathBuf::from("/tmp/round"),
"with_auto_export must store the provided config verbatim"
);
assert_eq!(
outer.auto_export, inner,
"round-trip must be lossless for AutoExportConfig"
);
}
#[test]
fn memscope_config_with_tracker_round_trips() {
let outer = MemScopeConfig::default().with_tracker(
crate::capture::backends::global_tracking::GlobalTrackerConfig::default(),
);
assert_eq!(
outer.auto_export,
AutoExportConfig::default(),
"with_tracker must not disturb the auto_export config"
);
}
#[test]
fn default_config_is_auto_export_enabled() {
let cfg = AutoExportConfig::default();
assert!(
cfg.is_auto_export_enabled(),
"default config (exit+panic+CtrlC) must enable auto-export"
);
}
#[test]
fn zero_bits_is_empty() {
let empty = ExportFormatSet::from_bits(0u8);
assert!(
empty.is_empty(),
"from_bits(0) must report empty since no format bits are set"
);
}
#[test]
fn zero_bits_does_not_contain_html() {
let empty = ExportFormatSet::from_bits(0u8);
assert!(
!empty.contains_html(),
"from_bits(0) must not report HTML selected"
);
}
#[test]
fn off_signal_disables_only_when_all_triggers_off() {
let mut cfg = AutoExportConfig {
on_exit: false,
on_panic: false,
on_signal: SignalPolicy::Off,
..Default::default()
};
assert!(
!cfg.is_auto_export_enabled(),
"all three triggers off must disable auto-export entirely"
);
cfg.on_exit = true;
assert!(
cfg.is_auto_export_enabled(),
"re-enabling on_exit alone must re-enable auto-export"
);
cfg.on_exit = false;
cfg.on_panic = true;
assert!(
cfg.is_auto_export_enabled(),
"re-enabling on_panic alone must re-enable auto-export"
);
cfg.on_panic = false;
cfg.on_signal = SignalPolicy::CtrlC;
assert!(
cfg.is_auto_export_enabled(),
"a non-Off signal policy alone must re-enable auto-export"
);
}
#[test]
fn remove_self_clears_set() {
let cleared = ExportFormatSet::HTML.remove(ExportFormatSet::HTML);
assert!(
cleared.is_empty(),
"removing a format from itself must yield an empty set"
);
}
#[test]
fn remove_html_from_both_leaves_json_only() {
let json_only = ExportFormatSet::HTML_JSON.remove(ExportFormatSet::HTML);
assert!(
!json_only.contains_html(),
"removing HTML must clear the HTML bit"
);
assert!(
json_only.contains_json(),
"removing HTML must preserve the JSON bit"
);
assert_eq!(
json_only,
ExportFormatSet::JSON,
"HTML_JSON - HTML must equal JSON"
);
}
#[test]
fn empty_formats_means_no_formats_wanted() {
let cfg = AutoExportConfig::default().with_formats(ExportFormatSet::from_bits(0u8));
assert!(
!cfg.wants_html(),
"with_formats(0) must disable HTML output"
);
assert!(
!cfg.wants_json(),
"with_formats(0) must disable JSON output"
);
}
#[test]
fn export_format_set_is_lock_free_safe_across_threads() {
const THREAD_COUNT: usize = 50;
let shared = Arc::new(ExportFormatSet::HTML_JSON);
let mut handles = Vec::with_capacity(THREAD_COUNT);
for _ in 0..THREAD_COUNT {
let snapshot = Arc::clone(&shared);
handles.push(thread::spawn(move || {
let base = *snapshot;
let with_extra = base.insert(ExportFormatSet::JSON);
let stripped = base.remove(ExportFormatSet::HTML);
(base, with_extra, stripped)
}));
}
let mut checked = 0usize;
for handle in handles {
let (base, with_extra, stripped) = handle
.join()
.expect("worker thread must not panic on pure Copy bit ops");
assert_eq!(
base,
ExportFormatSet::HTML_JSON,
"every thread must read the same shared base value"
);
assert_eq!(
with_extra,
ExportFormatSet::HTML_JSON,
"inserting an already-set bit must be idempotent across threads"
);
assert_eq!(
stripped,
ExportFormatSet::JSON,
"removing HTML from HTML_JSON must yield JSON in every thread"
);
checked += 1;
}
assert_eq!(
checked, THREAD_COUNT,
"all 50 worker threads must report consistent results"
);
}
proptest! {
#![proptest_config(ProptestConfig {
cases: 1000,
..ProptestConfig::default()
})]
#[test]
fn bits_round_trip(b in any::<u8>()) {
prop_assert_eq!(
ExportFormatSet::from_bits(b).to_bits(),
b,
"from_bits(b).to_bits() must equal b for every u8"
);
}
#[test]
fn insert_is_union(a in any::<u8>(), b in any::<u8>()) {
let got = ExportFormatSet::from_bits(a)
.insert(ExportFormatSet::from_bits(b))
.to_bits();
prop_assert_eq!(
got,
a | b,
"from_bits(a).insert(from_bits(b)) must equal (a | b)"
);
}
#[test]
fn remove_is_difference(a in any::<u8>(), b in any::<u8>()) {
let got = ExportFormatSet::from_bits(a)
.remove(ExportFormatSet::from_bits(b))
.to_bits();
prop_assert_eq!(
got,
a & !b,
"from_bits(a).remove(from_bits(b)) must equal (a & !b)"
);
}
#[test]
fn contains_html_tracks_low_bit(b in any::<u8>()) {
let got = ExportFormatSet::from_bits(b).contains_html();
prop_assert_eq!(
got,
(b & 0b01) != 0,
"contains_html must equal ((b & 0b01) != 0) for every u8"
);
}
}
}