1use std::collections::BTreeMap;
2use std::path::{Path, PathBuf};
3use std::sync::{mpsc, Arc};
4use std::time::{SystemTime, UNIX_EPOCH};
5
6use parking_lot::Mutex;
7
8use crate::config::GhReadConfig;
9use crate::db::github_read_cache::{
10 invalidate_github_read_cache_resource, lookup_github_read_cache_entry,
11 upsert_github_read_cache_entry, GithubReadCacheEntry, GithubReadCacheKey,
12 GithubReadResourceKind,
13};
14
15use super::attachments::{
16 download_github_image_attachments, GithubImageAttachment, GithubImageDownloader,
17};
18use super::fetch::{GithubFetchRequest, GithubFetcher, GithubReadError};
19use super::render::{render_document_for_resource, render_outline_for_resource};
20use super::resource::{parse_resource, GithubResource, GithubResourceKind, InvalidGithubResource};
21
22pub trait GithubReadClock: Send + Sync {
24 fn now_ms(&self) -> i64;
25}
26
27#[derive(Default)]
29pub struct SystemGithubReadClock;
30
31impl GithubReadClock for SystemGithubReadClock {
32 fn now_ms(&self) -> i64 {
33 SystemTime::now()
34 .duration_since(UNIX_EPOCH)
35 .unwrap_or_default()
36 .as_millis()
37 .try_into()
38 .unwrap_or(i64::MAX)
39 }
40}
41
42pub trait GithubReadCacheStore: Send + Sync {
45 fn lookup(&self, key: &GithubReadCacheKey) -> Result<Option<GithubReadCacheEntry>, String>;
46 fn upsert(
47 &self,
48 key: &GithubReadCacheKey,
49 canonical_text: &str,
50 fetched_at_ms: i64,
51 ) -> Result<(), String>;
52 fn invalidate(
53 &self,
54 kind: GithubResourceKind,
55 repository: &str,
56 number: u64,
57 authentication_identity: Option<&str>,
58 ) -> Result<usize, String>;
59}
60
61#[derive(Clone, Debug)]
63pub struct SqliteGithubReadCacheStore {
64 database_path: PathBuf,
65}
66
67impl SqliteGithubReadCacheStore {
68 pub fn new(database_path: impl Into<PathBuf>) -> Self {
69 Self {
70 database_path: database_path.into(),
71 }
72 }
73
74 fn connection(&self) -> Result<crate::db::TrackedConnection, String> {
75 crate::db::open(&self.database_path)
76 .map_err(|error| format!("failed to open GitHub read cache: {error}"))
77 }
78}
79
80impl GithubReadCacheStore for SqliteGithubReadCacheStore {
81 fn lookup(&self, key: &GithubReadCacheKey) -> Result<Option<GithubReadCacheEntry>, String> {
82 let connection = self.connection()?;
83 lookup_github_read_cache_entry(&connection, key)
84 .map_err(|error| format!("failed to look up GitHub read cache: {error}"))
85 }
86
87 fn upsert(
88 &self,
89 key: &GithubReadCacheKey,
90 canonical_text: &str,
91 fetched_at_ms: i64,
92 ) -> Result<(), String> {
93 let connection = self.connection()?;
94 upsert_github_read_cache_entry(&connection, key, canonical_text, fetched_at_ms)
95 .map_err(|error| format!("failed to update GitHub read cache: {error}"))
96 }
97
98 fn invalidate(
99 &self,
100 kind: GithubResourceKind,
101 repository: &str,
102 number: u64,
103 authentication_identity: Option<&str>,
104 ) -> Result<usize, String> {
105 let number = i64::try_from(number)
106 .map_err(|_| "GitHub resource number exceeds cache storage range".to_string())?;
107 let connection = self.connection()?;
108 invalidate_github_read_cache_resource(
109 &connection,
110 database_kind(kind),
111 repository,
112 number,
113 authentication_identity,
114 )
115 .map_err(|error| format!("failed to invalidate GitHub read cache: {error}"))
116 }
117}
118
119#[derive(Clone, Debug, Eq, PartialEq)]
123pub struct GithubReadRequest {
124 pub resource: GithubResource,
125 pub working_directory: PathBuf,
126 pub effective_authentication_identity: String,
127 pub vision_capability: Option<bool>,
128}
129
130impl GithubReadRequest {
131 pub fn parse(
132 resource: &str,
133 working_directory: impl Into<PathBuf>,
134 effective_authentication_identity: impl Into<String>,
135 vision_capability: Option<bool>,
136 ) -> Result<Self, InvalidGithubResource> {
137 Ok(Self {
138 resource: parse_resource(resource)?,
139 working_directory: working_directory.into(),
140 effective_authentication_identity: effective_authentication_identity.into(),
141 vision_capability,
142 })
143 }
144}
145
146#[derive(Clone, Debug, Eq, PartialEq)]
148pub enum GithubReadSelector {
149 WholeDocument,
150 LineRange {
151 start_line: usize,
152 end_line: Option<usize>,
153 limit: usize,
154 },
155 ByteOffset {
156 offset: usize,
157 limit: Option<usize>,
158 },
159}
160
161impl Default for GithubReadSelector {
162 fn default() -> Self {
163 Self::WholeDocument
164 }
165}
166
167#[derive(Clone, Copy, Debug, Eq, PartialEq)]
169pub enum GithubReadFreshness {
170 Fetched,
172 CachedFallback,
174}
175
176#[derive(Clone, Copy, Debug, Eq, PartialEq)]
178pub enum GithubReadView {
179 Document,
180 Outline,
181}
182
183impl GithubReadFreshness {
184 pub const fn note(self) -> Option<&'static str> {
186 None
187 }
188}
189
190#[derive(Clone, Debug, Eq, PartialEq)]
192pub struct GithubReadCompletion {
193 pub content: String,
194 pub total_lines: usize,
195 pub freshness: GithubReadFreshness,
196 pub attachments: Vec<GithubImageAttachment>,
197 pub document: Option<super::model::GithubDocument>,
199}
200
201pub struct GithubReadDeferred {
205 receiver: mpsc::Receiver<Result<GithubReadCompletion, GithubReadError>>,
206}
207
208impl GithubReadDeferred {
209 pub fn try_complete(&self) -> Option<Result<GithubReadCompletion, GithubReadError>> {
210 match self.receiver.try_recv() {
211 Ok(result) => Some(result),
212 Err(mpsc::TryRecvError::Empty) => None,
213 Err(mpsc::TryRecvError::Disconnected) => Some(Err(GithubReadError::FetchFailed(
214 "GitHub read worker stopped before completing".to_string(),
215 ))),
216 }
217 }
218}
219
220pub enum GithubReadStart {
223 Immediate(GithubReadCompletion),
224 Deferred(GithubReadDeferred),
225}
226
227#[derive(Default)]
228struct GithubReadEngineState {
229 aliases: BTreeMap<ShortResourceAlias, String>,
230 flights: BTreeMap<GithubReadFlightSlot, Vec<GithubReadFlightWaiter>>,
231}
232
233#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
234struct ShortResourceAlias {
235 kind: GithubResourceKind,
236 number: u64,
237 working_directory: PathBuf,
238 authentication_identity_hash: [u8; 32],
239}
240
241#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
242struct CacheSlot {
243 kind: GithubResourceKind,
244 repository: String,
245 number: u64,
246 authentication_identity_hash: [u8; 32],
247}
248
249#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
252enum GithubReadFlightSlot {
253 Resolved(CacheSlot),
254 Unresolved(ShortResourceAlias),
255}
256
257struct GithubReadFlightWaiter {
258 request: GithubReadRequest,
259 selector: GithubReadSelector,
260 view: GithubReadView,
261 fallback: Option<GithubReadCacheEntry>,
262 sender: mpsc::SyncSender<Result<GithubReadCompletion, GithubReadError>>,
263}
264
265pub struct GithubReadEngine {
268 cache: Arc<dyn GithubReadCacheStore>,
269 fetcher: Arc<dyn GithubFetcher>,
270 image_downloader: Arc<dyn GithubImageDownloader>,
271 clock: Arc<dyn GithubReadClock>,
272 state: Arc<Mutex<GithubReadEngineState>>,
273}
274
275impl GithubReadEngine {
276 pub fn new(
277 cache: Arc<dyn GithubReadCacheStore>,
278 fetcher: Arc<dyn GithubFetcher>,
279 image_downloader: Arc<dyn GithubImageDownloader>,
280 clock: Arc<dyn GithubReadClock>,
281 ) -> Self {
282 Self {
283 cache,
284 fetcher,
285 image_downloader,
286 clock,
287 state: Arc::new(Mutex::new(GithubReadEngineState::default())),
288 }
289 }
290
291 pub fn start_resource(
293 &self,
294 gh_read: &GhReadConfig,
295 resource: &str,
296 working_directory: impl Into<PathBuf>,
297 effective_authentication_identity: impl Into<String>,
298 vision_capability: Option<bool>,
299 selector: GithubReadSelector,
300 ) -> Result<GithubReadStart, GithubReadError> {
301 self.start_resource_with_view(
302 gh_read,
303 resource,
304 working_directory,
305 effective_authentication_identity,
306 vision_capability,
307 selector,
308 GithubReadView::Document,
309 )
310 }
311
312 pub fn start_resource_with_view(
315 &self,
316 gh_read: &GhReadConfig,
317 resource: &str,
318 working_directory: impl Into<PathBuf>,
319 effective_authentication_identity: impl Into<String>,
320 vision_capability: Option<bool>,
321 selector: GithubReadSelector,
322 view: GithubReadView,
323 ) -> Result<GithubReadStart, GithubReadError> {
324 self.require_enabled(gh_read)?;
325 let request = GithubReadRequest::parse(
326 resource,
327 working_directory,
328 effective_authentication_identity,
329 vision_capability,
330 )
331 .map_err(|error| GithubReadError::invalid_resource(error.to_string()))?;
332 self.start_with_view(gh_read, request, selector, view)
333 }
334
335 pub fn start(
337 &self,
338 gh_read: &GhReadConfig,
339 request: GithubReadRequest,
340 selector: GithubReadSelector,
341 ) -> Result<GithubReadStart, GithubReadError> {
342 self.start_with_view(gh_read, request, selector, GithubReadView::Document)
343 }
344
345 pub fn start_with_view(
346 &self,
347 gh_read: &GhReadConfig,
348 request: GithubReadRequest,
349 selector: GithubReadSelector,
350 view: GithubReadView,
351 ) -> Result<GithubReadStart, GithubReadError> {
352 self.require_enabled(gh_read)?;
353 let fallback = self.cache_fallback_for_request(&request);
354 Ok(self.defer_fetch(request, selector, view, fallback))
355 }
356
357 pub fn invalidate(
360 &self,
361 kind: GithubResourceKind,
362 resolved_repository: &str,
363 number: u64,
364 effective_authentication_identity: Option<&str>,
365 ) -> Result<usize, GithubReadError> {
366 self.cache
367 .invalidate(
368 kind,
369 resolved_repository,
370 number,
371 effective_authentication_identity,
372 )
373 .map_err(cache_failure)
374 }
375
376 fn require_enabled(&self, gh_read: &GhReadConfig) -> Result<(), GithubReadError> {
377 if gh_read.enabled {
378 Ok(())
379 } else {
380 Err(GithubReadError::GithubReadDisabled)
381 }
382 }
383
384 fn resolved_cache_slot(&self, request: &GithubReadRequest) -> Option<CacheSlot> {
385 let repository = match &request.resource.repository {
386 Some(repository) => Some(repository.clone()),
387 None => self
388 .state
389 .lock()
390 .aliases
391 .get(&short_alias(request))
392 .cloned(),
393 }?;
394 Some(cache_slot(
395 &request.resource,
396 &repository,
397 &request.effective_authentication_identity,
398 ))
399 }
400
401 fn cache_fallback_for_request(
402 &self,
403 request: &GithubReadRequest,
404 ) -> Option<GithubReadCacheEntry> {
405 if request.resource.comment_selector.is_some() {
409 return None;
410 }
411 let slot = self.resolved_cache_slot(request)?;
412 let key = match github_cache_key(&slot, &request.effective_authentication_identity) {
413 Some(key) => key,
414 None => return None,
415 };
416 match self.cache.lookup(&key) {
417 Ok(entry) => entry,
418 Err(error) => {
419 log::warn!("GitHub read cache lookup failed; live fetch will continue: {error}");
420 None
421 }
422 }
423 }
424
425 fn flight_slot_for_request(&self, request: &GithubReadRequest) -> GithubReadFlightSlot {
426 self.resolved_cache_slot(request)
427 .map(GithubReadFlightSlot::Resolved)
428 .unwrap_or_else(|| GithubReadFlightSlot::Unresolved(short_alias(request)))
429 }
430
431 fn defer_fetch(
432 &self,
433 request: GithubReadRequest,
434 selector: GithubReadSelector,
435 view: GithubReadView,
436 fallback: Option<GithubReadCacheEntry>,
437 ) -> GithubReadStart {
438 let slot = self.flight_slot_for_request(&request);
439 let fetch_request = request.clone();
440 let (sender, receiver) = mpsc::sync_channel(1);
441 let leader = {
442 let mut state = self.state.lock();
443 let waiters = state.flights.entry(slot.clone()).or_default();
444 let leader = waiters.is_empty();
445 waiters.push(GithubReadFlightWaiter {
446 request,
447 selector,
448 view,
449 fallback,
450 sender,
451 });
452 leader
453 };
454 if leader {
455 let cache = Arc::clone(&self.cache);
456 let fetcher = Arc::clone(&self.fetcher);
457 let downloader = Arc::clone(&self.image_downloader);
458 let clock = Arc::clone(&self.clock);
459 let state = Arc::clone(&self.state);
460 std::thread::spawn(move || {
461 let fetched = fetch_store(
462 &fetch_request,
463 cache.as_ref(),
464 fetcher.as_ref(),
465 clock.as_ref(),
466 &state,
467 );
468 let waiters = state.lock().flights.remove(&slot).unwrap_or_default();
469 for waiter in waiters {
470 let result = match &fetched {
471 Ok(document) => {
472 render_for_view(document, &waiter.request.resource, waiter.view)
473 .and_then(|canonical_text| {
474 complete_with_optional_attachments(
475 &waiter.request,
476 waiter.selector,
477 canonical_text,
478 GithubReadFreshness::Fetched,
479 downloader.as_ref(),
480 waiter.view,
481 Some(document.clone()),
482 )
483 })
484 }
485 Err(error) => match waiter.fallback {
486 Some(entry) => complete_with_optional_attachments(
487 &waiter.request,
488 waiter.selector,
489 cached_fallback_text(&entry, error),
490 GithubReadFreshness::CachedFallback,
491 downloader.as_ref(),
492 waiter.view,
493 None,
494 ),
495 None => Err(error.clone()),
496 },
497 };
498 let _ = waiter.sender.send(result);
499 }
500 });
501 }
502 GithubReadStart::Deferred(GithubReadDeferred { receiver })
503 }
504}
505
506fn fetch_store(
507 request: &GithubReadRequest,
508 cache: &dyn GithubReadCacheStore,
509 fetcher: &dyn GithubFetcher,
510 clock: &dyn GithubReadClock,
511 state: &Mutex<GithubReadEngineState>,
512) -> Result<super::model::GithubDocument, GithubReadError> {
513 let document = fetcher.fetch(&GithubFetchRequest {
514 resource: request.resource.clone(),
515 working_directory: request.working_directory.clone(),
516 })?;
517 let repository = document.repository.clone();
518 let cache_resource = super::resource::GithubResource {
519 kind: request.resource.kind,
520 number: request.resource.number,
521 repository: Some(repository.clone()),
522 comment_selector: None,
523 };
524 let canonical_text = render_document_for_resource(&document, &cache_resource)
525 .expect("cache rendering has no discussion selector");
526 let slot = cache_slot(
527 &request.resource,
528 &repository,
529 &request.effective_authentication_identity,
530 );
531 if let Some(key) = github_cache_key(&slot, &request.effective_authentication_identity) {
534 if let Err(error) = cache.upsert(&key, &canonical_text, clock.now_ms()) {
535 log::warn!("GitHub read cache write failed: {error}");
536 }
537 }
538 if request.resource.repository.is_none() {
539 state
540 .lock()
541 .aliases
542 .insert(short_alias(request), repository);
543 }
544 Ok(document)
545}
546
547fn render_for_view(
548 document: &super::model::GithubDocument,
549 resource: &GithubResource,
550 view: GithubReadView,
551) -> Result<String, GithubReadError> {
552 match view {
553 GithubReadView::Document => render_document_for_resource(document, resource),
554 GithubReadView::Outline => Ok(render_outline_for_resource(document, resource)),
555 }
556}
557
558fn complete_with_optional_attachments(
559 request: &GithubReadRequest,
560 selector: GithubReadSelector,
561 canonical_text: String,
562 freshness: GithubReadFreshness,
563 downloader: &dyn GithubImageDownloader,
564 view: GithubReadView,
565 document: Option<super::model::GithubDocument>,
566) -> Result<GithubReadCompletion, GithubReadError> {
567 let attachments = if view == GithubReadView::Document && request.vision_capability == Some(true)
568 {
569 download_github_image_attachments(&canonical_text, downloader)
570 } else {
571 Vec::new()
572 };
573 Ok(complete(
574 canonical_text,
575 selector,
576 freshness,
577 attachments,
578 document,
579 ))
580}
581
582fn complete(
583 canonical_text: String,
584 selector: GithubReadSelector,
585 freshness: GithubReadFreshness,
586 attachments: Vec<GithubImageAttachment>,
587 document: Option<super::model::GithubDocument>,
588) -> GithubReadCompletion {
589 let total_lines = canonical_text.lines().count();
590 GithubReadCompletion {
591 content: apply_selector(&canonical_text, selector),
592 total_lines,
593 freshness,
594 attachments,
595 document,
596 }
597}
598
599pub fn apply_selector(canonical_text: &str, selector: GithubReadSelector) -> String {
601 match selector {
602 GithubReadSelector::WholeDocument => canonical_text.to_string(),
603 GithubReadSelector::LineRange {
604 start_line,
605 end_line,
606 limit,
607 } => {
608 let lines: Vec<_> = canonical_text.lines().collect();
609 let start_index = start_line.saturating_sub(1).min(lines.len());
610 let requested_end =
611 end_line.unwrap_or_else(|| start_line.saturating_add(limit).saturating_sub(1));
612 let end_index = requested_end.min(lines.len()).max(start_index);
613 let selected = lines[start_index..end_index].join("\n");
614 (!selected.is_empty())
615 .then(|| format!("{selected}\n"))
616 .unwrap_or_default()
617 }
618 GithubReadSelector::ByteOffset { offset, limit } => {
619 let start = canonical_text.floor_char_boundary(offset.min(canonical_text.len()));
620 let requested_end = limit
621 .map(|limit| start.saturating_add(limit).min(canonical_text.len()))
622 .unwrap_or(canonical_text.len());
623 let end = canonical_text.floor_char_boundary(requested_end);
624 canonical_text[start..end].to_string()
625 }
626 }
627}
628
629fn short_alias(request: &GithubReadRequest) -> ShortResourceAlias {
630 ShortResourceAlias {
631 kind: request.resource.kind,
632 number: request.resource.number,
633 working_directory: request.working_directory.clone(),
634 authentication_identity_hash: authentication_identity_hash(
635 &request.effective_authentication_identity,
636 ),
637 }
638}
639
640fn cache_slot(
641 resource: &GithubResource,
642 repository: &str,
643 authentication_identity: &str,
644) -> CacheSlot {
645 CacheSlot {
646 kind: resource.kind,
647 repository: repository.trim().to_ascii_lowercase(),
648 number: resource.number,
649 authentication_identity_hash: authentication_identity_hash(authentication_identity),
650 }
651}
652
653fn github_cache_key(slot: &CacheSlot, authentication_identity: &str) -> Option<GithubReadCacheKey> {
654 let number = match i64::try_from(slot.number) {
655 Ok(number) => number,
656 Err(_) => {
657 log::warn!(
658 "GitHub resource number exceeds cache storage range; fallback is unavailable"
659 );
660 return None;
661 }
662 };
663 Some(GithubReadCacheKey::new(
664 database_kind(slot.kind),
665 &slot.repository,
666 number,
667 authentication_identity,
668 ))
669}
670
671fn database_kind(kind: GithubResourceKind) -> GithubReadResourceKind {
672 match kind {
673 GithubResourceKind::Issue => GithubReadResourceKind::Issue,
674 GithubResourceKind::PullRequest => GithubReadResourceKind::PullRequest,
675 }
676}
677
678fn authentication_identity_hash(identity: &str) -> [u8; 32] {
679 *blake3::hash(identity.as_bytes()).as_bytes()
680}
681
682fn cache_failure(error: String) -> GithubReadError {
683 GithubReadError::FetchFailed(format!("GitHub read cache is unavailable: {error}"))
684}
685
686fn cached_fallback_text(entry: &GithubReadCacheEntry, error: &GithubReadError) -> String {
687 format!(
688 "[cached copy from {}; live fetch failed: {}]\n{}",
689 iso8601_utc(entry.fetched_at_ms),
690 short_failure_reason(error),
691 entry.canonical_text
692 )
693}
694
695fn short_failure_reason(error: &GithubReadError) -> String {
696 const MAX_REASON_CHARS: usize = 160;
697 let reason = error
698 .to_string()
699 .split_whitespace()
700 .collect::<Vec<_>>()
701 .join(" ");
702 let mut characters = reason.chars();
703 let mut short = characters
704 .by_ref()
705 .take(MAX_REASON_CHARS)
706 .collect::<String>();
707 if characters.next().is_some() {
708 short.push('…');
709 }
710 if short.is_empty() {
711 "unknown fetch error".to_string()
712 } else {
713 short
714 }
715}
716
717fn iso8601_utc(unix_ms: i64) -> String {
718 let seconds = unix_ms.div_euclid(1_000);
719 let milliseconds = unix_ms.rem_euclid(1_000);
720 let days = seconds.div_euclid(86_400);
721 let seconds_of_day = seconds.rem_euclid(86_400);
722 let (year, month, day) = civil_date_from_unix_days(days);
723 let hour = seconds_of_day / 3_600;
724 let minute = (seconds_of_day % 3_600) / 60;
725 let second = seconds_of_day % 60;
726 format!("{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}.{milliseconds:03}Z")
727}
728
729fn civil_date_from_unix_days(days: i64) -> (i64, i64, i64) {
730 let z = days + 719_468;
733 let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
734 let day_of_era = z - era * 146_097;
735 let year_of_era =
736 (day_of_era - day_of_era / 1_460 + day_of_era / 36_524 - day_of_era / 146_096) / 365;
737 let year = year_of_era + era * 400;
738 let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100);
739 let month_prime = (5 * day_of_year + 2) / 153;
740 let day = day_of_year - (153 * month_prime + 2) / 5 + 1;
741 let month = month_prime + if month_prime < 10 { 3 } else { -9 };
742 let year = year + if month <= 2 { 1 } else { 0 };
743 (year, month, day)
744}
745
746pub fn sqlite_cache_store(database_path: impl AsRef<Path>) -> Arc<dyn GithubReadCacheStore> {
749 Arc::new(SqliteGithubReadCacheStore::new(database_path.as_ref()))
750}
751
752#[cfg(test)]
753mod tests {
754 use std::sync::atomic::{AtomicI64, AtomicUsize, Ordering};
755
756 use super::*;
757 use crate::github_read::attachments::{DownloadedGithubImage, GithubImageDownloader};
758 use crate::github_read::model::{GithubDocument, GithubDocumentKind};
759
760 #[derive(Default)]
761 struct MemoryCache(Mutex<Option<GithubReadCacheEntry>>);
762
763 impl MemoryCache {
764 fn with_entry(canonical_text: &str, fetched_at_ms: i64) -> Self {
765 Self(Mutex::new(Some(GithubReadCacheEntry {
766 canonical_text: canonical_text.to_string(),
767 fetched_at_ms,
768 updated_at_ms: fetched_at_ms,
769 })))
770 }
771 }
772
773 impl GithubReadCacheStore for MemoryCache {
774 fn lookup(
775 &self,
776 _key: &GithubReadCacheKey,
777 ) -> Result<Option<GithubReadCacheEntry>, String> {
778 Ok(self.0.lock().clone())
779 }
780
781 fn upsert(
782 &self,
783 _key: &GithubReadCacheKey,
784 canonical_text: &str,
785 fetched_at_ms: i64,
786 ) -> Result<(), String> {
787 *self.0.lock() = Some(GithubReadCacheEntry {
788 canonical_text: canonical_text.to_string(),
789 fetched_at_ms,
790 updated_at_ms: fetched_at_ms,
791 });
792 Ok(())
793 }
794
795 fn invalidate(
796 &self,
797 _kind: GithubResourceKind,
798 _repository: &str,
799 _number: u64,
800 _authentication_identity: Option<&str>,
801 ) -> Result<usize, String> {
802 Ok(0)
803 }
804 }
805
806 struct FixtureClock(AtomicI64);
807
808 impl FixtureClock {
809 fn new(now: i64) -> Self {
810 Self(AtomicI64::new(now))
811 }
812 }
813
814 impl GithubReadClock for FixtureClock {
815 fn now_ms(&self) -> i64 {
816 self.0.load(Ordering::SeqCst)
817 }
818 }
819
820 #[derive(Default)]
821 struct FixtureFetcher(AtomicUsize);
822
823 impl GithubFetcher for FixtureFetcher {
824 fn fetch(&self, request: &GithubFetchRequest) -> Result<GithubDocument, GithubReadError> {
825 self.0.fetch_add(1, Ordering::SeqCst);
826 Ok(GithubDocument {
827 repository: request
828 .resource
829 .repository
830 .clone()
831 .unwrap_or_else(|| "owner/repo".to_string()),
832 kind: GithubDocumentKind::Issue,
833 number: request.resource.number,
834 title: "fixture".to_string(),
835 state: "OPEN".to_string(),
836 body: "body https://user-images.githubusercontent.com/fixture.png".to_string(),
837 ..GithubDocument::default()
838 })
839 }
840 }
841
842 struct FailingFetcher;
843
844 impl GithubFetcher for FailingFetcher {
845 fn fetch(&self, _request: &GithubFetchRequest) -> Result<GithubDocument, GithubReadError> {
846 Err(GithubReadError::FetchFailed(
847 "fixture GitHub fetch failed".to_string(),
848 ))
849 }
850 }
851
852 struct GatedFetcher {
853 calls: AtomicUsize,
854 started: std::sync::mpsc::SyncSender<()>,
855 release: Mutex<Option<std::sync::mpsc::Receiver<()>>>,
856 }
857
858 impl GithubFetcher for GatedFetcher {
859 fn fetch(&self, request: &GithubFetchRequest) -> Result<GithubDocument, GithubReadError> {
860 self.calls.fetch_add(1, Ordering::SeqCst);
861 self.started.send(()).expect("report live fetch start");
862 if let Some(release) = self.release.lock().take() {
863 release.recv().expect("release live fetch");
864 }
865 Ok(GithubDocument {
866 repository: "owner/repo".to_string(),
867 kind: GithubDocumentKind::Issue,
868 number: request.resource.number,
869 title: "fixture".to_string(),
870 state: "OPEN".to_string(),
871 body: "body".to_string(),
872 ..GithubDocument::default()
873 })
874 }
875 }
876
877 #[derive(Default)]
878 struct CountingDownloader(AtomicUsize);
879
880 impl GithubImageDownloader for CountingDownloader {
881 fn download(
882 &self,
883 _url: &url::Url,
884 _maximum_bytes: usize,
885 ) -> Result<Option<DownloadedGithubImage>, String> {
886 self.0.fetch_add(1, Ordering::SeqCst);
887 Ok(None)
888 }
889 }
890
891 fn enabled_gh_read() -> GhReadConfig {
892 GhReadConfig { enabled: true }
893 }
894
895 fn request(vision_capability: Option<bool>) -> GithubReadRequest {
896 GithubReadRequest::parse("issue://1", "/fixture", "identity", vision_capability).unwrap()
897 }
898
899 fn wait_for(deferred: GithubReadDeferred) -> Result<GithubReadCompletion, GithubReadError> {
900 for _ in 0..1000 {
901 if let Some(result) = deferred.try_complete() {
902 return result;
903 }
904 std::thread::sleep(std::time::Duration::from_millis(1));
905 }
906 panic!("deferred read did not complete")
907 }
908
909 #[test]
910 fn disabled_read_refuses_before_the_fetch_seam_runs() {
911 let fetcher = Arc::new(FixtureFetcher::default());
912 let engine = GithubReadEngine::new(
913 Arc::new(MemoryCache::default()),
914 fetcher.clone(),
915 Arc::new(CountingDownloader::default()),
916 Arc::new(FixtureClock::new(1_000)),
917 );
918
919 let error = match engine.start_resource(
920 &GhReadConfig::default(),
921 "issue://1",
922 "/fixture",
923 "identity",
924 None,
925 GithubReadSelector::default(),
926 ) {
927 Err(error) => error,
928 Ok(_) => panic!("disabled GitHub reads must refuse"),
929 };
930
931 assert_eq!(error.code(), "gh_read_disabled");
932 assert_eq!(
933 error.to_string(),
934 "GitHub reads are disabled; set gh_read.enabled: true in aft.jsonc"
935 );
936 assert_eq!(fetcher.0.load(Ordering::SeqCst), 0);
937 }
938
939 #[test]
940 fn enabled_read_reaches_the_fixture_fetcher() {
941 let fetcher = Arc::new(FixtureFetcher::default());
942 let engine = GithubReadEngine::new(
943 Arc::new(MemoryCache::default()),
944 fetcher.clone(),
945 Arc::new(CountingDownloader::default()),
946 Arc::new(FixtureClock::new(1_000)),
947 );
948
949 let GithubReadStart::Deferred(deferred) = engine
950 .start_resource(
951 &enabled_gh_read(),
952 "issue://1",
953 "/fixture",
954 "identity",
955 None,
956 GithubReadSelector::default(),
957 )
958 .expect("enabled GitHub reads should proceed")
959 else {
960 panic!("a cache miss must defer its GitHub fetch");
961 };
962
963 assert!(wait_for(deferred).unwrap().content.contains("# Issue #1"));
964 assert_eq!(fetcher.0.load(Ordering::SeqCst), 1);
965 }
966
967 #[test]
968 fn every_read_fetches_live_before_optional_attachments() {
969 let cache = Arc::new(MemoryCache::default());
970 let fetcher = Arc::new(FixtureFetcher::default());
971 let downloader = Arc::new(CountingDownloader::default());
972 let engine = GithubReadEngine::new(
973 cache,
974 fetcher.clone(),
975 downloader.clone(),
976 Arc::new(FixtureClock::new(1_000)),
977 );
978
979 let first = match engine
980 .start(
981 &enabled_gh_read(),
982 request(None),
983 GithubReadSelector::default(),
984 )
985 .unwrap()
986 {
987 GithubReadStart::Deferred(deferred) => wait_for(deferred).unwrap(),
988 GithubReadStart::Immediate(_) => panic!("every GitHub read must fetch live"),
989 };
990 assert_eq!(first.freshness, GithubReadFreshness::Fetched);
991 assert_eq!(first.attachments.len(), 0);
992
993 let second = match engine
994 .start(
995 &enabled_gh_read(),
996 request(None),
997 GithubReadSelector::default(),
998 )
999 .unwrap()
1000 {
1001 GithubReadStart::Deferred(deferred) => wait_for(deferred).unwrap(),
1002 GithubReadStart::Immediate(_) => panic!("cached data must not satisfy a read"),
1003 };
1004 assert_eq!(second.freshness, GithubReadFreshness::Fetched);
1005 assert_eq!(fetcher.0.load(Ordering::SeqCst), 2);
1006 assert_eq!(downloader.0.load(Ordering::SeqCst), 0);
1007
1008 let vision = match engine
1009 .start(
1010 &enabled_gh_read(),
1011 request(Some(true)),
1012 GithubReadSelector::default(),
1013 )
1014 .unwrap()
1015 {
1016 GithubReadStart::Deferred(deferred) => wait_for(deferred).unwrap(),
1017 GithubReadStart::Immediate(_) => panic!("vision reads must fetch live"),
1018 };
1019 assert!(vision.attachments.is_empty());
1020 assert_eq!(fetcher.0.load(Ordering::SeqCst), 3);
1021 assert_eq!(downloader.0.load(Ordering::SeqCst), 1);
1022 }
1023
1024 #[test]
1025 fn failed_live_fetch_returns_a_loudly_disclosed_cached_fallback() {
1026 let engine = GithubReadEngine::new(
1027 Arc::new(MemoryCache::with_entry("# Cached issue\n", 1_234)),
1028 Arc::new(FailingFetcher),
1029 Arc::new(CountingDownloader::default()),
1030 Arc::new(FixtureClock::new(2_000)),
1031 );
1032
1033 let completion = match engine
1034 .start(
1035 &enabled_gh_read(),
1036 GithubReadRequest::parse("issue://owner/repo/1", "/fixture", "identity", None)
1037 .unwrap(),
1038 GithubReadSelector::default(),
1039 )
1040 .unwrap()
1041 {
1042 GithubReadStart::Deferred(deferred) => wait_for(deferred).unwrap(),
1043 GithubReadStart::Immediate(_) => panic!("fallback requires a live fetch attempt"),
1044 };
1045
1046 assert_eq!(completion.freshness, GithubReadFreshness::CachedFallback);
1047 assert!(completion.content.starts_with(
1048 "[cached copy from 1970-01-01T00:00:01.234Z; live fetch failed: fixture GitHub fetch failed]\n"
1049 ));
1050 assert!(completion.content.ends_with("# Cached issue\n"));
1051 }
1052
1053 #[test]
1054 fn failed_live_fetch_without_a_cached_copy_preserves_its_typed_error() {
1055 let engine = GithubReadEngine::new(
1056 Arc::new(MemoryCache::default()),
1057 Arc::new(FailingFetcher),
1058 Arc::new(CountingDownloader::default()),
1059 Arc::new(FixtureClock::new(2_000)),
1060 );
1061
1062 let error = match engine
1063 .start(
1064 &enabled_gh_read(),
1065 request(None),
1066 GithubReadSelector::default(),
1067 )
1068 .unwrap()
1069 {
1070 GithubReadStart::Deferred(deferred) => wait_for(deferred).unwrap_err(),
1071 GithubReadStart::Immediate(_) => panic!("every read must attempt a live fetch"),
1072 };
1073
1074 assert_eq!(
1075 error,
1076 GithubReadError::FetchFailed("fixture GitHub fetch failed".to_string())
1077 );
1078 }
1079
1080 #[test]
1081 fn concurrent_same_resource_reads_share_one_live_fetch() {
1082 let (started_tx, started_rx) = std::sync::mpsc::sync_channel(1);
1083 let (release_tx, release_rx) = std::sync::mpsc::sync_channel(1);
1084 let fetcher = Arc::new(GatedFetcher {
1085 calls: AtomicUsize::new(0),
1086 started: started_tx,
1087 release: Mutex::new(Some(release_rx)),
1088 });
1089 let engine = GithubReadEngine::new(
1090 Arc::new(MemoryCache::default()),
1091 fetcher.clone(),
1092 Arc::new(CountingDownloader::default()),
1093 Arc::new(FixtureClock::new(2_000)),
1094 );
1095
1096 let first = engine
1097 .start(
1098 &enabled_gh_read(),
1099 request(None),
1100 GithubReadSelector::default(),
1101 )
1102 .unwrap();
1103 started_rx
1104 .recv_timeout(std::time::Duration::from_secs(1))
1105 .expect("first live fetch started");
1106 let second = engine
1107 .start(
1108 &enabled_gh_read(),
1109 request(None),
1110 GithubReadSelector::default(),
1111 )
1112 .unwrap();
1113 release_tx.send(()).expect("release shared fetch");
1114
1115 for start in [first, second] {
1116 match start {
1117 GithubReadStart::Deferred(deferred) => {
1118 assert_eq!(
1119 wait_for(deferred).unwrap().freshness,
1120 GithubReadFreshness::Fetched
1121 )
1122 }
1123 GithubReadStart::Immediate(_) => panic!("live reads must be deferred"),
1124 }
1125 }
1126 assert_eq!(fetcher.calls.load(Ordering::SeqCst), 1);
1127 }
1128}