polyc-controller 2026.8.3

Conversation CRD + kube reconciler for the polychrome control plane.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
//! How a schedule reads to a human on an approval card (`#1808`).
//!
//! The card used to list three RFC3339 instants and leave the reader to infer
//! the pattern by diffing dates — so the one fact being approved (a routine
//! that runs on a cadence, indefinitely) was the one fact never stated. This
//! module produces the two display atoms that fix that, both of which need
//! context only the control plane has:
//!
//! - [`cadence_text`] turns a compiled [`RoutineSchedule`] into English. It
//!   needs the schedule itself, which never reaches an edge.
//! - [`zone_label`] turns a real instant into `PDT (UTC-7)` / `Dubai (UTC+4)`.
//!   It needs the tz database, which stays out of `polyc-rpc-client` (and so
//!   out of all four edges) precisely because this module already has it.
//!
//! Neither is layout. The card's wording and column alignment belong to the
//! one shared renderer every edge calls (`polyc_rpc_client::approval_preview_text`);
//! this module only decides what the strings say.
//!
//! # Best effort, never a guess
//!
//! [`cadence_text`] returns [`None`] for any expression it cannot describe
//! confidently — a step in the day-of-month field, a named weekday, anything
//! outside the shapes below. The card then omits the cadence line entirely
//! rather than assert a wrong sentence, and the run list still carries the
//! literal truth. This is the same discipline
//! [`crate::routine_preview`] applies to an unknown admin zone: label the
//! uncertainty or say nothing, never silently guess.

use chrono::{DateTime, Offset as _};
use chrono_tz::Tz;

use crate::routine::RoutineSchedule;

/// A schedule described in English, plus whether that description pins a
/// single clock time.
///
/// [`Self::has_time_of_day`] drives where the zone label goes on the card: a
/// cadence that names one clock time can carry the zone itself ("every
/// weekday at 9:00 AM PDT (UTC-7)"), while "every 15 minutes" cannot, so the
/// label moves onto the individual runs. See
/// [`crate::routine_preview::build_routine_preview`], which makes that
/// placement decision once so no edge has to.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Cadence {
    /// The cadence itself, with no leading verb and no zone — "every weekday
    /// at 9:00 AM", "once", "every 15 minutes". The card supplies "Runs ".
    pub phrase: String,
    /// Whether [`Self::phrase`] ends in a single clock time.
    pub has_time_of_day: bool,
}

/// Standard cron's weekday names, indexed `0 = Sunday … 6 = Saturday`.
///
/// This is the admin-facing numbering this project promises (see
/// `crate::cron_dow`), NOT `saffron`'s internal one — expressions are
/// described here exactly as an admin wrote them, before any translation.
const WEEKDAYS: [&str; 7] = [
    "Sunday",
    "Monday",
    "Tuesday",
    "Wednesday",
    "Thursday",
    "Friday",
    "Saturday",
];

/// Describe `schedule` in English, or [`None`] when it cannot be described
/// confidently.
///
/// Recognized `cron` shapes, all with a fixed minute and hour:
///
/// | Expression | Phrase |
/// | --- | --- |
/// | `0 9 * * *` | every day at 9:00 AM |
/// | `0 9 * * 1-5` | every weekday at 9:00 AM |
/// | `0 9 * * 0,6` | every Sunday and Saturday at 9:00 AM |
/// | `0 9 * * 1` | every Monday at 9:00 AM |
/// | `0 9 * * 1,3,5` | every Monday, Wednesday, and Friday at 9:00 AM |
/// | `0 9 15 * *` | on the 15th of every month at 9:00 AM |
///
/// Plus two shapes with no single clock time: `*/N * * * *` ("every N
/// minutes") and `M * * * *` ("every hour, at :MM").
#[must_use]
pub fn cadence_text(schedule: &RoutineSchedule) -> Option<Cadence> {
    match schedule {
        RoutineSchedule::Once { .. } => Some(Cadence {
            phrase: "once".to_owned(),
            has_time_of_day: false,
        }),
        RoutineSchedule::Cron { expression, .. } => cron_cadence(expression),
    }
}

