1use anyhow::{Context, Result};
2use reqwest::Client;
3use serde::Deserialize;
4
5use crate::models::{Document, DocumentType, SearchFilter, SearchResult};
6
7pub const DATATRACKER_BASE_URL: &str = "https://datatracker.ietf.org";
8
9pub struct DataTrackerClient {
12 client: Client,
13}
14
15#[derive(Debug, Deserialize)]
16struct SearchResponse {
17 meta: SearchMeta,
18 objects: Vec<ApiDocument>,
19}
20
21#[derive(Debug, Deserialize)]
22struct SearchMeta {
23 #[serde(default)]
24 total_count: Option<u32>,
25 #[serde(default)]
26 next: Option<String>,
27}
28
29#[derive(Debug, Deserialize)]
36struct ApiDocument {
37 name: String,
38 title: String,
39 #[serde(rename = "abstract")]
40 abstract_text: Option<String>,
41}
42
43impl DataTrackerClient {
44 pub fn new() -> Result<Self> {
46 Ok(Self::with_client(super::build_http_client()?))
47 }
48
49 pub fn with_client(client: Client) -> Self {
51 Self { client }
52 }
53
54 pub async fn search(
69 &self,
70 query: &str,
71 filter: SearchFilter,
72 limit: u32,
73 ) -> Result<SearchResult> {
74 let tokens: Vec<String> = query.split_whitespace().map(|t| t.to_lowercase()).collect();
75
76 let mut by_length: Vec<&str> = tokens.iter().map(String::as_str).collect();
80 by_length.sort_by_key(|t| std::cmp::Reverse(t.len()));
81 let primary_token = by_length.first().copied().unwrap_or(query);
82 let secondary_token = by_length.get(1).copied();
83
84 let type_filter = filter.api_param().unwrap_or("rfc,draft");
88
89 let base_limit = limit.max(25);
95 let api_limit = if secondary_token.is_some() {
96 base_limit
97 } else {
98 base_limit.saturating_mul(3)
99 };
100
101 let mut url = format!(
102 "{}/api/v1/doc/document/?title__icontains={}&type__in={}&limit={}&format=json",
103 DATATRACKER_BASE_URL,
104 urlencoding::encode(primary_token),
105 type_filter,
106 api_limit
107 );
108 if let Some(s) = secondary_token {
109 url.push_str(&format!("&abstract__icontains={}", urlencoding::encode(s)));
110 }
111
112 let response = self
113 .client
114 .get(&url)
115 .send()
116 .await
117 .context("Failed to send search request")?;
118
119 if !response.status().is_success() {
120 anyhow::bail!(
121 "Search request to {} failed: HTTP {}",
122 url,
123 response.status()
124 );
125 }
126
127 let search_response: SearchResponse = response
128 .json()
129 .await
130 .context("Failed to parse search response")?;
131
132 let extra_tokens: Vec<&str> = by_length.iter().copied().skip(2).collect();
135
136 let matches_extra_tokens = |doc: &ApiDocument| -> bool {
137 if extra_tokens.is_empty() {
138 return true;
139 }
140 let title_lc = doc.title.to_lowercase();
141 let abstract_lc = doc.abstract_text.as_deref().unwrap_or("").to_lowercase();
142 extra_tokens
143 .iter()
144 .all(|tok| title_lc.contains(tok) || abstract_lc.contains(tok))
145 };
146
147 let documents: Vec<Document> = search_response
150 .objects
151 .into_iter()
152 .filter(|doc| Self::is_rfc_or_draft(&doc.name))
153 .filter(matches_extra_tokens)
154 .map(Document::from)
155 .take(limit as usize)
156 .collect();
157
158 let total_count = if extra_tokens.is_empty() {
162 search_response.meta.total_count
163 } else {
164 None
165 };
166
167 Ok(SearchResult {
168 documents,
169 has_more: search_response.meta.next.is_some(),
170 total_count,
171 query: query.to_string(),
172 filter,
173 })
174 }
175
176 fn is_rfc_or_draft(name: &str) -> bool {
177 name.starts_with("rfc") || name.starts_with("draft-")
178 }
179
180 pub async fn get_document(&self, name: &str) -> Result<Document> {
182 let url = format!(
183 "{}/api/v1/doc/document/{}/?format=json",
184 DATATRACKER_BASE_URL, name
185 );
186
187 let response = self
188 .client
189 .get(&url)
190 .send()
191 .await
192 .context("Failed to fetch document metadata")?;
193
194 if !response.status().is_success() {
195 anyhow::bail!("Document not found: {}", name);
196 }
197
198 let api_doc: ApiDocument = response
199 .json()
200 .await
201 .context("Failed to parse document metadata")?;
202
203 Ok(api_doc.into())
204 }
205}
206
207impl From<ApiDocument> for Document {
208 fn from(doc: ApiDocument) -> Self {
209 let doc_type = DocumentType::from_canonical_name(&doc.name);
210 Document {
211 name: doc.name,
212 title: doc.title,
213 doc_type,
214 }
215 }
216}