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, render_outline_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<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/// 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
176/// Presentation selected after the shared GitHub gate and live-fetch path.
177#[derive(Clone, Copy, Debug, Eq, PartialEq)]
178pub enum GithubReadView {
179    Document,
180    Outline,
181}
182
183impl GithubReadFreshness {
184    /// Fallback status is part of the rendered text, where every agent can see it.
185    pub const fn note(self) -> Option<&'static str> {
186        None
187    }
188}
189
190/// A completed GitHub read ready for the transport-specific response adapter.
191#[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    /// Live structured data retained for mutation flows that must reuse read ordinals.
198    pub document: Option<super::model::GithubDocument>,
199}
200
201/// Handle for a fetch or attachment task that is running away from the request
202/// loop. Poll it from `PendingResponse`; never wait on it in standalone input
203/// handling.
204pub 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
220/// The initial outcome for a GitHub read. Every read is deferred because it
221/// performs a live GitHub fetch before considering any cached fallback.
222pub 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/// A resolved resource can share a flight across equivalent explicit forms.
250/// An unresolved short form stays scoped to its worktree until GitHub resolves it.
251#[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
265/// Coordinates live GitHub fetches, durable fallback copies, single-flight work,
266/// rendering, and deferred attachments for both issue and PR reads.
267pub 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    /// Begin a resource-string read with strict parser validation.
292    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    /// Begin an alternate read-only view with the same typed gate, live fetch,
313    /// and durable fallback behavior as `read`.
314    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    /// Start a read without blocking on GitHub or an image host.
336    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    /// Invalidate the exact resource after a successful structured `gh` mutation.
358    /// Passing no identity conservatively removes every principal's cache row.
359    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        // Cache rows contain the default compressed document. A discussion
406        // drill-down promises structurally stripped full bodies, so a stale
407        // default render cannot honestly stand in for that request.
408        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    // A cache outage or an unrepresentable cache key must not convert a live
532    // GitHub document into a failed read. The next request will fetch live again.
533    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
599/// Apply selection to the completed canonical render, never to raw GitHub data.
600pub 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    // Convert epoch days to a civil UTC date inline to avoid adding a second
731    // wall-clock dependency just to format fallback timestamps.
732    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
746/// Convenience constructor for the real cache location. The read integration
747/// supplies its existing `aft.db` path; this module never creates a second DB.
748pub 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}