Skip to main content

hpr_net/
thrustcurve.rs

1//! Motor records and thrust curves from [ThrustCurve.org](https://www.thrustcurve.org)'s API, and
2//! the join that gives a motor in stock its curve.
3//!
4//! ThrustCurve.org aims to hold every certified hobby motor's published figures and the simulator
5//! files (thrust curves) people have contributed for them. Its [API][api] answers two questions: a
6//! *search* ([`Search`], read by [`parse_search`]), which lists motor records, each with
7//! ThrustCurve's own id, its `motorId`; and a *download* ([`Download`], read by
8//! [`parse_download`]), which gives a motor's data files, each a RASP (`.eng`) or RockSim (`.rse`)
9//! file that [`DataFile::read`] hands to `hpr_motor`'s readers. The `fetch_` functions ask a
10//! [`Client`] for the answer, so it comes from the cache when it can, and offline from the cache
11//! only; an answer that doesn't parse is never cached. Motor records and curves change seldom, so
12//! a copy stays fresh for [`TTL_S`], a day. Show [`ATTRIBUTION`] (it is on every [`Fetched`])
13//! wherever the data is shown.
14//!
15//! [`join`] maps the motor finder's motors ([`crate::motor_finder`]) to ThrustCurve records. The
16//! finder carries no ThrustCurve id, but spells each designation as ThrustCurve does, so a motor
17//! maps when exactly one record has its manufacturer's full name and its designation, character
18//! for character. Anything else is a [`Miss`], with its reason, and [`Join::report`] lists them.
19//! [`fetch_finder_records`] fetches the records the join needs: one search per maker the finder
20//! reads.
21//!
22//! **How far to trust it:** a value is the answer's, unchanged (`tests/thrustcurve.rs` reads every
23//! field of every committed answer back: two recorded downloads, and three stand-in searches in
24//! the API's shape, as ThrustCurve grants no license for its records). A curve is the file a contributor uploaded, as
25//! ThrustCurve serves it, read by the same readers as a file on disk. The join is by name only: it
26//! doesn't compare impulse or diameter, so a finder motor whose designation ThrustCurve spells
27//! differently is a miss, not a wrong match. On ThrustCurve's answers of 2026-10-01 (recorded
28//! then, no longer committed), 282 of the finder's 282 motors in stock mapped, each to one record.
29//!
30//! ```
31//! use hpr_net::thrustcurve;
32//!
33//! // AeroTech's J450DM curve as ThrustCurve.org served it on 2026-10-01.
34//! let body = include_bytes!("../tests/fixtures/replay/thrustcurve-download-J450DM.json");
35//! let answer = thrustcurve::parse_download(body)?;
36//! let curve = answer.results[0].read()?.thrust_curve()?;
37//! // The file's 36 points, after the curve's start at zero thrust.
38//! assert_eq!((curve.times_s().len(), curve.end_time_s()), (37, 2.311));
39//! # Ok::<(), Box<dyn std::error::Error>>(())
40//! ```
41//!
42//! [api]: https://www.thrustcurve.org/info/api.html
43
44use std::collections::BTreeMap;
45use std::fmt::Write as _;
46
47use base64::Engine as _;
48use hpr_motor::text::Parsed;
49use hpr_motor::{MotorError, ThrustCurve, eng, rse};
50use serde::{Deserialize, Serialize};
51
52use crate::motor_finder::{self, MANUFACTURERS};
53use crate::{Client, Fetched, NetError, Source, Transport};
54
55/// The API's base URL, version 1 of its JSON endpoints.
56pub const BASE_URL: &str = "https://www.thrustcurve.org/api/v1";
57
58/// How long a cached answer counts as fresh, s: a day. Motor records and curves change seldom.
59pub const TTL_S: u64 = 86_400;
60
61/// The most records a search asks for. ThrustCurve's whole database held 1,156 motors on
62/// 2026-10-01; a search that matches more than it returns is refused by
63/// [`fetch_finder_records`].
64pub const MAX_RESULTS: u32 = 5_000;
65
66/// The credit for ThrustCurve.org's data.
67pub const ATTRIBUTION: &str = "Motor data and thrust curves courtesy of ThrustCurve.org, \
68                               https://www.thrustcurve.org/";
69
70/// A motor search. Every field set narrows it; the API joins them with "and". Start from
71/// [`Search::manufacturer`] or `Search::default()` and set the fields wanted: more may be added.
72#[non_exhaustive]
73#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
74pub struct Search {
75    /// The manufacturer's name or abbreviation (`AeroTech`, `Cesaroni Technology`, `Loki`).
76    pub manufacturer: Option<String>,
77    /// The manufacturer's designation (`J450DM`, `F27R/L`).
78    pub designation: Option<String>,
79    /// The common name (`J450`).
80    pub common_name: Option<String>,
81    /// The impulse class letter (`L`).
82    pub impulse_class: Option<String>,
83    /// The most records to return; the API's own default when `None`.
84    pub max_results: Option<u32>,
85}
86
87impl Search {
88    /// Every motor of one manufacturer, up to [`MAX_RESULTS`].
89    #[must_use]
90    pub fn manufacturer(name: &str) -> Self {
91        Self {
92            manufacturer: Some(name.to_owned()),
93            max_results: Some(MAX_RESULTS),
94            ..Self::default()
95        }
96    }
97
98    /// The search's URL under [`BASE_URL`]: its fields as query parameters, in the order of the
99    /// struct, each value percent-encoded; no query at all when no field is set.
100    #[must_use]
101    pub fn url(&self) -> String {
102        let max_results = self.max_results.map(|n| n.to_string());
103        let fields = [
104            ("manufacturer", self.manufacturer.as_deref()),
105            ("designation", self.designation.as_deref()),
106            ("commonName", self.common_name.as_deref()),
107            ("impulseClass", self.impulse_class.as_deref()),
108            ("maxResults", max_results.as_deref()),
109        ];
110        let query: Vec<String> = fields
111            .iter()
112            .filter_map(|(name, value)| Some(format!("{name}={}", encode(value.as_ref()?))))
113            .collect();
114        if query.is_empty() {
115            return format!("{BASE_URL}/search.json");
116        }
117        format!("{BASE_URL}/search.json?{}", query.join("&"))
118    }
119}
120
121/// A data file's format, as the API names it.
122#[non_exhaustive]
123#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
124pub enum Format {
125    /// RASP, a `.eng` file.
126    #[serde(rename = "RASP")]
127    Rasp,
128    /// RockSim, a `.rse` file.
129    #[serde(rename = "RockSim")]
130    RockSim,
131}
132
133impl Format {
134    /// The API's word for it.
135    #[must_use]
136    pub fn as_str(self) -> &'static str {
137        match self {
138            Self::Rasp => "RASP",
139            Self::RockSim => "RockSim",
140        }
141    }
142}
143
144/// A request for one motor's data files, in one format. Build it with [`Download::new`], which
145/// checks the motor id.
146#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
147pub struct Download {
148    motor_id: String,
149    format: Format,
150}
151
152impl Download {
153    /// A motor's data files in `format`, by its ThrustCurve id, in either case: it is kept in
154    /// lower case, as the API writes ids, so one motor has one URL and its answer matches.
155    ///
156    /// # Errors
157    /// [`ThrustCurveError::Request`] for an id that isn't 24 hexadecimal digits, the form the
158    /// API's ids take.
159    pub fn new(motor_id: &str, format: Format) -> Result<Self, ThrustCurveError> {
160        if !is_motor_id(motor_id) {
161            return Err(ThrustCurveError::Request {
162                what: "motor id",
163                value: motor_id.to_owned(),
164            });
165        }
166        Ok(Self {
167            motor_id: motor_id.to_ascii_lowercase(),
168            format,
169        })
170    }
171
172    /// The motor's ThrustCurve id, in lower case.
173    #[must_use]
174    pub fn motor_id(&self) -> &str {
175        &self.motor_id
176    }
177
178    /// The format asked for.
179    #[must_use]
180    pub fn format(&self) -> Format {
181        self.format
182    }
183
184    /// The request's URL under [`BASE_URL`], asking for the files themselves (`data=file`).
185    #[must_use]
186    pub fn url(&self) -> String {
187        format!(
188            "{BASE_URL}/download.json?motorId={}&format={}&data=file",
189            self.motor_id,
190            self.format.as_str()
191        )
192    }
193}
194
195/// The [`Source`] a [`Client`] caches every answer under: ThrustCurve.org, its [`ATTRIBUTION`],
196/// and [`TTL_S`].
197#[must_use]
198pub fn source() -> Source {
199    Source {
200        name: "ThrustCurve.org".to_owned(),
201        attribution: ATTRIBUTION.to_owned(),
202        ttl_s: TTL_S,
203    }
204}
205
206/// A search's answer.
207#[non_exhaustive]
208#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
209pub struct SearchAnswer {
210    /// Each criterion asked, with how many motors it matches alone.
211    pub criteria: Vec<Criterion>,
212    /// How many motors match every criterion: more than [`SearchAnswer::results`] holds when the
213    /// search's `maxResults` cut it short.
214    pub matches: u32,
215    /// The motors returned.
216    pub results: Vec<MotorRecord>,
217    /// The page of the site's own search.
218    #[serde(skip_serializing_if = "Option::is_none")]
219    pub source_url: Option<String>,
220}
221
222impl SearchAnswer {
223    /// Whether it holds every motor that matched.
224    #[must_use]
225    pub fn is_complete(&self) -> bool {
226        usize::try_from(self.matches).ok() == Some(self.results.len())
227    }
228}
229
230/// One search criterion, as the answer echoes it.
231#[non_exhaustive]
232#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
233pub struct Criterion {
234    /// Its name (`manufacturer`).
235    pub name: String,
236    /// Its value, as text.
237    pub value: String,
238    /// How many motors it matches alone.
239    pub matches: u32,
240}
241
242/// One motor's record. The API returns only the fields that have values, so all but the id,
243/// manufacturer and designation are optional. Units are the API's: newtons, newton-seconds,
244/// seconds, millimeters and grams.
245#[non_exhaustive]
246#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
247#[serde(rename_all = "camelCase")]
248pub struct MotorRecord {
249    /// ThrustCurve's id, 24 hexadecimal digits: what a [`Download`] names.
250    pub motor_id: String,
251    /// The manufacturer's full name (`Cesaroni Technology`).
252    pub manufacturer: String,
253    /// Its short name (`Cesaroni`).
254    #[serde(skip_serializing_if = "Option::is_none")]
255    pub manufacturer_abbrev: Option<String>,
256    /// The manufacturer's designation (`3683L851-P`).
257    pub designation: String,
258    /// The common name (`L851`).
259    #[serde(skip_serializing_if = "Option::is_none")]
260    pub common_name: Option<String>,
261    /// The impulse class letter.
262    #[serde(skip_serializing_if = "Option::is_none")]
263    pub impulse_class: Option<String>,
264    /// The certifying body's name.
265    #[serde(skip_serializing_if = "Option::is_none")]
266    pub cert_org: Option<String>,
267    /// Diameter, mm.
268    #[serde(rename = "diameter", skip_serializing_if = "Option::is_none")]
269    pub diameter_mm: Option<f64>,
270    /// Length, mm.
271    #[serde(rename = "length", skip_serializing_if = "Option::is_none")]
272    pub length_mm: Option<f64>,
273    /// `SU` (single use), `reload` or `hybrid`, as the API writes it.
274    #[serde(rename = "type", skip_serializing_if = "Option::is_none")]
275    pub motor_type: Option<String>,
276    /// Average thrust, N.
277    #[serde(rename = "avgThrustN", skip_serializing_if = "Option::is_none")]
278    pub avg_thrust_n: Option<f64>,
279    /// Peak thrust, N.
280    #[serde(rename = "maxThrustN", skip_serializing_if = "Option::is_none")]
281    pub max_thrust_n: Option<f64>,
282    /// Total impulse, N·s.
283    #[serde(rename = "totImpulseNs", skip_serializing_if = "Option::is_none")]
284    pub total_impulse_ns: Option<f64>,
285    /// Burn time, s.
286    #[serde(rename = "burnTimeS", skip_serializing_if = "Option::is_none")]
287    pub burn_time_s: Option<f64>,
288    /// How many data files (curves) it has.
289    #[serde(skip_serializing_if = "Option::is_none")]
290    pub data_files: Option<u32>,
291    /// The certification or manufacturer's page.
292    #[serde(skip_serializing_if = "Option::is_none")]
293    pub info_url: Option<String>,
294    /// Loaded weight, g.
295    #[serde(rename = "totalWeightG", skip_serializing_if = "Option::is_none")]
296    pub total_weight_g: Option<f64>,
297    /// Propellant weight, g.
298    #[serde(rename = "propWeightG", skip_serializing_if = "Option::is_none")]
299    pub propellant_weight_g: Option<f64>,
300    /// The delays offered, as the API writes them (`6,10,14`, `P` for a plugged motor).
301    #[serde(skip_serializing_if = "Option::is_none")]
302    pub delays: Option<String>,
303    /// Whether the delay is cut to length by the flier.
304    #[serde(skip_serializing_if = "Option::is_none")]
305    pub delay_adjustable: Option<bool>,
306    /// The reload's case (`RMS-54/852`).
307    #[serde(skip_serializing_if = "Option::is_none")]
308    pub case_info: Option<String>,
309    /// The propellant's name (`Dark Matter`).
310    #[serde(skip_serializing_if = "Option::is_none")]
311    pub prop_info: Option<String>,
312    /// Whether the exhaust throws sparks.
313    #[serde(skip_serializing_if = "Option::is_none")]
314    pub sparky: Option<bool>,
315    /// When the record last changed, `YYYY-MM-DD`.
316    #[serde(skip_serializing_if = "Option::is_none")]
317    pub updated_on: Option<String>,
318    /// `regular`, `occasional` or `OOP` (out of production), as the API writes it.
319    #[serde(skip_serializing_if = "Option::is_none")]
320    pub availability: Option<String>,
321    /// The motor's page on the site.
322    #[serde(rename = "source_url", skip_serializing_if = "Option::is_none")]
323    pub source_url: Option<String>,
324}
325
326/// A download's answer.
327#[non_exhaustive]
328#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
329pub struct DownloadAnswer {
330    /// The data files: none for a motor with none in the format asked, or an unknown id.
331    pub results: Vec<DataFile>,
332}
333
334/// One data file of a motor.
335#[non_exhaustive]
336#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
337#[serde(rename_all = "camelCase")]
338pub struct DataFile {
339    /// The motor's ThrustCurve id.
340    pub motor_id: String,
341    /// The file's own id.
342    pub simfile_id: String,
343    /// Its format.
344    pub format: Format,
345    /// Who measured it, as the API writes it: `cert` (a certification test), `mfr` (the
346    /// manufacturer) or `user`.
347    #[serde(skip_serializing_if = "Option::is_none")]
348    pub source: Option<String>,
349    /// Its license, as the API writes it: `PD` (public domain), `free` or `other`; `None` when the
350    /// contributor named none.
351    #[serde(skip_serializing_if = "Option::is_none")]
352    pub license: Option<String>,
353    /// The file, base64-encoded; [`DataFile::text`] decodes it.
354    pub data: String,
355    /// The file's page on the site, as a path (`/simfiles/{id}/`).
356    #[serde(skip_serializing_if = "Option::is_none")]
357    pub info_url: Option<String>,
358    /// Where the site serves the file itself, as a path.
359    #[serde(skip_serializing_if = "Option::is_none")]
360    pub data_url: Option<String>,
361    /// The file's page, as a full URL.
362    #[serde(rename = "source_url", skip_serializing_if = "Option::is_none")]
363    pub source_url: Option<String>,
364}
365
366/// A data file read by `hpr_motor`.
367#[non_exhaustive]
368#[derive(Debug, Clone, PartialEq)]
369pub enum Curve {
370    /// A RASP file, read by [`hpr_motor::eng::parse`].
371    Rasp(Parsed<eng::EngFile>),
372    /// A RockSim file, read by [`hpr_motor::rse::parse`].
373    RockSim(Parsed<rse::RseFile>),
374}
375
376impl Curve {
377    /// The first motor's thrust curve: a RASP file's first entry, a RockSim file's first engine.
378    ///
379    /// # Errors
380    /// [`MotorError`] when the curve breaks `hpr_motor`'s rules, or the file holds no motor.
381    pub fn thrust_curve(&self) -> Result<ThrustCurve, MotorError> {
382        let none = || MotorError::Inconsistent("the file holds no motor".to_owned());
383        match self {
384            Self::Rasp(parsed) => parsed
385                .value
386                .entries
387                .first()
388                .ok_or_else(none)?
389                .thrust_curve(),
390            Self::RockSim(parsed) => parsed
391                .value
392                .engines
393                .first()
394                .ok_or_else(none)?
395                .thrust_curve(),
396        }
397    }
398}
399
400impl DataFile {
401    /// The file's text: its data decoded from base64, as UTF-8.
402    ///
403    /// # Errors
404    /// [`ThrustCurveError::Field`] when the file isn't UTF-8, or (only in a file not read by
405    /// [`parse_download`], which refuses that) its data isn't base64. One file that isn't UTF-8
406    /// doesn't refuse the answer: the motor's other files stay readable.
407    pub fn text(&self) -> Result<String, ThrustCurveError> {
408        let bytes = base64::engine::general_purpose::STANDARD
409            .decode(&self.data)
410            .map_err(|e| field(format!("{}.data", self.simfile_id), e))?;
411        String::from_utf8(bytes)
412            .map_err(|e| field(format!("{}.data", self.simfile_id), e.utf8_error()))
413    }
414
415    /// The file read by `hpr_motor`'s reader for its format.
416    ///
417    /// # Errors
418    /// As [`DataFile::text`], and [`ThrustCurveError::Motor`] when the reader refuses the file.
419    pub fn read(&self) -> Result<Curve, ThrustCurveError> {
420        let text = self.text()?;
421        Ok(match self.format {
422            Format::Rasp => Curve::Rasp(eng::parse(&text)?),
423            Format::RockSim => Curve::RockSim(rse::parse(&text)?),
424        })
425    }
426}
427
428/// Reads a search's answer.
429///
430/// # Errors
431/// [`ThrustCurveError::Json`] when the body is not JSON of this shape; [`ThrustCurveError::Api`]
432/// when the answer or one of its criteria carries the API's `error`; [`ThrustCurveError::Count`]
433/// when it returns more motors than it says match; [`ThrustCurveError::Field`] for a motor id
434/// that isn't 24 hexadecimal digits, or a diameter, length, thrust, impulse, burn time or weight
435/// below zero.
436pub fn parse_search(body: &[u8]) -> Result<SearchAnswer, ThrustCurveError> {
437    #[derive(Deserialize)]
438    struct Errors {
439        error: Option<String>,
440        #[serde(default)]
441        criteria: Vec<CriterionError>,
442    }
443    #[derive(Deserialize)]
444    struct CriterionError {
445        name: String,
446        error: Option<String>,
447    }
448    let errors: Errors = json(body)?;
449    if let Some(error) = errors.error {
450        return Err(ThrustCurveError::Api(error));
451    }
452    if let Some(c) = errors.criteria.into_iter().find(|c| c.error.is_some()) {
453        let error = c.error.unwrap_or_default();
454        return Err(ThrustCurveError::Api(format!("{}: {error}", c.name)));
455    }
456    let answer: SearchAnswer = json(body)?;
457    let found = answer.results.len();
458    if usize::try_from(answer.matches).map_or(true, |m| found > m) {
459        return Err(ThrustCurveError::Count {
460            matches: answer.matches,
461            found,
462        });
463    }
464    for (i, motor) in answer.results.iter().enumerate() {
465        check_record(motor, &format!("results[{i}]"))?;
466    }
467    Ok(answer)
468}
469
470/// Reads a download's answer.
471///
472/// # Errors
473/// [`ThrustCurveError::Json`] when the body is not JSON of this shape; [`ThrustCurveError::Api`]
474/// when it carries the API's `error`; [`ThrustCurveError::Field`] for a motor id that isn't 24
475/// hexadecimal digits, or a file's data that isn't base64. A file that decodes but isn't UTF-8
476/// text is kept: [`DataFile::text`] reports it, for that file alone.
477pub fn parse_download(body: &[u8]) -> Result<DownloadAnswer, ThrustCurveError> {
478    #[derive(Deserialize)]
479    struct Errors {
480        error: Option<String>,
481    }
482    if let Some(error) = json::<Errors>(body)?.error {
483        return Err(ThrustCurveError::Api(error));
484    }
485    let answer: DownloadAnswer = json(body)?;
486    for (i, file) in answer.results.iter().enumerate() {
487        if !is_motor_id(&file.motor_id) {
488            return Err(field(format!("results[{i}].motorId"), &file.motor_id));
489        }
490        base64::engine::general_purpose::STANDARD
491            .decode(&file.data)
492            .map_err(|e| field(format!("results[{i}].data"), e))?;
493    }
494    Ok(answer)
495}
496
497/// Runs a search through `client`.
498///
499/// The answer comes from the client's cache while fresh (see [`source`]); offline, from the cache
500/// only. The [`Fetched`] says which, carries [`ATTRIBUTION`] and holds the body. Only an answer
501/// that [`parse_search`] reads is cached ([`Client::fetch_checked`]): one that doesn't never takes
502/// a good copy's place, and online a stale good copy is returned instead, with the reason.
503///
504/// # Errors
505/// [`ThrustCurveError::Net`] when the fetch fails, or with [`NetError::Refused`] naming what the
506/// parser refused when the only answer there is doesn't parse.
507pub fn fetch_search<T: Transport>(
508    client: &Client<T>,
509    search: &Search,
510    now_s: u64,
511) -> Result<(SearchAnswer, Fetched), ThrustCurveError> {
512    fetch(client, &search.url(), now_s, parse_search)
513}
514
515/// Fetches a motor's data files through `client`, as [`fetch_search`] does. An answer holding
516/// another motor's file, or a file in another format, is refused and never cached.
517///
518/// # Errors
519/// As [`fetch_search`]; with [`NetError::Refused`] naming [`ThrustCurveError::OtherMotor`] for a
520/// file of another motor or format than asked.
521pub fn fetch_download<T: Transport>(
522    client: &Client<T>,
523    download: &Download,
524    now_s: u64,
525) -> Result<(DownloadAnswer, Fetched), ThrustCurveError> {
526    let read = |body: &[u8]| {
527        let answer = parse_download(body)?;
528        let other = answer.results.iter().find(|f| {
529            !f.motor_id.eq_ignore_ascii_case(&download.motor_id) || f.format != download.format
530        });
531        if let Some(file) = other {
532            return Err(ThrustCurveError::OtherMotor {
533                asked: format!("{} ({})", download.motor_id, download.format.as_str()),
534                found: format!("{} ({})", file.motor_id, file.format.as_str()),
535            });
536        }
537        Ok(answer)
538    };
539    fetch(client, &download.url(), now_s, read)
540}
541
542/// Fetches every record of the makers the motor finder reads ([`MANUFACTURERS`]), one complete
543/// search each, as [`fetch_search`] does: the records [`join`] needs. A search that matches more
544/// motors than it returns, or holds another maker's record, is refused and never cached, so a
545/// join never runs on part of a maker's records.
546///
547/// # Errors
548/// As [`fetch_search`]; with [`NetError::Refused`] naming [`ThrustCurveError::Incomplete`] for a
549/// search cut short, or [`ThrustCurveError::OtherManufacturer`] for another maker's record.
550pub fn fetch_finder_records<T: Transport>(
551    client: &Client<T>,
552    now_s: u64,
553) -> Result<(Vec<MotorRecord>, Vec<Fetched>), ThrustCurveError> {
554    let mut records = Vec::new();
555    let mut fetched = Vec::new();
556    for &(name, _) in MANUFACTURERS {
557        let read = |body: &[u8]| {
558            let answer = parse_search(body)?;
559            if !answer.is_complete() {
560                return Err(ThrustCurveError::Incomplete {
561                    matches: answer.matches,
562                    found: answer.results.len(),
563                });
564            }
565            if let Some(other) = answer.results.iter().find(|r| r.manufacturer != name) {
566                return Err(ThrustCurveError::OtherManufacturer {
567                    asked: name.to_owned(),
568                    found: other.manufacturer.clone(),
569                });
570            }
571            Ok(answer)
572        };
573        let (answer, f) = fetch(client, &Search::manufacturer(name).url(), now_s, read)?;
574        records.extend(answer.results);
575        fetched.push(f);
576    }
577    Ok((records, fetched))
578}
579
580/// The motor finder's motors matched to ThrustCurve records, with the misses.
581#[non_exhaustive]
582#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
583pub struct Join {
584    /// The motors that mapped, in the finder's order.
585    pub mapped: Vec<Mapped>,
586    /// The motors that didn't, in the finder's order.
587    pub misses: Vec<Miss>,
588}
589
590/// A finder motor and the one ThrustCurve record with its name.
591#[non_exhaustive]
592#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
593pub struct Mapped {
594    /// The manufacturer's full name.
595    pub manufacturer: String,
596    /// The designation.
597    pub designation: String,
598    /// Where the motor is in the list given to [`join`].
599    pub finder_index: usize,
600    /// The record: its `motor_id` is what a [`Download`] names.
601    pub record: MotorRecord,
602}
603
604/// A finder motor that didn't map, and why.
605#[non_exhaustive]
606#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
607pub struct Miss {
608    /// The manufacturer's full name.
609    pub manufacturer: String,
610    /// The designation.
611    pub designation: String,
612    /// Why it didn't map.
613    pub reason: MissReason,
614}
615
616/// Why a finder motor didn't map.
617#[non_exhaustive]
618#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
619pub enum MissReason {
620    /// No record has its manufacturer and designation.
621    NoRecord,
622    /// Several do; their ids.
623    SeveralRecords(Vec<String>),
624}
625
626impl Join {
627    /// How many motors mapped, and how many were joined.
628    #[must_use]
629    pub fn coverage(&self) -> (usize, usize) {
630        (self.mapped.len(), self.mapped.len() + self.misses.len())
631    }
632
633    /// The join as Markdown: counts by manufacturer, among them the mapped motors whose record
634    /// lists no data file (`dataFiles` 0, or left out, as the API leaves out a field with no
635    /// value), and every miss with its reason.
636    #[must_use]
637    pub fn report(&self) -> String {
638        let mut by_maker: BTreeMap<&str, (usize, usize, usize)> = BTreeMap::new();
639        for m in &self.mapped {
640            let row = by_maker.entry(&m.manufacturer).or_default();
641            row.0 += 1;
642            row.1 += 1;
643            if m.record.data_files.unwrap_or(0) == 0 {
644                row.2 += 1;
645            }
646        }
647        for m in &self.misses {
648            by_maker.entry(&m.manufacturer).or_default().1 += 1;
649        }
650        let (mapped, total) = self.coverage();
651        let mut out = String::new();
652        // Writing to a String can't fail.
653        let _ = writeln!(
654            out,
655            "{mapped} of {total} motors mapped to one record each.\n"
656        );
657        let _ = writeln!(
658            out,
659            "| Manufacturer | Motors | Mapped | Mapped, no data file listed | Missed |"
660        );
661        let _ = writeln!(out, "|---|---:|---:|---:|---:|");
662        for (maker, (mapped, total, no_file)) in &by_maker {
663            let missed = total - mapped;
664            let _ = writeln!(
665                out,
666                "| {maker} | {total} | {mapped} | {no_file} | {missed} |"
667            );
668        }
669        let _ = writeln!(out);
670        if self.misses.is_empty() {
671            let _ = writeln!(out, "No misses.");
672        } else {
673            let _ = writeln!(out, "| Manufacturer | Designation | Why |");
674            let _ = writeln!(out, "|---|---|---|");
675            for m in &self.misses {
676                let why = match &m.reason {
677                    MissReason::NoRecord => "no record of that name".to_owned(),
678                    MissReason::SeveralRecords(ids) => {
679                        format!("{} records of that name: {}", ids.len(), ids.join(", "))
680                    }
681                };
682                let _ = writeln!(out, "| {} | `{}` | {why} |", m.manufacturer, m.designation);
683            }
684        }
685        out
686    }
687}
688
689/// Maps each finder motor to the one ThrustCurve record whose manufacturer (its full name) and
690/// designation equal the motor's, character for character. A motor with no such record, or
691/// several, is a [`Miss`]. A record given twice (the same `motor_id`, as when two searches
692/// overlap) counts once: the first copy is kept.
693#[must_use]
694pub fn join(motors: &[motor_finder::Motor], records: &[MotorRecord]) -> Join {
695    let mut by_name: BTreeMap<(&str, &str), Vec<&MotorRecord>> = BTreeMap::new();
696    for r in records {
697        let same_name = by_name
698            .entry((&r.manufacturer, &r.designation))
699            .or_default();
700        if same_name
701            .iter()
702            .all(|kept| !kept.motor_id.eq_ignore_ascii_case(&r.motor_id))
703        {
704            same_name.push(r);
705        }
706    }
707    let mut join = Join {
708        mapped: Vec::new(),
709        misses: Vec::new(),
710    };
711    for (finder_index, m) in motors.iter().enumerate() {
712        let found = by_name
713            .get(&(m.manufacturer.as_str(), m.designation.as_str()))
714            .map_or(&[][..], Vec::as_slice);
715        let miss = |reason| Miss {
716            manufacturer: m.manufacturer.clone(),
717            designation: m.designation.clone(),
718            reason,
719        };
720        match found {
721            [record] => join.mapped.push(Mapped {
722                manufacturer: m.manufacturer.clone(),
723                designation: m.designation.clone(),
724                finder_index,
725                record: (*record).clone(),
726            }),
727            [] => join.misses.push(miss(MissReason::NoRecord)),
728            several => join.misses.push(miss(MissReason::SeveralRecords(
729                several.iter().map(|r| r.motor_id.clone()).collect(),
730            ))),
731        }
732    }
733    join
734}
735
736/// Fetches `url` through `client`, caching only an answer `read` reads.
737fn fetch<T: Transport, R>(
738    client: &Client<T>,
739    url: &str,
740    now_s: u64,
741    read: impl Fn(&[u8]) -> Result<R, ThrustCurveError>,
742) -> Result<(R, Fetched), ThrustCurveError> {
743    let check = |body: &[u8]| read(body).map(drop).map_err(|e| e.to_string());
744    let fetched = client.fetch_checked(&source(), url, now_s, check)?;
745    let value = read(&fetched.body)?;
746    Ok((value, fetched))
747}
748
749/// Whether `id` has the form of a ThrustCurve id: 24 hexadecimal digits.
750fn is_motor_id(id: &str) -> bool {
751    id.len() == 24 && id.bytes().all(|b| b.is_ascii_hexdigit())
752}
753
754/// Percent-encodes a query value: every byte but an ASCII letter, digit, `-`, `.`, `_` or `~`
755/// (RFC 3986's unreserved characters) is written `%XX`.
756fn encode(value: &str) -> String {
757    let mut out = String::with_capacity(value.len());
758    for b in value.bytes() {
759        if b.is_ascii_alphanumeric() || b"-._~".contains(&b) {
760            out.push(char::from(b));
761        } else {
762            let _ = write!(out, "%{b:02X}");
763        }
764    }
765    out
766}
767
768/// Checks a record: a motor id of the API's form, and no figure below zero.
769fn check_record(motor: &MotorRecord, at: &str) -> Result<(), ThrustCurveError> {
770    if !is_motor_id(&motor.motor_id) {
771        return Err(field(format!("{at}.motorId"), &motor.motor_id));
772    }
773    let figures = [
774        ("diameter", motor.diameter_mm),
775        ("length", motor.length_mm),
776        ("avgThrustN", motor.avg_thrust_n),
777        ("maxThrustN", motor.max_thrust_n),
778        ("totImpulseNs", motor.total_impulse_ns),
779        ("burnTimeS", motor.burn_time_s),
780        ("totalWeightG", motor.total_weight_g),
781        ("propWeightG", motor.propellant_weight_g),
782    ];
783    for (name, value) in figures {
784        if let Some(value) = value.filter(|v| *v < 0.0) {
785            return Err(field(format!("{at}.{name}"), value));
786        }
787    }
788    Ok(())
789}
790
791/// Deserializes `body`.
792fn json<'a, D: Deserialize<'a>>(body: &'a [u8]) -> Result<D, ThrustCurveError> {
793    serde_json::from_slice(body).map_err(|e| ThrustCurveError::Json(e.to_string()))
794}
795
796/// A [`ThrustCurveError::Field`].
797fn field(field: String, value: impl ToString) -> ThrustCurveError {
798    ThrustCurveError::Field {
799        field,
800        value: value.to_string(),
801    }
802}
803
804/// Why a ThrustCurve request or answer was refused.
805#[non_exhaustive]
806#[derive(Debug, thiserror::Error)]
807pub enum ThrustCurveError {
808    /// A request field is not one the API serves.
809    #[error("the ThrustCurve request's {what} is not one it serves: {value:?}")]
810    Request {
811        /// The field.
812        what: &'static str,
813        /// Its value.
814        value: String,
815    },
816    /// The fetch failed.
817    #[error(transparent)]
818    Net(#[from] NetError),
819    /// The body is not JSON of the expected shape: a field missing, or of the wrong type.
820    #[error("the ThrustCurve answer is not of the expected shape: {0}")]
821    Json(String),
822    /// The answer carries the API's `error`, or one of its criteria does.
823    #[error("ThrustCurve refused the request: {0}")]
824    Api(String),
825    /// A search returns more motors than it says match.
826    #[error("the ThrustCurve search says {matches} motors match but returns {found}")]
827    Count {
828        /// The `matches` stated.
829        matches: u32,
830        /// The motors returned.
831        found: usize,
832    },
833    /// A search returns fewer motors than match: its `maxResults` cut it short.
834    #[error("the ThrustCurve search matches {matches} motors but returns only {found}")]
835    Incomplete {
836        /// The `matches` stated.
837        matches: u32,
838        /// The motors returned.
839        found: usize,
840    },
841    /// A field breaks the API's rules.
842    #[error("the ThrustCurve answer's {field} is not usable: {value}")]
843    Field {
844        /// Where it is (`results[12].motorId`).
845        field: String,
846        /// Its value.
847        value: String,
848    },
849    /// A maker's search holds another maker's record.
850    #[error("asked ThrustCurve for {asked}'s motors, but the answer holds one of {found}'s")]
851    OtherManufacturer {
852        /// The maker asked for.
853        asked: String,
854        /// The maker found.
855        found: String,
856    },
857    /// A download holds a file of another motor or format than asked.
858    #[error("asked ThrustCurve for {asked}'s files, but the answer holds {found}'s")]
859    OtherMotor {
860        /// The motor id and format asked for.
861        asked: String,
862        /// The motor id and format found.
863        found: String,
864    },
865    /// `hpr_motor`'s reader refused a data file.
866    #[error(transparent)]
867    Motor(#[from] MotorError),
868}
869
870#[cfg(test)]
871mod tests {
872    use super::*;
873
874    #[test]
875    fn urls_follow_the_apis_names() {
876        assert_eq!(
877            Search::manufacturer("Cesaroni Technology").url(),
878            "https://www.thrustcurve.org/api/v1/search.json?manufacturer=Cesaroni%20Technology&maxResults=5000"
879        );
880        let search = Search {
881            designation: Some("F27R/L".to_owned()),
882            impulse_class: Some("F".to_owned()),
883            ..Search::default()
884        };
885        assert_eq!(
886            search.url(),
887            "https://www.thrustcurve.org/api/v1/search.json?designation=F27R%2FL&impulseClass=F"
888        );
889        assert_eq!(
890            Search::default().url(),
891            "https://www.thrustcurve.org/api/v1/search.json"
892        );
893        let download = Download::new("5f4294d2000231000000044f", Format::Rasp).unwrap();
894        assert_eq!(
895            download.url(),
896            "https://www.thrustcurve.org/api/v1/download.json?motorId=5f4294d2000231000000044f&format=RASP&data=file"
897        );
898    }
899
900    #[test]
901    fn a_motor_id_must_be_24_hex_digits() {
902        for bad in [
903            "",
904            "5f4294d2000231000000044",
905            "5f4294d2000231000000044f0",
906            "5f4294d2000231000000044g",
907            "5f4294d200023100000004&f",
908        ] {
909            let err = Download::new(bad, Format::Rasp).unwrap_err();
910            assert!(
911                matches!(&err, ThrustCurveError::Request { what: "motor id", value } if value == bad),
912                "{bad}: {err}"
913            );
914        }
915        let upper = Download::new("5F4294D2000231000000044F", Format::RockSim).unwrap();
916        assert_eq!(upper.motor_id(), "5f4294d2000231000000044f");
917        assert!(upper.url().contains("motorId=5f4294d2000231000000044f&"));
918    }
919
920    #[test]
921    fn query_values_are_percent_encoded() {
922        assert_eq!(encode("AZaz09-._~"), "AZaz09-._~");
923        assert_eq!(encode("a b/c&d=e?é"), "a%20b%2Fc%26d%3De%3F%C3%A9");
924    }
925}