irontide-session 1.0.1

BitTorrent session management: peers, torrents, and piece selection
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
#![allow(
    clippy::cast_possible_truncation,
    clippy::cast_possible_wrap,
    clippy::cast_sign_loss,
    reason = "M175: save-path token expansion — piece counts bounded by realistic torrent size"
)]

//! Save-path token expansion (M173 Lane A — Decision 5).
//!
//! Implements the locked five-token grammar that expands the on-disk
//! `CategoryRegistry` `save_path` template into a concrete download
//! directory at torrent-add time. The grammar is intentionally small and
//! frozen: additions go through a separate plan (and a separate version
//! bump). qBt-parity is the design constraint.
//!
//! ## Grammar
//!
//! | Token            | Source                                                                  | Example         |
//! |------------------|-------------------------------------------------------------------------|-----------------|
//! | `{category}`     | `CategoryRegistry` lookup of the named category                         | `Linux`         |
//! | `{tracker}`      | `TorrentSavePathContext::primary_tracker_host()` (lowercased hostname)  | `archlinux.org` |
//! | `{yyyy}`         | UTC year of `TorrentSavePathContext::added_at`                          | `2026`          |
//! | `{mm}`           | UTC two-digit month of `TorrentSavePathContext::added_at`               | `04`            |
//! | `{content_type}` | `TorrentSavePathContext::classified_content_type()`                     | `Audio`         |
//!
//! Unknown tokens return [`ExpandSavePathError::UnknownToken`] — never a
//! silent literal pass-through, never an empty string. This is the
//! `[REGRESSION CRITICAL]` failure-mode test pinned in the master plan
//! "Required test coverage" section.
//!
//! ## Lane purity
//!
//! This module is the only addition Lane A makes inside `irontide-session`.
//! It is invisible to `apply_settings`, never spins up any actor, never
//! mutates session state, and exposes no `async` API. The session crate's
//! public surface gains:
//!
//! - `pub mod save_path;` (re-export in `lib.rs`).
//! - The five public symbols below: [`expand_save_path_template`],
//!   [`expand_save_path_for_category`], [`TorrentSavePathContext`],
//!   [`SimpleContentType`], [`ExpandSavePathError`].
//!
//! See the master plan Decision 5 for the rationale (eng-review §1: the
//! session owns `CategoryRegistry` + the global `download_dir`, so token
//! expansion lives in `irontide-session` rather than a new crate or the
//! GUI).

use std::path::{Path, PathBuf};

use crate::category_manager::{CategoryError, CategoryRegistry};

/// Coarse content-type classification surfaced as `{content_type}`.
///
/// Detection is left to the caller (the GUI infers from the torrent's
/// file list at add-time). Three buckets keep the storage layout simple
/// and predictable for users — `Audio` and `Video` map onto common library
/// roots, `Other` catches everything else.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum SimpleContentType {
    /// Predominantly audio files (e.g. `.mp3`, `.flac`, `.wav`).
    Audio,
    /// Predominantly video files (e.g. `.mkv`, `.mp4`, `.avi`).
    Video,
    /// Anything that did not classify as Audio or Video.
    Other,
}

impl SimpleContentType {
    /// Render as the literal token replacement.
    #[must_use]
    pub fn as_str(self) -> &'static str {
        match self {
            Self::Audio => "Audio",
            Self::Video => "Video",
            Self::Other => "Other",
        }
    }
}

/// Per-torrent context used to expand `{tracker}` / `{yyyy}` / `{mm}` /
/// `{content_type}` tokens.
///
/// Built GUI-side (or by any future caller) from the torrent's tracker
/// list, the add-time UTC timestamp, and the GUI's coarse content-type
/// classifier. Construction stays plain-data so unit tests can build
/// fixtures without touching the session actor.
#[derive(Debug, Clone)]
pub struct TorrentSavePathContext {
    /// Lowercased hostname of the torrent's primary tracker. `None` when
    /// the torrent has no tracker yet (DHT-only or unresolved magnet) —
    /// the `{tracker}` token then expands to the empty string `unknown`
    /// to keep the resulting path well-formed. The fallback lives in
    /// [`Self::primary_tracker_host`].
    pub primary_tracker_host: Option<String>,
    /// UTC seconds-since-epoch when the torrent was added to the session.
    /// Mirrors `TorrentSummary::added_time` / `TorrentStats::added_time`.
    pub added_at_utc_secs: i64,
    /// GUI-side coarse content-type classification.
    pub content_type: SimpleContentType,
}