/// [`cadence_text`]'s `cron` arm: five whitespace-separated fields, described
/// as written (standard numbering, no `saffron` translation).
fn cron_cadence(expression: &str) -> Option<Cadence> {
    let fields: Vec<&str> = expression.split_ascii_whitespace().collect();
    let [minute, hour, dom, month, dow] = fields[..] else {
        return None;
    };
    // Sub-daily shapes first: they pin no clock time, so they never carry a
    // zone label and never reach the time-of-day formatting below.
    if hour == "*" && dom == "*" && month == "*" && dow == "*" {
        if let Some(step) = minute.strip_prefix("*/")
            && let Ok(n) = step.parse::<u32>()
            && (2..60).contains(&n)
        {
            return Some(Cadence {
                phrase: format!("every {n} minutes"),
                has_time_of_day: false,
            });
        }
        if let Ok(m) = minute.parse::<u32>()
            && m < 60
        {
            return Some(Cadence {
                phrase: format!("every hour, at :{m:02}"),
                has_time_of_day: false,
            });
        }
        return None;
    }
    // Everything below is "once a day, at a fixed clock time" — anything else
    // in the minute or hour field is not describable.
    let (minute, hour) = (minute.parse::<u32>().ok()?, hour.parse::<u32>().ok()?);
    if minute >= 60 || hour >= 24 {
        return None;
    }
    let at = format!("at {}", clock_time(hour, minute));
    // A month field narrower than "every month" needs wording this doesn't
    // have yet; describing it as if it ran every month would be wrong.
    if month != "*" {
        return None;
    }
    let phrase = match (dom, dow) {
        ("*", "*") => format!("every day {at}"),
        ("*", dow) => format!("{} {at}", weekday_phrase(dow)?),
        (dom, "*") => {
            let day = dom.parse::<u32>().ok().filter(|d| (1..=31).contains(d))?;
            format!("on the {} of every month {at}", ordinal(day))
        }
        // Standard cron ORs a restricted day-of-month with a restricted
        // day-of-week rather than ANDing them — a genuine trap to word, so it
        // is left undescribed instead of worded wrong.
        _ => return None,
    };
    Some(Cadence {
        phrase,
        has_time_of_day: true,
    })
}

/// Describe a day-of-week field: `1-5` as "every weekday", a weekend pair as
/// its two day names, otherwise the named days in week order.
fn weekday_phrase(dow: &str) -> Option<String> {
    let mut days = parse_dow(dow)?;
    days.sort_unstable();
    days.dedup();
    if days == [1, 2, 3, 4, 5] {
        return Some("every weekday".to_owned());
    }
    if days.len() == 7 {
        return Some("every day".to_owned());
    }
    let names: Vec<&str> = days.iter().map(|&d| WEEKDAYS[d as usize]).collect();
    Some(format!("every {}", list_phrase(&names)))
}

/// Parse a comma-separated day-of-week field into standard day numbers.
///
/// Accepts single digits and simple ascending ranges; `7` is Sunday's second
/// spelling, as everywhere else in this crate. Returns [`None`] for named
/// days, steps, or anything else — those fall through to no cadence line.
fn parse_dow(dow: &str) -> Option<Vec<u8>> {
    let mut out = Vec::new();
    for atom in dow.split(',') {
        match atom.split_once('-') {
            Some((a, b)) => {
                let (a, b) = (parse_day(a)?, parse_day(b)?);
                if a > b {
                    return None;
                }
                out.extend(a..=b);
            }
            None => out.push(parse_day(atom)?),
        }
    }
    Some(out)
}

/// One day-of-week digit in standard numbering, folding `7` onto Sunday.
fn parse_day(s: &str) -> Option<u8> {
    match s.parse::<u8>().ok()? {
        7 => Some(0),
        d if d <= 6 => Some(d),
        _ => None,
    }
}

