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