impl TorrentSavePathContext {
    /// Build a context with sensible defaults — useful for tests.
    #[must_use]
    pub fn new(added_at_utc_secs: i64) -> Self {
        Self {
            primary_tracker_host: None,
            added_at_utc_secs,
            content_type: SimpleContentType::Other,
        }
    }

    /// Resolve the `{tracker}` replacement.
    ///
    /// Returns the lowercased hostname when set, or the literal string
    /// `unknown` when no tracker has resolved yet. Never an empty
    /// component — empty path segments would create `//` in the output.
    #[must_use]
    pub fn primary_tracker_host(&self) -> &str {
        self.primary_tracker_host.as_deref().unwrap_or("unknown")
    }

    /// Resolve the `{content_type}` replacement.
    #[must_use]
    pub fn classified_content_type(&self) -> &'static str {
        self.content_type.as_str()
    }
}

/// Errors from save-path expansion.
#[derive(Debug, thiserror::Error)]
pub enum ExpandSavePathError {
    /// The template referenced a token outside the locked five-token
    /// grammar. The offending token name (without the `{}`) is returned
    /// verbatim so callers can surface it in error UI.
    #[error("unknown save-path token: {{{token}}}")]
    UnknownToken {
        /// Token name as it appeared in the template (without braces).
        token: String,
    },
    /// A `{` was opened but never closed. Returned with the byte offset
    /// of the unclosed brace inside the template's string representation.
    #[error("unterminated save-path token starting at byte offset {offset}")]
    UnterminatedToken {
        /// Byte offset of the offending `{` inside the template.
        offset: usize,
    },
    /// `{}` (empty braces) — the template has a brace pair with no token
    /// name between them. Treated as a separate failure mode from
    /// `UnknownToken` so callers can render a more specific message.
    #[error("empty save-path token at byte offset {offset}")]
    EmptyToken {
        /// Byte offset of the offending `{}` inside the template.
        offset: usize,
    },
    /// The template referred to a category name that is not present in
    /// the supplied [`CategoryRegistry`].
    #[error("category not found: {name}")]
    CategoryNotFound {
        /// The name as it appeared at the call site.
        name: String,
    },
    /// Underlying [`CategoryError`] surfaced for completeness — the
    /// expander itself never produces these, but
    /// [`expand_save_path_for_category`] forwards them when a registry
    /// operation fails.
    #[error("category lookup: {0}")]
    Category(#[from] CategoryError),
}

/// Expand the five locked tokens inside `template` against `ctx`.
///
/// The template is treated as a `Path` for ergonomics — internally we walk
/// the lossy string form. UTF-8-invalid templates round-trip through
/// `to_string_lossy()` (any invalid bytes become `U+FFFD`); since
/// `CategoryRegistry` writes templates as UTF-8 TOML this is a non-issue
/// in practice.
///
/// Token resolution rules:
///
/// - `{category}` is supplied separately (callers who use this entry
///   point already know the category name; if you have only the registry
///   call [`expand_save_path_for_category`] instead).
/// - Unknown tokens return [`ExpandSavePathError::UnknownToken`].
/// - Empty `{}` returns [`ExpandSavePathError::EmptyToken`].
/// - An unmatched `{` returns [`ExpandSavePathError::UnterminatedToken`].
/// - Literal `{` and `}` characters are not escapable in this grammar
///   (consistent with qBt's behaviour). If a future caller needs that,
///   bump the grammar in a separate plan.
///
/// # Errors
///
/// Returns one of the [`ExpandSavePathError`] variants on grammar
/// violations or unknown tokens.
pub fn expand_save_path_template(
    template: &Path,
    category_name: &str,
    ctx: &TorrentSavePathContext,
) -> Result<PathBuf, ExpandSavePathError> {
    let template_str = template.to_string_lossy();
    let expanded = expand_str(&template_str, category_name, ctx)?;
    Ok(PathBuf::from(expanded))
}

/// Convenience wrapper that pulls the `save_path` template out of a
/// [`CategoryRegistry`] then delegates to [`expand_save_path_template`].
///
/// Returns [`ExpandSavePathError::CategoryNotFound`] when the named
/// category is not in the registry.
///
/// # Errors
///
/// Returns the same set as [`expand_save_path_template`], plus
/// [`ExpandSavePathError::CategoryNotFound`] when the lookup fails.
pub fn expand_save_path_for_category(
    registry: &CategoryRegistry,
    category_name: &str,
    ctx: &TorrentSavePathContext,
) -> Result<PathBuf, ExpandSavePathError> {
    let meta =
        registry
            .get(category_name)
            .ok_or_else(|| ExpandSavePathError::CategoryNotFound {
                name: category_name.to_owned(),
            })?;
    expand_save_path_template(&meta.save_path, category_name, ctx)
}

// ── String-level expander ──────────────────────────────────────────────────

/// Walk `template` once, copying literal characters into the output and
/// dispatching `{token}` runs through [`resolve_token`].
fn expand_str(
    template: &str,
    category_name: &str,
    ctx: &TorrentSavePathContext,
) -> Result<String, ExpandSavePathError> {
    let mut out = String::with_capacity(template.len());
    let bytes = template.as_bytes();
    let mut i = 0;
    while i < bytes.len() {
        let b = bytes[i];
        if b == b'{' {
            // Find the matching '}'.
            let start = i;
            let close = template[i..]
                .find('}')
                .ok_or(ExpandSavePathError::UnterminatedToken { offset: start })?;
            let close_abs = start + close;
            let token = &template[start + 1..close_abs];
            if token.is_empty() {
                return Err(ExpandSavePathError::EmptyToken { offset: start });
            }
            let replacement = resolve_token(token, category_name, ctx)?;
            out.push_str(&replacement);
            i = close_abs + 1;
        } else {
            // Push the next char verbatim — using the str index, not byte,
            // so multi-byte UTF-8 stays intact.
            let ch = template[i..].chars().next().expect("non-empty slice");
            out.push(ch);
            i = i.saturating_add(ch.len_utf8());
        }
    }
    Ok(out)
}

fn resolve_token(
    token: &str,
    category_name: &str,
    ctx: &TorrentSavePathContext,
) -> Result<String, ExpandSavePathError> {
    match token {
        "category" => Ok(category_name.to_owned()),
        "tracker" => Ok(ctx.primary_tracker_host().to_owned()),
        "yyyy" => Ok(format_year(ctx.added_at_utc_secs)),
        "mm" => Ok(format_month(ctx.added_at_utc_secs)),
        "content_type" => Ok(ctx.classified_content_type().to_owned()),
        other => Err(ExpandSavePathError::UnknownToken {
            token: other.to_owned(),
        }),
    }
}

// ── UTC date helpers ───────────────────────────────────────────────────────
//
// We deliberately avoid pulling `chrono` / `time` into the session crate
// for two tokens. The Howard Hinnant civil_from_days algorithm (CC0,
// public domain) gives us proleptic Gregorian year/month/day from UTC
// seconds in 30 lines of arithmetic. Saturating arithmetic guarantees
// no panics for any `i64` input.

fn format_year(utc_secs: i64) -> String {
    let (year, _, _) = ymd_from_utc_secs(utc_secs);
    format!("{year:04}")
}

fn format_month(utc_secs: i64) -> String {
    let (_, month, _) = ymd_from_utc_secs(utc_secs);
    format!("{month:02}")
}

/// Derive (year, month, day) in UTC from seconds since the Unix epoch.
///
/// Implements Howard Hinnant's `civil_from_days` algorithm
/// (<https://howardhinnant.github.io/date_algorithms.html>) — CC0,
/// branch-free, valid across the full `i64` range. Returns
/// (year: i64, month: u32 in 1..=12, day: u32 in 1..=31).
fn ymd_from_utc_secs(utc_secs: i64) -> (i64, u32, u32) {
    // Days since 1970-01-01, floor-rounded for negative seconds.
    let days_secs = 86_400_i64;
    let days = utc_secs.div_euclid(days_secs);
    // Civil_from_days expects "days since 0000-03-01" so shift the epoch.
    let z = days.saturating_add(719_468);
    let era = if z >= 0 { z } else { z.saturating_sub(146_096) } / 146_097;
    let doe = (z - era.saturating_mul(146_097)) as u64; // 0..=146_096
    let yoe = (doe
        .saturating_sub(doe / 1460)
        .saturating_sub(doe / 36_524)
        .saturating_add(doe / 146_096))
        / 365;
    let y = (yoe as i64).saturating_add(era.saturating_mul(400));
    let doy = doe
        .saturating_sub(yoe.saturating_mul(365))
        .saturating_sub(yoe / 4)
        .saturating_add(yoe / 100);
    let mp = (5 * doy + 2) / 153;
    #[allow(clippy::cast_possible_truncation)]
    let day = (doy.saturating_sub((153 * mp + 2) / 5).saturating_add(1)) as u32;
    let month: u32 = if mp < 10 {
        u32::try_from(mp + 3).unwrap_or(0)
    } else {
        u32::try_from(mp.saturating_sub(9)).unwrap_or(0)
    };
    let year = if month <= 2 { y.saturating_add(1) } else { y };
    (year, month, day)
}

// ── Tests ──────────────────────────────────────────────────────────────────

#[cfg(test)]
mod tests {
    use super::*;
    use crate::category_manager::CategoryRegistry;

