Skip to main content

aft/github_read/
cache.rs

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
22/// Clock seam used to make freshness boundaries deterministic in tests.
23pub trait GithubReadClock: Send + Sync {
24    fn now_ms(&self) -> i64;
25}
26
27/// Production wall clock for cache timestamps.
28#[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
42/// A cache persistence seam. The production implementation stores canonical
43/// text in AFT's existing `aft.db`; tests can use an in-memory fixture store.
44pub 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/// `aft.db` implementation of the GitHub read cache seam.
62#[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/// Request context that the read integration supplies before entering a
120/// deferred fetch. An absent capability is intentionally different from an
121/// inferred one: only `Some(true)` permits image downloads.
122#[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/// Selector applied only after the complete canonical document is rendered.
147#[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/// Origin of the text that satisfied a request.
168#[derive(Clone, Copy, Debug, Eq, PartialEq)]
169pub enum GithubReadFreshness {
170    /// The response came from the live GitHub fetch.
171    Fetched,
172    /// The live fetch failed, so the response is the explicitly disclosed fallback copy.
173    CachedFallback,
174}
175
176impl GithubReadFreshness {
177    /// Fallback status is part of the rendered text, where every agent can see it.
178    pub const fn note(self) -> Option<&'static str> {
179        None
180    }
181}
182
183/// A completed GitHub read ready for the transport-specific response adapter.
184#[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
192/// Handle for a fetch or attachment task that is running away from the request
193/// loop. Poll it from `PendingResponse`; never wait on it in standalone input
194/// handling.
195pub 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
211/// The initial outcome for a GitHub read. Every read is deferred because it
212/// performs a live GitHub fetch before considering any cached fallback.
213pub 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/// A resolved resource can share a flight across equivalent explicit forms.
241/// An unresolved short form stays scoped to its worktree until GitHub resolves it.
242#[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
255/// Coordinates live GitHub fetches, durable fallback copies, single-flight work,
256/// rendering, and deferred attachments for both issue and PR reads.
257pub 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    /// Begin a resource-string read with strict parser validation.
282    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    /// Start a read without blocking on GitHub or an image host.
303    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    /// Invalidate the exact resource after a successful structured `gh` mutation.
315    /// Passing no identity conservatively removes every principal's cache row.
316    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        // Cache rows contain the default compressed document. A discussion
363        // drill-down promises structurally stripped full bodies, so a stale
364        // default render cannot honestly stand in for that request.
365        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    // A cache outage or an unrepresentable cache key must not convert a live
483    // GitHub document into a failed read. The next request will fetch live again.
484    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
528/// Apply selection to the completed canonical render, never to raw GitHub data.
529pub 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    // Convert epoch days to a civil UTC date inline to avoid adding a second
660    // wall-clock dependency just to format fallback timestamps.
661    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
675/// Convenience constructor for the real cache location. The read integration
676/// supplies its existing `aft.db` path; this module never creates a second DB.
677pub 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}