spec_driven_docs/self_depend/
registry.rs1use std::time::Duration;
13
14use semver::Version;
15use serde::Deserialize;
16
17use crate::error::AppError;
18
19pub const CRATE_NAME: &str = "spec-driven-docs";
21
22pub const INDEX_ROOT: &str = "https://index.crates.io";
24
25const CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
27const RESPONSE_TIMEOUT: Duration = Duration::from_secs(30);
29const TOTAL_TIMEOUT: Duration = Duration::from_secs(120);
31const RETRY_BUDGET: u32 = 2;
33const MAX_RETRY_AFTER: Duration = Duration::from_secs(10);
35const MAX_INDEX_BYTES: u64 = 16 * 1024 * 1024;
37
38#[derive(Debug, Clone, Deserialize)]
40struct IndexEntry {
41 vers: String,
42 #[serde(default)]
43 yanked: bool,
44}
45
46#[derive(Debug, Clone)]
48pub struct Index {
49 root: String,
50 offline: bool,
51}
52
53impl Default for Index {
54 fn default() -> Self {
55 Self::new()
56 }
57}
58
59impl Index {
60 #[must_use]
67 pub fn new() -> Self {
68 Self {
69 root: INDEX_ROOT.to_string(),
70 offline: crate::domain::paths::variable(crate::domain::paths::OFFLINE_VAR).is_some(),
71 }
72 }
73
74 #[must_use]
76 pub const fn offline(mut self, offline: bool) -> Self {
77 self.offline = self.offline || offline;
78 self
79 }
80
81 #[must_use]
83 pub fn with_root(mut self, root: &str) -> Self {
84 self.root = root.trim_end_matches('/').to_string();
85 self
86 }
87
88 pub fn latest_version(&self) -> Result<Version, AppError> {
95 if self.offline {
96 return Err(AppError::Refused(
97 "offline: only the index says which release is newest".to_string(),
98 ));
99 }
100 let url = format!("{}/{}", self.root, index_path(CRATE_NAME));
101 let bytes = read(&url)?;
102 let text = String::from_utf8(bytes)
103 .map_err(|source| AppError::Refused(format!("{url} is not text: {source}")))?;
104 let mut entries = Vec::new();
105 for line in text.lines().filter(|line| !line.trim().is_empty()) {
106 let entry: IndexEntry = serde_json::from_str(line).map_err(|source| {
107 AppError::Refused(format!(
108 "{url} carries a line this tool cannot read: {source}"
109 ))
110 })?;
111 entries.push(entry);
112 }
113 entries
114 .iter()
115 .filter(|entry| !entry.yanked)
116 .filter_map(|entry| entry.vers.parse::<Version>().ok())
117 .filter(|version| version.pre.is_empty())
118 .max()
119 .ok_or_else(|| AppError::Refused(format!("the registry serves no stable {CRATE_NAME}")))
120 }
121}
122
123fn read(url: &str) -> Result<Vec<u8>, AppError> {
125 let agent: ureq::Agent = ureq::Agent::config_builder()
126 .timeout_connect(Some(CONNECT_TIMEOUT))
127 .timeout_recv_response(Some(RESPONSE_TIMEOUT))
128 .timeout_global(Some(TOTAL_TIMEOUT))
129 .user_agent(format!(
130 "sdd/{} (+{})",
131 env!("CARGO_PKG_VERSION"),
132 CRATE_NAME
133 ))
134 .build()
135 .into();
136 let mut attempt = 0;
137 loop {
138 match agent.get(url).call() {
139 Ok(mut response) => {
140 let status = response.status().as_u16();
141 if transient(status) && attempt < RETRY_BUDGET {
142 std::thread::sleep(retry_after(&response));
143 attempt += 1;
144 continue;
145 }
146 if status != 200 {
147 return Err(AppError::Refused(format!("{url} answered {status}")));
148 }
149 return response
150 .body_mut()
151 .with_config()
152 .limit(MAX_INDEX_BYTES)
153 .read_to_vec()
154 .map_err(|source| {
155 AppError::Refused(format!(
156 "{url} did not read within {MAX_INDEX_BYTES} bytes: {source}"
157 ))
158 });
159 }
160 Err(source) if attempt < RETRY_BUDGET && is_transport(&source) => {
161 std::thread::sleep(Duration::from_millis(250));
162 attempt += 1;
163 }
164 Err(source) => {
165 return Err(AppError::Refused(format!(
166 "{url} could not be read: {source}"
167 )));
168 }
169 }
170 }
171}
172
173#[must_use]
175pub fn index_path(name: &str) -> String {
176 let lower = name.to_lowercase();
177 match lower.len() {
178 0 => lower,
179 1 => format!("1/{lower}"),
180 2 => format!("2/{lower}"),
181 3 => format!("3/{}/{lower}", &lower[..1]),
182 _ => format!("{}/{}/{lower}", &lower[..2], &lower[2..4]),
183 }
184}
185
186const fn transient(status: u16) -> bool {
188 status == 429 || matches!(status, 500..=599)
189}
190
191const fn is_transport(error: &ureq::Error) -> bool {
193 matches!(
194 error,
195 ureq::Error::Io(_) | ureq::Error::Timeout(_) | ureq::Error::ConnectionFailed
196 )
197}
198
199fn retry_after(response: &ureq::http::Response<ureq::Body>) -> Duration {
201 response
202 .headers()
203 .get("retry-after")
204 .and_then(|value| value.to_str().ok())
205 .and_then(|value| value.trim().parse::<u64>().ok())
206 .map_or(Duration::from_millis(500), |seconds| {
207 Duration::from_secs(seconds).min(MAX_RETRY_AFTER)
208 })
209}
210
211#[cfg(test)]
212mod tests {
213 use super::*;
214
215 #[test]
216 fn the_index_path_follows_the_registry_protocol() {
217 assert_eq!(index_path("a"), "1/a");
218 assert_eq!(index_path("ab"), "2/ab");
219 assert_eq!(index_path("abc"), "3/a/abc");
220 assert_eq!(index_path(CRATE_NAME), "sp/ec/spec-driven-docs");
221 }
222
223 #[test]
224 fn an_offline_index_refuses_rather_than_guessing() {
225 let error = Index::new().offline(true).latest_version().unwrap_err();
226 assert_eq!(error.kind(), "Refused");
227 assert!(error.to_string().contains("newest"), "{error}");
228 }
229
230 #[test]
231 fn a_transient_answer_is_worth_asking_again() {
232 assert!(transient(429));
233 assert!(transient(503));
234 assert!(!transient(404));
235 }
236}