1- //! Time expression: either a fixed `HH:MM` or a sun-relative
2- //! `sunrise/sunset ± HH:MM`.
1+ //! Time expression: either a fixed `HH:MM`, a sun-relative
2+ //! `sunrise/sunset ± HH:MM`, or `max(a, b)` / `min(a, b)` of two
3+ //! sub-expressions.
34//!
45//! Parsed from a string in the JSON config. Examples:
5- //! - `"06:00"` → Fixed 06:00
6- //! - `"23:00"` → Fixed 23:00
7- //! - `"24:00"` → Fixed 24:00 (exclusive end-of-day sentinel)
8- //! - `"sunset"` → Sun-relative, sunset + 0
9- //! - `"sunset-01:00"` → Sun-relative, sunset − 60 min
10- //! - `"sunrise+01:30"` → Sun-relative, sunrise + 90 min
6+ //! - `"06:00"` → Fixed 06:00
7+ //! - `"23:00"` → Fixed 23:00
8+ //! - `"24:00"` → Fixed 24:00 (exclusive end-of-day sentinel)
9+ //! - `"sunset"` → Sun-relative, sunset + 0
10+ //! - `"sunset-01:00"` → Sun-relative, sunset − 60 min
11+ //! - `"sunrise+01:30"` → Sun-relative, sunrise + 90 min
12+ //! - `"max(sunset+01:00, 23:00)"` → The later of two times
13+ //! - `"min(sunrise-01:00, 05:00)"` → The earlier of two times
1114
1215use std:: fmt;
1316use std:: str:: FromStr ;
@@ -24,6 +27,10 @@ pub enum TimeExpr {
2427 Fixed { minute_of_day : u16 } ,
2528 /// A time relative to a solar event.
2629 SunRelative { event : SunEvent , offset_minutes : i16 } ,
30+ /// The maximum (later) of two sub-expressions.
31+ Max ( Box < TimeExpr > , Box < TimeExpr > ) ,
32+ /// The minimum (earlier) of two sub-expressions.
33+ Min ( Box < TimeExpr > , Box < TimeExpr > ) ,
2734}
2835
2936#[ derive( Debug , Clone , Copy , PartialEq , Eq ) ]
@@ -35,7 +42,8 @@ pub enum SunEvent {
3542impl TimeExpr {
3643 /// Resolve to minutes-since-midnight (0..=1440). For `Fixed`, returns
3744 /// the stored value directly. For `SunRelative`, computes base + offset
38- /// using the provided sun times.
45+ /// using the provided sun times. For `Max`/`Min`, resolves both operands
46+ /// and returns the later/earlier.
3947 pub fn resolve ( & self , sun : Option < & SunTimes > ) -> u16 {
4048 match self {
4149 TimeExpr :: Fixed { minute_of_day } => * minute_of_day,
@@ -48,12 +56,18 @@ impl TimeExpr {
4856 let raw = base as i32 + * offset_minutes as i32 ;
4957 raw. clamp ( 0 , 1440 ) as u16
5058 }
59+ TimeExpr :: Max ( a, b) => a. resolve ( sun) . max ( b. resolve ( sun) ) ,
60+ TimeExpr :: Min ( a, b) => a. resolve ( sun) . min ( b. resolve ( sun) ) ,
5161 }
5262 }
5363
5464 /// True if this expression depends on solar position.
5565 pub fn uses_sun ( & self ) -> bool {
56- matches ! ( self , TimeExpr :: SunRelative { .. } )
66+ match self {
67+ TimeExpr :: Fixed { .. } => false ,
68+ TimeExpr :: SunRelative { .. } => true ,
69+ TimeExpr :: Max ( a, b) | TimeExpr :: Min ( a, b) => a. uses_sun ( ) || b. uses_sun ( ) ,
70+ }
5771 }
5872}
5973
@@ -89,61 +103,135 @@ pub(crate) fn parse_hhmm(s: &str) -> Result<(u8, u8), ParseTimeExprError> {
89103 Ok ( ( h, m) )
90104}
91105
106+ /// Parse a single atomic time expression (fixed or sun-relative, no max/min).
107+ fn parse_atom ( s : & str ) -> Result < TimeExpr , ParseTimeExprError > {
108+ for ( prefix, event) in [ ( "sunrise" , SunEvent :: Sunrise ) , ( "sunset" , SunEvent :: Sunset ) ] {
109+ if let Some ( rest) = s. strip_prefix ( prefix) {
110+ if rest. is_empty ( ) {
111+ return Ok ( TimeExpr :: SunRelative { event, offset_minutes : 0 } ) ;
112+ }
113+ let ( sign, hhmm) = if let Some ( hhmm) = rest. strip_prefix ( '+' ) {
114+ ( 1i16 , hhmm)
115+ } else if let Some ( hhmm) = rest. strip_prefix ( '-' ) {
116+ ( -1i16 , hhmm)
117+ } else {
118+ return Err ( ParseTimeExprError ( format ! (
119+ "{s:?}: expected +HH:MM or -HH:MM after {prefix}"
120+ ) ) ) ;
121+ } ;
122+ let ( h, m) = parse_hhmm ( hhmm) ?;
123+ let offset = sign * ( h as i16 * 60 + m as i16 ) ;
124+ return Ok ( TimeExpr :: SunRelative { event, offset_minutes : offset } ) ;
125+ }
126+ }
127+ let ( h, m) = parse_hhmm ( s) ?;
128+ Ok ( TimeExpr :: Fixed { minute_of_day : h as u16 * 60 + m as u16 } )
129+ }
130+
131+ /// Parse `max(a, b)` or `min(a, b)`. Returns `None` if the string doesn't
132+ /// start with `max(` or `min(`.
133+ fn parse_minmax ( s : & str ) -> Option < Result < TimeExpr , ParseTimeExprError > > {
134+ let ( func, rest) = if let Some ( rest) = s. strip_prefix ( "max(" ) {
135+ ( "max" , rest)
136+ } else if let Some ( rest) = s. strip_prefix ( "min(" ) {
137+ ( "min" , rest)
138+ } else {
139+ return None ;
140+ } ;
141+
142+ let inner = match rest. strip_suffix ( ')' ) {
143+ Some ( inner) => inner,
144+ None => return Some ( Err ( ParseTimeExprError ( format ! (
145+ "{s:?}: missing closing parenthesis"
146+ ) ) ) ) ,
147+ } ;
148+
149+ // Split on ", " (comma + space). Both operands are atoms (no nesting).
150+ let ( left, right) = match inner. split_once ( ", " ) {
151+ Some ( pair) => pair,
152+ None => return Some ( Err ( ParseTimeExprError ( format ! (
153+ "{s:?}: expected two comma-separated arguments inside {func}()"
154+ ) ) ) ) ,
155+ } ;
156+
157+ let left = match left. trim ( ) . parse :: < TimeExpr > ( ) {
158+ Ok ( e) => e,
159+ Err ( e) => return Some ( Err ( e) ) ,
160+ } ;
161+ let right = match right. trim ( ) . parse :: < TimeExpr > ( ) {
162+ Ok ( e) => e,
163+ Err ( e) => return Some ( Err ( e) ) ,
164+ } ;
165+
166+ Some ( Ok ( match func {
167+ "max" => TimeExpr :: Max ( Box :: new ( left) , Box :: new ( right) ) ,
168+ "min" => TimeExpr :: Min ( Box :: new ( left) , Box :: new ( right) ) ,
169+ _ => unreachable ! ( ) ,
170+ } ) )
171+ }
172+
92173impl FromStr for TimeExpr {
93174 type Err = ParseTimeExprError ;
94175
95176 fn from_str ( s : & str ) -> Result < Self , Self :: Err > {
96- // Try sun-relative first.
97- for ( prefix, event) in [ ( "sunrise" , SunEvent :: Sunrise ) , ( "sunset" , SunEvent :: Sunset ) ] {
98- if let Some ( rest) = s. strip_prefix ( prefix) {
99- if rest. is_empty ( ) {
100- return Ok ( TimeExpr :: SunRelative { event, offset_minutes : 0 } ) ;
101- }
102- let ( sign, hhmm) = if let Some ( hhmm) = rest. strip_prefix ( '+' ) {
103- ( 1i16 , hhmm)
104- } else if let Some ( hhmm) = rest. strip_prefix ( '-' ) {
105- ( -1i16 , hhmm)
106- } else {
107- return Err ( ParseTimeExprError ( format ! (
108- "{s:?}: expected +HH:MM or -HH:MM after {prefix}"
109- ) ) ) ;
110- } ;
111- let ( h, m) = parse_hhmm ( hhmm) ?;
112- let offset = sign * ( h as i16 * 60 + m as i16 ) ;
113- return Ok ( TimeExpr :: SunRelative { event, offset_minutes : offset } ) ;
114- }
177+ // Try max/min wrapper first.
178+ if let Some ( result) = parse_minmax ( s) {
179+ return result;
115180 }
116- // Fixed time.
117- let ( h, m) = parse_hhmm ( s) ?;
118- Ok ( TimeExpr :: Fixed { minute_of_day : h as u16 * 60 + m as u16 } )
181+ // Atom: fixed or sun-relative.
182+ parse_atom ( s)
119183 }
120184}
121185
122186// ---- Display ---------------------------------------------------------------
123187
188+ fn fmt_atom ( f : & mut fmt:: Formatter < ' _ > , expr : & TimeExpr ) -> fmt:: Result {
189+ match expr {
190+ TimeExpr :: Fixed { minute_of_day } => {
191+ let h = minute_of_day / 60 ;
192+ let m = minute_of_day % 60 ;
193+ write ! ( f, "{h:02}:{m:02}" )
194+ }
195+ TimeExpr :: SunRelative { event, offset_minutes } => {
196+ let name = match event {
197+ SunEvent :: Sunrise => "sunrise" ,
198+ SunEvent :: Sunset => "sunset" ,
199+ } ;
200+ if * offset_minutes == 0 {
201+ write ! ( f, "{name}" )
202+ } else {
203+ let sign = if * offset_minutes > 0 { '+' } else { '-' } ;
204+ let abs = offset_minutes. unsigned_abs ( ) ;
205+ let h = abs / 60 ;
206+ let m = abs % 60 ;
207+ write ! ( f, "{name}{sign}{h:02}:{m:02}" )
208+ }
209+ }
210+ // Max/Min are handled by the main Display impl, not here.
211+ TimeExpr :: Max ( _, _) | TimeExpr :: Min ( _, _) => {
212+ write ! ( f, "{expr}" )
213+ }
214+ }
215+ }
216+
124217impl fmt:: Display for TimeExpr {
125218 fn fmt ( & self , f : & mut fmt:: Formatter < ' _ > ) -> fmt:: Result {
126219 match self {
127- TimeExpr :: Fixed { minute_of_day } => {
128- let h = minute_of_day / 60 ;
129- let m = minute_of_day % 60 ;
130- write ! ( f, "{h:02}:{m:02}" )
220+ TimeExpr :: Max ( a, b) => {
221+ write ! ( f, "max(" ) ?;
222+ fmt_atom ( f, a) ?;
223+ write ! ( f, ", " ) ?;
224+ fmt_atom ( f, b) ?;
225+ write ! ( f, ")" )
131226 }
132- TimeExpr :: SunRelative { event, offset_minutes } => {
133- let name = match event {
134- SunEvent :: Sunrise => "sunrise" ,
135- SunEvent :: Sunset => "sunset" ,
136- } ;
137- if * offset_minutes == 0 {
138- write ! ( f, "{name}" )
139- } else {
140- let sign = if * offset_minutes > 0 { '+' } else { '-' } ;
141- let abs = offset_minutes. unsigned_abs ( ) ;
142- let h = abs / 60 ;
143- let m = abs % 60 ;
144- write ! ( f, "{name}{sign}{h:02}:{m:02}" )
145- }
227+ TimeExpr :: Min ( a, b) => {
228+ write ! ( f, "min(" ) ?;
229+ fmt_atom ( f, a) ?;
230+ write ! ( f, ", " ) ?;
231+ fmt_atom ( f, b) ?;
232+ write ! ( f, ")" )
146233 }
234+ other => fmt_atom ( f, other) ,
147235 }
148236 }
149237}
@@ -200,19 +288,49 @@ mod tests {
200288 ) ;
201289 }
202290
291+ #[ test]
292+ fn parse_max ( ) {
293+ let expr = "max(sunset+01:00, 23:00)" . parse :: < TimeExpr > ( ) . unwrap ( ) ;
294+ assert_eq ! (
295+ expr,
296+ TimeExpr :: Max (
297+ Box :: new( TimeExpr :: SunRelative { event: SunEvent :: Sunset , offset_minutes: 60 } ) ,
298+ Box :: new( TimeExpr :: Fixed { minute_of_day: 1380 } ) ,
299+ )
300+ ) ;
301+ }
302+
303+ #[ test]
304+ fn parse_min ( ) {
305+ let expr = "min(sunrise-01:00, 05:00)" . parse :: < TimeExpr > ( ) . unwrap ( ) ;
306+ assert_eq ! (
307+ expr,
308+ TimeExpr :: Min (
309+ Box :: new( TimeExpr :: SunRelative { event: SunEvent :: Sunrise , offset_minutes: -60 } ) ,
310+ Box :: new( TimeExpr :: Fixed { minute_of_day: 300 } ) ,
311+ )
312+ ) ;
313+ }
314+
203315 #[ test]
204316 fn parse_rejects_invalid ( ) {
205317 assert ! ( "25:00" . parse:: <TimeExpr >( ) . is_err( ) ) ;
206318 assert ! ( "12:60" . parse:: <TimeExpr >( ) . is_err( ) ) ;
207319 assert ! ( "sunset*01:00" . parse:: <TimeExpr >( ) . is_err( ) ) ;
208320 assert ! ( "noon" . parse:: <TimeExpr >( ) . is_err( ) ) ;
321+ assert ! ( "max(23:00)" . parse:: <TimeExpr >( ) . is_err( ) ) ; // missing second arg
322+ assert ! ( "max(23:00, " . parse:: <TimeExpr >( ) . is_err( ) ) ; // missing closing paren
209323 }
210324
211325 #[ test]
212326 fn display_roundtrip ( ) {
213- for s in [ "06:00" , "23:00" , "24:00" , "00:00" , "sunrise" , "sunset-01:00" , "sunrise+01:30" ] {
327+ for s in [
328+ "06:00" , "23:00" , "24:00" , "00:00" ,
329+ "sunrise" , "sunset-01:00" , "sunrise+01:30" ,
330+ "max(sunset+01:00, 23:00)" , "min(sunrise-01:00, 05:00)" ,
331+ ] {
214332 let expr: TimeExpr = s. parse ( ) . unwrap ( ) ;
215- assert_eq ! ( expr. to_string( ) , s) ;
333+ assert_eq ! ( expr. to_string( ) , s, "display roundtrip failed for {s}" ) ;
216334 }
217335 }
218336
@@ -225,6 +343,14 @@ mod tests {
225343 assert_eq ! ( back, expr) ;
226344 }
227345
346+ #[ test]
347+ fn serde_roundtrip_max ( ) {
348+ let expr: TimeExpr = "max(sunset+01:00, 23:00)" . parse ( ) . unwrap ( ) ;
349+ let json = serde_json:: to_string ( & expr) . unwrap ( ) ;
350+ let back: TimeExpr = serde_json:: from_str ( & json) . unwrap ( ) ;
351+ assert_eq ! ( back, expr) ;
352+ }
353+
228354 #[ test]
229355 fn resolve_fixed ( ) {
230356 let expr = TimeExpr :: Fixed { minute_of_day : 360 } ;
@@ -244,4 +370,47 @@ mod tests {
244370 let expr = TimeExpr :: SunRelative { event : SunEvent :: Sunrise , offset_minutes : -60 } ;
245371 assert_eq ! ( expr. resolve( Some ( & sun) ) , 0 ) ; // clamped, not underflow
246372 }
373+
374+ #[ test]
375+ fn resolve_max_picks_later ( ) {
376+ // Summer: sunset at 22:10 (1330), +1h = 23:10 (1390)
377+ // max(sunset+01:00, 23:00) = max(1390, 1380) = 1390
378+ let sun = SunTimes { sunrise_minute_of_day : 260 , sunset_minute_of_day : 1330 } ;
379+ let expr: TimeExpr = "max(sunset+01:00, 23:00)" . parse ( ) . unwrap ( ) ;
380+ assert_eq ! ( expr. resolve( Some ( & sun) ) , 1390 ) ;
381+
382+ // Winter: sunset at 16:30 (990), +1h = 17:30 (1050)
383+ // max(sunset+01:00, 23:00) = max(1050, 1380) = 1380
384+ let sun = SunTimes { sunrise_minute_of_day : 530 , sunset_minute_of_day : 990 } ;
385+ assert_eq ! ( expr. resolve( Some ( & sun) ) , 1380 ) ;
386+ }
387+
388+ #[ test]
389+ fn resolve_min_picks_earlier ( ) {
390+ // Summer: sunrise at 04:20 (260), -1h = 03:20 (200)
391+ // min(sunrise-01:00, 05:00) = min(200, 300) = 200
392+ let sun = SunTimes { sunrise_minute_of_day : 260 , sunset_minute_of_day : 1330 } ;
393+ let expr: TimeExpr = "min(sunrise-01:00, 05:00)" . parse ( ) . unwrap ( ) ;
394+ assert_eq ! ( expr. resolve( Some ( & sun) ) , 200 ) ;
395+
396+ // Winter: sunrise at 08:50 (530), -1h = 07:50 (470)
397+ // min(sunrise-01:00, 05:00) = min(470, 300) = 300
398+ let sun = SunTimes { sunrise_minute_of_day : 530 , sunset_minute_of_day : 990 } ;
399+ assert_eq ! ( expr. resolve( Some ( & sun) ) , 300 ) ;
400+ }
401+
402+ #[ test]
403+ fn uses_sun_propagates_through_max_min ( ) {
404+ let fixed: TimeExpr = "23:00" . parse ( ) . unwrap ( ) ;
405+ assert ! ( !fixed. uses_sun( ) ) ;
406+
407+ let sun_expr: TimeExpr = "sunset+01:00" . parse ( ) . unwrap ( ) ;
408+ assert ! ( sun_expr. uses_sun( ) ) ;
409+
410+ let max_expr: TimeExpr = "max(sunset+01:00, 23:00)" . parse ( ) . unwrap ( ) ;
411+ assert ! ( max_expr. uses_sun( ) ) ;
412+
413+ let min_fixed: TimeExpr = "min(05:00, 06:00)" . parse ( ) . unwrap ( ) ;
414+ assert ! ( !min_fixed. uses_sun( ) ) ;
415+ }
247416}
0 commit comments