feat(android): Phase 4 — Rust core to Android .so via JNI

Implement the shared-Rust-core leg of the Android Stage-1 prototype
(docs/superpowers/specs/2026-06-27-android-stage1-prototype-plan.md).

android/rust/catacomb_core is a cdylib that reuses the pure desktop
modules (vtt, error_class, platform) verbatim via #[path] includes — no
logic fork — and exposes them to Kotlin over JNI as String-in/String-out
entry points:

  RustCore.vttParse(vtt)        -> JSON [{start,text},...]
  RustCore.classifyError(log)   -> JSON {class,label,hint}
  RustCore.platformFromUrl(url) -> JSON {dir_name,display_name,icon}
  RustCore.platformDirName(url) -> folder name

Built with panic=abort + per-entry catch_unwind so a Rust panic can never
unwind into the JVM. build.sh locates the NDK portably and cross-compiles
to arm64-v8a + x86_64, depositing the .so into app/src/main/jniLibs/.

Verified: cargo test (38 pass), llvm-readelf confirms all 4 JNI symbols
exported in the arm64 .so, and an end-to-end host-JVM round-trip
(System.load + call each native method) returns correct JSON.

Phases 2/3/5 (WebView-BotGuard anti-bot) remain device-only and untouched.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Luna 2026-07-03 08:11:21 -07:00
parent f3e6d3b5b7
commit 9a613239f2
7 changed files with 796 additions and 0 deletions

315
android/rust/catacomb_core/Cargo.lock generated Normal file
View file

@ -0,0 +1,315 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "bytes"
version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593"
[[package]]
name = "catacomb_core"
version = "0.1.0"
dependencies = [
"jni",
"serde",
"serde_json",
]
[[package]]
name = "cesu8"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c"
[[package]]
name = "cfg-if"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "combine"
version = "4.6.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd"
dependencies = [
"bytes",
"memchr",
]
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "jni"
version = "0.21.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97"
dependencies = [
"cesu8",
"cfg-if",
"combine",
"jni-sys 0.3.1",
"log",
"thiserror",
"walkdir",
"windows-sys 0.45.0",
]
[[package]]
name = "jni-sys"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258"
dependencies = [
"jni-sys 0.4.1",
]
[[package]]
name = "jni-sys"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2"
dependencies = [
"jni-sys-macros",
]
[[package]]
name = "jni-sys-macros"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264"
dependencies = [
"quote",
"syn",
]
[[package]]
name = "log"
version = "0.4.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
[[package]]
name = "memchr"
version = "2.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4"
[[package]]
name = "proc-macro2"
version = "1.0.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368"
dependencies = [
"proc-macro2",
]
[[package]]
name = "same-file"
version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
dependencies = [
"winapi-util",
]
[[package]]
name = "serde"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
dependencies = [
"serde_core",
"serde_derive",
]
[[package]]
name = "serde_core"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "serde_json"
version = "1.0.150"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"
dependencies = [
"itoa",
"memchr",
"serde",
"serde_core",
"zmij",
]
[[package]]
name = "syn"
version = "2.0.118"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "thiserror"
version = "1.0.69"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
dependencies = [
"thiserror-impl",
]
[[package]]
name = "thiserror-impl"
version = "1.0.69"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "unicode-ident"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "walkdir"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
dependencies = [
"same-file",
"winapi-util",
]
[[package]]
name = "winapi-util"
version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-sys"
version = "0.45.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0"
dependencies = [
"windows-targets",
]
[[package]]
name = "windows-sys"
version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
dependencies = [
"windows-link",
]
[[package]]
name = "windows-targets"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071"
dependencies = [
"windows_aarch64_gnullvm",
"windows_aarch64_msvc",
"windows_i686_gnu",
"windows_i686_msvc",
"windows_x86_64_gnu",
"windows_x86_64_gnullvm",
"windows_x86_64_msvc",
]
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8"
[[package]]
name = "windows_aarch64_msvc"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43"
[[package]]
name = "windows_i686_gnu"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f"
[[package]]
name = "windows_i686_msvc"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060"
[[package]]
name = "windows_x86_64_gnu"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3"
[[package]]
name = "windows_x86_64_msvc"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0"
[[package]]
name = "zmij"
version = "1.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"

View file

