Skip to main content

irontide_session/
save_path.rs

1#![allow(
2    clippy::cast_possible_truncation,
3    clippy::cast_possible_wrap,
4    clippy::cast_sign_loss,
5    reason = "M175: save-path token expansion — piece counts bounded by realistic torrent size"
6)]
7
8//! Save-path token expansion (M173 Lane A — Decision 5).
9//!
10//! Implements the locked five-token grammar that expands the on-disk
11//! `CategoryRegistry` `save_path` template into a concrete download
12//! directory at torrent-add time. The grammar is intentionally small and
13//! frozen: additions go through a separate plan (and a separate version
14//! bump). qBt-parity is the design constraint.
15//!
16//! ## Grammar
17//!
18//! | Token            | Source                                                                  | Example         |
19//! |------------------|-------------------------------------------------------------------------|-----------------|
20//! | `{category}`     | `CategoryRegistry` lookup of the named category                         | `Linux`         |
21//! | `{tracker}`      | `TorrentSavePathContext::primary_tracker_host()` (lowercased hostname)  | `archlinux.org` |
22//! | `{yyyy}`         | UTC year of `TorrentSavePathContext::added_at`                          | `2026`          |
23//! | `{mm}`           | UTC two-digit month of `TorrentSavePathContext::added_at`               | `04`            |
24//! | `{content_type}` | `TorrentSavePathContext::classified_content_type()`                     | `Audio`         |
25//!
26//! Unknown tokens return [`ExpandSavePathError::UnknownToken`] — never a
27//! silent literal pass-through, never an empty string. This is the
28//! `[REGRESSION CRITICAL]` failure-mode test pinned in the master plan
29//! "Required test coverage" section.
30//!
31//! ## Lane purity
32//!
33//! This module is the only addition Lane A makes inside `irontide-session`.
34//! It is invisible to `apply_settings`, never spins up any actor, never
35//! mutates session state, and exposes no `async` API. The session crate's
36//! public surface gains:
37//!
38//! - `pub mod save_path;` (re-export in `lib.rs`).
39//! - The five public symbols below: [`expand_save_path_template`],
40//!   [`expand_save_path_for_category`], [`TorrentSavePathContext`],
41//!   [`SimpleContentType`], [`ExpandSavePathError`].
42//!
43//! See the master plan Decision 5 for the rationale (eng-review §1: the
44//! session owns `CategoryRegistry` + the global `download_dir`, so token
45//! expansion lives in `irontide-session` rather than a new crate or the
46//! GUI).
47
48use std::path::{Path, PathBuf};
49
50use crate::category_manager::{CategoryError, CategoryRegistry};
51
52/// Coarse content-type classification surfaced as `{content_type}`.
53///
54/// Detection is left to the caller (the GUI infers from the torrent's
55/// file list at add-time). Three buckets keep the storage layout simple
56/// and predictable for users — `Audio` and `Video` map onto common library
57/// roots, `Other` catches everything else.
58#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
59pub enum SimpleContentType {
60    /// Predominantly audio files (e.g. `.mp3`, `.flac`, `.wav`).
61    Audio,
62    /// Predominantly video files (e.g. `.mkv`, `.mp4`, `.avi`).
63    Video,
64    /// Anything that did not classify as Audio or Video.
65    Other,
66}
67
68impl SimpleContentType {
69    /// Render as the literal token replacement.
70    #[must_use]
71    pub fn as_str(self) -> &'static str {
72        match self {
73            Self::Audio => "Audio",
74            Self::Video => "Video",
75            Self::Other => "Other",
76        }
77    }
78}
79
80/// Per-torrent context used to expand `{tracker}` / `{yyyy}` / `{mm}` /
81/// `{content_type}` tokens.
82///
83/// Built GUI-side (or by any future caller) from the torrent's tracker
84/// list, the add-time UTC timestamp, and the GUI's coarse content-type
85/// classifier. Construction stays plain-data so unit tests can build
86/// fixtures without touching the session actor.
87#[derive(Debug, Clone)]
88pub struct TorrentSavePathContext {
89    /// Lowercased hostname of the torrent's primary tracker. `None` when
90    /// the torrent has no tracker yet (DHT-only or unresolved magnet) —
91    /// the `{tracker}` token then expands to the empty string `unknown`
92    /// to keep the resulting path well-formed. The fallback lives in
93    /// [`Self::primary_tracker_host`].
94    pub primary_tracker_host: Option<String>,
95    /// UTC seconds-since-epoch when the torrent was added to the session.
96    /// Mirrors `TorrentSummary::added_time` / `TorrentStats::added_time`.
97    pub added_at_utc_secs: i64,
98    /// GUI-side coarse content-type classification.
99    pub content_type: SimpleContentType,
100}
101
102impl TorrentSavePathContext {
103    /// Build a context with sensible defaults — useful for tests.
104    #[must_use]
105    pub fn new(added_at_utc_secs: i64) -> Self {
106        Self {
107            primary_tracker_host: None,
108            added_at_utc_secs,
109            content_type: SimpleContentType::Other,
110        }
111    }
112
113    /// Resolve the `{tracker}` replacement.
114    ///
115    /// Returns the lowercased hostname when set, or the literal string
116    /// `unknown` when no tracker has resolved yet. Never an empty
117    /// component — empty path segments would create `//` in the output.
118    #[must_use]
119    pub fn primary_tracker_host(&self) -> &str {
120        self.primary_tracker_host.as_deref().unwrap_or("unknown")
121    }
122
123    /// Resolve the `{content_type}` replacement.
124    #[must_use]
125    pub fn classified_content_type(&self) -> &'static str {
126        self.content_type.as_str()
127    }
128}
129
130/// Errors from save-path expansion.
131#[derive(Debug, thiserror::Error)]
132pub enum ExpandSavePathError {
133    /// The template referenced a token outside the locked five-token
134    /// grammar. The offending token name (without the `{}`) is returned
135    /// verbatim so callers can surface it in error UI.
136    #[error("unknown save-path token: {{{token}}}")]
137    UnknownToken {
138        /// Token name as it appeared in the template (without braces).
139        token: String,
140    },
141    /// A `{` was opened but never closed. Returned with the byte offset
142    /// of the unclosed brace inside the template's string representation.
143    #[error("unterminated save-path token starting at byte offset {offset}")]
144    UnterminatedToken {
145        /// Byte offset of the offending `{` inside the template.
146        offset: usize,
147    },
148    /// `{}` (empty braces) — the template has a brace pair with no token
149    /// name between them. Treated as a separate failure mode from
150    /// `UnknownToken` so callers can render a more specific message.
151    #[error("empty save-path token at byte offset {offset}")]
152    EmptyToken {
153        /// Byte offset of the offending `{}` inside the template.
154        offset: usize,
155    },
156    /// The template referred to a category name that is not present in
157    /// the supplied [`CategoryRegistry`].
158    #[error("category not found: {name}")]
159    CategoryNotFound {
160        /// The name as it appeared at the call site.
161        name: String,
162    },
163    /// Underlying [`CategoryError`] surfaced for completeness — the
164    /// expander itself never produces these, but
165    /// [`expand_save_path_for_category`] forwards them when a registry
166    /// operation fails.
167    #[error("category lookup: {0}")]
168    Category(#[from] CategoryError),
169}
170
171/// Expand the five locked tokens inside `template` against `ctx`.
172///
173/// The template is treated as a `Path` for ergonomics — internally we walk
174/// the lossy string form. UTF-8-invalid templates round-trip through
175/// `to_string_lossy()` (any invalid bytes become `U+FFFD`); since
176/// `CategoryRegistry` writes templates as UTF-8 TOML this is a non-issue
177/// in practice.
178///
179/// Token resolution rules:
180///
181/// - `{category}` is supplied separately (callers who use this entry
182///   point already know the category name; if you have only the registry
183///   call [`expand_save_path_for_category`] instead).
184/// - Unknown tokens return [`ExpandSavePathError::UnknownToken`].
185/// - Empty `{}` returns [`ExpandSavePathError::EmptyToken`].
186/// - An unmatched `{` returns [`ExpandSavePathError::UnterminatedToken`].
187/// - Literal `{` and `}` characters are not escapable in this grammar
188///   (consistent with qBt's behaviour). If a future caller needs that,
189///   bump the grammar in a separate plan.
190///
191/// # Errors
192///
193/// Returns one of the [`ExpandSavePathError`] variants on grammar
194/// violations or unknown tokens.
195pub fn expand_save_path_template(
196    template: &Path,
197    category_name: &str,
198    ctx: &TorrentSavePathContext,
199) -> Result<PathBuf, ExpandSavePathError> {
200    let template_str = template.to_string_lossy();
201    let expanded = expand_str(&template_str, category_name, ctx)?;
202    Ok(PathBuf::from(expanded))
203}
204
205/// Convenience wrapper that pulls the `save_path` template out of a
206/// [`CategoryRegistry`] then delegates to [`expand_save_path_template`].
207///
208/// Returns [`ExpandSavePathError::CategoryNotFound`] when the named
209/// category is not in the registry.
210///
211/// # Errors
212///
213/// Returns the same set as [`expand_save_path_template`], plus
214/// [`ExpandSavePathError::CategoryNotFound`] when the lookup fails.
215pub fn expand_save_path_for_category(
216    registry: &CategoryRegistry,
217    category_name: &str,
218    ctx: &TorrentSavePathContext,
219) -> Result<PathBuf, ExpandSavePathError> {
220    let meta =
221        registry
222            .get(category_name)
223            .ok_or_else(|| ExpandSavePathError::CategoryNotFound {
224                name: category_name.to_owned(),
225            })?;
226    expand_save_path_template(&meta.save_path, category_name, ctx)
227}
228
229// ── String-level expander ──────────────────────────────────────────────────
230
231/// Walk `template` once, copying literal characters into the output and
232/// dispatching `{token}` runs through [`resolve_token`].
233fn expand_str(
234    template: &str,
235    category_name: &str,
236    ctx: &TorrentSavePathContext,
237) -> Result<String, ExpandSavePathError> {
238    let mut out = String::with_capacity(template.len());
239    let bytes = template.as_bytes();
240    let mut i = 0;
241    while i < bytes.len() {
242        let b = bytes[i];
243        if b == b'{' {
244            // Find the matching '}'.
245            let start = i;
246            let close = template[i..]
247                .find('}')
248                .ok_or(ExpandSavePathError::UnterminatedToken { offset: start })?;
249            let close_abs = start + close;
250            let token = &template[start + 1..close_abs];
251            if token.is_empty() {
252                return Err(ExpandSavePathError::EmptyToken { offset: start });
253            }
254            let replacement = resolve_token(token, category_name, ctx)?;
255            out.push_str(&replacement);
256            i = close_abs + 1;
257        } else {
258            // Push the next char verbatim — using the str index, not byte,
259            // so multi-byte UTF-8 stays intact.
260            let ch = template[i..].chars().next().expect("non-empty slice");
261            out.push(ch);
262            i = i.saturating_add(ch.len_utf8());
263        }
264    }
265    Ok(out)
266}
267
268fn resolve_token(
269    token: &str,
270    category_name: &str,
271    ctx: &TorrentSavePathContext,
272) -> Result<String, ExpandSavePathError> {
273    match token {
274        "category" => Ok(category_name.to_owned()),
275        "tracker" => Ok(ctx.primary_tracker_host().to_owned()),
276        "yyyy" => Ok(format_year(ctx.added_at_utc_secs)),
277        "mm" => Ok(format_month(ctx.added_at_utc_secs)),
278        "content_type" => Ok(ctx.classified_content_type().to_owned()),
279        other => Err(ExpandSavePathError::UnknownToken {
280            token: other.to_owned(),
281        }),
282    }
283}
284
285// ── UTC date helpers ───────────────────────────────────────────────────────
286//
287// We deliberately avoid pulling `chrono` / `time` into the session crate
288// for two tokens. The Howard Hinnant civil_from_days algorithm (CC0,
289// public domain) gives us proleptic Gregorian year/month/day from UTC
290// seconds in 30 lines of arithmetic. Saturating arithmetic guarantees
291// no panics for any `i64` input.
292
293fn format_year(utc_secs: i64) -> String {
294    let (year, _, _) = ymd_from_utc_secs(utc_secs);
295    format!("{year:04}")
296}
297
298fn format_month(utc_secs: i64) -> String {
299    let (_, month, _) = ymd_from_utc_secs(utc_secs);
300    format!("{month:02}")
301}
302
303/// Derive (year, month, day) in UTC from seconds since the Unix epoch.
304///
305/// Implements Howard Hinnant's `civil_from_days` algorithm
306/// (<https://howardhinnant.github.io/date_algorithms.html>) — CC0,
307/// branch-free, valid across the full `i64` range. Returns
308/// (year: i64, month: u32 in 1..=12, day: u32 in 1..=31).
309fn ymd_from_utc_secs(utc_secs: i64) -> (i64, u32, u32) {
310    // Days since 1970-01-01, floor-rounded for negative seconds.
311    let days_secs = 86_400_i64;
312    let days = utc_secs.div_euclid(days_secs);
313    // Civil_from_days expects "days since 0000-03-01" so shift the epoch.
314    let z = days.saturating_add(719_468);
315    let era = if z >= 0 { z } else { z.saturating_sub(146_096) } / 146_097;
316    let doe = (z - era.saturating_mul(146_097)) as u64; // 0..=146_096
317    let yoe = (doe
318        .saturating_sub(doe / 1460)
319        .saturating_sub(doe / 36_524)
320        .saturating_add(doe / 146_096))
321        / 365;
322    let y = (yoe as i64).saturating_add(era.saturating_mul(400));
323    let doy = doe
324        .saturating_sub(yoe.saturating_mul(365))
325        .saturating_sub(yoe / 4)
326        .saturating_add(yoe / 100);
327    let mp = (5 * doy + 2) / 153;
328    #[allow(clippy::cast_possible_truncation)]
329    let day = (doy.saturating_sub((153 * mp + 2) / 5).saturating_add(1)) as u32;
330    let month: u32 = if mp < 10 {
331        u32::try_from(mp + 3).unwrap_or(0)
332    } else {
333        u32::try_from(mp.saturating_sub(9)).unwrap_or(0)
334    };
335    let year = if month <= 2 { y.saturating_add(1) } else { y };
336    (year, month, day)
337}
338
339// ── Tests ──────────────────────────────────────────────────────────────────
340
341#[cfg(test)]
342mod tests {
343    use super::*;
344    use crate::category_manager::CategoryRegistry;
345
346    fn ctx(secs: i64) -> TorrentSavePathContext {
347        TorrentSavePathContext {
348            primary_tracker_host: Some("archlinux.org".into()),
349            added_at_utc_secs: secs,
350            content_type: SimpleContentType::Audio,
351        }
352    }
353
354    // ── ymd_from_utc_secs (smoke test) ─────────────────────────────────
355
356    #[test]
357    fn ymd_from_utc_secs_known_dates() {
358        // 1970-01-01 00:00:00 UTC.
359        assert_eq!(ymd_from_utc_secs(0), (1970, 1, 1));
360        // 2024-04-22 12:34:56 UTC — 1_713_789_296.
361        assert_eq!(ymd_from_utc_secs(1_713_789_296), (2024, 4, 22));
362        // 2025-04-22 00:00:00 UTC — verify the year token rolls.
363        let secs = 1_745_280_000_i64;
364        assert_eq!(ymd_from_utc_secs(secs), (2025, 4, 22));
365        // 2000-02-29 12:00:00 UTC — leap-year smoke test.
366        assert_eq!(ymd_from_utc_secs(951_825_600), (2000, 2, 29));
367        // Pre-epoch: 1969-12-31 23:59:59 UTC.
368        assert_eq!(ymd_from_utc_secs(-1), (1969, 12, 31));
369    }
370
371    // ── expand_save_path_template — happy path ─────────────────────────
372
373    #[test]
374    fn expand_template_no_tokens_passes_through_verbatim() {
375        // 1_713_789_296 = 2024-04-22.
376        let expanded = expand_save_path_template(
377            Path::new("/srv/downloads/static"),
378            "Linux",
379            &ctx(1_713_789_296),
380        )
381        .expect("plain literal must succeed");
382        assert_eq!(expanded, PathBuf::from("/srv/downloads/static"));
383    }
384
385    #[test]
386    fn expand_template_resolves_all_five_tokens() {
387        let expanded = expand_save_path_template(
388            Path::new("/srv/{category}/{tracker}/{yyyy}/{mm}/{content_type}"),
389            "Linux",
390            &ctx(1_713_789_296), // 2024-04-22
391        )
392        .expect("five-token template must succeed");
393        assert_eq!(
394            expanded,
395            PathBuf::from("/srv/Linux/archlinux.org/2024/04/Audio")
396        );
397    }
398
399    #[test]
400    fn expand_template_year_and_month_zero_padded() {
401        // 631_152_000 = 1990-01-01 00:00:00 UTC.
402        let expanded =
403            expand_save_path_template(Path::new("{yyyy}/{mm}"), "Other", &ctx(631_152_000))
404                .expect("zero-padded month");
405        assert_eq!(expanded, PathBuf::from("1990/01"));
406    }
407
408    #[test]
409    fn expand_template_tracker_falls_back_to_unknown() {
410        let mut c = ctx(0);
411        c.primary_tracker_host = None;
412        let expanded = expand_save_path_template(Path::new("/srv/{tracker}"), "Linux", &c)
413            .expect("missing tracker → 'unknown'");
414        assert_eq!(expanded, PathBuf::from("/srv/unknown"));
415    }
416
417    #[test]
418    fn expand_template_repeated_tokens() {
419        let expanded = expand_save_path_template(
420            Path::new("/{category}/{category}/{yyyy}-{mm}"),
421            "Music",
422            &ctx(1_713_789_296),
423        )
424        .expect("repeated tokens compose");
425        assert_eq!(expanded, PathBuf::from("/Music/Music/2024-04"));
426    }
427
428    // ── expand_save_path_template — REGRESSION CRITICAL: typed errors ──
429
430    #[test]
431    fn unknown_token_returns_typed_error_never_silent_literal() {
432        // Master plan "Required test coverage" — this is the pinned test.
433        let err = expand_save_path_template(Path::new("/srv/{nonsense}"), "Linux", &ctx(0))
434            .expect_err("unknown token must error");
435        match err {
436            ExpandSavePathError::UnknownToken { token } => {
437                assert_eq!(
438                    token, "nonsense",
439                    "the offending token name must round-trip verbatim"
440                );
441            }
442            other => panic!("expected UnknownToken, got {other:?}"),
443        }
444    }
445
446    #[test]
447    fn unterminated_token_returns_typed_error() {
448        let err = expand_save_path_template(Path::new("/srv/{category"), "Linux", &ctx(0))
449            .expect_err("unterminated token must error");
450        assert!(matches!(err, ExpandSavePathError::UnterminatedToken { .. }));
451    }
452
453    #[test]
454    fn empty_token_returns_typed_error() {
455        let err = expand_save_path_template(Path::new("/srv/{}"), "Linux", &ctx(0))
456            .expect_err("empty token must error");
457        assert!(matches!(err, ExpandSavePathError::EmptyToken { .. }));
458    }
459
460    // ── expand_save_path_for_category — registry lookup wrapper ────────
461
462    #[test]
463    fn expand_for_category_unknown_name_returns_category_not_found() {
464        let dir = tempfile::tempdir().expect("temp");
465        let registry = CategoryRegistry::new(dir.path().join("categories.toml"));
466        let err = expand_save_path_for_category(&registry, "Ghost", &ctx(0))
467            .expect_err("absent category must error");
468        match err {
469            ExpandSavePathError::CategoryNotFound { name } => {
470                assert_eq!(name, "Ghost");
471            }
472            other => panic!("expected CategoryNotFound, got {other:?}"),
473        }
474    }
475
476    #[test]
477    fn expand_for_category_uses_registry_template() {
478        let dir = tempfile::tempdir().expect("temp");
479        let mut registry = CategoryRegistry::new(dir.path().join("categories.toml"));
480        registry
481            .create("Linux".into(), PathBuf::from("/srv/{category}/{yyyy}"))
482            .expect("create");
483        let expanded = expand_save_path_for_category(&registry, "Linux", &ctx(1_713_789_296))
484            .expect("registry-driven expansion");
485        assert_eq!(expanded, PathBuf::from("/srv/Linux/2024"));
486    }
487
488    // ── SimpleContentType / TorrentSavePathContext sanity ─────────────
489
490    #[test]
491    fn content_type_str_round_trip() {
492        assert_eq!(SimpleContentType::Audio.as_str(), "Audio");
493        assert_eq!(SimpleContentType::Video.as_str(), "Video");
494        assert_eq!(SimpleContentType::Other.as_str(), "Other");
495    }
496
497    #[test]
498    fn ctx_new_defaults_to_unknown_tracker_and_other_content() {
499        let c = TorrentSavePathContext::new(0);
500        assert_eq!(c.primary_tracker_host(), "unknown");
501        assert_eq!(c.classified_content_type(), "Other");
502    }
503}