    fn ctx(secs: i64) -> TorrentSavePathContext {
        TorrentSavePathContext {
            primary_tracker_host: Some("archlinux.org".into()),
            added_at_utc_secs: secs,
            content_type: SimpleContentType::Audio,
        }
    }

    // ── ymd_from_utc_secs (smoke test) ─────────────────────────────────

    #[test]
    fn ymd_from_utc_secs_known_dates() {
        // 1970-01-01 00:00:00 UTC.
        assert_eq!(ymd_from_utc_secs(0), (1970, 1, 1));
        // 2024-04-22 12:34:56 UTC — 1_713_789_296.
        assert_eq!(ymd_from_utc_secs(1_713_789_296), (2024, 4, 22));
        // 2025-04-22 00:00:00 UTC — verify the year token rolls.
        let secs = 1_745_280_000_i64;
        assert_eq!(ymd_from_utc_secs(secs), (2025, 4, 22));
        // 2000-02-29 12:00:00 UTC — leap-year smoke test.
        assert_eq!(ymd_from_utc_secs(951_825_600), (2000, 2, 29));
        // Pre-epoch: 1969-12-31 23:59:59 UTC.
        assert_eq!(ymd_from_utc_secs(-1), (1969, 12, 31));
    }

    // ── expand_save_path_template — happy path ─────────────────────────

