1use std::fmt;
6use std::num::NonZeroU16;
7
8#[cfg(feature = "serde")]
9use serde::{Deserialize, Serialize};
10use thiserror::Error;
11
12const FIRST_PACKED_YEAR: u16 = 2001;
13const LAST_PACKED_YEAR: u16 = 2127;
14
15#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
20pub struct IniSessionDate {
21 year: u16,
22 month: u8,
23 day: u8,
24}
25
26#[cfg(feature = "serde")]
27#[derive(Serialize, Deserialize)]
28#[serde(rename = "IniSessionDate")]
29struct IniSessionDateWire {
30 year: u16,
31 month: u8,
32 day: u8,
33}
34
35#[cfg(feature = "serde")]
36impl Serialize for IniSessionDate {
37 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
38 where
39 S: serde::Serializer,
40 {
41 IniSessionDateWire {
42 year: self.year,
43 month: self.month,
44 day: self.day,
45 }
46 .serialize(serializer)
47 }
48}
49
50#[cfg(feature = "serde")]
51impl<'de> Deserialize<'de> for IniSessionDate {
52 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
53 where
54 D: serde::Deserializer<'de>,
55 {
56 let wire = IniSessionDateWire::deserialize(deserializer)?;
57 Self::new(wire.year, wire.month, wire.day).map_err(serde::de::Error::custom)
58 }
59}
60
61impl IniSessionDate {
62 pub fn new(year: u16, month: u8, day: u8) -> Result<Self, IniSessionDateError> {
64 Self::from_parts(u32::from(year), u32::from(month), u32::from(day))
65 }
66
67 #[inline]
69 pub const fn year(self) -> u16 {
70 self.year
71 }
72
73 #[inline]
75 pub const fn month(self) -> u8 {
76 self.month
77 }
78
79 #[inline]
81 pub const fn day(self) -> u8 {
82 self.day
83 }
84
85 #[inline]
87 pub const fn as_yyyymmdd(self) -> u32 {
88 self.year as u32 * 10_000 + self.month as u32 * 100 + self.day as u32
89 }
90
91 #[inline]
95 pub const fn max_auto_discard_months(self) -> u16 {
96 (self.year - FIRST_PACKED_YEAR) * 12 + (self.month - 1) as u16
97 }
98
99 fn from_parts(year: u32, month: u32, day: u32) -> Result<Self, IniSessionDateError> {
100 if !(u32::from(FIRST_PACKED_YEAR)..=u32::from(LAST_PACKED_YEAR)).contains(&year) {
101 return Err(IniSessionDateError::YearOutOfRange { year });
102 }
103 if !(1..=12).contains(&month) {
104 return Err(IniSessionDateError::MonthOutOfRange { month });
105 }
106 let days_in_month = days_in_month(year, month);
107 if !(1..=days_in_month).contains(&day) {
108 return Err(IniSessionDateError::DayOutOfRange { year, month, day });
109 }
110
111 Ok(Self {
112 year: year as u16,
113 month: month as u8,
114 day: day as u8,
115 })
116 }
117}
118
119impl TryFrom<u32> for IniSessionDate {
120 type Error = IniSessionDateError;
121
122 fn try_from(value: u32) -> Result<Self, Self::Error> {
123 Self::from_parts(value / 10_000, (value / 100) % 100, value % 100)
124 }
125}
126
127impl From<IniSessionDate> for u32 {
128 #[inline]
129 fn from(value: IniSessionDate) -> Self {
130 value.as_yyyymmdd()
131 }
132}
133
134impl fmt::Display for IniSessionDate {
135 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
136 write!(
137 formatter,
138 "{:04}-{:02}-{:02}",
139 self.year, self.month, self.day
140 )
141 }
142}
143
144#[derive(Clone, Copy, Debug, Error, PartialEq, Eq)]
146pub enum IniSessionDateError {
147 #[error("year {year} is outside Dear ImGui's supported packed-date range 2001 through 2127")]
149 YearOutOfRange { year: u32 },
150 #[error("month {month} is outside the Gregorian range 1 through 12")]
152 MonthOutOfRange { month: u32 },
153 #[error("day {day} is invalid for {year:04}-{month:02}")]
155 DayOutOfRange { year: u32, month: u32, day: u32 },
156}
157
158#[derive(Clone, Copy, Debug, PartialEq, Eq)]
179#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
180pub enum IniSettingsRetention {
181 Disabled {
184 session_date: Option<IniSessionDate>,
186 },
187 RecordLastUsed {
193 session_date: Option<IniSessionDate>,
195 },
196 AutoDiscard {
202 session_date: IniSessionDate,
204 months: NonZeroU16,
206 },
207}
208
209impl IniSettingsRetention {
210 #[inline]
212 pub const fn disabled() -> Self {
213 Self::Disabled { session_date: None }
214 }
215
216 #[inline]
218 pub const fn session_date(self) -> Option<IniSessionDate> {
219 match self {
220 Self::Disabled { session_date } => session_date,
221 Self::RecordLastUsed { session_date } => session_date,
222 Self::AutoDiscard { session_date, .. } => Some(session_date),
223 }
224 }
225
226 #[inline]
228 pub const fn auto_discard_months(self) -> Option<NonZeroU16> {
229 match self {
230 Self::AutoDiscard { months, .. } => Some(months),
231 Self::Disabled { .. } | Self::RecordLastUsed { .. } => None,
232 }
233 }
234
235 #[inline]
241 pub const fn last_used_date_recording_enabled(self) -> bool {
242 !matches!(self, Self::Disabled { .. })
243 }
244
245 pub(crate) fn validate(self) -> Result<(), IniSettingsRetentionError> {
246 if let Self::AutoDiscard {
247 session_date,
248 months,
249 } = self
250 {
251 let max_months = session_date.max_auto_discard_months();
252 if months.get() > max_months {
253 return Err(IniSettingsRetentionError::RetentionUnderflowsSessionDate {
254 session_date,
255 months,
256 max_months,
257 });
258 }
259 }
260 Ok(())
261 }
262
263 pub(crate) const fn raw_parts(self) -> (i32, bool, i32) {
264 match self {
265 Self::Disabled { session_date } => (
266 match session_date {
267 Some(date) => date.as_yyyymmdd() as i32,
268 None => 0,
269 },
270 false,
271 0,
272 ),
273 Self::RecordLastUsed { session_date } => (
274 match session_date {
275 Some(date) => date.as_yyyymmdd() as i32,
276 None => 0,
277 },
278 true,
279 0,
280 ),
281 Self::AutoDiscard {
282 session_date,
283 months,
284 } => (session_date.as_yyyymmdd() as i32, true, months.get() as i32),
285 }
286 }
287
288 pub(crate) fn from_raw(
289 session_date: i32,
290 save_last_used_date: bool,
291 auto_discard_months: i32,
292 ) -> Result<Self, IniSettingsRetentionError> {
293 let session_date = match session_date {
294 0 => None,
295 raw if raw > 0 => IniSessionDate::try_from(raw as u32)
296 .map(Some)
297 .map_err(|_| IniSettingsRetentionError::InvalidNativeSessionDate { raw })?,
298 raw => return Err(IniSettingsRetentionError::InvalidNativeSessionDate { raw }),
299 };
300 let months = match auto_discard_months {
301 0 => None,
302 raw if raw > 0 => {
303 NonZeroU16::new(u16::try_from(raw).map_err(|_| {
304 IniSettingsRetentionError::InvalidNativeAutoDiscardMonths { raw }
305 })?)
306 }
307 raw => return Err(IniSettingsRetentionError::InvalidNativeAutoDiscardMonths { raw }),
308 };
309
310 match (save_last_used_date, session_date, months) {
311 (false, session_date, None) => Ok(Self::Disabled { session_date }),
312 (true, session_date, None) => Ok(Self::RecordLastUsed { session_date }),
313 (true, Some(session_date), Some(months)) => {
314 let retention = Self::AutoDiscard {
315 session_date,
316 months,
317 };
318 retention.validate()?;
319 Ok(retention)
320 }
321 (false, _, Some(_)) => Err(IniSettingsRetentionError::InvalidNativeState {
322 reason: "automatic discard is enabled while last-used dates are disabled",
323 }),
324 (true, None, Some(_)) => Err(IniSettingsRetentionError::InvalidNativeState {
325 reason: "automatic discard is enabled without a session date",
326 }),
327 }
328 }
329}
330
331#[derive(Clone, Copy, Debug, Error, PartialEq, Eq)]
333pub enum IniSettingsRetentionError {
334 #[error(
336 "automatic discard period of {months} months exceeds the {max_months}-month limit for session date {session_date}"
337 )]
338 RetentionUnderflowsSessionDate {
339 session_date: IniSessionDate,
341 months: NonZeroU16,
343 max_months: u16,
345 },
346 #[error("`.ini` retention must be configured before the first Dear ImGui frame")]
348 LockedAfterFirstFrame,
349 #[error("`.ini` retention must be configured before loading Dear ImGui settings")]
351 LockedAfterSettingsLoad,
352 #[error("native Platform_SessionDate {raw} is not a valid Dear ImGui packed date")]
354 InvalidNativeSessionDate { raw: i32 },
355 #[error("native ConfigIniSettingsAutoDiscardMonths {raw} is invalid")]
357 InvalidNativeAutoDiscardMonths { raw: i32 },
358 #[error("native `.ini` retention state is invalid: {reason}")]
360 InvalidNativeState {
361 reason: &'static str,
363 },
364}
365
366fn days_in_month(year: u32, month: u32) -> u32 {
367 match month {
368 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
369 4 | 6 | 9 | 11 => 30,
370 2 if is_leap_year(year) => 29,
371 2 => 28,
372 _ => unreachable!("IniSessionDate validates the month before asking for its day count"),
373 }
374}
375
376const fn is_leap_year(year: u32) -> bool {
377 year.is_multiple_of(400) || (year.is_multiple_of(4) && !year.is_multiple_of(100))
378}
379
380#[cfg(all(test, feature = "serde"))]
381mod serde_tests {
382 use super::*;
383 use serde_test::{Token, assert_tokens};
384
385 #[test]
386 fn session_date_serde_uses_one_stable_wire_shape() {
387 let date = IniSessionDate::new(2024, 2, 29).unwrap();
388 assert_tokens(
389 &date,
390 &[
391 Token::Struct {
392 name: "IniSessionDate",
393 len: 3,
394 },
395 Token::Str("year"),
396 Token::U16(2024),
397 Token::Str("month"),
398 Token::U8(2),
399 Token::Str("day"),
400 Token::U8(29),
401 Token::StructEnd,
402 ],
403 );
404 }
405
406 #[test]
407 fn session_date_deserialization_preserves_validation() {
408 let valid = r#"{"year":2024,"month":2,"day":29}"#;
409 assert_eq!(
410 serde_json::from_str::<IniSessionDate>(valid).unwrap(),
411 IniSessionDate::new(2024, 2, 29).unwrap()
412 );
413
414 for invalid in [
415 r#"{"year":2000,"month":1,"day":1}"#,
416 r#"{"year":2128,"month":1,"day":1}"#,
417 r#"{"year":2026,"month":13,"day":1}"#,
418 r#"{"year":2100,"month":2,"day":29}"#,
419 ] {
420 assert!(serde_json::from_str::<IniSessionDate>(invalid).is_err());
421 }
422 }
423
424 #[test]
425 fn retention_deserialization_cannot_bypass_session_date_validation() {
426 for invalid in [
427 r#"{"Disabled":{"session_date":{"year":2000,"month":1,"day":1}}}"#,
428 r#"{"RecordLastUsed":{"session_date":{"year":2128,"month":1,"day":1}}}"#,
429 r#"{"AutoDiscard":{"session_date":{"year":2100,"month":2,"day":29},"months":1}}"#,
430 ] {
431 assert!(serde_json::from_str::<IniSettingsRetention>(invalid).is_err());
432 }
433 }
434}