@ -0,0 +1,40 @@
# Android JNI core for Catacomb (Stage-1 prototype, Phase 4).
#
# A thin cdylib that reuses Catacomb's *pure* modules (vtt, error_class,
# platform) verbatim via `#[path]` includes so the on-device engine stays in
# lockstep with the desktop/web build instead of forking the logic. It exposes
# a handful of String-in / String-out JNI entry points the Kotlin layer calls.
#
# The empty `[workspace]` table isolates this crate from the desktop package
# that lives at the repository root (which is a plain package, not a
# workspace); without it cargo would try to treat the two as one workspace.
[workspace]
[package]
name = "catacomb_core"
version = "0.1.0"
edition = "2021"
license = "AGPL-3.0-only"
description = "Android JNI bridge exposing Catacomb's pure Rust modules on-device."
[lib]
# cdylib → a .so loadable by the JVM via System.loadLibrary("catacomb_core").
crate-type = ["cdylib"]
[dependencies]
# Same major as the desktop crate so the shared modules' derives resolve
# identically.
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
# JNI bindings. 0.21 is the current stable line and links against the JVM's
# built-in libjvm at runtime (no extra native dep on device).
jni = "0.21"
[profile.release]
# Small, fast .so: LTO + panic=abort (a Rust panic must never unwind across
# the FFI boundary into the JVM — that's UB; abort is the safe choice, and we
# also catch panics at each entry point as defense in depth).
opt-level = "z"
lto = true
panic = "abort"
strip = true

View file