    #[test]
    fn expand_template_no_tokens_passes_through_verbatim() {
        // 1_713_789_296 = 2024-04-22.
        let expanded = expand_save_path_template(
            Path::new("/srv/downloads/static"),
            "Linux",
            &ctx(1_713_789_296),
        )
        .expect("plain literal must succeed");
        assert_eq!(expanded, PathBuf::from("/srv/downloads/static"));
    }

    #[test]
    fn expand_template_resolves_all_five_tokens() {
        let expanded = expand_save_path_template(
            Path::new("/srv/{category}/{tracker}/{yyyy}/{mm}/{content_type}"),
            "Linux",
            &ctx(1_713_789_296), // 2024-04-22
        )
        .expect("five-token template must succeed");
        assert_eq!(
            expanded,
            PathBuf::from("/srv/Linux/archlinux.org/2024/04/Audio")
        );
    }

    #[test]
    fn expand_template_year_and_month_zero_padded() {
        // 631_152_000 = 1990-01-01 00:00:00 UTC.
        let expanded =
            expand_save_path_template(Path::new("{yyyy}/{mm}"), "Other", &ctx(631_152_000))
                .expect("zero-padded month");
        assert_eq!(expanded, PathBuf::from("1990/01"));
    }

    #[test]
    fn expand_template_tracker_falls_back_to_unknown() {
        let mut c = ctx(0);
        c.primary_tracker_host = None;
        let expanded = expand_save_path_template(Path::new("/srv/{tracker}"), "Linux", &c)
            .expect("missing tracker → 'unknown'");
        assert_eq!(expanded, PathBuf::from("/srv/unknown"));
    }

