polyc-controller 2026.8.3

Conversation CRD + kube reconciler for the polychrome control plane.
//! The approval card's spec-computed preview (`#1496`).
//!
//! The next three fire times (admin-local zone and UTC) plus the exact
//! prompt text, built from a routine's already-COMPILED [`RoutineSchedule`] +
//! prompt — never from a model's narration of them (INV-RL3). Fully offline
//! and computable from the spec alone: no destination line, no live lookup,
//! so this module reasons only about the schedule and prompt text a
//! `routine_create` call names.
//!
//! Next-fire math is not reimplemented here — every instant comes from
//! [`crate::routine_next_fire::next_n_fires_after`], the one shared callable
//! the scheduler, the inspector, and this preview all consume (INV-RL10).
//! This module's own job is narrower: pick which zone to render each instant
//! in, and label the fallback when the admin's own zone isn't known (per the
//! design's "the preview's local time zone is best-effort" assumption —
//! never a silent guess).

use chrono::{DateTime, Utc};
use chrono_tz::Tz;

use crate::routine::RoutineSchedule;
use crate::routine_cadence::{cadence_text, zone_label};
use crate::routine_next_fire::{next_n_fires_after, schedule_zone};

/// One fire instant rendered for a human: the same real instant twice, in
/// [`RoutinePreview::zone_name`] and in UTC.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PreviewFire {
    /// The instant in [`RoutinePreview::zone_name`]'s local wall clock.
    pub local_time: DateTime<Tz>,
    /// The identical instant, in UTC.
    pub utc_time: DateTime<Utc>,
    /// How this instant's zone reads to a human — `PDT (UTC-7)` — or EMPTY
    /// when [`RoutinePreview::cadence`] already carries the label for every
    /// run. See [`build_routine_preview`] for that placement decision; the
    /// card renders this whenever it is non-empty and nothing more.
    pub zone_label: String,
}

/// The computed preview a routine-create approval card renders (`#1496`).
///
/// Every field here is DATA, not copy — the exact English sentence a human
/// reads (including the zone-fallback disclosure) is the shared edge
/// helper's job (`polyc_rpc_client::approval_preview`), so the four chat
/// edges cannot word the same state differently.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RoutinePreview {
    /// The exact prompt text the routine will run — copied verbatim from the
    /// compiled spec's payload, never re-typed or summarized.
    pub prompt_text: String,
    /// Up to the next three fire instants, oldest first. Fewer than three
    /// (or none) when the schedule itself yields fewer — see
    /// [`next_n_fires_after`]'s doc (a `once` schedule yields at most one; a
    /// `cron` expression that can never match again yields zero).
    pub next_fires: Vec<PreviewFire>,
    /// IANA name of the zone `next_fires[].local_time` is rendered in.
    pub zone_name: String,
    /// `true` when [`Self::zone_name`] is the schedule's OWN zone shown as a
    /// labeled fallback because the admin's zone could not be resolved —
    /// `false` when it is the admin's actual zone.
    pub zone_is_fallback: bool,
    /// The schedule in English — `every weekday at 9:00 AM PDT (UTC-7)` —
    /// carrying the zone label when it belongs here rather than on each run.
    /// EMPTY when the schedule is not confidently describable, in which case
    /// the card omits its cadence line entirely (see
    /// [`crate::routine_cadence::cadence_text`]).
    pub cadence: String,
}

