use std::sync::Arc;
use async_trait::async_trait;
use trusty_common::intent_source::backend_fetcher::{BackendTicketFetcher, FsSpecLookup};
use trusty_common::intent_source::{
ChangedFile, IntentQuery, IntentTokenResolver, IsrError, Method, Precedence, ResolvedIntent,
SpecLookup, TicketFetcher, resolve_default,
};
use super::{
ContextSection, ContextSnippet, ContextSource, ContextSourceError, RetrievalMode,
ReviewSubject, SNIPPET_BODY_CHARS, truncate_on_char_boundary,
};
use crate::config::ReviewConfig;
use crate::integrations::github::{AuthStrategy, GithubClient, RunMode};
const SOURCE_NAME: &str = "conformance";
const SECTION_HEADING: &str = "Intended method (ticket/spec)";
#[async_trait]
pub trait ConformanceTokenResolver: Send + Sync {
async fn resolve(&self, owner: &str) -> Result<String, ContextSourceError>;
}
pub struct DualModeConformanceToken {
run_mode: RunMode,
config: ReviewConfig,
client: Result<GithubClient, String>,
}
impl DualModeConformanceToken {
#[must_use]
pub fn new(run_mode: RunMode, config: ReviewConfig) -> Self {
let client = GithubClient::new().map_err(|e| format!("failed to build HTTP client: {e}"));
Self {
run_mode,
config,
client,
}
}
}
#[async_trait]
impl ConformanceTokenResolver for DualModeConformanceToken {
async fn resolve(&self, owner: &str) -> Result<String, ContextSourceError> {
let client = self
.client
.as_ref()
.map_err(|reason| ContextSourceError::NotConfigured {
src: SOURCE_NAME,
reason: reason.clone(),
})?;
AuthStrategy::select(self.run_mode, None)
.resolve_token(client, &self.config, owner)
.await
.map_err(|e| ContextSourceError::NotConfigured {
src: SOURCE_NAME,
reason: format!("GitHub token unavailable: {e}"),
})
}
}
struct IsrTokenBridge {
inner: Arc<dyn ConformanceTokenResolver>,
}
#[async_trait]
impl IntentTokenResolver for IsrTokenBridge {
async fn token(&self, owner: &str, _repo: &str) -> Result<String, IsrError> {
self.inner
.resolve(owner)
.await
.map_err(|e| IsrError::NoToken(e.to_string()))
}
}
pub struct ConformanceSource {
enabled: bool,
mode: RetrievalMode,
fetcher: Box<dyn TicketFetcher>,
spec_lookup: Box<dyn SpecLookup>,
}
impl ConformanceSource {
pub fn from_config(cfg: &super::SourceConfig, run_mode: RunMode, config: ReviewConfig) -> Self {
let token: Arc<dyn ConformanceTokenResolver> =
Arc::new(DualModeConformanceToken::new(run_mode, config));
let bridge = IsrTokenBridge { inner: token };
let fetcher = Box::new(BackendTicketFetcher::new(Box::new(bridge)));
let repo_root = crate::config::index_resolver::repo_root_from_cwd();
let spec_lookup = Box::new(FsSpecLookup::new(repo_root));
Self {
enabled: cfg.effective_enabled(false),
mode: cfg.mode,
fetcher,
spec_lookup,
}
}
#[must_use]
pub fn new(
enabled: bool,
mode: RetrievalMode,
fetcher: Box<dyn TicketFetcher>,
spec_lookup: Box<dyn SpecLookup>,
) -> Self {
Self {
enabled,
mode,
fetcher,
spec_lookup,
}
}
fn build_query(subject: &ReviewSubject) -> Option<IntentQuery> {
if subject.owner.is_empty() || subject.repo.is_empty() {
return None;
}
let changed_files = subject
.changed_files
.iter()
.map(|p| ChangedFile {
path: p.clone(),
content: String::new(),
})
.collect();
Some(IntentQuery::Pr {
owner: subject.owner.clone(),
repo: subject.repo.clone(),
pr_number: subject.pr_number,
body: subject.body.clone(),
branch: None,
commit_messages: Vec::new(),
changed_files,
})
}
fn render_section(intent: &ResolvedIntent) -> ContextSection {
if intent.unresolved.is_some() || intent.is_gap() {
return Self::empty_section();
}
let mut snippets: Vec<ContextSnippet> = Vec::new();
if let Some(method) = Self::winning_method(intent) {
let ticket_label = intent
.ticket
.as_ref()
.map(|t| format!("ticket {}", t.id))
.unwrap_or_else(|| "ticket".to_string());
let source_label = match intent.precedence_winner {
Precedence::Ticket => ticket_label,
Precedence::Spec => intent
.spec_section
.as_ref()
.map(|s| format!("spec {}", s.spec_id))
.unwrap_or_else(|| "spec".to_string()),
Precedence::None => "intent".to_string(),
};
let body =
truncate_on_char_boundary(method.text.trim(), SNIPPET_BODY_CHARS).to_string();
snippets.push(ContextSnippet {
title: format!("Prescribed method (from {source_label})"),
subtitle: Some(
"Flag the diff ONLY if it explicitly contradicts this method.".to_string(),
),
body: (!body.is_empty()).then_some(body),
link: intent.ticket.as_ref().and_then(|t| t.url.clone()),
});
}
if intent.stale_spec
&& let Some(spec_method) = &intent.spec_method
{
let body =
truncate_on_char_boundary(spec_method.text.trim(), SNIPPET_BODY_CHARS).to_string();
snippets.push(ContextSnippet {
title: "Conflicting spec method (stale — advisory only)".to_string(),
subtitle: Some(
"The ticket overrides this; do NOT flag the diff for matching the ticket."
.to_string(),
),
body: (!body.is_empty()).then_some(body),
link: None,
});
}
ContextSection {
heading: SECTION_HEADING.to_string(),
snippets,
}
}
fn winning_method(intent: &ResolvedIntent) -> Option<&Method> {
match intent.precedence_winner {
Precedence::Ticket => intent.ticket_method.as_ref(),
Precedence::Spec => intent.spec_method.as_ref(),
Precedence::None => None,
}
}
#[must_use]
pub fn would_flag(intent: &ResolvedIntent) -> bool {
!Self::render_section(intent).snippets.is_empty()
}
fn empty_section() -> ContextSection {
ContextSection {
heading: SECTION_HEADING.to_string(),
snippets: Vec::new(),
}
}
}
#[async_trait]
impl ContextSource for ConformanceSource {
fn name(&self) -> &'static str {
SOURCE_NAME
}
fn is_enabled(&self) -> bool {
self.enabled
}
fn mode(&self) -> RetrievalMode {
self.mode
}
async fn gather(&self, subject: &ReviewSubject) -> Result<ContextSection, ContextSourceError> {
if self.mode == RetrievalMode::Semantic {
return Err(ContextSourceError::SemanticNotImplemented { src: SOURCE_NAME });
}
let Some(query) = Self::build_query(subject) else {
return Ok(Self::empty_section());
};
let intent = resolve_default(query, self.fetcher.as_ref(), self.spec_lookup.as_ref()).await;
Ok(Self::render_section(&intent))
}
}
#[cfg(test)]
#[path = "conformance_tests.rs"]
mod tests;