1use std::time::{SystemTime, UNIX_EPOCH};
7
8use crate::drift::baseline::{Source, UPSTREAM_PATH, UPSTREAM_REPO};
9
10const USER_AGENT: &str = "ag-ui-rust-xtask-drift-check";
11
12pub struct Fetched {
14 pub text: String,
15 pub source: Source,
16}
17
18pub fn events_ts() -> Result<Fetched, String> {
21 let commits = get(&format!(
22 "https://api.github.com/repos/{UPSTREAM_REPO}/commits?path={UPSTREAM_PATH}&per_page=1"
23 ))?;
24 let commits: serde_json::Value = serde_json::from_str(&commits)
25 .map_err(|e| format!("GitHub returned a commit list that is not JSON: {e}"))?;
26 let head = commits
27 .get(0)
28 .ok_or_else(|| format!("no commits found for {UPSTREAM_PATH} in {UPSTREAM_REPO}"))?;
29 let commit = head
30 .get("sha")
31 .and_then(|v| v.as_str())
32 .ok_or("GitHub commit list has no `sha`")?
33 .to_string();
34 let commit_date = head
35 .pointer("/commit/committer/date")
36 .or_else(|| head.pointer("/commit/author/date"))
37 .and_then(|v| v.as_str())
38 .unwrap_or("")
39 .split('T')
40 .next()
41 .unwrap_or("")
42 .to_string();
43
44 let url = format!("https://raw.githubusercontent.com/{UPSTREAM_REPO}/{commit}/{UPSTREAM_PATH}");
45 let text = get(&url)?;
46 looks_like_events_ts(&text)
47 .map_err(|why| format!("{url} did not return {UPSTREAM_PATH}: {why}"))?;
48
49 Ok(Fetched {
50 text,
51 source: Source {
52 repo: UPSTREAM_REPO.to_string(),
53 path: UPSTREAM_PATH.to_string(),
54 commit,
55 commit_date,
56 fetched_at: today_utc(),
57 },
58 })
59}
60
61fn looks_like_events_ts(text: &str) -> Result<(), String> {
70 let head = text.trim_start();
71 if head.is_empty() {
72 return Err("the response body was empty".to_string());
73 }
74 if head.starts_with('<') {
75 return Err(format!(
76 "the response is markup, not TypeScript — probably an error page or a proxy \
77 interstitial. It starts: {}",
78 snippet(head)
79 ));
80 }
81 if !text.contains("EventType") {
82 return Err(format!(
83 "the response never mentions `EventType`, so it is not the events module (or it was \
84 truncated before reaching it). {} bytes, starting: {}",
85 text.len(),
86 snippet(head)
87 ));
88 }
89 Ok(())
90}
91
92fn snippet(text: &str) -> String {
94 let line = text.lines().next().unwrap_or("").trim();
95 let cut: String = line.chars().take(80).collect();
96 if cut.chars().count() < line.chars().count() {
97 format!("`{cut}...`")
98 } else {
99 format!("`{cut}`")
100 }
101}
102
103fn get(url: &str) -> Result<String, String> {
104 let mut request = ureq::get(url)
105 .header("User-Agent", USER_AGENT)
106 .header("Accept", "application/vnd.github+json");
107 if let Some(token) = github_token() {
110 request = request.header("Authorization", &format!("Bearer {token}"));
111 }
112 let mut response = request.call().map_err(|e| explain(url, &e))?;
113 response
114 .body_mut()
115 .read_to_string()
116 .map_err(|e| format!("cannot read the response from {url}: {e}"))
117}
118
119fn github_token() -> Option<String> {
120 ["GITHUB_TOKEN", "GH_TOKEN"]
121 .iter()
122 .find_map(|key| std::env::var(key).ok())
123 .filter(|token| !token.is_empty())
124}
125
126fn explain(url: &str, error: &ureq::Error) -> String {
127 match error {
128 ureq::Error::StatusCode(403 | 429) => format!(
129 "{url} returned {error}.\n\
130 That is usually GitHub's unauthenticated rate limit (60 requests/hour/IP).\n\
131 Set GITHUB_TOKEN and retry, or run the offline check without --upstream/--refresh."
132 ),
133 ureq::Error::StatusCode(404) => format!(
134 "{url} returned 404 — upstream may have moved the file.\n\
135 Check {UPSTREAM_REPO} and update UPSTREAM_PATH in xtask/src/drift/baseline.rs."
136 ),
137 _ => format!("cannot fetch {url}: {error}"),
138 }
139}
140
141pub fn today_utc() -> String {
146 let secs = SystemTime::now()
147 .duration_since(UNIX_EPOCH)
148 .map(|d| d.as_secs())
149 .unwrap_or(0);
150 let (y, m, d) = civil_from_days((secs / 86_400) as i64);
151 format!("{y:04}-{m:02}-{d:02}")
152}
153
154fn civil_from_days(days: i64) -> (i64, u32, u32) {
156 let z = days + 719_468;
157 let era = z.div_euclid(146_097);
158 let doe = z.rem_euclid(146_097);
159 let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
160 let y = yoe + era * 400;
161 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
162 let mp = (5 * doy + 2) / 153;
163 let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
164 let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
165 (if m <= 2 { y + 1 } else { y }, m, d)
166}
167
168#[cfg(test)]
169mod tests {
170 use super::{civil_from_days, looks_like_events_ts};
171
172 #[test]
173 fn accepts_the_real_module() {
174 let source = "import { z } from \"zod\";\n\nexport enum EventType {\n RAW = \"RAW\",\n}\n";
175 assert!(looks_like_events_ts(source).is_ok());
176 }
177
178 #[test]
179 fn rejects_an_html_error_page() {
180 let error =
181 looks_like_events_ts("<!DOCTYPE html>\n<title>404 Not Found</title>\n").unwrap_err();
182 assert!(error.contains("markup, not TypeScript"), "{error}");
183 assert!(error.contains("<!DOCTYPE html>"), "{error}");
184 }
185
186 #[test]
187 fn rejects_a_proxy_interstitial_with_leading_whitespace() {
188 let error = looks_like_events_ts("\n\n <html><body>Sign in to continue</body></html>")
189 .unwrap_err();
190 assert!(error.contains("markup, not TypeScript"), "{error}");
191 }
192
193 #[test]
194 fn rejects_an_empty_body() {
195 assert!(
196 looks_like_events_ts(" \n ")
197 .unwrap_err()
198 .contains("empty")
199 );
200 }
201
202 #[test]
204 fn rejects_a_response_truncated_before_the_enum() {
205 let error = looks_like_events_ts("import { z } from \"zod\";\n\nexport const Role = z.")
206 .unwrap_err();
207 assert!(error.contains("never mentions `EventType`"), "{error}");
208 }
209
210 #[test]
211 fn converts_known_days() {
212 assert_eq!(civil_from_days(0), (1970, 1, 1));
213 assert_eq!(civil_from_days(19_723), (2024, 1, 1));
214 assert_eq!(civil_from_days(19_784), (2024, 3, 2)); assert_eq!(civil_from_days(20_682), (2026, 8, 17));
216 }
217}