/// Join names as English prose: "Monday", "Monday and Friday", "Monday,
/// Wednesday, and Friday".
fn list_phrase(names: &[&str]) -> String {
    match names {
        [] => String::new(),
        [one] => (*one).to_owned(),
        [a, b] => format!("{a} and {b}"),
        [rest @ .., last] => format!("{}, and {last}", rest.join(", ")),
    }
}

/// A 12-hour clock time with no seconds: `9:00 AM`, `12:30 PM`.
fn clock_time(hour: u32, minute: u32) -> String {
    let suffix = if hour < 12 { "AM" } else { "PM" };
    let display = match hour % 12 {
        0 => 12,
        h => h,
    };
    format!("{display}:{minute:02} {suffix}")
}

/// An English ordinal: `1st`, `2nd`, `3rd`, `4th`, `11th`, `21st`.
fn ordinal(n: u32) -> String {
    let suffix = match (n % 10, n % 100) {
        (_, 11..=13) => "th",
        (1, _) => "st",
        (2, _) => "nd",
        (3, _) => "rd",
        _ => "th",
    };
    format!("{n}{suffix}")
}

/// How `at`'s zone reads to a human: `PDT (UTC-7)`, `Dubai (UTC+4)`,
/// `IST (UTC+5:30)`.
///
/// The tz database supplies an alphabetic abbreviation for some zones and a
/// bare numeric offset (`+04`) for others, so this uses the abbreviation when
/// there is one and the zone's city otherwise — a reader should never need to
/// know where a city is to know what time it is, and should never be shown
/// `+04` as if it were a name. The UTC offset always follows, which also
/// disambiguates the abbreviations that collide: `CST (UTC+8)` cannot be read
/// as US Central, and `IST (UTC+5:30)` cannot be Israel or Ireland.
///
/// Takes an INSTANT, not a zone, because the answer changes with daylight
/// saving time — the same zone is `PST (UTC-8)` in January and `PDT (UTC-7)`
/// in July, and a card that hardcoded either would be wrong half the year.
///
/// Two zones are special-cased:
///
/// - `UTC` renders as bare `UTC`, never the tautological `UTC (UTC+0)`.
/// - Every `Etc/*` zone renders as its offset alone, because their names
///   invert the sign POSIX-style: `Etc/GMT+4` really is UTC-4, so the
///   ordinary shape would print a label contradicting its own offset.
///
/// Diacritics are absent from the tz database, so São Paulo renders as
/// `Sao Paulo`. Accepted deliberately rather than patched around.
#[must_use]
pub fn zone_label(at: DateTime<Tz>) -> String {
    let name = at.timezone().name();
    let offset = offset_label(at);
    if name == "UTC" {
        return "UTC".to_owned();
    }
    if name.starts_with("Etc/") {
        return offset;
    }
    let abbreviation = at.format("%Z").to_string();
    if abbreviation.starts_with(|c: char| c.is_ascii_alphabetic()) {
        format!("{abbreviation} ({offset})")
    } else {
        format!("{} ({offset})", city_of(name))
    }
}

/// The city in an IANA zone name, underscores swapped for spaces:
/// `America/Argentina/Buenos_Aires` → `Buenos Aires`.
fn city_of(zone_name: &str) -> String {
    zone_name
        .rsplit('/')
        .next()
        .unwrap_or(zone_name)
        .replace('_', " ")
}

/// `at`'s UTC offset, minutes kept only when non-zero: `UTC-7`, `UTC+5:30`.
fn offset_label(at: DateTime<Tz>) -> String {
    let seconds = at.offset().fix().local_minus_utc();
    let sign = if seconds < 0 { '-' } else { '+' };
    let (hours, minutes) = (seconds.abs() / 3600, (seconds.abs() % 3600) / 60);
    if minutes == 0 {
        format!("UTC{sign}{hours}")
    } else {
        format!("UTC{sign}{hours}:{minutes:02}")
    }
}

#[cfg(test)]
mod tests {
    #![allow(clippy::pedantic, clippy::nursery, missing_docs)]

