//! FIT activity file encoder (REQUIREMENTS.md §5.8, FR-8). //! //! Records a ride to a crash-safe journal and turns it into a FIT activity file //! that Strava and Garmin Connect will accept. //! //! # Why this is hand-rolled //! //! RISK-3 in the requirements is accurate: the Rust ecosystem reads FIT far //! better than it writes it. There *is* a capable encoder on crates.io //! (`rustyfit`), but it was not the right dependency here: //! //! * The value it adds is the generated Garmin profile — several megabytes of //! message definitions — of which an activity file needs seven messages. The //! binary container underneath is about two hundred lines and is the part //! that decides whether an upload is accepted. //! * We have no Strava to test against, so correctness has to come from tests. //! Encoding with someone's crate and round-tripping through the same crate's //! decoder proves only self-consistency. Encoding with our own writer and //! decoding with an *independent* parser — `fitparser`, a dev-dependency — //! is a genuinely independent check, and it is the check this crate rests on. //! * When an upload is rejected, the fix is at the byte level. Owning those //! bytes is worth more here than saving a few hundred lines. //! //! The field numbers and enum values in [`profile`] were transcribed from the //! Garmin FIT SDK profile and cross-checked against `rustyfit`'s generated //! tables, so the SDK's knowledge is used — just not its code. //! //! # Shape of the crate //! //! ```text //! RideSnapshot --> Recorder --> raw journal (JSON Lines, flushed per sample) //! | //! v //! build_fit_from_log --> .fit //! ``` //! //! The FIT file cannot be written incrementally: its header carries a data size //! and its last two bytes are a CRC over everything before them, so a //! half-written FIT is a broken FIT. Crash safety therefore lives one level //! down, in the journal — see [`rawlog`]. A ride that ends in a crash is //! recovered by pointing [`build_fit_from_log`] at the journal, and the //! resulting file is byte-identical to the one a clean shutdown would have //! produced. //! //! # Example //! //! ```no_run //! use bikecontrol_fit::{Recorder, RecorderOptions}; //! # use bikecontrol_core::RideSnapshot; //! # fn demo(snapshots: &[RideSnapshot]) -> Result<(), bikecontrol_fit::FitError> { //! let mut rec = Recorder::create("rides/2026-08-05.jsonl", RecorderOptions::default())?; //! for snap in snapshots { //! rec.record(snap)?; //! } //! let summary = rec.finish("rides/2026-08-05.fit")?; //! # Ok(()) //! # } //! ``` #![warn(missing_docs)] use std::path::{Path, PathBuf}; pub mod builder; pub mod crc; pub mod encode; pub mod profile; pub mod rawlog; pub mod recorder; pub mod timestamp; pub use builder::{encode_activity, FitSummary}; pub use crc::crc16; pub use encode::{verify, VerifyError}; pub use rawlog::{parse_log, read_log, LogEntry, RawLog, Sample, SessionStart}; pub use recorder::{Recorder, RecorderOptions}; pub use timestamp::FIT_EPOCH_UNIX_SECS; /// Anything that can go wrong recording or encoding a ride. #[derive(Debug, thiserror::Error)] pub enum FitError { /// Reading or writing a file failed. #[error("i/o error on {path}: {source}")] Io { /// The file involved. path: PathBuf, /// The underlying error. #[source] source: std::io::Error, }, /// A journal line could not be serialised or deserialised. #[error("journal encoding error: {0}")] Json(#[from] serde_json::Error), /// The journal has no `start` header, so elapsed times cannot be anchored /// to the wall clock. #[error("raw log has no session start entry")] MissingSessionStart, /// The journal contains no telemetry. An activity with no records is /// rejected by every uploader, so it is refused here instead. #[error("raw log contains no samples; nothing to encode")] NoSamples, /// A timestamp lies outside the FIT `date_time` range — before /// 1989-12-31 UTC, or beyond 2158. #[error("timestamp {unix_secs} is outside the FIT date_time range (1989-12-31 onwards)")] TimestampOutOfRange { /// The offending Unix timestamp, in seconds. unix_secs: i64, }, } /// Build a FIT activity from a raw journal and write it to `fit_path`. /// /// This is the crash-recovery entry point (FR-8.4): point it at a journal left /// behind by a ride that ended badly and it produces the activity that ride /// should have exported. It is also what [`Recorder::finish`] calls, so the two /// paths cannot drift apart. /// /// The encoded file is verified — header, declared data size, both CRCs — /// before it is written, so a file that reaches disk is structurally sound. pub fn build_fit_from_log( log_path: impl AsRef, fit_path: impl AsRef, ) -> Result { let log = read_log(log_path.as_ref())?; let (bytes, summary) = encode_activity(&log)?; debug_assert!( verify(&bytes).is_ok(), "encoder produced a structurally invalid FIT file: {:?}", verify(&bytes) ); let fit_path = fit_path.as_ref(); if let Some(parent) = fit_path.parent() { if !parent.as_os_str().is_empty() { std::fs::create_dir_all(parent).map_err(|source| FitError::Io { path: parent.to_path_buf(), source, })?; } } std::fs::write(fit_path, &bytes).map_err(|source| FitError::Io { path: fit_path.to_path_buf(), source, })?; Ok(summary) } /// Build a FIT activity from a raw journal and return the bytes without /// touching the filesystem. pub fn fit_bytes_from_log(log_path: impl AsRef) -> Result<(Vec, FitSummary), FitError> { let log = read_log(log_path.as_ref())?; encode_activity(&log) }