1pub(crate) mod decide;
23pub mod run;
24
25pub use decide::{Hold, Standing};
26
27use crate::context::Context;
28use crate::error::{Error, Result};
29use crate::host::Os;
30use crate::provider::ProviderId;
31use crate::service::Permit;
32use crate::state::State;
33use crate::{atomic, home};
34use jiff::Timestamp;
35use jiff::tz::TimeZone;
36use serde::{Deserialize, Serialize};
37use std::collections::BTreeSet;
38use std::path::PathBuf;
39
40const DAY: u16 = 24 * 60;
41
42#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46pub struct Range {
47 from: u16,
48 to: u16,
49}
50
51impl Range {
52 pub const DEFAULT: Range = Range {
53 from: 6 * 60,
54 to: 22 * 60,
55 };
56
57 pub fn new(from: u16, to: u16) -> Option<Range> {
60 let to = if to == 0 { DAY } else { to };
61 (from < DAY && to <= DAY && from != to).then_some(Range { from, to })
62 }
63
64 pub fn parse(from: &str, to: &str) -> std::result::Result<Range, String> {
66 let from = minutes(from).filter(|&m| m < DAY);
67 let to = minutes(to);
68 match (from, to) {
69 (Some(from), Some(to)) => Range::new(from, to)
70 .ok_or_else(|| "the span starts and ends at the same time".to_owned()),
71 _ => Err("give each time as hours and minutes, such as 06:00 or 22:30".to_owned()),
72 }
73 }
74
75 pub fn from(self) -> u16 {
76 self.from
77 }
78
79 pub fn to(self) -> u16 {
80 self.to
81 }
82
83 pub fn contains(self, minute: u16) -> bool {
85 if self.from < self.to {
86 (self.from..self.to).contains(&minute)
87 } else {
88 minute >= self.from || minute < self.to
89 }
90 }
91
92 pub(crate) fn holds(self, now: i64, zone: &TimeZone) -> bool {
94 local_minute(now, zone).is_some_and(|minute| self.contains(minute))
95 }
96
97 pub(crate) fn opens_after(self, now: i64, zone: &TimeZone) -> Option<i64> {
100 let today = Timestamp::from_second(now)
101 .ok()?
102 .to_zoned(zone.clone())
103 .date();
104 let at = |date: jiff::civil::Date| {
105 date.at(
106 i8::try_from(self.from / 60).ok()?,
107 i8::try_from(self.from % 60).ok()?,
108 0,
109 0,
110 )
111 .to_zoned(zone.clone())
112 .ok()
113 .map(|zoned| zoned.timestamp().as_second())
114 };
115 at(today)
116 .filter(|&opens| opens > now)
117 .or_else(|| at(today.tomorrow().ok()?))
118 }
119}
120
121impl Default for Range {
122 fn default() -> Range {
123 Range::DEFAULT
124 }
125}
126
127impl std::fmt::Display for Range {
128 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
129 write!(f, "{} to {}", clock(self.from), clock(self.to))
130 }
131}
132
133fn minutes(text: &str) -> Option<u16> {
135 let (hours, minutes) = text.trim().split_once(':')?;
136 let all_digits = |part: &str| part.len() == 2 && part.bytes().all(|b| b.is_ascii_digit());
137 if !all_digits(hours) || !all_digits(minutes) {
138 return None;
139 }
140 let (hours, minutes): (u16, u16) = (hours.parse().ok()?, minutes.parse().ok()?);
141 let total = hours * 60 + minutes;
142 (minutes < 60 && total <= DAY).then_some(total)
143}
144
145pub fn clock(minutes: u16) -> String {
147 format!("{:02}:{:02}", minutes / 60, minutes % 60)
148}
149
150fn local_minute(now: i64, zone: &TimeZone) -> Option<u16> {
152 let zoned = Timestamp::from_second(now).ok()?.to_zoned(zone.clone());
153 u16::try_from(i32::from(zoned.hour()) * 60 + i32::from(zoned.minute())).ok()
154}
155
156#[derive(Debug, Clone, PartialEq, Eq)]
158#[non_exhaustive]
159pub enum Failure {
160 LimitReached,
162 LoginRefused,
164 NotStarted,
166 Unreachable,
168 TimedOut,
170 Failed(String),
172}
173
174impl Failure {
175 pub fn code(&self) -> &'static str {
177 match self {
178 Failure::LimitReached => "warm_up_limit_reached",
179 Failure::LoginRefused => "warm_up_login_refused",
180 Failure::NotStarted => "warm_up_not_started",
181 Failure::Unreachable => "warm_up_unreachable",
182 Failure::TimedOut => "warm_up_timed_out",
183 Failure::Failed(_) => "warm_up_failed",
184 }
185 }
186
187 pub(crate) fn mended_by_waiting(&self) -> bool {
190 matches!(
191 self,
192 Failure::LimitReached | Failure::Unreachable | Failure::TimedOut
193 )
194 }
195}
196
197#[derive(Debug, Clone, Default, PartialEq, Eq)]
199pub struct Setting {
200 pub range: Range,
201 accounts: BTreeSet<String>,
202}
203
204impl Setting {
205 pub fn is_on(&self, id: &str) -> bool {
207 self.accounts.contains(id)
208 }
209
210 pub fn any(&self) -> bool {
212 !self.accounts.is_empty()
213 }
214
215 pub(crate) fn turn(&mut self, id: &str, on: bool) -> bool {
217 if on {
218 self.accounts.insert(id.to_owned())
219 } else {
220 self.accounts.remove(id)
221 }
222 }
223}
224
225#[derive(Debug, Clone, PartialEq, Eq)]
227#[non_exhaustive]
228pub enum Halt {
229 Unbuilt,
231 SwitchInterrupted,
233 CustomOauth,
235 Overridden(Vec<String>),
238 ProgramMissing,
240}
241
242impl Halt {
243 pub fn code(&self) -> &'static str {
245 match self {
246 Halt::Unbuilt => "warm_up_unbuilt",
247 Halt::SwitchInterrupted => "switch_interrupted",
248 Halt::CustomOauth => "custom_oauth_endpoint",
249 Halt::Overridden(_) => "warm_up_auth_overridden",
250 Halt::ProgramMissing => "claude_not_found",
251 }
252 }
253}
254
255pub(crate) fn halted(ctx: &Context) -> Option<Halt> {
258 match crate::host::OS {
259 Os::Windows => return Some(Halt::Unbuilt),
260 Os::MacOs | Os::Linux => {}
261 }
262 if crate::switch::unfinished(ctx) == Some(crate::switch::Unfinished::Interrupted) {
263 return Some(Halt::SwitchInterrupted);
264 }
265 if crate::settings::custom_oauth(ctx) {
266 return Some(Halt::CustomOauth);
267 }
268 let overridden = overridden(ctx);
269 if !overridden.is_empty() {
270 return Some(Halt::Overridden(overridden));
271 }
272 crate::provider::of(ProviderId::Claude)
273 .program(ctx)
274 .is_none()
275 .then_some(Halt::ProgramMissing)
276}
277
278pub(crate) fn overridden(ctx: &Context) -> Vec<String> {
282 let config = run::home(ctx, ProviderId::Claude).join("config");
283 crate::settings::overrides_of_a_run_in(&config)
284 .iter()
285 .map(ToString::to_string)
286 .collect()
287}
288
289#[derive(Debug, Clone, PartialEq)]
292pub struct Overview {
293 pub range: Range,
294 pub accounts: Vec<Choice>,
295 pub halted: Option<Halt>,
296}
297
298#[derive(Debug, Clone, PartialEq)]
302pub struct Choice {
303 pub account: String,
304 pub qualified: String,
306 pub on: bool,
307 pub standing: Option<Standing>,
308 pub now: Standing,
309}
310
311impl Overview {
312 pub fn next(&self, now: i64) -> Option<(&str, i64)> {
315 self.accounts
316 .iter()
317 .filter_map(|choice| {
318 let at = match choice.standing.as_ref()? {
319 Standing::Due => now,
320 Standing::Running { next, .. } => (*next)?,
321 Standing::Resting { at } => *at,
322 Standing::Waiting { until } => *until,
323 Standing::Held(_) => return None,
324 };
325 Some((choice.account.as_str(), at))
326 })
327 .min_by_key(|(_, at)| *at)
328 }
329}
330
331pub(crate) fn in_use(ctx: &Context, state: &State) -> Option<crate::state::Key> {
333 crate::in_use::known(ctx, state, ProviderId::Claude)
334 .owner()
335 .and_then(|owner| state.account_of(ProviderId::Claude, owner))
336 .map(crate::state::Account::key)
337}
338
339pub(crate) fn overview(ctx: &Context, state: &State, setting: &Setting) -> Overview {
342 let now = ctx.now();
343 let zone = ctx.zone();
344 let in_use = in_use(ctx, state);
345 let readings = crate::readings::load(ctx);
346 let ledger = decide::Ledger::load(ctx);
347 Overview {
348 range: setting.range,
349 halted: setting.any().then(|| halted(ctx)).flatten(),
350 accounts: state
351 .accounts
352 .iter()
353 .filter(|account| account.provider() == ProviderId::Claude)
354 .map(|account| {
355 let on = setting.is_on(&account.id);
356 let standing = |asked| {
357 decide::decide(
358 decide::Login::of(account, in_use.as_ref(), now),
359 readings.get(&account.id),
360 ledger.get(&account.id),
361 setting.range,
362 asked,
363 now,
364 &zone,
365 )
366 };
367 Choice {
368 account: state.typed(&account.key()),
369 qualified: account.key().qualified(),
370 on,
371 standing: on.then(|| standing(decide::Asked::ByItself)),
372 now: standing(decide::Asked::Now),
373 }
374 })
375 .collect(),
376 }
377}
378
379pub(crate) fn look(ctx: &Context, state: &State) -> Result<crate::duty::Look<Overview>> {
383 let setting = load(ctx)?;
384 let overview = overview(ctx, state, &setting);
385 let due = overview.halted.is_none()
386 && overview
387 .accounts
388 .iter()
389 .any(|choice| choice.standing == Some(Standing::Due));
390 Ok(if due {
391 crate::duty::Look::Act
392 } else {
393 crate::duty::Look::Stands(Box::new(overview))
394 })
395}
396
397const VERSION: u32 = 1;
399
400#[derive(Debug, Serialize, Deserialize)]
402struct Kept {
403 version: u32,
404 range: KeptRange,
405 #[serde(default)]
406 accounts: Vec<String>,
407}
408
409#[derive(Debug, Serialize, Deserialize)]
410struct KeptRange {
411 from: String,
412 to: String,
413}
414
415pub(crate) fn path(ctx: &Context) -> PathBuf {
416 home::dir(ctx).join("warm.json")
417}
418
419pub fn load(ctx: &Context) -> Result<Setting> {
421 let path = path(ctx);
422 let raw = match std::fs::read_to_string(&path) {
423 Ok(raw) => raw,
424 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Setting::default()),
425 Err(source) => return Err(Error::WarmSettingUnreadable { path, source }),
426 };
427 let corrupt = |detail: String| Error::WarmSettingCorrupt {
428 path: path.clone(),
429 detail,
430 };
431 let version = serde_json::from_str::<serde_json::Value>(&raw)
432 .map_err(|e| corrupt(e.to_string()))?
433 .get("version")
434 .and_then(serde_json::Value::as_u64)
435 .ok_or_else(|| corrupt("it names no version".into()))?;
436 if version > u64::from(VERSION) {
437 return Err(Error::StateFromNewerVersion {
438 path,
439 found: u32::try_from(version).unwrap_or(u32::MAX),
440 expected: VERSION,
441 });
442 }
443 let kept: Kept = serde_json::from_str(&raw).map_err(|e| corrupt(e.to_string()))?;
444 let range = Range::parse(&kept.range.from, &kept.range.to).map_err(corrupt)?;
445 Ok(Setting {
446 range,
447 accounts: kept.accounts.into_iter().collect(),
448 })
449}
450
451pub(crate) fn save(ctx: &Context, permit: Permit, setting: &Setting) -> Result<()> {
453 let path = path(ctx);
454 let kept = Kept {
455 version: VERSION,
456 range: KeptRange {
457 from: clock(setting.range.from),
458 to: clock(setting.range.to),
459 },
460 accounts: setting.accounts.iter().cloned().collect(),
461 };
462 let failed = |source| Error::StateWriteFailed {
463 path: path.clone(),
464 source,
465 };
466 let body = serde_json::to_vec(&kept).map_err(|e| failed(std::io::Error::other(e)))?;
467 home::ensure(ctx, permit).map_err(failed)?;
468 atomic::write(permit, &path, &body, atomic::Perms::Secret).map_err(failed)
469}
470
471pub(crate) fn forget(ctx: &Context, permit: Permit, id: &str) {
475 if let Ok(mut setting) = load(ctx)
476 && setting.turn(id, false)
477 {
478 let _ = save(ctx, permit, &setting);
479 }
480 let mut ledger = decide::Ledger::load(ctx);
481 if ledger.forget(id) {
482 let _ = ledger.save(ctx, permit);
483 }
484}
485
486#[cfg(test)]
487mod tests {
488 use super::*;
489
490 const HOUR: i64 = 3_600;
491
492 const MIDNIGHT: i64 = 1_791_738_000;
494
495 fn saigon() -> TimeZone {
496 TimeZone::fixed(jiff::tz::offset(7))
497 }
498
499 #[test]
500 fn a_span_reads_as_a_person_writes_it() {
501 let range = Range::parse("06:00", "22:00").expect("a span");
502 assert_eq!(range, Range::DEFAULT);
503 assert_eq!(range.to_string(), "06:00 to 22:00");
504 assert_eq!(
505 Range::parse("00:00", "24:00").map(|r| (r.from(), r.to())),
506 Ok((0, 1440))
507 );
508 assert!(Range::parse("22:00", "06:00").is_ok(), "past midnight");
509 assert_eq!(
510 Range::parse("00:00", "00:00"),
511 Range::parse("00:00", "24:00")
512 );
513 assert_eq!(
514 Range::parse("22:00", "00:00").map(|r| r.to_string()),
515 Ok("22:00 to 24:00".to_owned())
516 );
517 for (from, to) in [
518 ("06:00", "06:00"),
519 ("24:00", "06:00"),
520 ("6:00", "22:00"),
521 ("06:60", "22:00"),
522 ("06:00", "24:01"),
523 ("noon", "22:00"),
524 ] {
525 assert!(Range::parse(from, to).is_err(), "{from} to {to}");
526 }
527 }
528
529 #[test]
530 fn a_span_holds_from_its_start_up_to_its_end() {
531 let day = Range::DEFAULT;
532 assert!(!day.contains(359));
533 assert!(day.contains(360));
534 assert!(day.contains(1319));
535 assert!(!day.contains(1320));
536 let night = Range::parse("22:00", "06:00").expect("a span");
537 assert!(night.contains(1320));
538 assert!(night.contains(0));
539 assert!(night.contains(359));
540 assert!(!night.contains(360));
541 let all = Range::parse("00:00", "24:00").expect("a span");
542 assert!(all.contains(0) && all.contains(1439));
543 }
544
545 #[test]
546 fn a_span_opens_today_or_else_tomorrow_in_the_zone_given() {
547 let zone = saigon();
548 let day = Range::DEFAULT;
549 assert!(!day.holds(MIDNIGHT + 5 * HOUR, &zone));
550 assert!(day.holds(MIDNIGHT + 6 * HOUR, &zone));
551 assert_eq!(
552 day.opens_after(MIDNIGHT + 5 * HOUR, &zone),
553 Some(MIDNIGHT + 6 * HOUR)
554 );
555 assert_eq!(
556 day.opens_after(MIDNIGHT + 6 * HOUR, &zone),
557 Some(MIDNIGHT + 30 * HOUR),
558 "once open, the next opening is tomorrow's"
559 );
560 assert_eq!(
561 day.opens_after(MIDNIGHT + 23 * HOUR, &zone),
562 Some(MIDNIGHT + 30 * HOUR)
563 );
564 }
565
566 #[test]
567 fn a_span_skipped_by_a_clock_change_opens_as_the_clock_comes_back_to_it() {
568 let zone = TimeZone::posix("EST5EDT,M3.2.0,M11.1.0").expect("a rule jiff reads");
570 let range = Range::parse("02:30", "05:00").expect("a span");
571 let before = jiff::civil::date(2026, 3, 8)
572 .at(1, 0, 0, 0)
573 .to_zoned(zone.clone())
574 .expect("a time")
575 .timestamp()
576 .as_second();
577 let opens = range.opens_after(before, &zone).expect("it opens");
578 assert_eq!(opens - before, HOUR + 30 * 60, "03:30 by the clock");
579 }
580}