    use super::*;
    use chrono::TimeZone as _;

    fn cron(expr: &str) -> RoutineSchedule {
        RoutineSchedule::Cron {
            expression: expr.to_owned(),
            timezone: None,
        }
    }

    fn phrase(expr: &str) -> Option<String> {
        cadence_text(&cron(expr)).map(|c| c.phrase)
    }

    /// One `cadence_text` table case.
    struct CadenceCase {
        expression: &'static str,
        phrase: Option<&'static str>,
        has_time_of_day: bool,
    }

    #[test]
    fn cadence_text_table() {
        let cases = [
            CadenceCase {
                expression: "0 9 * * *",
                phrase: Some("every day at 9:00 AM"),
                has_time_of_day: true,
            },
            CadenceCase {
                expression: "0 9 * * 1-5",
                phrase: Some("every weekday at 9:00 AM"),
                has_time_of_day: true,
            },
            CadenceCase {
                expression: "30 14 * * 1",
                phrase: Some("every Monday at 2:30 PM"),
                has_time_of_day: true,
            },
            CadenceCase {
                expression: "0 9 * * 1,3,5",
                phrase: Some("every Monday, Wednesday, and Friday at 9:00 AM"),
                has_time_of_day: true,
            },
            CadenceCase {
                expression: "0 9 * * 0,6",
                phrase: Some("every Sunday and Saturday at 9:00 AM"),
                has_time_of_day: true,
            },
            CadenceCase {
                expression: "0 9 15 * *",
                phrase: Some("on the 15th of every month at 9:00 AM"),
                has_time_of_day: true,
            },
            CadenceCase {
                expression: "0 9 1 * *",
                phrase: Some("on the 1st of every month at 9:00 AM"),
                has_time_of_day: true,
            },
            CadenceCase {
                expression: "0 0 * * *",
                phrase: Some("every day at 12:00 AM"),
                has_time_of_day: true,
            },
            CadenceCase {
                expression: "0 12 * * *",
                phrase: Some("every day at 12:00 PM"),
                has_time_of_day: true,
            },
            CadenceCase {
                expression: "*/15 * * * *",
                phrase: Some("every 15 minutes"),
                has_time_of_day: false,
            },
            CadenceCase {
                expression: "5 * * * *",
                phrase: Some("every hour, at :05"),
                has_time_of_day: false,
            },
            // `7` is Sunday's second spelling, as everywhere else in the crate.
            CadenceCase {
                expression: "0 9 * * 7",
                phrase: Some("every Sunday at 9:00 AM"),
                has_time_of_day: true,
            },
            // Every day named individually collapses back to "every day".
            CadenceCase {
                expression: "0 9 * * 0-6",
                phrase: Some("every day at 9:00 AM"),
                has_time_of_day: true,
            },
        ];
        for case in cases {
            let actual = cadence_text(&cron(case.expression));
            assert_eq!(
                actual.as_ref().map(|c| c.phrase.as_str()),
                case.phrase,
                "phrase for `{}`",
                case.expression
            );
            assert_eq!(
                actual.map(|c| c.has_time_of_day),
                Some(case.has_time_of_day),
                "has_time_of_day for `{}`",
                case.expression
            );
        }
    }

    /// The card omits its cadence line rather than describe an expression
    /// wrongly — every shape this module does not confidently understand
    /// yields `None`, never a plausible-looking sentence.
    #[test]
    fn undescribable_expressions_yield_no_cadence() {
        for expression in [
            "0 9 * * MON-FRI", // named days: `saffron` numbering, not ours.
            "0 9 */2 * *",     // stepped day-of-month.
            "0 9 1 1 *",       // a single month.
            "0 9 1 * 1",       // day-of-month OR day-of-week, a trap to word.
            "0 9-17 * * *",    // an hour range, not one clock time.
            "0 9 * *",         // four fields.
            "0 9 * * * *",     // six fields.
            "0 99 * * *",      // out of range.
            "0 9 * * 5-1",     // descending range.
            "*/1000 * * * *",  // out-of-range step.
            "",
        ] {
            assert_eq!(
                phrase(expression),
                None,
                "`{expression}` must not be described"
            );
        }
    }

