//! Pure terminal-bell synthesis (docs/03 ยง3.10, Phase 12). //! //! This crate never prints, flushes, reads environment variables, or opens an //! audio device. [`Synth::pattern`] returns a bounded string for the terminal //! backend to append to its next flush. Patterns contain ASCII BEL (`0x07`) //! bytes only: sound identity is a count, never a delay or terminal control //! sequence that could collide with prompt input. //! //! SSH state is an input, not a hidden environment probe. An SSH session always //! suppresses output, while the user's mutable mute preference is preserved so //! leaving SSH restores that preference. Rich sampled synthesis (including any //! optional audio backend) is an honest Phase 13+ hook; Phase 12 has no `rodio` //! dependency and no pretending that repeated terminal bells have pitch. /// ASCII terminal bell, the only byte emitted in a Phase 12 pattern. pub const TERMINAL_BELL: char = '\x07'; /// Hard safety cap on bell bytes returned for one event. pub const MAX_BELLS_PER_PATTERN: usize = 4; /// Since every pattern byte is one ASCII BEL, this is also the byte-length cap. pub const MAX_PATTERN_LEN: usize = MAX_BELLS_PER_PATTERN; /// A semantic sound event. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Sound { BellToll, Clack, Hum, Silence, } impl Sound { /// Canonical count-only terminal pattern before caller/config caps. pub const fn bell_count(self) -> usize { match self { Self::BellToll => 3, Self::Clack => 1, Self::Hum => 2, Self::Silence => 0, } } } /// Pure startup inputs supplied by CLI/config and terminal capability probing. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct SynthConfig { /// User/config mute preference. pub muted: bool, /// Whether terminal probing identified an SSH session. pub ssh: bool, /// Per-event preference, clamped to [`MAX_BELLS_PER_PATTERN`]. pub max_bells: usize, } impl Default for SynthConfig { fn default() -> Self { Self { muted: false, ssh: false, max_bells: MAX_BELLS_PER_PATTERN, } } } /// Build a deterministic, bounded count-only bell pattern. /// /// `max_bells` is untrusted configuration and is always clamped to the global /// safety cap. [`Sound::Silence`] always yields an empty string. #[allow(clippy::manual_repeat_n)] // repeat_n stabilized after the Rust 1.75 MSRV. pub fn bell_pattern(sound: Sound, max_bells: usize) -> String { let count = sound.bell_count().min(max_bells).min(MAX_BELLS_PER_PATTERN); std::iter::repeat(TERMINAL_BELL).take(count).collect() } /// Mutable pure synth state. The caller owns all actual terminal I/O. #[derive(Clone, Debug, PartialEq, Eq)] pub struct Synth { muted: bool, ssh: bool, max_bells: usize, } impl Default for Synth { fn default() -> Self { Self::new(SynthConfig::default()) } } impl Synth { /// Construct from explicit config/probe inputs. pub fn new(config: SynthConfig) -> Self { Self { muted: config.muted, ssh: config.ssh, max_bells: config.max_bells.min(MAX_BELLS_PER_PATTERN), } } /// Effective mute state. SSH cannot be overridden by toggling user mute. pub const fn is_muted(&self) -> bool { self.muted || self.ssh } /// User/config mute preference (separate from mandatory SSH suppression). pub const fn user_muted(&self) -> bool { self.muted } /// Current SSH-probe input. pub const fn is_ssh(&self) -> bool { self.ssh } /// Effective bounded per-event bell cap. pub const fn max_bells(&self) -> usize { self.max_bells } /// Change the user's mute preference without bypassing SSH suppression. pub fn set_muted(&mut self, muted: bool) { self.muted = muted; } /// Update the terminal probe result. Leaving SSH restores user preference. pub fn set_ssh(&mut self, ssh: bool) { self.ssh = ssh; } /// Change the preference, clamped to the global safety cap. pub fn set_max_bells(&mut self, max_bells: usize) { self.max_bells = max_bells.min(MAX_BELLS_PER_PATTERN); } /// Return the next pure output pattern; never performs I/O. pub fn pattern(&self, sound: Sound) -> String { if self.is_muted() { String::new() } else { bell_pattern(sound, self.max_bells) } } } #[cfg(test)] mod tests { use super::*; #[test] fn canonical_patterns_are_count_only_and_distinct() { assert_eq!(bell_pattern(Sound::BellToll, usize::MAX), "\x07\x07\x07"); assert_eq!(bell_pattern(Sound::Clack, usize::MAX), "\x07"); assert_eq!(bell_pattern(Sound::Hum, usize::MAX), "\x07\x07"); assert_eq!(bell_pattern(Sound::Silence, usize::MAX), ""); } #[test] fn pattern_builder_is_deterministic() { for sound in [Sound::BellToll, Sound::Clack, Sound::Hum, Sound::Silence] { assert_eq!(bell_pattern(sound, 4), bell_pattern(sound, 4)); } } #[test] fn untrusted_cap_is_globally_bounded() { let pattern = bell_pattern(Sound::BellToll, usize::MAX); assert!(pattern.len() <= MAX_PATTERN_LEN); assert!(pattern.chars().count() <= MAX_BELLS_PER_PATTERN); } #[test] fn caller_cap_truncates_pattern() { assert_eq!(bell_pattern(Sound::BellToll, 2), "\x07\x07"); assert_eq!(bell_pattern(Sound::BellToll, 0), ""); } #[test] fn patterns_contain_no_terminal_control_language_except_bell() { for sound in [Sound::BellToll, Sound::Clack, Sound::Hum] { assert!(bell_pattern(sound, 4).bytes().all(|byte| byte == 0x07)); } } #[test] fn default_synth_is_local_and_audible() { let synth = Synth::default(); assert!(!synth.is_muted()); assert!(!synth.pattern(Sound::Clack).is_empty()); } #[test] fn explicit_mute_suppresses_every_sound() { let mut synth = Synth::default(); synth.set_muted(true); for sound in [Sound::BellToll, Sound::Clack, Sound::Hum, Sound::Silence] { assert_eq!(synth.pattern(sound), ""); } } #[test] fn unmuting_is_mutable() { let mut synth = Synth::new(SynthConfig { muted: true, ..SynthConfig::default() }); synth.set_muted(false); assert_eq!(synth.pattern(Sound::Hum), "\x07\x07"); } #[test] fn ssh_forces_sound_off_even_after_unmute() { let mut synth = Synth::new(SynthConfig { ssh: true, ..SynthConfig::default() }); synth.set_muted(false); assert!(synth.is_muted()); assert_eq!(synth.pattern(Sound::BellToll), ""); } #[test] fn leaving_ssh_restores_user_preference() { let mut synth = Synth::new(SynthConfig { muted: false, ssh: true, max_bells: 4, }); synth.set_ssh(false); assert!(!synth.is_muted()); assert_eq!(synth.pattern(Sound::Clack), "\x07"); } #[test] fn leaving_ssh_does_not_clear_user_mute() { let mut synth = Synth::new(SynthConfig { muted: true, ssh: true, max_bells: 4, }); synth.set_ssh(false); assert!(synth.is_muted()); } #[test] fn mutable_cap_remains_globally_bounded() { let mut synth = Synth::default(); synth.set_max_bells(1); assert_eq!(synth.pattern(Sound::BellToll), "\x07"); synth.set_max_bells(usize::MAX); assert_eq!(synth.max_bells(), MAX_BELLS_PER_PATTERN); assert!(synth.pattern(Sound::BellToll).len() <= MAX_PATTERN_LEN); } }