    #[test]
    fn expand_template_repeated_tokens() {
        let expanded = expand_save_path_template(
            Path::new("/{category}/{category}/{yyyy}-{mm}"),
            "Music",
            &ctx(1_713_789_296),
        )
        .expect("repeated tokens compose");
        assert_eq!(expanded, PathBuf::from("/Music/Music/2024-04"));
    }

    // ── expand_save_path_template — REGRESSION CRITICAL: typed errors ──

    #[test]
    fn unknown_token_returns_typed_error_never_silent_literal() {
        // Master plan "Required test coverage" — this is the pinned test.
        let err = expand_save_path_template(Path::new("/srv/{nonsense}"), "Linux", &ctx(0))
            .expect_err("unknown token must error");
        match err {
            ExpandSavePathError::UnknownToken { token } => {
                assert_eq!(
                    token, "nonsense",
                    "the offending token name must round-trip verbatim"
                );
            }
            other => panic!("expected UnknownToken, got {other:?}"),
        }
    }

    #[test]
    fn unterminated_token_returns_typed_error() {
        let err = expand_save_path_template(Path::new("/srv/{category"), "Linux", &ctx(0))
            .expect_err("unterminated token must error");
        assert!(matches!(err, ExpandSavePathError::UnterminatedToken { .. }));
    }

    #[test]
    fn empty_token_returns_typed_error() {
        let err = expand_save_path_template(Path::new("/srv/{}"), "Linux", &ctx(0))
            .expect_err("empty token must error");
        assert!(matches!(err, ExpandSavePathError::EmptyToken { .. }));
    }

    // ── expand_save_path_for_category — registry lookup wrapper ────────

    #[test]
    fn expand_for_category_unknown_name_returns_category_not_found() {
        let dir = tempfile::tempdir().expect("temp");
        let registry = CategoryRegistry::new(dir.path().join("categories.toml"));
        let err = expand_save_path_for_category(&registry, "Ghost", &ctx(0))
            .expect_err("absent category must error");
        match err {
            ExpandSavePathError::CategoryNotFound { name } => {
                assert_eq!(name, "Ghost");
            }
            other => panic!("expected CategoryNotFound, got {other:?}"),
        }
    }

    #[test]
    fn expand_for_category_uses_registry_template() {
        let dir = tempfile::tempdir().expect("temp");
        let mut registry = CategoryRegistry::new(dir.path().join("categories.toml"));
        registry
            .create("Linux".into(), PathBuf::from("/srv/{category}/{yyyy}"))
            .expect("create");
        let expanded = expand_save_path_for_category(&registry, "Linux", &ctx(1_713_789_296))
            .expect("registry-driven expansion");
        assert_eq!(expanded, PathBuf::from("/srv/Linux/2024"));
    }

    // ── SimpleContentType / TorrentSavePathContext sanity ─────────────

    #[test]
    fn content_type_str_round_trip() {
        assert_eq!(SimpleContentType::Audio.as_str(), "Audio");
        assert_eq!(SimpleContentType::Video.as_str(), "Video");
        assert_eq!(SimpleContentType::Other.as_str(), "Other");
    }

    #[test]
    fn ctx_new_defaults_to_unknown_tracker_and_other_content() {
        let c = TorrentSavePathContext::new(0);
        assert_eq!(c.primary_tracker_host(), "unknown");
        assert_eq!(c.classified_content_type(), "Other");
    }
}