    #[test]
    fn a_once_schedule_reads_as_once_and_pins_no_clock_time() {
        let schedule = RoutineSchedule::Once {
            at: "2026-07-29T09:00:00Z".to_owned(),
        };
        let cadence = cadence_text(&schedule).expect("once is always describable");
        assert_eq!(cadence.phrase, "once");
        // No single time-of-day means the zone label belongs on the run row.
        assert!(!cadence.has_time_of_day);
    }

    /// One `zone_label` table case: an IANA zone plus a month, since the
    /// answer legitimately differs across a daylight-saving boundary.
    struct ZoneCase {
        zone: &'static str,
        month: u32,
        label: &'static str,
    }

    #[test]
    fn zone_label_table() {
        let cases = [
            // Alphabetic abbreviations win, and they track daylight saving.
            ZoneCase {
                zone: "America/Los_Angeles",
                month: 7,
                label: "PDT (UTC-7)",
            },
            ZoneCase {
                zone: "America/Los_Angeles",
                month: 1,
                label: "PST (UTC-8)",
            },
            ZoneCase {
                zone: "Australia/Sydney",
                month: 7,
                label: "AEST (UTC+10)",
            },
            ZoneCase {
                zone: "Australia/Sydney",
                month: 1,
                label: "AEDT (UTC+11)",
            },
            // Half- and quarter-hour offsets keep their minutes.
            ZoneCase {
                zone: "Asia/Kolkata",
                month: 7,
                label: "IST (UTC+5:30)",
            },
            ZoneCase {
                zone: "Asia/Katmandu",
                month: 7,
                label: "Katmandu (UTC+5:45)",
            },
            // The offset disambiguates a colliding abbreviation.
            ZoneCase {
                zone: "Asia/Shanghai",
                month: 7,
                label: "CST (UTC+8)",
            },
            // No abbreviation in the tz database: the city stands in, never `+04`.
            ZoneCase {
                zone: "Asia/Dubai",
                month: 7,
                label: "Dubai (UTC+4)",
            },
            ZoneCase {
                zone: "Asia/Singapore",
                month: 7,
                label: "Singapore (UTC+8)",
            },
            ZoneCase {
                zone: "Asia/Ho_Chi_Minh",
                month: 7,
                label: "Ho Chi Minh (UTC+7)",
            },
            // Diacritics are absent from the tz database, by acceptance.
            ZoneCase {
                zone: "America/Sao_Paulo",
                month: 7,
                label: "Sao Paulo (UTC-3)",
            },
            // The city is the LAST segment, not the second.
            ZoneCase {
                zone: "America/Argentina/Buenos_Aires",
                month: 7,
                label: "Buenos Aires (UTC-3)",
            },
            // UTC never renders as the tautological `UTC (UTC+0)`.
            ZoneCase {
                zone: "UTC",
                month: 7,
                label: "UTC",
            },
        ];
        for case in cases {
            let zone: Tz = case.zone.parse().expect("known zone");
            let at = zone
                .with_ymd_and_hms(2026, case.month, 15, 9, 0, 0)
                .single()
                .expect("unambiguous instant");
            assert_eq!(zone_label(at), case.label, "label for {}", case.zone);
        }
    }

    /// `Etc/*` names invert the sign POSIX-style — `Etc/GMT+4` really is
    /// UTC-4 — so they render as the offset alone rather than a label that
    /// contradicts the offset printed beside it.
    #[test]
    fn etc_zones_render_as_the_offset_alone() {
        let zone: Tz = "Etc/GMT+4".parse().expect("known zone");
        let at = zone
            .with_ymd_and_hms(2026, 7, 15, 9, 0, 0)
            .single()
            .expect("unambiguous instant");
        assert_eq!(zone_label(at), "UTC-4");
    }
}