@ -0,0 +1,77 @@
#!/usr/bin/env bash
# Cross-compile the Catacomb Android JNI core to Android .so files.
#
# Portable: locates the NDK from (in order) $ANDROID_NDK_HOME, $ANDROID_NDK_ROOT,
# or the newest ndk/<ver> under $ANDROID_HOME / $ANDROID_SDK_ROOT / ~/Android/Sdk.
# Only the target linker is needed — every dependency (serde, serde_json, jni)
# is pure Rust, so there's no C compiler / archiver to configure.
#
# Usage:
# ./build.sh # build arm64-v8a + x86_64 (release)
# ./build.sh arm64-v8a # single ABI
# API=24 ./build.sh # override the min-API linker (default 24)
#
# Output .so files land in target/<rust-triple>/release/libcatacomb_core.so.
set -euo pipefail
cd "$(dirname "$0")"
API="${API:-24}"
# ── Locate the NDK ──────────────────────────────────────────────────────────
find_ndk() {
if [[ -n "${ANDROID_NDK_HOME:-}" && -d "$ANDROID_NDK_HOME" ]]; then
echo "$ANDROID_NDK_HOME"; return
fi
if [[ -n "${ANDROID_NDK_ROOT:-}" && -d "$ANDROID_NDK_ROOT" ]]; then
echo "$ANDROID_NDK_ROOT"; return
fi
for sdk in "${ANDROID_HOME:-}" "${ANDROID_SDK_ROOT:-}" "$HOME/Android/Sdk"; do
[[ -n "$sdk" && -d "$sdk/ndk" ]] || continue
# newest installed NDK version
local ver
ver="$(ls -1 "$sdk/ndk" | sort -V | tail -1)"
[[ -n "$ver" ]] && { echo "$sdk/ndk/$ver"; return; }
done
echo "ERROR: could not locate an Android NDK. Set ANDROID_NDK_HOME." >&2
exit 1
}
NDK="$(find_ndk)"
HOST_TAG="linux-x86_64"
[[ "$(uname -s)" == "Darwin" ]] && HOST_TAG="darwin-x86_64"
TOOLS="$NDK/toolchains/llvm/prebuilt/$HOST_TAG/bin"
echo "Using NDK: $NDK (API $API)"
# ── ABI → rust triple + clang wrapper ────────────────────────────────────────
declare -A TRIPLE=( [arm64-v8a]=aarch64-linux-android [x86_64]=x86_64-linux-android )
build_one() {
local abi="$1" triple="${TRIPLE[$1]}"
local linker="$TOOLS/${triple}${API}-clang"
if [[ ! -x "$linker" ]]; then
echo "ERROR: linker not found: $linker" >&2; exit 1
fi
echo "── Building $abi ($triple) ─────────────────────────────"
# cargo derives the env var name from the uppercased triple.
local var="CARGO_TARGET_$(echo "$triple" | tr 'a-z-' 'A-Z_')_LINKER"
env "$var=$linker" cargo build --release --target "$triple"
local out="target/$triple/release/libcatacomb_core.so"
if [[ ! -f "$out" ]]; then
echo " ✗ expected $out was not produced" >&2; exit 1
fi
# Deposit into the app's ABI-specific jniLibs dir so the Kotlin
# `System.loadLibrary("catacomb_core")` finds it at packaging time.
local jnidir="../../app/src/main/jniLibs/$abi"
mkdir -p "$jnidir"
cp "$out" "$jnidir/libcatacomb_core.so"
echo "$out ($(du -h "$out" | cut -f1)) → $jnidir/"
}
if [[ $# -gt 0 ]]; then
build_one "$1"
else
build_one arm64-v8a
build_one x86_64
fi
echo "Done."

View file

@ -0,0 +1,221 @@
//! Catacomb Android JNI core (Stage-1 prototype, Phase 4).
//!
//! This crate proves the "shared Rust core" leg of the Android feasibility
//! spike: the *same* pure modules the desktop/web binary uses
//! (`vtt`, `error_class`, `platform`) cross-compile to an Android `.so` and are
//! callable from Kotlin over JNI — no logic fork, no rewrite.
//!
//! ## What's exposed
//!
//! Every entry point is String-in / String-out (JNI's easiest, most portable
//! shape) and mirrors a desktop function:
//!
//! | Kotlin (`RustCore`) | Rust module | Purpose |
//! |--------------------------|----------------------|-------------------------------------------|
//! | `vttParse(String)` | [`vtt::parse`] | VTT/SRT → JSON `[{start,text}, …]` |
//! | `classifyError(String)` | [`error_class`] | yt-dlp log → `{class,label,hint}` JSON |
//! | `platformFromUrl(String)`| [`platform`] | URL → `{dir_name,display_name,icon}` JSON |
//! | `platformDirName(String)`| [`platform`] | URL → backup-folder name (plain string) |
//!
//! ## Panic safety
//!
//! A Rust panic unwinding across the FFI boundary into the JVM is undefined
//! behaviour. The crate is built with `panic = "abort"`, and every entry point
//! additionally wraps its body in [`std::panic::catch_unwind`] so a bug
//! degrades to an empty/typed result instead of tearing down the process.
// ── Shared, unmodified Catacomb modules ─────────────────────────────────────
// Included by path (not copied) so this stays in lockstep with the desktop
// source. These are pure std + serde and carry no platform-specific code.
#[path = "../../../../src/vtt.rs"]
pub mod vtt;
#[path = "../../../../src/error_class.rs"]
pub mod error_class;
#[path = "../../../../src/platform.rs"]
pub mod platform;
use jni::objects::{JClass, JString};
use jni::sys::jstring;
use jni::JNIEnv;
use serde::Serialize;
/// Serializable view of a parsed subtitle cue (JNI can't hand back the native
/// [`vtt::Cue`] directly, so we JSON-encode a flat list of these).
#[derive(Serialize)]
struct CueJson {
start: f64,
text: String,
}
/// Serializable classification result: the kebab-case class id plus its
/// human-facing label and hint.
#[derive(Serialize)]
struct ClassificationJson {
class: String,
label: String,
hint: String,
}
/// Serializable platform descriptor for a URL.
#[derive(Serialize)]
struct PlatformJson {
dir_name: String,
display_name: String,
icon: String,
}
/// Read a `JString` argument into an owned Rust `String`, tolerating a null /
/// undecodable value by returning an empty string (never panics).
fn jstring_to_string(env: &mut JNIEnv, s: &JString) -> String {
if s.is_null() {
return String::new();
}
env.get_string(s)
.map(|js| js.into())
.unwrap_or_default()
}
/// Build a Java `String` from a Rust `&str`. On the (near-impossible) failure
/// to allocate a Java string, returns a null jstring so the JVM sees `null`
/// rather than a dangling pointer.
fn to_jstring(env: &mut JNIEnv, s: &str) -> jstring {
match env.new_string(s) {
Ok(js) => js.into_raw(),
Err(_) => std::ptr::null_mut(),
}
}
/// Run `f` (which produces a String) under `catch_unwind`, returning a Java
/// string of the result or `fallback` if the closure panicked.
fn guarded_string<F: FnOnce() -> String + std::panic::UnwindSafe>(
env: &mut JNIEnv,
fallback: &str,
f: F,
) -> jstring {
match std::panic::catch_unwind(f) {
Ok(out) => to_jstring(env, &out),
Err(_) => to_jstring(env, fallback),
}
}
/// `RustCore.vttParse(vtt: String): String` — parse WebVTT/SRT text into a JSON
/// array of `{start, text}` cues (empty array on any error).
#[no_mangle]
pub extern "system" fn Java_com_catacomb_spike_RustCore_vttParse<'local>(
mut env: JNIEnv<'local>,
_class: JClass<'local>,
input: JString<'local>,
) -> jstring {
let text = jstring_to_string(&mut env, &input);
guarded_string(&mut env, "[]", move || {
let cues: Vec<CueJson> = vtt::parse(&text)
.into_iter()
.map(|c| CueJson { start: c.start, text: c.text })
.collect();
serde_json::to_string(&cues).unwrap_or_else(|_| "[]".to_string())
})
}
/// `RustCore.classifyError(log: String): String` — classify a yt-dlp failure
/// log (one entry per line) into `{class, label, hint}` JSON.
#[no_mangle]
pub extern "system" fn Java_com_catacomb_spike_RustCore_classifyError<'local>(
mut env: JNIEnv<'local>,
_class: JClass<'local>,
input: JString<'local>,
) -> jstring {
let log = jstring_to_string(&mut env, &input);
guarded_string(&mut env, "{}", move || {
let class = error_class::classify(log.lines());
// The enum serializes to its kebab-case id (e.g. "rate-limited").
let class_id = serde_json::to_value(class)
.ok()
.and_then(|v| v.as_str().map(str::to_string))
.unwrap_or_else(|| "other".to_string());
let out = ClassificationJson {
class: class_id,
label: class.label().to_string(),
hint: class.hint().to_string(),
};
serde_json::to_string(&out).unwrap_or_else(|_| "{}".to_string())
})
}
/// `RustCore.platformFromUrl(url: String): String` — resolve a URL to its
/// platform descriptor `{dir_name, display_name, icon}` JSON.
#[no_mangle]
pub extern "system" fn Java_com_catacomb_spike_RustCore_platformFromUrl<'local>(
mut env: JNIEnv<'local>,
_class: JClass<'local>,
input: JString<'local>,
) -> jstring {
let url = jstring_to_string(&mut env, &input);
guarded_string(&mut env, "{}", move || {
let p = platform::Platform::from_url(&url);
let out = PlatformJson {
dir_name: p.dir_name().to_string(),
display_name: p.display_name().to_string(),
icon: p.icon().to_string(),
};
serde_json::to_string(&out).unwrap_or_else(|_| "{}".to_string())
})
}
/// `RustCore.platformDirName(url: String): String` — the backup-folder name for
/// a URL's platform (e.g. `"channels"` for YouTube). Plain string, no JSON.
#[no_mangle]
pub extern "system" fn Java_com_catacomb_spike_RustCore_platformDirName<'local>(
mut env: JNIEnv<'local>,
_class: JClass<'local>,
input: JString<'local>,
) -> jstring {
let url = jstring_to_string(&mut env, &input);
guarded_string(&mut env, "other", move || {
platform::Platform::from_url(&url).dir_name().to_string()
})
}
// ── Host-side tests ─────────────────────────────────────────────────────────
// These exercise the wrapped logic through this crate (not JNI, which needs a
// JVM) so a plain `cargo test` on the dev host proves the shared modules are
// reachable and produce the JSON shapes the Kotlin layer expects.
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn vtt_parse_produces_cue_json() {
let sample = "WEBVTT\n\n00:00:01.000 --> 00:00:03.000\nHello world\n";
let cues: Vec<CueJson> = vtt::parse(sample)
.into_iter()
.map(|c| CueJson { start: c.start, text: c.text })
.collect();
let json = serde_json::to_string(&cues).unwrap();
assert!(json.contains("\"start\":1.0"), "json: {json}");
assert!(json.contains("Hello world"), "json: {json}");
}
#[test]
fn classify_rate_limit_serializes_kebab_case() {
let class = error_class::classify(
["ERROR: HTTP Error 429: Too Many Requests"].into_iter(),
);
let id = serde_json::to_value(class).unwrap();
assert_eq!(id.as_str(), Some("rate-limited"));
assert_eq!(class.label(), "rate-limited");
assert!(!class.hint().is_empty());
}
#[test]
fn platform_from_url_youtube_dir_is_channels() {
let p = platform::Platform::from_url("https://youtu.be/dQw4w9WgXcQ");
assert_eq!(p.dir_name(), "channels");
assert_eq!(p.display_name(), "YouTube");
}
#[test]
fn platform_from_url_unknown_is_other() {
let p = platform::Platform::from_url("https://example.com/x");
assert_eq!(p.dir_name(), "other");
}
}