qbt-clean 0.122.0

Automated rules-based cleaning of qBittorrent torrents.
fn now() -> std::time::SystemTime {
	// Non-epoch so duration subtraction works in both directions.
	std::time::SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1_700_000_000)
}

#[tokio::test]
async fn fast_rule_priority_last_config_rule_wins_for_pin() {
	// Two fast rules with conflicting `pin` both match; reverse iteration
	// makes the last-in-config (pin=false) finalize first and win.
	let t = crate::Torrent {
		category: "linux".into(),
		..crate::Torrent::zero()
	};

	let c = crate::Config {
		rules: vec![
			crate::Rule {
				match_: crate::Match {
					categories: Some(["linux".into()].into()),
					..Default::default()
				},
				pin: Some(true),
				..Default::default()
			},
			crate::Rule {
				match_: crate::Match {
					categories: Some(["linux".into()].into()),
					..Default::default()
				},
				pin: Some(false),
				..Default::default()
			},
		],
		..crate::Config::empty()
	};

	let g = crate::qbt_mock::global_with_trackers(c, now(), vec![]);
	let res = g.rules(&t, false).await.unwrap();
	assert!(res.pinned.is_none());
}

#[tokio::test]
async fn fast_rules_compose_across_independent_fields() {
	// Two fast rules touching different fields (include_in_score vs pin)
	// both apply.
	let t = crate::Torrent {
		category: "linux".into(),
		..crate::Torrent::zero()
	};

	let c = crate::Config {
		rules: vec![
			crate::Rule {
				match_: crate::Match {
					categories: Some(["linux".into()].into()),
					..Default::default()
				},
				include_in_score: Some(false),
				..Default::default()
			},
			crate::Rule {
				match_: crate::Match {
					categories: Some(["linux".into()].into()),
					..Default::default()
				},
				pin: Some(true),
				..Default::default()
			},
		],
		..crate::Config::empty()
	};

	let g = crate::qbt_mock::global_with_trackers(c, now(), vec![]);
	let res = g.rules(&t, false).await.unwrap();
	assert!(matches!(res.pinned, Some(crate::PinReason::Explicit)));
	assert!(!res.include_in_score);
}

#[tokio::test]
async fn slow_phase2_resolves_when_phase1_has_multiple_possible_pin_values() {
	// Two slow rules with conflicting `pin`. Phase 1 sees both Some(true)
	// and Some(false) as possible, so it punts to phase 2, which fetches
	// trackers and resolves definitively.
	let t = crate::Torrent {
		category: "linux".into(),
		seeding_time: std::time::Duration::from_secs(1),
		..crate::Torrent::zero()
	};

	let c = crate::Config {
		categories_allowed: ["linux".into()].into(),
		rules: vec![
			crate::Rule {
				match_: crate::Match {
					tracker_msg: Some(regex::RegexSet::new([r"^ok$"]).unwrap()),
					..Default::default()
				},
				pin: Some(true),
				..Default::default()
			},
			crate::Rule {
				match_: crate::Match {
					tracker_url: Some(regex::RegexSet::new([r"^http://bad/.*$"]).unwrap()),
					..Default::default()
				},
				pin: Some(false),
				..Default::default()
			},
		],
		..crate::Config::empty()
	};

	// pin matches.
	let g = crate::qbt_mock::global_with_trackers(c.clone(), now(), vec![
		crate::Tracker {
			msg: "ok".into(),
			url: "http://t/".into(),
		},
	]);
	let res = g.rules(&t, false).await.unwrap();
	assert!(matches!(res.pinned, Some(crate::PinReason::Explicit)));

	// unpin matches.
	let g = crate::qbt_mock::global_with_trackers(c.clone(), now(), vec![
		crate::Tracker {
			msg: "nope".into(),
			url: "http://bad/x".into(),
		},
	]);
	let res = g.rules(&t, false).await.unwrap();
	assert!(res.pinned.is_none());

	// Both match — last in config wins.
	let g = crate::qbt_mock::global_with_trackers(c.clone(), now(), vec![
		crate::Tracker {
			msg: "ok".into(),
			url: "http://bad/x".into(),
		},
	]);
	let res = g.rules(&t, false).await.unwrap();
	assert!(res.pinned.is_none());

	// Neither matches.
	let g = crate::qbt_mock::global_with_trackers(c, now(), vec![
		crate::Tracker {
			msg: "nope".into(),
			url: "http://t/".into(),
		},
	]);
	let res = g.rules(&t, false).await.unwrap();
	assert!(res.pinned.is_none());
}

#[tokio::test]
async fn mixed_fast_and_slow_rules() {
	// A fast rule sets `include_in_score=false` unconditionally; a slow
	// rule raises `seeder_count_min` when its tracker matches. Phase 1
	// can't resolve `seeder_count_min`, so phase 2 fetches trackers and
	// re-evaluates both rules.
	let t = crate::Torrent {
		category: "linux".into(),
		seeders: 5,
		seeding_time: std::time::Duration::from_secs(1),
		..crate::Torrent::zero()
	};

	let c = crate::Config {
		categories_allowed: ["linux".into()].into(),
		rules: vec![
			crate::Rule {
				match_: crate::Match {
					categories: Some(["linux".into()].into()),
					..Default::default()
				},
				include_in_score: Some(false),
				..Default::default()
			},
			crate::Rule {
				match_: crate::Match {
					tracker_msg: Some(regex::RegexSet::new([r"^private$"]).unwrap()),
					..Default::default()
				},
				seeder_count_min: Some(50),
				..Default::default()
			},
		],
		..crate::Config::empty()
	};

	// Slow rule applies; seeder_count_min=50 violated.
	let g = crate::qbt_mock::global_with_trackers(c.clone(), now(), vec![
		crate::Tracker {
			msg: "private".into(),
			url: "http://t/".into(),
		},
	]);
	let res = g.rules(&t, false).await.unwrap();
	assert!(matches!(res.pinned, Some(crate::PinReason::Seeders(5))));
	assert!(!res.include_in_score);

	// Slow rule skipped; only the fast rule applies.
	let g = crate::qbt_mock::global_with_trackers(c, now(), vec![
		crate::Tracker {
			msg: "public".into(),
			url: "http://t/".into(),
		},
	]);
	let res = g.rules(&t, false).await.unwrap();
	assert!(res.pinned.is_none());
	assert!(!res.include_in_score);
}