Skip to main content

memscope_rs/
auto_export.rs

1//! Auto-export configuration for memscope-rs.
2//!
3//! Defines the configuration types that control automatic export of tracking
4//! data on program exit (normal return, panic, Ctrl-C, SIGTERM) and optional
5//! periodic background flushing for long-running services.
6//!
7//! This is module 1 of the 4-module auto-export feature. It is intentionally
8//! dependency-light: only `std` and `serde` (both already in the crate graph)
9//! are used. Every type here is plain data with no I/O or global state, so the
10//! module can be unit-tested in isolation and downstream modules (signal
11//! installation, exit hooks, background flusher) can build on these contracts.
12
13use std::path::PathBuf;
14use std::time::Duration;
15
16use serde::{Deserialize, Serialize};
17
18/// Which signals trigger an automatic export before termination.
19///
20/// The policy is consulted by the signal-installing layer (module 2); this
21/// type only carries the user's intent so the wiring code can decide which
22/// handlers to register. Keeping it as a plain enum (rather than a bitfield)
23/// makes the supported combinations explicit and exhaustive.
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
25pub enum SignalPolicy {
26    /// No signal handler installed. Only Drop + panic-hook cover exit paths.
27    Off,
28    /// Catch SIGINT (Ctrl-C) only. This is the default policy.
29    #[default]
30    CtrlC,
31    /// Catch both SIGINT and SIGTERM.
32    ///
33    /// # Current limitation
34    ///
35    /// SIGTERM is NOT yet wired up — only SIGINT is caught by the underlying
36    /// `ctrlc` crate. SIGTERM support requires `signal_hook` (or a raw libc
37    /// handler) and will be added when that dependency is approved. Selecting
38    /// this policy currently behaves identically to [`SignalPolicy::CtrlC`]
39    /// from the perspective of which signals actually trigger an export.
40    CtrlCAndTerm,
41}
42
43/// Bitflags-style set of export formats. Uses a raw `u8` to avoid adding the
44/// `bitflags` crate as a dependency.
45///
46/// # Concurrency
47///
48/// `ExportFormatSet` is `Copy` and every accessor is `const fn`, so values
49/// carry no interior mutability and no shared mutable state. The type is
50/// trivially `Sync`: multiple threads may read the same value (or copies of
51/// it) without locking. It is therefore safe to pass across thread boundaries
52/// by value or behind an `Arc` for read-only sharing — no mutex is required.
53#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
54pub struct ExportFormatSet(u8);
55
56impl ExportFormatSet {
57    /// HTML dashboard only.
58    pub const HTML: Self = Self(0b01);
59    /// JSON data files only.
60    pub const JSON: Self = Self(0b10);
61    /// Both HTML and JSON (the recommended default).
62    pub const HTML_JSON: Self = Self(0b11);
63
64    /// Construct from raw bits.
65    pub const fn from_bits(bits: u8) -> Self {
66        Self(bits)
67    }
68    /// Read raw bits.
69    pub const fn to_bits(self) -> u8 {
70        self.0
71    }
72    /// True when no format is selected.
73    pub const fn is_empty(self) -> bool {
74        self.0 == 0
75    }
76    /// True when the HTML format is selected.
77    pub const fn contains_html(self) -> bool {
78        (self.0 & Self::HTML.0) != 0
79    }
80    /// True when the JSON format is selected.
81    pub const fn contains_json(self) -> bool {
82        (self.0 & Self::JSON.0) != 0
83    }
84    /// Set union (`self | other`).
85    pub const fn insert(self, other: Self) -> Self {
86        Self(self.0 | other.0)
87    }
88    /// Set difference (`self & !other`).
89    pub const fn remove(self, other: Self) -> Self {
90        Self(self.0 & !other.0)
91    }
92}
93
94impl Default for ExportFormatSet {
95    fn default() -> Self {
96        Self::HTML_JSON
97    }
98}
99
100/// Configuration for the automatic export subsystem.
101///
102/// Controls WHERE reports are written, WHICH formats, and WHICH exit paths
103/// trigger an export. All fields have sensible defaults via `Default`, so a
104/// plain `AutoExportConfig::default()` yields a working "export HTML+JSON to
105/// `./memscope-report` on any exit" setup.
106#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
107pub struct AutoExportConfig {
108    /// Directory to write reports into. Created if missing.
109    /// Default: `./memscope-report`.
110    pub output_path: PathBuf,
111    /// Which output formats to produce. Default: HTML + JSON.
112    pub formats: ExportFormatSet,
113    /// Export on normal program return (Drop guard fires). Default: `true`.
114    pub on_exit: bool,
115    /// Export on panic (panic-hook chaining). Critical for
116    /// `panic = "abort"` release builds. Default: `true`.
117    pub on_panic: bool,
118    /// Which signals trigger an export. Default: `CtrlC`.
119    pub on_signal: SignalPolicy,
120    /// If `Some(interval)`, spawn a background worker that flushes every
121    /// `interval`. If `None`, no background flushing (export only on exit).
122    /// Default: `None`.
123    pub flush_interval: Option<Duration>,
124    /// Max time the shutdown path waits for an in-flight export to finish
125    /// before giving up. Default: 5s.
126    pub exit_timeout: Duration,
127}
128
129impl Default for AutoExportConfig {
130    fn default() -> Self {
131        Self {
132            output_path: PathBuf::from("./memscope-report"),
133            formats: ExportFormatSet::default(),
134            on_exit: true,
135            on_panic: true,
136            on_signal: SignalPolicy::default(),
137            flush_interval: None,
138            exit_timeout: Duration::from_secs(5),
139        }
140    }
141}
142
143impl AutoExportConfig {
144    /// Builder-style: set output path.
145    pub fn with_output_path(mut self, path: impl Into<PathBuf>) -> Self {
146        self.output_path = path.into();
147        self
148    }
149    /// Builder-style: set formats.
150    pub fn with_formats(mut self, formats: ExportFormatSet) -> Self {
151        self.formats = formats;
152        self
153    }
154    /// Builder-style: enable periodic flushing.
155    pub fn with_flush_interval(mut self, interval: Duration) -> Self {
156        self.flush_interval = Some(interval);
157        self
158    }
159    /// Builder-style: set signal policy.
160    pub fn with_signal_policy(mut self, policy: SignalPolicy) -> Self {
161        self.on_signal = policy;
162        self
163    }
164    /// Builder-style: set the `on_exit` flag (export on normal Drop).
165    pub fn with_on_exit(mut self, on_exit: bool) -> Self {
166        self.on_exit = on_exit;
167        self
168    }
169    /// Builder-style: set the `on_panic` flag (export on panic).
170    pub fn with_on_panic(mut self, on_panic: bool) -> Self {
171        self.on_panic = on_panic;
172        self
173    }
174    /// Builder-style: set the shutdown timeout for in-flight exports.
175    pub fn with_exit_timeout(mut self, timeout: Duration) -> Self {
176        self.exit_timeout = timeout;
177        self
178    }
179    /// True if any export path is enabled (exit, panic, or signal != `Off`).
180    pub fn is_auto_export_enabled(&self) -> bool {
181        self.on_exit || self.on_panic || self.on_signal != SignalPolicy::Off
182    }
183    /// True if the HTML format is selected.
184    pub fn wants_html(&self) -> bool {
185        self.formats.contains_html()
186    }
187    /// True if the JSON format is selected.
188    pub fn wants_json(&self) -> bool {
189        self.formats.contains_json()
190    }
191}
192
193/// Top-level configuration for `memscope_rs::start_with`.
194///
195/// Combines the auto-export subsystem config with the existing
196/// `GlobalTrackerConfig` (tracking sampling, passport, etc.) so callers can
197/// configure both halves of a `start_with` call from a single value.
198#[derive(Debug, Clone, Default)]
199pub struct MemScopeConfig {
200    /// Auto-export subsystem configuration.
201    pub auto_export: AutoExportConfig,
202    /// Underlying global tracker configuration (sampling, passport tracker, etc.).
203    pub tracker: crate::capture::backends::global_tracking::GlobalTrackerConfig,
204}
205
206impl MemScopeConfig {
207    /// Builder-style: set auto-export config.
208    pub fn with_auto_export(mut self, cfg: AutoExportConfig) -> Self {
209        self.auto_export = cfg;
210        self
211    }
212    /// Builder-style: set tracker config.
213    pub fn with_tracker(
214        mut self,
215        cfg: crate::capture::backends::global_tracking::GlobalTrackerConfig,
216    ) -> Self {
217        self.tracker = cfg;
218        self
219    }
220}
221
222#[cfg(test)]
223mod tests {
224    use super::*;
225    use proptest::prelude::*;
226    use std::sync::Arc;
227    use std::thread;
228
229    // ===================== Positive tests (happy path) ====================
230
231    /// Objective: Verify `AutoExportConfig::default()` populates every field
232    /// with the documented default value.
233    /// Invariants: No field is left at an accidental zero/unset state.
234    #[test]
235    fn default_config_has_all_documented_defaults() {
236        let cfg = AutoExportConfig::default();
237
238        assert_eq!(
239            cfg.output_path,
240            PathBuf::from("./memscope-report"),
241            "default output_path must be ./memscope-report per the field doc"
242        );
243        assert_eq!(
244            cfg.formats,
245            ExportFormatSet::HTML_JSON,
246            "default formats must be HTML+JSON (the recommended default)"
247        );
248        assert!(
249            cfg.on_exit,
250            "on_exit defaults to true so the Drop-guard export fires"
251        );
252        assert!(
253            cfg.on_panic,
254            "on_panic defaults to true to cover panic=abort release builds"
255        );
256        assert_eq!(
257            cfg.on_signal,
258            SignalPolicy::CtrlC,
259            "default signal policy is CtrlC per SignalPolicy::default"
260        );
261        assert_eq!(
262            cfg.flush_interval, None,
263            "no background flushing by default; export only on exit"
264        );
265        assert_eq!(
266            cfg.exit_timeout,
267            Duration::from_secs(5),
268            "shutdown path waits up to 5s for an in-flight export"
269        );
270    }
271
272    /// Objective: Verify the combined format constant reports both flags set.
273    /// Invariants: HTML_JSON must contain HTML and JSON simultaneously.
274    #[test]
275    fn html_json_format_contains_both_html_and_json() {
276        let both = ExportFormatSet::HTML_JSON;
277        assert!(
278            both.contains_html(),
279            "HTML_JSON must select the HTML format"
280        );
281        assert!(
282            both.contains_json(),
283            "HTML_JSON must select the JSON format"
284        );
285        assert!(!both.is_empty(), "HTML_JSON must not be empty");
286    }
287
288    /// Objective: Verify builder methods chain without clobbering prior fields.
289    /// Invariants: Each chained with_* call sets exactly its own field and
290    /// leaves the rest at their defaults.
291    #[test]
292    fn builder_methods_chain_correctly() {
293        let cfg = AutoExportConfig::default()
294            .with_output_path("/tmp/x")
295            .with_flush_interval(Duration::from_secs(10));
296
297        assert_eq!(
298            cfg.output_path,
299            PathBuf::from("/tmp/x"),
300            "with_output_path must override the default directory"
301        );
302        assert_eq!(
303            cfg.flush_interval,
304            Some(Duration::from_secs(10)),
305            "with_flush_interval must enable periodic flushing at 10s"
306        );
307        // Untouched fields keep their defaults.
308        assert_eq!(
309            cfg.formats,
310            ExportFormatSet::HTML_JSON,
311            "chaining must not reset previously-defaulted formats"
312        );
313        assert!(cfg.on_exit, "chaining must not reset on_exit");
314        assert_eq!(
315            cfg.on_signal,
316            SignalPolicy::CtrlC,
317            "chaining must not reset on_signal"
318        );
319    }
320
321    /// Objective: Verify MemScopeConfig builder round-trips an auto-export cfg.
322    /// Invariants: with_auto_export stores exactly the provided config.
323    #[test]
324    fn memscope_config_with_auto_export_round_trips() {
325        let inner = AutoExportConfig::default().with_output_path("/tmp/round");
326        let outer = MemScopeConfig::default().with_auto_export(inner.clone());
327
328        assert_eq!(
329            outer.auto_export.output_path,
330            PathBuf::from("/tmp/round"),
331            "with_auto_export must store the provided config verbatim"
332        );
333        assert_eq!(
334            outer.auto_export, inner,
335            "round-trip must be lossless for AutoExportConfig"
336        );
337    }
338
339    /// Objective: Verify `with_tracker` stores the provided tracker config.
340    /// Invariants: The tracker field is replaced wholesale by the builder.
341    #[test]
342    fn memscope_config_with_tracker_round_trips() {
343        let outer = MemScopeConfig::default().with_tracker(
344            crate::capture::backends::global_tracking::GlobalTrackerConfig::default(),
345        );
346        // GlobalTrackerConfig derives Default; the builder must accept it and
347        // leave the auto_export half untouched.
348        assert_eq!(
349            outer.auto_export,
350            AutoExportConfig::default(),
351            "with_tracker must not disturb the auto_export config"
352        );
353    }
354
355    /// Objective: Verify is_auto_export_enabled is true for the default config.
356    /// Invariants: Default has on_exit=true, on_panic=true, signal=CtrlC, so
357    /// at least three independent triggers are armed.
358    #[test]
359    fn default_config_is_auto_export_enabled() {
360        let cfg = AutoExportConfig::default();
361        assert!(
362            cfg.is_auto_export_enabled(),
363            "default config (exit+panic+CtrlC) must enable auto-export"
364        );
365    }
366
367    // ===================== Negative tests (edge cases) ====================
368
369    /// Objective: Verify a zero-bits set is considered empty.
370    /// Invariants: from_bits(0) yields no selected formats.
371    #[test]
372    fn zero_bits_is_empty() {
373        let empty = ExportFormatSet::from_bits(0u8);
374        assert!(
375            empty.is_empty(),
376            "from_bits(0) must report empty since no format bits are set"
377        );
378    }
379
380    /// Objective: Verify zero-bits does not claim HTML.
381    /// Invariants: contains_html must be false when the HTML bit is clear.
382    #[test]
383    fn zero_bits_does_not_contain_html() {
384        let empty = ExportFormatSet::from_bits(0u8);
385        assert!(
386            !empty.contains_html(),
387            "from_bits(0) must not report HTML selected"
388        );
389    }
390
391    /// Objective: Verify SignalPolicy::Off alone disables auto-export ONLY
392    /// when on_exit and on_panic are also false; flipping any single trigger
393    /// back on re-enables it.
394    /// Invariants: is_auto_export_enabled is the OR of the three triggers.
395    #[test]
396    fn off_signal_disables_only_when_all_triggers_off() {
397        let mut cfg = AutoExportConfig {
398            on_exit: false,
399            on_panic: false,
400            on_signal: SignalPolicy::Off,
401            ..Default::default()
402        };
403        assert!(
404            !cfg.is_auto_export_enabled(),
405            "all three triggers off must disable auto-export entirely"
406        );
407
408        // Flipping on_exit alone back on re-enables auto-export.
409        cfg.on_exit = true;
410        assert!(
411            cfg.is_auto_export_enabled(),
412            "re-enabling on_exit alone must re-enable auto-export"
413        );
414
415        // Flipping on_panic alone back on re-enables auto-export.
416        cfg.on_exit = false;
417        cfg.on_panic = true;
418        assert!(
419            cfg.is_auto_export_enabled(),
420            "re-enabling on_panic alone must re-enable auto-export"
421        );
422
423        // A non-Off signal policy alone re-enables auto-export.
424        cfg.on_panic = false;
425        cfg.on_signal = SignalPolicy::CtrlC;
426        assert!(
427            cfg.is_auto_export_enabled(),
428            "a non-Off signal policy alone must re-enable auto-export"
429        );
430    }
431
432    /// Objective: Verify remove(self, self) clears the set.
433    /// Invariants: HTML.remove(HTML) leaves no bits set.
434    #[test]
435    fn remove_self_clears_set() {
436        let cleared = ExportFormatSet::HTML.remove(ExportFormatSet::HTML);
437        assert!(
438            cleared.is_empty(),
439            "removing a format from itself must yield an empty set"
440        );
441    }
442
443    /// Objective: Verify removing HTML from HTML_JSON leaves JSON only.
444    /// Invariants: After removal, JSON is set and HTML is clear.
445    #[test]
446    fn remove_html_from_both_leaves_json_only() {
447        let json_only = ExportFormatSet::HTML_JSON.remove(ExportFormatSet::HTML);
448        assert!(
449            !json_only.contains_html(),
450            "removing HTML must clear the HTML bit"
451        );
452        assert!(
453            json_only.contains_json(),
454            "removing HTML must preserve the JSON bit"
455        );
456        assert_eq!(
457            json_only,
458            ExportFormatSet::JSON,
459            "HTML_JSON - HTML must equal JSON"
460        );
461    }
462
463    /// Objective: Verify a config built with empty formats reports neither
464    /// HTML nor JSON desired.
465    /// Invariants: wants_html/wants_json follow the formats bits exactly.
466    #[test]
467    fn empty_formats_means_no_formats_wanted() {
468        let cfg = AutoExportConfig::default().with_formats(ExportFormatSet::from_bits(0u8));
469        assert!(
470            !cfg.wants_html(),
471            "with_formats(0) must disable HTML output"
472        );
473        assert!(
474            !cfg.wants_json(),
475            "with_formats(0) must disable JSON output"
476        );
477    }
478
479    // ===================== Stress tests (concurrency) =====================
480
481    /// Objective: Verify ExportFormatSet is safe to share across 50 threads
482    /// with no locking; all const-fn operations must be consistent.
483    /// Invariants: Because the type is Copy with no interior mutability,
484    /// every thread observes identical results for the same input bits.
485    #[test]
486    fn export_format_set_is_lock_free_safe_across_threads() {
487        const THREAD_COUNT: usize = 50;
488        // Share one read-only value via Arc; threads also make local copies.
489        let shared = Arc::new(ExportFormatSet::HTML_JSON);
490        let mut handles = Vec::with_capacity(THREAD_COUNT);
491
492        for _ in 0..THREAD_COUNT {
493            let snapshot = Arc::clone(&shared);
494            handles.push(thread::spawn(move || {
495                // Read the shared value: consistent because Copy + Sync.
496                let base = *snapshot;
497                // Local pure computations on a copy — no shared mutation.
498                let with_extra = base.insert(ExportFormatSet::JSON);
499                let stripped = base.remove(ExportFormatSet::HTML);
500                // Every thread must see the same deterministic results.
501                (base, with_extra, stripped)
502            }));
503        }
504
505        let mut checked = 0usize;
506        for handle in handles {
507            // join().expect() is infallible here: the closure performs only
508            // Copy bit-twiddling (insert/remove/contains), none of which can
509            // panic, so the thread cannot have panicked. A panic would itself
510            // be a regression we want to surface rather than swallow.
511            let (base, with_extra, stripped) = handle
512                .join()
513                .expect("worker thread must not panic on pure Copy bit ops");
514            assert_eq!(
515                base,
516                ExportFormatSet::HTML_JSON,
517                "every thread must read the same shared base value"
518            );
519            assert_eq!(
520                with_extra,
521                ExportFormatSet::HTML_JSON,
522                "inserting an already-set bit must be idempotent across threads"
523            );
524            assert_eq!(
525                stripped,
526                ExportFormatSet::JSON,
527                "removing HTML from HTML_JSON must yield JSON in every thread"
528            );
529            checked += 1;
530        }
531        assert_eq!(
532            checked, THREAD_COUNT,
533            "all 50 worker threads must report consistent results"
534        );
535    }
536
537    // ===================== proptest (1000 cases each) =====================
538
539    proptest! {
540        #![proptest_config(ProptestConfig {
541            cases: 1000,
542            ..ProptestConfig::default()
543        })]
544
545        /// Property: from_bits is a perfect inverse of to_bits.
546        /// Why: the format set is a transparent newtype over u8, so any u8
547        /// must round-trip without loss.
548        #[test]
549        fn bits_round_trip(b in any::<u8>()) {
550            prop_assert_eq!(
551                ExportFormatSet::from_bits(b).to_bits(),
552                b,
553                "from_bits(b).to_bits() must equal b for every u8"
554            );
555        }
556
557        /// Property: insert computes set union.
558        /// Why: insert is documented as self | other.
559        #[test]
560        fn insert_is_union(a in any::<u8>(), b in any::<u8>()) {
561            let got = ExportFormatSet::from_bits(a)
562                .insert(ExportFormatSet::from_bits(b))
563                .to_bits();
564            prop_assert_eq!(
565                got,
566                a | b,
567                "from_bits(a).insert(from_bits(b)) must equal (a | b)"
568            );
569        }
570
571        /// Property: remove computes set difference.
572        /// Why: remove is documented as self & !other.
573        #[test]
574        fn remove_is_difference(a in any::<u8>(), b in any::<u8>()) {
575            let got = ExportFormatSet::from_bits(a)
576                .remove(ExportFormatSet::from_bits(b))
577                .to_bits();
578            prop_assert_eq!(
579                got,
580                a & !b,
581                "from_bits(a).remove(from_bits(b)) must equal (a & !b)"
582            );
583        }
584
585        /// Property: contains_html tracks the low bit exactly.
586        /// Why: HTML is defined as 0b01, so contains_html is (b & 0b01) != 0.
587        #[test]
588        fn contains_html_tracks_low_bit(b in any::<u8>()) {
589            let got = ExportFormatSet::from_bits(b).contains_html();
590            prop_assert_eq!(
591                got,
592                (b & 0b01) != 0,
593                "contains_html must equal ((b & 0b01) != 0) for every u8"
594            );
595        }
596    }
597}