Skip to main content

hpr_net/
motor_finder.rs

1//! Motor stock and prices from [motor.fusionspace.co](https://motor.fusionspace.co)'s public API.
2//!
3//! The motor finder lists every AeroTech, Cesaroni and Loki motor that a dozen U.S. vendors carry,
4//! with each vendor's stock and price. Its [API][api] is a handful of static JSON files, rebuilt
5//! about hourly, with no key, no rate limit and no query parameters: a client fetches a whole file
6//! and filters it itself. The files are [`Endpoint::Meta`] (when the data was built, and counts),
7//! [`Endpoint::Motors`] (every motor some vendor lists, D class and up), [`Endpoint::InStock`]
8//! (the same, only those in stock at one vendor or more), [`Endpoint::Vendors`], and one motor's
9//! page ([`Endpoint::motor`]). Prices are whole cents; a listing's unit price is its sticker
10//! price over its pack size. The motor data itself (classes, impulses, delays) comes from
11//! ThrustCurve.org, and designations are spelled as ThrustCurve spells them.
12//!
13//! [`parse_meta`], [`parse_motors`], [`parse_in_stock`], [`parse_vendors`] and [`parse_motor`]
14//! read an answer, refusing one of another schema version, one whose count disagrees with its
15//! list, and a motor or listing that breaks the API's own rules (a pack of no motors, a unit price
16//! over the sticker price, a cheapest offer on a motor out of stock). The `fetch_` functions ask a
17//! [`Client`] for the file, so the answer comes from the cache when it can, and offline from the
18//! cache only; an answer that doesn't parse is never cached. Stock moves by the hour, so a copy
19//! stays fresh for [`TTL_S`], an hour. Show [`ATTRIBUTION`] (it is on every [`Fetched`]) wherever
20//! a price or stock is shown: the API's data license, CC BY 4.0, asks for the credit "Motor stock
21//! data from motor.fusionspace.co", and its terms say to check stock and price on the vendor's
22//! own page before relying on them.
23//!
24//! **How far to trust it:** a value is the answer's, unchanged, bar a listing status the API adds
25//! later (`tests/motor_finder.rs` reads every field of every recorded answer back). Whether a vendor really has a motor, at that price,
26//! is the vendor's to say: the finder reads their public listings, up to about an hour old when it
27//! builds its files, and a fresh cached copy may be an hour older again. One value that breaks a
28//! rule refuses the whole file, so the last good copy is kept; a cheapest offer with no price,
29//! which the API allows, and a listing status it adds later (read as
30//! [`ListingStatus::Unknown`]) are read.
31//!
32//! ```
33//! use hpr_net::motor_finder;
34//!
35//! // The in-stock list as recorded on 2026-10-01 at 07:07 UTC.
36//! let body = include_bytes!("../tests/fixtures/replay/motor-finder-in-stock.json");
37//! let list = motor_finder::parse_in_stock(body)?;
38//! let l_class: Vec<_> = list.motors.iter().filter(|m| m.impulse_class == "L").collect();
39//! assert_eq!((list.motors.len(), l_class.len()), (282, 20));
40//! # Ok::<(), Box<dyn std::error::Error>>(())
41//! ```
42//!
43//! [api]: https://github.com/nrdptel/Hobby-Rocket-Motor-Finder/blob/main/docs/api.md
44
45use serde::{Deserialize, Serialize};
46use std::collections::BTreeMap;
47
48use crate::{Client, Fetched, NetError, Source, Transport};
49
50/// The API's base URL, version 1.
51pub const BASE_URL: &str = "https://motor.fusionspace.co/api/v1";
52
53/// The schema version this module reads. A breaking change ships under a new path (`/api/v2/`),
54/// so an answer of another version is refused.
55pub const SCHEMA_VERSION: u32 = 1;
56
57/// How long a cached answer counts as fresh, s: an hour, as often as the site rebuilds the files.
58pub const TTL_S: u64 = 3_600;
59
60/// The credit the API's data license asks for, in its words ("Motor stock data from
61/// motor.fusionspace.co"), with the license, CC BY 4.0, and the API's caution about stock and
62/// price.
63pub const ATTRIBUTION: &str = "Motor stock data from motor.fusionspace.co, licensed CC BY 4.0 \
64                               (https://creativecommons.org/licenses/by/4.0/); aggregated from \
65                               public vendor listings and ThrustCurve.org; provided as is, with \
66                               no warranty: check stock and price on the vendor's own page \
67                               before relying on them";
68
69/// The manufacturers the API lists: each one's name as the API writes it, and the slug its
70/// motors' pages sit under.
71pub const MANUFACTURERS: &[(&str, &str)] = &[
72    ("AeroTech", "aerotech"),
73    ("Cesaroni Technology", "cesaroni"),
74    ("Loki Research", "loki"),
75];
76
77/// One of the API's files.
78#[non_exhaustive]
79#[derive(Debug, Clone, PartialEq, Eq)]
80pub enum Endpoint {
81    /// `meta.json`: when the data was built, its counts and the endpoint index.
82    Meta,
83    /// `motors.json`: every motor a vendor lists.
84    Motors,
85    /// `in-stock.json`: the motors in stock at one vendor or more.
86    InStock,
87    /// `vendors.json`: the vendors read.
88    Vendors,
89    /// One motor's page. Build it with [`Endpoint::motor`], which checks the motor's name.
90    Motor(MotorName),
91}
92
93/// A motor's manufacturer and designation, checked by [`Endpoint::motor`] so its page's URL stays
94/// in the API's `motors/` folder. Its fields are private, so it can't be changed past the checks.
95#[derive(Debug, Clone, PartialEq, Eq)]
96pub struct MotorName {
97    manufacturer_slug: &'static str,
98    designation: String,
99}
100
101impl MotorName {
102    /// The manufacturer's slug, one of [`MANUFACTURERS`].
103    #[must_use]
104    pub fn manufacturer_slug(&self) -> &'static str {
105        self.manufacturer_slug
106    }
107
108    /// The designation, as ThrustCurve spells it (`H128W`, `F27R/L`, `3683L851-P`).
109    #[must_use]
110    pub fn designation(&self) -> &str {
111        &self.designation
112    }
113}
114
115impl Endpoint {
116    /// One motor's page, by its manufacturer (the API's name or slug, any case) and designation.
117    ///
118    /// # Errors
119    /// [`MotorFinderError::Request`] for a manufacturer not in [`MANUFACTURERS`], or a designation
120    /// that is empty, all dots, or holds a character other than an ASCII letter, a digit, `-`,
121    /// `_`, `.` or `/` (the characters ThrustCurve's designations use).
122    pub fn motor(manufacturer: &str, designation: &str) -> Result<Self, MotorFinderError> {
123        let manufacturer_slug = manufacturer_slug(manufacturer)?;
124        let allowed = |c: char| c.is_ascii_alphanumeric() || "-_./".contains(c);
125        if designation.is_empty()
126            || designation.chars().all(|c| c == '.')
127            || !designation.chars().all(allowed)
128        {
129            return Err(MotorFinderError::Request {
130                what: "designation",
131                value: designation.to_owned(),
132            });
133        }
134        Ok(Self::Motor(MotorName {
135            manufacturer_slug,
136            designation: designation.to_owned(),
137        }))
138    }
139
140    /// The file's URL under [`BASE_URL`]. A `/` in a motor's designation is written `~`, as the
141    /// API names the file.
142    #[must_use]
143    pub fn url(&self) -> String {
144        match self {
145            Self::Meta => format!("{BASE_URL}/meta.json"),
146            Self::Motors => format!("{BASE_URL}/motors.json"),
147            Self::InStock => format!("{BASE_URL}/in-stock.json"),
148            Self::Vendors => format!("{BASE_URL}/vendors.json"),
149            Self::Motor(name) => format!(
150                "{BASE_URL}/motors/{}/{}.json",
151                name.manufacturer_slug,
152                name.designation.replace('/', "~")
153            ),
154        }
155    }
156
157    /// The [`Source`] a [`Client`] caches every file under: the motor finder, its
158    /// [`ATTRIBUTION`], and [`TTL_S`].
159    #[must_use]
160    pub fn source() -> Source {
161        Source {
162            name: "motor.fusionspace.co".to_owned(),
163            attribution: ATTRIBUTION.to_owned(),
164            ttl_s: TTL_S,
165        }
166    }
167}
168
169/// `meta.json`: when the data was built, and how much there is.
170#[non_exhaustive]
171#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
172pub struct Meta {
173    /// The schema version, [`SCHEMA_VERSION`].
174    pub schema_version: u32,
175    /// When the files were built, ISO 8601 UTC (`2026-10-01T07:07:29+00:00`).
176    pub generated_at: String,
177    /// How many motors, motors in stock and vendors the files hold.
178    pub counts: Counts,
179    /// The manufacturers' names, as motors write them.
180    pub manufacturers: Vec<String>,
181    /// The endpoint index: each file's name and its path on the site.
182    pub endpoints: BTreeMap<String, String>,
183    /// Where the API is documented.
184    pub docs: Option<String>,
185    /// The terms, in the API's words.
186    pub license: Option<String>,
187    /// Notes on how the API is served.
188    pub notes: Option<String>,
189}
190
191/// [`Meta`]'s counts.
192#[non_exhaustive]
193#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
194pub struct Counts {
195    /// Motors in `motors.json`.
196    pub motors: u32,
197    /// Motors in `in-stock.json`.
198    pub in_stock: u32,
199    /// Vendors in `vendors.json`.
200    pub vendors: u32,
201}
202
203/// `motors.json` or `in-stock.json`.
204#[non_exhaustive]
205#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
206pub struct MotorList {
207    /// The schema version, [`SCHEMA_VERSION`].
208    pub schema_version: u32,
209    /// When the file was built, ISO 8601 UTC.
210    pub generated_at: String,
211    /// How many motors it holds.
212    pub count: u32,
213    /// The motors.
214    pub motors: Vec<Motor>,
215}
216
217/// One motor's page, `motors/{manufacturer}/{designation}.json`.
218#[non_exhaustive]
219#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
220pub struct MotorPage {
221    /// The schema version, [`SCHEMA_VERSION`].
222    pub schema_version: u32,
223    /// When the file was built, ISO 8601 UTC.
224    pub generated_at: String,
225    /// The motor.
226    pub motor: Motor,
227}
228
229/// A motor, with every vendor's listing of it.
230#[non_exhaustive]
231#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
232pub struct Motor {
233    /// The finder's own id for the motor, stable across builds; not ThrustCurve's.
234    pub id: u64,
235    /// The path of the motor's own page on the site.
236    pub path: String,
237    /// The manufacturer's name, one of [`MANUFACTURERS`] today.
238    pub manufacturer: String,
239    /// The designation, as ThrustCurve spells it (`H128W`, `3683L851-P`).
240    pub designation: String,
241    /// The designation without its propellant code (`H128`).
242    pub common_name: Option<String>,
243    /// The impulse class, one letter (`D` to `O` today).
244    pub impulse_class: String,
245    /// The motor's diameter, mm.
246    pub diameter_mm: f64,
247    /// Total impulse, N·s.
248    pub total_impulse_ns: Option<f64>,
249    /// Average thrust, N.
250    pub avg_thrust_n: Option<f64>,
251    /// Burn time, s.
252    pub burn_time_s: Option<f64>,
253    /// The propellant's trade name (`White Lightning`).
254    pub propellant: Option<String>,
255    /// Whether the propellant throws sparks (a metal additive).
256    pub sparky: bool,
257    /// A reload, a single-use motor or a hybrid.
258    pub motor_type: Option<MotorType>,
259    /// The reload hardware it fits (`RMS-29/180`); none for a single-use motor.
260    pub case_info: Option<String>,
261    /// Whether it ships as hazardous material, as the site labels it.
262    pub hazmat: Hazmat,
263    /// The delays it comes with, s, comma-separated, or `P` for plugged.
264    pub delays: Option<String>,
265    /// Whether the delay can be shortened by the flyer.
266    pub delay_adjustable: bool,
267    /// Out of production: old stock only.
268    pub discontinued: bool,
269    /// In stock at one vendor or more.
270    pub in_stock: bool,
271    /// How many vendors list it, in stock or not.
272    pub vendor_count: u32,
273    /// How many vendors have it in stock.
274    pub in_stock_vendor_count: u32,
275    /// How many listings it has: a vendor may list several (delays, packs).
276    pub listing_count: u32,
277    /// The in-stock listing with the lowest unit price; `None` exactly when out of stock.
278    pub cheapest_in_stock: Option<Offer>,
279    /// Every listing.
280    pub listings: Vec<Listing>,
281}
282
283/// How a motor is built.
284#[non_exhaustive]
285#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
286pub enum MotorType {
287    /// Reloadable: a propellant kit for reusable hardware.
288    #[serde(rename = "reload")]
289    Reload,
290    /// Single-use.
291    #[serde(rename = "SU")]
292    SingleUse,
293    /// Hybrid.
294    #[serde(rename = "hybrid")]
295    Hybrid,
296}
297
298/// Whether a motor ships as hazardous material (U.S. DOT), as the site labels it from the
299/// propellant's weight. Its API describes the labels as "required (>62.5g or H+), varies (F/G near
300/// the limit — vendor-dependent), none (<=62.5g, A-E)". On the 2026-10-01 recording, every D and
301/// E motor is `NotRequired` and every H and up `Required`; F and G motors are `Varies` (75) or
302/// `Required` (29).
303#[non_exhaustive]
304#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
305pub enum Hazmat {
306    /// It ships as hazardous material: over 62.5 g of propellant, or H class and up.
307    #[serde(rename = "required")]
308    Required,
309    /// It depends on the vendor: F and G motors near the limit.
310    #[serde(rename = "varies")]
311    Varies,
312    /// It doesn't: 62.5 g of propellant or less, A to E.
313    #[serde(rename = "none")]
314    NotRequired,
315}
316
317/// The cheapest in-stock offer of a motor: the in-stock listing with the lowest unit price. The
318/// API's schema lets its prices be null; no recorded offer has them so.
319#[non_exhaustive]
320#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
321pub struct Offer {
322    /// The sticker price, cents; `None` when the vendor's page shows none.
323    pub price_cents: Option<u64>,
324    /// The price of one motor, cents: the sticker price over the pack size.
325    pub unit_price_cents: Option<u64>,
326    /// The currency (`USD`).
327    pub currency: String,
328    /// The vendor's name.
329    pub vendor: String,
330    /// The vendor's slug, as [`Vendor::slug`].
331    pub vendor_slug: String,
332    /// The product page.
333    pub url: String,
334    /// How many motors the pack holds.
335    pub pack_size: u32,
336}
337
338/// One vendor's listing of a motor.
339#[non_exhaustive]
340#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
341pub struct Listing {
342    /// The vendor's name.
343    pub vendor: String,
344    /// The vendor's slug, as [`Vendor::slug`].
345    pub vendor_slug: String,
346    /// The product page.
347    pub url: String,
348    /// In stock, out of stock, special order or unknown.
349    pub status: ListingStatus,
350    /// The sticker price, cents.
351    pub price_cents: Option<u64>,
352    /// The price of one motor, cents: the sticker price over the pack size.
353    pub unit_price_cents: Option<u64>,
354    /// The currency (`USD`).
355    pub currency: String,
356    /// How many motors the pack holds.
357    pub pack_size: u32,
358    /// Units on hand, when the vendor shows them, as the vendor shows them.
359    pub stock_count: Option<i64>,
360    /// The wait on a back order (`16–20 weeks`).
361    pub lead_time: Option<String>,
362    /// When the finder last read the listing, ISO 8601 UTC.
363    pub last_seen: String,
364}
365
366/// A listing's stock. A status the API adds later reads as [`ListingStatus::Unknown`], so one
367/// new word doesn't refuse the whole list.
368#[non_exhaustive]
369#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
370#[serde(rename_all = "snake_case")]
371pub enum ListingStatus {
372    /// In stock, whether or not a count is shown.
373    InStock,
374    /// Out of stock.
375    OutOfStock,
376    /// Made or ordered for the buyer, with a lead time.
377    SpecialOrder,
378    /// The vendor's page doesn't say, or the API gave a status this module doesn't know.
379    #[serde(other)]
380    Unknown,
381}
382
383/// `vendors.json`.
384#[non_exhaustive]
385#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
386pub struct VendorList {
387    /// The schema version, [`SCHEMA_VERSION`].
388    pub schema_version: u32,
389    /// When the file was built, ISO 8601 UTC.
390    pub generated_at: String,
391    /// How many vendors it holds.
392    pub count: u32,
393    /// The vendors.
394    pub vendors: Vec<Vendor>,
395}
396
397/// A vendor the finder reads.
398#[non_exhaustive]
399#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
400pub struct Vendor {
401    /// Its slug, as listings name it (`csrocketry`).
402    pub slug: String,
403    /// Its name (`Chris' Rocket Supplies`).
404    pub name: String,
405    /// How many motors it lists.
406    pub motor_count: u32,
407    /// How many of them it has in stock.
408    pub in_stock_count: u32,
409}
410
411/// Reads `meta.json`.
412///
413/// # Errors
414/// [`MotorFinderError::Json`] when the body is not JSON of this shape; [`MotorFinderError::Schema`]
415/// for another schema version; [`MotorFinderError::Field`] for a `generated_at` that isn't an
416/// ISO 8601 UTC time.
417pub fn parse_meta(body: &[u8]) -> Result<Meta, MotorFinderError> {
418    let meta: Meta = json(body)?;
419    header(meta.schema_version, &meta.generated_at)?;
420    Ok(meta)
421}
422
423/// Reads `motors.json`.
424///
425/// # Errors
426/// As [`parse_meta`]; [`MotorFinderError::Count`] when `count` disagrees with the list; and
427/// [`MotorFinderError::Field`] for a motor or listing that breaks the API's rules: an impulse
428/// class that isn't one capital letter, a diameter, impulse, thrust or burn time below zero (or a
429/// diameter of zero), a `listing_count` other than its listings', a cheapest offer on a motor out
430/// of stock or none on one in stock, a pack of no motors, or a unit price over its sticker price.
431pub fn parse_motors(body: &[u8]) -> Result<MotorList, MotorFinderError> {
432    let list: MotorList = json(body)?;
433    header(list.schema_version, &list.generated_at)?;
434    let found = list.motors.len();
435    if usize::try_from(list.count).ok() != Some(found) {
436        return Err(MotorFinderError::Count {
437            expected: list.count,
438            found,
439        });
440    }
441    for (i, motor) in list.motors.iter().enumerate() {
442        check_motor(motor, &format!("motors[{i}]"))?;
443    }
444    Ok(list)
445}
446
447/// Reads `in-stock.json`: as [`parse_motors`], and every motor must be in stock.
448///
449/// # Errors
450/// As [`parse_motors`], and [`MotorFinderError::Field`] naming a motor out of stock.
451pub fn parse_in_stock(body: &[u8]) -> Result<MotorList, MotorFinderError> {
452    let list = parse_motors(body)?;
453    if let Some(i) = list.motors.iter().position(|m| !m.in_stock) {
454        return Err(field(format!("motors[{i}].in_stock"), "false"));
455    }
456    Ok(list)
457}
458
459/// Reads `vendors.json`.
460///
461/// # Errors
462/// As [`parse_meta`], and [`MotorFinderError::Count`] when `count` disagrees with the list.
463pub fn parse_vendors(body: &[u8]) -> Result<VendorList, MotorFinderError> {
464    let list: VendorList = json(body)?;
465    header(list.schema_version, &list.generated_at)?;
466    let found = list.vendors.len();
467    if usize::try_from(list.count).ok() != Some(found) {
468        return Err(MotorFinderError::Count {
469            expected: list.count,
470            found,
471        });
472    }
473    Ok(list)
474}
475
476/// Reads one motor's page.
477///
478/// # Errors
479/// As [`parse_meta`], and [`MotorFinderError::Field`] for a motor that breaks the rules
480/// [`parse_motors`] lists.
481pub fn parse_motor(body: &[u8]) -> Result<MotorPage, MotorFinderError> {
482    let page: MotorPage = json(body)?;
483    header(page.schema_version, &page.generated_at)?;
484    check_motor(&page.motor, "motor")?;
485    Ok(page)
486}
487
488/// Fetches `meta.json` through `client`.
489///
490/// The answer comes from the client's cache while fresh (see [`Endpoint::source`]); offline,
491/// from the cache only. The [`Fetched`] says which, carries [`ATTRIBUTION`] and holds the body.
492/// Only an answer that [`parse_meta`] reads is cached ([`Client::fetch_checked`]): one that
493/// doesn't never takes a good copy's place, and online a stale good copy is returned instead,
494/// with the reason. The other `fetch_` functions work the same way.
495///
496/// # Errors
497/// [`MotorFinderError::Net`] when the fetch fails, or with [`NetError::Refused`] naming what the
498/// parser refused when the only answer there is doesn't parse.
499pub fn fetch_meta<T: Transport>(
500    client: &Client<T>,
501    now_s: u64,
502) -> Result<(Meta, Fetched), MotorFinderError> {
503    fetch(client, &Endpoint::Meta, now_s, parse_meta)
504}
505
506/// Fetches `motors.json` through `client`, as [`fetch_meta`] does.
507///
508/// # Errors
509/// As [`fetch_meta`].
510pub fn fetch_motors<T: Transport>(
511    client: &Client<T>,
512    now_s: u64,
513) -> Result<(MotorList, Fetched), MotorFinderError> {
514    fetch(client, &Endpoint::Motors, now_s, parse_motors)
515}
516
517/// Fetches `in-stock.json` through `client`, as [`fetch_meta`] does.
518///
519/// # Errors
520/// As [`fetch_meta`].
521pub fn fetch_in_stock<T: Transport>(
522    client: &Client<T>,
523    now_s: u64,
524) -> Result<(MotorList, Fetched), MotorFinderError> {
525    fetch(client, &Endpoint::InStock, now_s, parse_in_stock)
526}
527
528/// Fetches `vendors.json` through `client`, as [`fetch_meta`] does.
529///
530/// # Errors
531/// As [`fetch_meta`].
532pub fn fetch_vendors<T: Transport>(
533    client: &Client<T>,
534    now_s: u64,
535) -> Result<(VendorList, Fetched), MotorFinderError> {
536    fetch(client, &Endpoint::Vendors, now_s, parse_vendors)
537}
538
539/// Fetches one motor's page through `client`, as [`fetch_meta`] does: the motor, and when its
540/// page was built. A page for another motor than the one asked for is refused, and never cached.
541///
542/// # Errors
543/// [`MotorFinderError::Request`] for a bad manufacturer or designation ([`Endpoint::motor`]); as
544/// [`fetch_meta`] otherwise. An unknown motor is the site's "not found" page, which `Http`
545/// reports as [`NetError::Transport`] naming status 404.
546pub fn fetch_motor<T: Transport>(
547    client: &Client<T>,
548    manufacturer: &str,
549    designation: &str,
550    now_s: u64,
551) -> Result<(MotorPage, Fetched), MotorFinderError> {
552    let endpoint = Endpoint::motor(manufacturer, designation)?;
553    let asked_slug = manufacturer_slug(manufacturer)?;
554    let read = |body: &[u8]| {
555        let page = parse_motor(body)?;
556        let slug = MANUFACTURERS
557            .iter()
558            .find(|(name, _)| *name == page.motor.manufacturer)
559            .map(|&(_, slug)| slug);
560        if slug != Some(asked_slug) || page.motor.designation != designation {
561            return Err(MotorFinderError::OtherMotor {
562                asked: format!("{asked_slug}/{designation}"),
563                found: format!("{}/{}", page.motor.manufacturer, page.motor.designation),
564            });
565        }
566        Ok(page)
567    };
568    fetch(client, &endpoint, now_s, read)
569}
570
571/// The slug of a manufacturer in [`MANUFACTURERS`], by its name or slug, any case.
572fn manufacturer_slug(manufacturer: &str) -> Result<&'static str, MotorFinderError> {
573    MANUFACTURERS
574        .iter()
575        .find(|(name, slug)| {
576            manufacturer.eq_ignore_ascii_case(name) || manufacturer.eq_ignore_ascii_case(slug)
577        })
578        .map(|&(_, slug)| slug)
579        .ok_or_else(|| MotorFinderError::Request {
580            what: "manufacturer",
581            value: manufacturer.to_owned(),
582        })
583}
584
585/// Fetches `endpoint` through `client`, caching only an answer `read` reads.
586fn fetch<T: Transport, R>(
587    client: &Client<T>,
588    endpoint: &Endpoint,
589    now_s: u64,
590    read: impl Fn(&[u8]) -> Result<R, MotorFinderError>,
591) -> Result<(R, Fetched), MotorFinderError> {
592    let check = |body: &[u8]| read(body).map(drop).map_err(|e| e.to_string());
593    let fetched = client.fetch_checked(&Endpoint::source(), &endpoint.url(), now_s, check)?;
594    let value = read(&fetched.body)?;
595    Ok((value, fetched))
596}
597
598/// Seconds since the Unix epoch of an ISO 8601 UTC time as the API writes it,
599/// `YYYY-MM-DDTHH:MM:SS` followed by `Z` or `+00:00`, with optional fractional seconds dropped;
600/// `None` for anything else.
601#[must_use]
602pub fn unix_s(timestamp: &str) -> Option<i64> {
603    let rest = timestamp
604        .strip_suffix('Z')
605        .or_else(|| timestamp.strip_suffix("+00:00"))?;
606    let (date, time) = rest.split_once('T')?;
607    let time = time.split_once('.').map_or(time, |(whole, fraction)| {
608        if !fraction.is_empty() && fraction.bytes().all(|b| b.is_ascii_digit()) {
609            whole
610        } else {
611            ""
612        }
613    });
614    let number = |text: &str, digits: usize| -> Option<i64> {
615        (text.len() == digits && text.bytes().all(|b| b.is_ascii_digit()))
616            .then(|| text.parse().ok())
617            .flatten()
618    };
619    let mut parts = date.split('-');
620    let (year, month, day) = (
621        number(parts.next()?, 4)?,
622        number(parts.next()?, 2)?,
623        number(parts.next()?, 2)?,
624    );
625    let mut parts = time.split(':');
626    let (hour, minute, second) = (
627        number(parts.next()?, 2)?,
628        number(parts.next()?, 2)?,
629        number(parts.next()?, 2)?,
630    );
631    if parts.next().is_some() || date.split('-').count() != 3 {
632        return None;
633    }
634    if hour > 23 || minute > 59 || second > 59 {
635        return None;
636    }
637    let start = crate::civil::unix_day_start(year, month, day)?;
638    Some(start + hour * 3_600 + minute * 60 + second)
639}
640
641/// Deserializes `body`.
642fn json<'a, D: Deserialize<'a>>(body: &'a [u8]) -> Result<D, MotorFinderError> {
643    serde_json::from_slice(body).map_err(|e| MotorFinderError::Json(e.to_string()))
644}
645
646/// Checks the schema version and the build time every file carries.
647fn header(schema_version: u32, generated_at: &str) -> Result<(), MotorFinderError> {
648    if schema_version != SCHEMA_VERSION {
649        return Err(MotorFinderError::Schema {
650            found: schema_version,
651        });
652    }
653    if unix_s(generated_at).is_none() {
654        return Err(field("generated_at".to_owned(), generated_at));
655    }
656    Ok(())
657}
658
659/// A [`MotorFinderError::Field`].
660fn field(field: String, value: impl ToString) -> MotorFinderError {
661    MotorFinderError::Field {
662        field,
663        value: value.to_string(),
664    }
665}
666
667/// Checks a motor against the API's rules; `at` names it in an error (`motors[12]`).
668fn check_motor(motor: &Motor, at: &str) -> Result<(), MotorFinderError> {
669    let class = motor.impulse_class.as_bytes();
670    if !(class.len() == 1 && class[0].is_ascii_uppercase()) {
671        return Err(field(format!("{at}.impulse_class"), &motor.impulse_class));
672    }
673    if motor.diameter_mm <= 0.0 {
674        return Err(field(format!("{at}.diameter_mm"), motor.diameter_mm));
675    }
676    let figures = [
677        ("total_impulse_ns", motor.total_impulse_ns),
678        ("avg_thrust_n", motor.avg_thrust_n),
679        ("burn_time_s", motor.burn_time_s),
680    ];
681    for (name, value) in figures {
682        if let Some(value) = value.filter(|v| *v < 0.0) {
683            return Err(field(format!("{at}.{name}"), value));
684        }
685    }
686    if usize::try_from(motor.listing_count).ok() != Some(motor.listings.len()) {
687        let value = format!(
688            "{} for {} listings",
689            motor.listing_count,
690            motor.listings.len()
691        );
692        return Err(field(format!("{at}.listing_count"), value));
693    }
694    if motor.cheapest_in_stock.is_some() != motor.in_stock {
695        let value = format!(
696            "in_stock {} beside {:?}",
697            motor.in_stock, motor.cheapest_in_stock
698        );
699        return Err(field(format!("{at}.cheapest_in_stock"), value));
700    }
701    if let Some(offer) = &motor.cheapest_in_stock {
702        let at = format!("{at}.cheapest_in_stock");
703        check_price(
704            &at,
705            offer.price_cents,
706            offer.unit_price_cents,
707            offer.pack_size,
708        )?;
709    }
710    for (j, listing) in motor.listings.iter().enumerate() {
711        let at = format!("{at}.listings[{j}]");
712        check_price(
713            &at,
714            listing.price_cents,
715            listing.unit_price_cents,
716            listing.pack_size,
717        )?;
718    }
719    Ok(())
720}
721
722/// Checks a price: a pack of one motor or more, and a unit price no more than the sticker price.
723fn check_price(
724    at: &str,
725    price_cents: Option<u64>,
726    unit_price_cents: Option<u64>,
727    pack_size: u32,
728) -> Result<(), MotorFinderError> {
729    if pack_size == 0 {
730        return Err(field(format!("{at}.pack_size"), pack_size));
731    }
732    if let (Some(price), Some(unit)) = (price_cents, unit_price_cents)
733        && unit > price
734    {
735        let value = format!("{unit} over a price of {price}");
736        return Err(field(format!("{at}.unit_price_cents"), value));
737    }
738    Ok(())
739}
740
741/// Why a motor finder request or answer was refused.
742#[non_exhaustive]
743#[derive(Debug, thiserror::Error)]
744pub enum MotorFinderError {
745    /// A request field is not one the API serves.
746    #[error("the motor finder request's {what} is not one it serves: {value:?}")]
747    Request {
748        /// The field.
749        what: &'static str,
750        /// Its value.
751        value: String,
752    },
753    /// The fetch failed.
754    #[error(transparent)]
755    Net(#[from] NetError),
756    /// The body is not JSON of the expected shape: a field missing, or of the wrong type.
757    #[error("the motor finder answer is not of the expected shape: {0}")]
758    Json(String),
759    /// The answer is of another schema version than [`SCHEMA_VERSION`].
760    #[error("the motor finder answer is schema version {found}, not {SCHEMA_VERSION}")]
761    Schema {
762        /// The version found.
763        found: u32,
764    },
765    /// A list's `count` disagrees with its length.
766    #[error("the motor finder answer counts {expected} but lists {found}")]
767    Count {
768        /// The `count` stated.
769        expected: u32,
770        /// The entries listed.
771        found: usize,
772    },
773    /// A field breaks the API's rules.
774    #[error("the motor finder answer's {field} is not usable: {value}")]
775    Field {
776        /// Where it is (`motors[12].listings[3].pack_size`).
777        field: String,
778        /// Its value.
779        value: String,
780    },
781    /// A motor's page is another motor's.
782    #[error("asked the motor finder for {asked}, but its page is {found}'s")]
783    OtherMotor {
784        /// The manufacturer's slug and designation asked for.
785        asked: String,
786        /// The manufacturer and designation the page holds.
787        found: String,
788    },
789}
790
791#[cfg(test)]
792mod tests {
793    use super::*;
794
795    #[test]
796    fn urls_follow_the_apis_names() {
797        assert_eq!(
798            Endpoint::InStock.url(),
799            "https://motor.fusionspace.co/api/v1/in-stock.json"
800        );
801        let f27 = Endpoint::motor("AeroTech", "F27R/L").unwrap();
802        assert_eq!(
803            f27.url(),
804            "https://motor.fusionspace.co/api/v1/motors/aerotech/F27R~L.json"
805        );
806        let cti = Endpoint::motor("CESARONI", "3683L851-P").unwrap();
807        assert!(cti.url().ends_with("/motors/cesaroni/3683L851-P.json"));
808        let loki = Endpoint::motor("Loki Research", "D2.3T").unwrap();
809        assert!(loki.url().ends_with("/motors/loki/D2.3T.json"));
810    }
811
812    #[test]
813    fn requests_outside_the_apis_files_are_refused() {
814        let refusal = |manufacturer: &str, designation: &str| match Endpoint::motor(
815            manufacturer,
816            designation,
817        ) {
818            Err(MotorFinderError::Request { what, value }) => (what, value),
819            other => panic!("{manufacturer}/{designation}: {other:?}"),
820        };
821        assert_eq!(refusal("Estes", "C6"), ("manufacturer", "Estes".to_owned()));
822        for designation in [
823            "", ".", "..", "H128W?x", "H128W#x", "a b", "H128W%2F", "~", "é",
824        ] {
825            let expected = ("designation", designation.to_owned());
826            assert_eq!(refusal("aerotech", designation), expected);
827        }
828    }
829
830    #[test]
831    fn times_read_as_the_api_writes_them() {
832        assert_eq!(unix_s("2026-10-01T07:07:29+00:00"), Some(1_790_838_449));
833        assert_eq!(unix_s("2026-10-01T07:07:29Z"), Some(1_790_838_449));
834        assert_eq!(unix_s("2026-10-01T07:07:29.123Z"), Some(1_790_838_449));
835        assert_eq!(unix_s("1970-01-01T00:00:00Z"), Some(0));
836        for bad in [
837            "2026-10-01T07:07:29",
838            "2026-10-01T07:07:29+01:00",
839            "2026-10-01 07:07:29Z",
840            "2026-13-01T07:07:29Z",
841            "2026-02-30T07:07:29Z",
842            "2026-10-01T24:00:00Z",
843            "2026-10-01T07:60:00Z",
844            "2026-10-01T07:07:60Z",
845            "2026-10-01T07:07:29.Z",
846            "2026-10-01T07:07:29.1aZ",
847            "2026-10-01T07:07Z",
848            "2026-10-01T07:07:29:00Z",
849            "26-10-01T07:07:29Z",
850            "2026-10-1T07:07:29Z",
851            "+026-10-01T07:07:29Z",
852            "",
853        ] {
854            assert_eq!(unix_s(bad), None, "{bad}");
855        }
856    }
857}