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(®istry, "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(®istry, "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}