diff --git a/.gitignore b/.gitignore index 10036ed..1408e08 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,7 @@ build ddate.exe ddate.obj .vscode/ + +# Rust +target/ +Cargo.lock diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..f928a3a --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "ddate" +version = "0.1.0" +edition = "2021" +description = "Discordian date converter" +license = "CC0-1.0" + +[features] +default = ["old_immediate_fmt", "kill_bob"] +old_immediate_fmt = [] +us_format = [] +kill_bob = [] +praise_bob = [] + +[profile.release] +opt-level = "z" # Optimize for size +lto = true # Link-time optimization +codegen-units = 1 # Better optimization +panic = "abort" # No unwinding +strip = true # Strip symbols diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..1bc9329 --- /dev/null +++ b/Makefile @@ -0,0 +1,24 @@ +.PHONY: all release debug clean install + +CARGO = cargo +UPX = upx +PREFIX ?= /usr/local + +all: release + +release: + $(CARGO) build --release + $(UPX) --best target/release/ddate + +debug: + $(CARGO) build + +clean: + $(CARGO) clean + +install: release + install -Dm755 target/release/ddate $(PREFIX)/bin/ddate + +# Build without UPX compression +release-uncompressed: + $(CARGO) build --release diff --git a/src/constants.rs b/src/constants.rs new file mode 100644 index 0000000..6e40dc4 --- /dev/null +++ b/src/constants.rs @@ -0,0 +1,94 @@ +pub const DAY_LONG: [&str; 5] = [ + "Sweetmorn", + "Boomtime", + "Pungenday", + "Prickle-Prickle", + "Setting Orange", +]; + +pub const DAY_SHORT: [&str; 5] = ["SM", "BT", "PD", "PP", "SO"]; + +pub const SEASON_LONG: [&str; 5] = [ + "Chaos", + "Discord", + "Confusion", + "Bureaucracy", + "The Aftermath", +]; + +pub const SEASON_SHORT: [&str; 5] = ["Chs", "Dsc", "Cfn", "Bcy", "Afm"]; + +pub const HOLYDAYS: [[&str; 2]; 5] = [ + ["Mungday", "Chaoflux"], + ["Mojoday", "Discoflux"], + ["Syaday", "Confuflux"], + ["Zaraday", "Bureflux"], + ["Maladay", "Afflux"], +]; + +/// Static exclamations array matching C's exact order +#[cfg(all(not(feature = "praise_bob"), target_os = "linux"))] +pub const EXCLAMATIONS: &[&str] = &[ + "Hail Eris!", "All Hail Discordia!", "Kallisti!", "Fnord.", "Or not.", + "Wibble.", "Pzat!", "P'tang!", "Frink!", + "Grudnuk demand sustenance!", "Keep the Lasagna flying!", "You are what you see.", + "Or is it?", "This statement is false.", "Lies and slander, sire!", "Hee hee hee!", + "Hail Eris, Hack Linux!", "", +]; + +#[cfg(all(not(feature = "praise_bob"), target_os = "macos"))] +pub const EXCLAMATIONS: &[&str] = &[ + "Hail Eris!", "All Hail Discordia!", "Kallisti!", "Fnord.", "Or not.", + "Wibble.", "Pzat!", "P'tang!", "Frink!", + "Grudnuk demand sustenance!", "Keep the Lasagna flying!", "You are what you see.", + "Or is it?", "This statement is false.", "Lies and slander, sire!", "Hee hee hee!", + "This Fruit is not the True Fruit of Discord.", "", +]; + +#[cfg(all(not(feature = "praise_bob"), not(target_os = "linux"), not(target_os = "macos")))] +pub const EXCLAMATIONS: &[&str] = &[ + "Hail Eris!", "All Hail Discordia!", "Kallisti!", "Fnord.", "Or not.", + "Wibble.", "Pzat!", "P'tang!", "Frink!", + "Grudnuk demand sustenance!", "Keep the Lasagna flying!", "You are what you see.", + "Or is it?", "This statement is false.", "Lies and slander, sire!", "Hee hee hee!", + "", +]; + +#[cfg(all(feature = "praise_bob", target_os = "linux"))] +pub const EXCLAMATIONS: &[&str] = &[ + "Hail Eris!", "All Hail Discordia!", "Kallisti!", "Fnord.", "Or not.", + "Wibble.", "Pzat!", "P'tang!", "Frink!", + "Slack!", "Praise \"Bob\"!", "Or kill me.", + "Grudnuk demand sustenance!", "Keep the Lasagna flying!", "You are what you see.", + "Or is it?", "This statement is false.", "Lies and slander, sire!", "Hee hee hee!", + "Hail Eris, Hack Linux!", "", +]; + +#[cfg(all(feature = "praise_bob", target_os = "macos"))] +pub const EXCLAMATIONS: &[&str] = &[ + "Hail Eris!", "All Hail Discordia!", "Kallisti!", "Fnord.", "Or not.", + "Wibble.", "Pzat!", "P'tang!", "Frink!", + "Slack!", "Praise \"Bob\"!", "Or kill me.", + "Grudnuk demand sustenance!", "Keep the Lasagna flying!", "You are what you see.", + "Or is it?", "This statement is false.", "Lies and slander, sire!", "Hee hee hee!", + "This Fruit is not the True Fruit of Discord.", "", +]; + +#[cfg(all(feature = "praise_bob", not(target_os = "linux"), not(target_os = "macos")))] +pub const EXCLAMATIONS: &[&str] = &[ + "Hail Eris!", "All Hail Discordia!", "Kallisti!", "Fnord.", "Or not.", + "Wibble.", "Pzat!", "P'tang!", "Frink!", + "Slack!", "Praise \"Bob\"!", "Or kill me.", + "Grudnuk demand sustenance!", "Keep the Lasagna flying!", "You are what you see.", + "Or is it?", "This statement is false.", "Lies and slander, sire!", "Hee hee hee!", + "", +]; + +#[cfg(feature = "old_immediate_fmt")] +pub const DEFAULT_IMMEDIATE_FMT: &str = + "Today is %{%A, the %e day of %B%} in the YOLD %Y%N%nCelebrate %H"; + +#[cfg(not(feature = "old_immediate_fmt"))] +pub const DEFAULT_IMMEDIATE_FMT: &str = "%{%A, %B %d%}, %Y YOLD"; + +pub const DEFAULT_FMT: &str = "%{%A, %B %d%}, %Y YOLD"; diff --git a/src/disc_time.rs b/src/disc_time.rs new file mode 100644 index 0000000..d591052 --- /dev/null +++ b/src/disc_time.rs @@ -0,0 +1,135 @@ +use crate::constants::HOLYDAYS; + +/// Days in each Gregorian month (non-leap year) +const CALENDAR: [i32; 12] = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; + +/// Represents a Discordian date +#[derive(Debug, Clone, Copy, Default)] +pub struct DiscTime { + pub season: i32, // 0-4, or -1 for invalid + pub day: i32, // 0-72, or -1 for St. Tib's Day + pub yday: i32, // 0-365 (day of Discordian year) + pub year: i32, // Discordian year (Gregorian + 1166) +} + +impl DiscTime { + /// Check if this is St. Tib's Day + pub fn is_tibs_day(&self) -> bool { + self.day == -1 + } + + /// Get the holyday name if applicable + pub fn holyday_name(&self) -> Option<&'static str> { + if self.day == 4 { + Some(HOLYDAYS[self.season as usize][0]) + } else if self.day == 49 { + Some(HOLYDAYS[self.season as usize][1]) + } else { + None + } + } +} + +/// Convert day-of-year and year (as tm_yday, tm_year from localtime) to DiscTime +/// +/// nday: days since January 1 (0-365) +/// nyear: years since 1900 +pub fn convert(nday: i32, nyear: i32) -> DiscTime { + // nyear is years since 1900, add 3066 to get Discordian year + // 1900 + 1166 = 3066 + let year = nyear + 3066; + let mut day = nday; + + // Handle leap year St. Tib's Day + // Discordian leap years have year % 4 == 2 (because 1166 % 4 == 2) + if year % 4 == 2 { + if day == 59 { + // February 29 (0-indexed day 59) is St. Tib's Day + day = -1; + } else if day > 59 { + // Days after St. Tib's Day are shifted back by one + day -= 1; + } + } + + let yday = day; + + // Calculate season (each season has 73 days) + let (season, day) = if day >= 0 { + (day / 73, day % 73) + } else { + (0, day) // St. Tib's Day + }; + + DiscTime { + season, + day, + yday, + year, + } +} + +/// Convert Gregorian date to DiscTime +/// +/// Arguments order depends on us_format feature: +/// - us_format disabled (default): arg1=day, arg2=month +/// - us_format enabled: arg1=month, arg2=day +pub fn makeday(arg1: i32, arg2: i32, iyear: i32) -> DiscTime { + #[cfg(feature = "us_format")] + let (imonth, iday) = (arg1, arg2); + #[cfg(not(feature = "us_format"))] + let (iday, imonth) = (arg1, arg2); + + // Validate month (1-12) and year (not 0) + if !(1..=12).contains(&imonth) || iyear == 0 { + return DiscTime { + season: -1, + ..Default::default() + }; + } + + // Validate day + let month_idx = (imonth - 1) as usize; + let max_day = CALENDAR[month_idx]; + + if iday < 1 || iday > max_day { + // Special case: Feb 29 on leap year + let is_leap = iyear % 4 == 0 && (iyear % 100 != 0 || iyear % 400 == 0); + if !(imonth == 2 && iday == 29 && is_leap) { + return DiscTime { + season: -1, + ..Default::default() + }; + } + } + + // Calculate Discordian year + // Note: Gregorian year 0 doesn't exist, so add 1 for negative years + let year = iyear + 1166 + if iyear < 0 { 1 } else { 0 }; + + // Calculate day of year (0-indexed) + let days_past: i32 = CALENDAR.iter().take(month_idx).sum(); + let mut day = days_past + iday - 1; + + // Handle St. Tib's Day + // Discordian leap years have year % 4 == 2 + if year % 4 == 2 && day == 59 && iday == 29 { + day = -1; + } + + let yday = day; + + // Calculate season (each season has 73 days) + let (season, day) = if day >= 0 { + (day / 73, day % 73) + } else { + (0, day) // St. Tib's Day + }; + + DiscTime { + season, + day, + yday, + year, + } +} diff --git a/src/format.rs b/src/format.rs new file mode 100644 index 0000000..17d88d0 --- /dev/null +++ b/src/format.rs @@ -0,0 +1,156 @@ +use crate::constants::{DAY_LONG, DAY_SHORT, EXCLAMATIONS, SEASON_LONG, SEASON_SHORT}; +use crate::disc_time::DiscTime; +use crate::random::SimpleRng; + +/// Ordinal suffixes lookup table +const ENDINGS: [&str; 10] = ["th", "st", "nd", "rd", "th", "th", "th", "th", "th", "th"]; + +/// Get ordinal suffix for a number (1st, 2nd, 3rd, 4th, etc.) +#[inline] +fn ending(i: i32) -> &'static str { + if (11..=13).contains(&(i % 100)) { + "th" + } else { + ENDINGS[(i % 10) as usize] + } +} + +/// Check if a year is a leap year using the C code's formula +/// C: (!(DY(i)%4))&&((DY(i)%100)||(!(DY(i)%400))) +/// where DY(y) = y + 1166 +/// Note: This replicates the C code's behavior exactly, including when +/// passed a Discordian year (which adds 1166 again) +#[cfg(feature = "kill_bob")] +fn leapp(i: i32) -> bool { + let dy = i + 1166; + (dy % 4 == 0) && ((dy % 100 != 0) || (dy % 400 == 0)) +} + +/// Calculate days until X-Day (Cfn 40, 9827) +/// Matches C code exactly +#[cfg(feature = "kill_bob")] +fn xday_countdown(yday: i32, year: i32) -> i32 { + let mut year = year; + let mut r = (185 - yday) + if yday < 59 && leapp(year) { 1 } else { 0 }; + + while year < 9827 { + year += 1; + r += if leapp(year) { 366 } else { 365 }; + } + while year > 9827 { + r -= if leapp(year) { 366 } else { 365 }; + year -= 1; + } + + r +} + +/// Format a Discordian date according to the format string +/// Matches C code's format() function exactly +pub fn format(fmt: &str, dt: DiscTime, rng: &mut SimpleRng) -> String { + let bytes = fmt.as_bytes(); + let fmtlen = bytes.len(); + let mut output = String::with_capacity(64); + + // First pass: find extents of St. Tib's Day region + // C code uses tib_start=-1 and checks tib_start>0, meaning position 0 + // is treated specially (won't update tib_end if tib_start==0) + let mut tib_start: i32 = -1; + let mut tib_end: usize = 0; + + let mut i = 0; + while i < fmtlen { + if bytes[i] == b'%' && i + 1 < fmtlen { + match bytes[i + 1] { + b'A' | b'a' | b'd' | b'e' => { + if tib_start > 0 { + tib_end = i + 1; + } else { + tib_start = i as i32; + } + } + b'{' => tib_start = i as i32, + b'}' => tib_end = i + 1, + _ => {} + } + } + i += 1; + } + + // Second pass: format output + // Precompute values used multiple times + let day_idx = (dt.yday % 5) as usize; + let season_idx = dt.season as usize; + let day_num = dt.day + 1; + + let mut i = 0; + while i < fmtlen { + // Handle St. Tib's Day replacement + if i as i32 == tib_start && dt.is_tibs_day() { + output.push_str("St. Tib's Day"); + i = tib_end + 1; + continue; + } + + if bytes[i] == b'%' && i + 1 < fmtlen { + i += 1; + match bytes[i] { + b'A' => output.push_str(DAY_LONG[day_idx]), + b'a' => output.push_str(DAY_SHORT[day_idx]), + b'B' => output.push_str(SEASON_LONG[season_idx]), + b'b' => output.push_str(SEASON_SHORT[season_idx]), + b'd' => itoa(&mut output, day_num), + b'e' => { + itoa(&mut output, day_num); + output.push_str(ending(day_num)); + } + b'H' => { + if let Some(name) = dt.holyday_name() { + output.push_str(name); + } + } + b'N' => { + // Early exit if not a holyday (goto eschaton in C) + if dt.day != 4 && dt.day != 49 { + return output; + } + } + b'n' => output.push('\n'), + b't' => output.push('\t'), + b'Y' => itoa(&mut output, dt.year), + #[allow(clippy::explicit_auto_deref)] + b'.' => output.push_str(*rng.select(EXCLAMATIONS)), + #[cfg(feature = "kill_bob")] + b'X' => itoa(&mut output, xday_countdown(dt.yday, dt.year)), + b'{' | b'}' => {} + _ => {} + } + } else { + output.push(bytes[i] as char); + } + i += 1; + } + + output +} + +/// Fast integer to string, writes directly to output +#[inline] +fn itoa(output: &mut String, mut n: i32) { + if n < 0 { + output.push('-'); + n = -n; + } + if n == 0 { + output.push('0'); + return; + } + let start = output.len(); + while n > 0 { + output.push((b'0' + (n % 10) as u8) as char); + n /= 10; + } + // Reverse the digits we just pushed + let bytes = unsafe { output.as_bytes_mut() }; + bytes[start..].reverse(); +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..df969da --- /dev/null +++ b/src/main.rs @@ -0,0 +1,159 @@ +mod constants; +mod disc_time; +mod format; +mod random; + +use std::env; +use std::process; + +use constants::{DEFAULT_FMT, DEFAULT_IMMEDIATE_FMT}; +use disc_time::{convert, makeday, DiscTime}; +use format::format; +use random::SimpleRng; + +const PACKAGE_STRING: &str = "Stand Alone"; + +fn main() { + let args: Vec = env::args().collect(); + + // Get program name (basename) + let progname = args[0].rsplit('/').next().unwrap_or(&args[0]); + + let mut rng = SimpleRng::new(); + let mut format_str: Option<&str> = None; + let mut pi = 1; + + // Parse arguments matching C's loop behavior exactly + while pi < args.len() { + let arg = args[pi].as_bytes(); + if arg.is_empty() { + break; + } + + match arg[0] { + b'+' => { + format_str = Some(&args[pi][1..]); + pi += 1; + } + b'-' => { + if arg.len() > 1 && arg[1] == b'V' { + println!("{} ({})", progname, PACKAGE_STRING); + } + usage(&args[0]); + } + _ => break, + } + } + + // After loop: check remaining arguments + let remaining = args.len() - pi; + + let (dt, default_format): (DiscTime, &str) = if remaining == 3 { + // Explicit date provided: day month year (or month day year with us_format) + let arg1: i32 = args[pi].parse().unwrap_or(0); + let arg2: i32 = args[pi + 1].parse().unwrap_or(0); + let year: i32 = args[pi + 2].parse().unwrap_or(0); + + let dt = makeday(arg1, arg2, year); + + if dt.season == -1 { + println!("Invalid date -- out of range"); + process::exit(-1); + } + + (dt, DEFAULT_FMT) + } else if remaining == 0 { + // Current date + let (yday, year) = get_current_date(); + let dt = convert(yday, year); + (dt, DEFAULT_IMMEDIATE_FMT) + } else { + // Wrong number of arguments + usage(&args[0]); + }; + + let fmt = format_str.unwrap_or(default_format); + let output = format(fmt, dt, &mut rng); + println!("{}", output); +} + +fn usage(progname: &str) -> ! { + eprintln!("usage: {} [+format] [day month year]", progname); + process::exit(1); +} + +/// Get current date as (tm_yday, tm_year) matching C's localtime() +/// tm_yday: days since January 1 (0-365) +/// tm_year: years since 1900 +fn get_current_date() -> (i32, i32) { + #[cfg(unix)] + { + use std::ffi::c_long; + + #[repr(C)] + struct Tm { + tm_sec: i32, + tm_min: i32, + tm_hour: i32, + tm_mday: i32, + tm_mon: i32, + tm_year: i32, + tm_wday: i32, + tm_yday: i32, + tm_isdst: i32, + // Additional fields on some platforms, but we only need up to tm_yday + } + + extern "C" { + fn time(tloc: *mut c_long) -> c_long; + fn localtime(timep: *const c_long) -> *const Tm; + } + + unsafe { + let t = time(std::ptr::null_mut()); + let tm = localtime(&t); + ((*tm).tm_yday, (*tm).tm_year) + } + } + + #[cfg(not(unix))] + { + // Fallback for non-Unix: calculate from SystemTime (UTC only) + use std::time::{SystemTime, UNIX_EPOCH}; + + let secs = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + + calculate_utc_date(secs) + } +} + +/// Calculate yday and year from Unix timestamp (UTC only, fallback) +#[cfg(not(unix))] +fn calculate_utc_date(secs: u64) -> (i32, i32) { + // Days since Unix epoch (Jan 1, 1970) + let days = (secs / 86400) as i32; + + // Calculate year + let mut year = 1970; + let mut remaining_days = days; + + loop { + let days_in_year = if is_leap_year_greg(year) { 366 } else { 365 }; + if remaining_days < days_in_year { + break; + } + remaining_days -= days_in_year; + year += 1; + } + + // remaining_days is now yday (0-indexed) + (remaining_days, year - 1900) +} + +#[cfg(not(unix))] +fn is_leap_year_greg(year: i32) -> bool { + year % 4 == 0 && (year % 100 != 0 || year % 400 == 0) +} diff --git a/src/random.rs b/src/random.rs new file mode 100644 index 0000000..3665401 --- /dev/null +++ b/src/random.rs @@ -0,0 +1,36 @@ +use std::time::{SystemTime, UNIX_EPOCH}; + +/// Simple Linear Congruential Generator matching glibc's rand() +/// glibc uses: next = (a * prev + c) % m +/// where a = 1103515245, c = 12345, m = 2^31 +pub struct SimpleRng { + state: u32, +} + +impl SimpleRng { + /// Create a new RNG seeded with current time (matching srand(time(NULL))) + pub fn new() -> Self { + let seed = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs() as u32) + .unwrap_or(0); + Self { state: seed } + } + + /// Generate next random number (matching glibc rand()) + pub fn next(&mut self) -> i32 { + // glibc LCG parameters + const A: u32 = 1103515245; + const C: u32 = 12345; + const M: u32 = 1 << 31; + + self.state = (A.wrapping_mul(self.state).wrapping_add(C)) % M; + self.state as i32 + } + + /// Select a random element from a slice + pub fn select<'a, T>(&mut self, items: &'a [T]) -> &'a T { + let index = (self.next() as usize) % items.len(); + &items[index] + } +}