/// Build the preview for a `routine_create` approval card.
///
/// `admin_zone` is the creating admin's own IANA zone, when the edge's user
/// profile exposed one (`#1496`); `None` when it didn't (every edge but the
/// one that parses it today, or that edge's own profile lookup coming back
/// empty) — the preview then falls back to the schedule's own zone
/// ([`schedule_zone`]) and [`RoutinePreview::zone_is_fallback`] is set so the
/// fallback is labeled, never silently guessed.
///
/// `now` is caller-injected (never read from the system clock), matching
/// every other function in this crate's pure decision cores.
#[must_use]
pub fn build_routine_preview(
    schedule: &RoutineSchedule,
    prompt: &str,
    admin_zone: Option<Tz>,
    now: DateTime<Utc>,
) -> RoutinePreview {
    let (zone, zone_is_fallback) =
        admin_zone.map_or_else(|| (schedule_zone(schedule), true), |z| (z, false));
    let mut next_fires: Vec<PreviewFire> = next_n_fires_after(schedule, now, 3)
        .into_iter()
        .map(|fire| {
            let local_time = fire.at.with_timezone(&zone);
            PreviewFire {
                local_time,
                utc_time: fire.at,
                zone_label: zone_label(local_time),
            }
        })
        .collect();
    // Decide ONCE where the zone label goes, so no edge has to. It can ride
    // the cadence line only when that line names a single clock time AND
    // every previewed run agrees on the label; a schedule straddling a
    // daylight-saving boundary fails the second test, and its runs keep their
    // own labels so the reader sees the offset move while the wall clock
    // holds. Whichever side wins, the other is cleared — the label is stated
    // exactly once.
    let cadence = cadence_text(schedule).map_or_else(String::new, |cadence| {
        let shared = next_fires
            .split_first()
            .filter(|(head, rest)| rest.iter().all(|f| f.zone_label == head.zone_label))
            .map(|(head, _)| head.zone_label.clone());
        match shared.filter(|_| cadence.has_time_of_day) {
            Some(label) => {
                for fire in &mut next_fires {
                    fire.zone_label = String::new();
                }
                format!("{} {label}", cadence.phrase)
            }
            None => cadence.phrase,
        }
    });
    RoutinePreview {
        prompt_text: prompt.to_owned(),
        next_fires,
        zone_name: zone.name().to_owned(),
        zone_is_fallback,
        cadence,
    }
}

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

    use super::*;
    use crate::routine::RoutineSchedule;

    fn utc(s: &str) -> DateTime<Utc> {
        s.parse().expect("valid RFC3339")
    }

    fn cron(expr: &str, tz: Option<&str>) -> RoutineSchedule {
        RoutineSchedule::Cron {
            expression: expr.to_owned(),
            timezone: tz.map(str::to_owned),
        }
    }

    /// INV-RL3 / acceptance criterion 1: the preview's prompt text is the
    /// compiled spec's prompt, byte for byte — comparable against the spec a
    /// caller subsequently writes.
    #[test]
    fn preview_prompt_text_is_the_compiled_specs_prompt_verbatim() {
        let schedule = cron("0 9 * * *", Some("America/New_York"));
        let preview = build_routine_preview(
            &schedule,
            "Post the daily standup summary.",
            None,
            utc("2026-07-20T00:00:00Z"),
        );
        assert_eq!(preview.prompt_text, "Post the daily standup summary.");
    }

    /// INV-RL10 (consumer side): the preview's next-fire instants are exactly
    /// [`next_n_fires_after`]'s output for the same schedule — no separate
    /// reimplementation to drift from the scheduler's own math.
    #[test]
    fn preview_next_fires_match_the_shared_next_n_fires_after_callable() {
        let schedule = cron("0 9 * * *", Some("America/New_York"));
        let now = utc("2026-07-20T00:00:00Z");
        let preview = build_routine_preview(&schedule, "prompt", None, now);
        let expected = next_n_fires_after(&schedule, now, 3);
        assert_eq!(preview.next_fires.len(), expected.len());
        for (fire, exp) in preview.next_fires.iter().zip(expected.iter()) {
            assert_eq!(fire.utc_time, exp.at);
            assert_eq!(fire.local_time, exp.at.with_timezone(&exp.zone));
        }
    }

    /// Acceptance criterion 3: when the admin's zone IS known, the preview
    /// renders in that zone — not the schedule's own — and does not mark it
    /// a fallback.
    #[test]
    fn known_admin_zone_renders_local_times_in_that_zone_and_is_not_a_fallback() {
        let schedule = cron("0 9 * * *", None); // UTC schedule.
        let admin_zone: Tz = "Asia/Tokyo".parse().unwrap();
        let preview = build_routine_preview(
            &schedule,
            "prompt",
            Some(admin_zone),
            utc("2026-07-20T00:00:00Z"),
        );
        assert!(!preview.zone_is_fallback);
        assert_eq!(preview.zone_name, "Asia/Tokyo");
        assert_eq!(preview.next_fires[0].local_time.timezone(), admin_zone);
    }

    /// Acceptance criterion 3: with no admin zone, the fallback is the
    /// schedule's OWN zone, and it is LABELED as a fallback, never a silent
    /// guess.
    #[test]
    fn unknown_admin_zone_falls_back_to_the_schedules_own_zone_and_labels_it() {
        let schedule = cron("0 9 * * *", Some("America/New_York"));
        let preview = build_routine_preview(&schedule, "prompt", None, utc("2026-07-20T00:00:00Z"));
        assert!(preview.zone_is_fallback);
        assert_eq!(preview.zone_name, "America/New_York");
    }

    /// The zone label is stated exactly once: on the cadence line when that
    /// line names a single clock time and every run agrees on the label, and
    /// then NOT repeated on any run.
    #[test]
    fn a_single_clock_time_carries_the_zone_label_on_the_cadence_line() {
        let schedule = cron("0 9 * * 1-5", Some("America/Los_Angeles"));
        let preview = build_routine_preview(&schedule, "prompt", None, utc("2026-07-28T00:00:00Z"));
        assert_eq!(preview.cadence, "every weekday at 9:00 AM PDT (UTC-7)");
        assert!(
            preview.next_fires.iter().all(|f| f.zone_label.is_empty()),
            "the cadence line already states the zone"
        );
    }

    /// A cadence with no single clock time cannot carry a zone, so the label
    /// moves onto each run instead of being dropped.
    #[test]
    fn a_cadence_without_a_clock_time_labels_each_run_instead() {
        let schedule = cron("*/15 * * * *", Some("America/Los_Angeles"));
        let preview = build_routine_preview(&schedule, "prompt", None, utc("2026-07-28T00:00:00Z"));
        assert_eq!(preview.cadence, "every 15 minutes");
        assert!(
            preview
                .next_fires
                .iter()
                .all(|f| f.zone_label == "PDT (UTC-7)"),
            "each run must state the zone the cadence line cannot"
        );
    }

    /// Across a daylight-saving boundary the runs disagree on their label, so
    /// it stays on the runs — the reader sees the offset move while the wall
    /// clock holds at 9:00 AM, which a single shared label would hide.
    #[test]
    fn a_daylight_saving_straddle_labels_each_run() {
        let schedule = cron("0 9 * * *", Some("America/Los_Angeles"));
        // 2026-11-01 is the US fall-back date: the runs either side differ.
        let preview = build_routine_preview(&schedule, "prompt", None, utc("2026-10-30T18:00:00Z"));
        assert_eq!(preview.cadence, "every day at 9:00 AM");
        let labels: Vec<&str> = preview
            .next_fires
            .iter()
            .map(|f| f.zone_label.as_str())
            .collect();
        // Oct 31 is still PDT; clocks fall back on Nov 1, so the last two are PST.
        assert_eq!(labels, ["PDT (UTC-7)", "PST (UTC-8)", "PST (UTC-8)"]);
    }

    /// An expression this crate cannot describe yields no cadence line at
    /// all — never a plausible-looking wrong sentence — and its runs keep
    /// their own labels so the zone is still stated.
    #[test]
    fn an_undescribable_schedule_has_no_cadence_and_labels_its_runs() {
        let schedule = cron("0 9-17 * * *", Some("America/Los_Angeles"));
        let preview = build_routine_preview(&schedule, "prompt", None, utc("2026-07-28T00:00:00Z"));
        assert!(preview.cadence.is_empty());
        assert!(
            preview
                .next_fires
                .iter()
                .all(|f| f.zone_label == "PDT (UTC-7)")
        );
    }

    /// A `once` schedule already past its instant previews zero fires
    /// (INV-RL9) — the preview must not synthesize one.
    #[test]
    fn a_completed_once_schedule_previews_no_fires() {
        let schedule = RoutineSchedule::Once {
            at: "2026-07-20T00:10:00Z".to_owned(),
        };
        let preview = build_routine_preview(&schedule, "prompt", None, utc("2026-07-20T00:20:00Z"));
        assert!(preview.next_fires.is_empty());
    }

    /// A `once` schedule names no clock time (`cadence_text`'s
    /// `has_time_of_day` is `false` for it), so the label-placement decision
    /// in [`build_routine_preview`] must send the zone label to the single
    /// run, not fold it into the cadence line — "once" is the whole cadence.
    #[test]
    fn a_once_schedule_puts_the_zone_label_on_its_run_not_the_cadence() {
        let schedule = RoutineSchedule::Once {
            at: "2026-07-21T09:00:00Z".to_owned(),
        };
        let admin_zone: Tz = "America/Los_Angeles".parse().unwrap();
        let preview = build_routine_preview(
            &schedule,
            "prompt",
            Some(admin_zone),
            utc("2026-07-20T00:00:00Z"),
        );
        assert_eq!(preview.cadence, "once");
        assert_eq!(preview.next_fires.len(), 1);
        assert!(!preview.next_fires[0].zone_label.is_empty());
    }
}