Files
tubular/src/config.rs
T

1964 lines
79 KiB
Rust

use std::{
collections::{BTreeMap, BTreeSet},
ffi::OsString,
fs::{self, OpenOptions},
io::{self, Write},
path::{Path, PathBuf},
sync::atomic::{AtomicU64, Ordering},
};
use toml_edit::{Array, DocumentMut, Item, Table, value};
use crate::keys;
#[derive(Default, Debug)]
pub struct Config {
pub channels: BTreeMap<String, String>,
pub categories: BTreeMap<String, Vec<String>>,
pub pinned_categories: Vec<String>,
pub keys: keys::Bindings,
}
impl Config {
pub fn subscriptions(&self) -> Vec<String> {
let mut channels: Vec<_> = self
.channels
.values()
.chain(self.categories.values().flatten())
.collect();
channels.sort_unstable();
channels.dedup();
channels.into_iter().cloned().collect()
}
pub fn saved_channels(&self) -> Vec<(String, String)> {
let mut labels = BTreeMap::new();
// Mappings are sorted by handle, so aliases use the first readable label.
for (handle, identity) in &self.channels {
labels.entry(identity).or_insert(handle);
}
for identity in self.categories.values().flatten() {
labels.entry(identity).or_insert(identity);
}
let mut channels: Vec<_> = labels
.into_iter()
.map(|(identity, label)| (label.clone(), identity.clone()))
.collect();
channels.sort_unstable();
channels
}
}
pub fn config_path() -> io::Result<PathBuf> {
resolve_path(
std::env::var_os("XDG_CONFIG_HOME"),
std::env::var_os("HOME"),
)
}
fn resolve_path(xdg: Option<OsString>, home: Option<OsString>) -> io::Result<PathBuf> {
let root = match xdg.filter(|path| !path.is_empty() && Path::new(path).is_absolute()) {
Some(path) => PathBuf::from(path),
None => PathBuf::from(
home.filter(|path| !path.is_empty())
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "HOME is unavailable"))?,
)
.join(".config"),
};
Ok(root.join("tubular/config.toml"))
}
pub fn load(path: &Path) -> io::Result<Config> {
let (_, document) = read_document(path)?;
let channels = parse_channels(&document)?;
let categories = parse_categories(&document, &channels)?;
let pinned_categories = parse_pins(&document)?;
let keys = keys::Bindings::from_toml(document.get("keys"))?;
Ok(Config {
channels,
categories,
pinned_categories,
keys,
})
}
pub fn set_channel_tags(
path: &Path,
tags: &BTreeMap<String, bool>,
handle: &str,
channel: &str,
) -> io::Result<Config> {
let normalized_tags = normalize_tags(tags)?;
let handle = normalize_handle(handle)?;
let channel = normalize_channel(channel)?;
if !channel.starts_with("https://www.youtube.com/channel/") {
return Err(invalid(
"Channel mappings require a canonical UC channel ID",
));
}
let (original, mut document) = read_document(path)?;
let mut mappings = parse_channels(&document)?;
// Validate the whole file before changing it or creating directories.
parse_categories(&document, &mappings)?;
let pinned_categories = parse_pins(&document)?;
let keys = keys::Bindings::from_toml(document.get("keys"))?;
match mappings.get(&handle) {
Some(existing) if existing != &channel => {
return Err(invalid(format!(
"Handle {handle:?} already maps to another channel"
)));
}
Some(_) => {}
None => {
if !document.contains_key("channels") {
document.insert("channels", Item::Table(Table::new()));
}
document["channels"]
.as_table_like_mut()
.expect("channel mappings were validated")
.insert(&handle, value(&channel));
mappings.insert(handle.clone(), channel.clone());
}
}
if !document.contains_key("categories") {
document.insert("categories", Item::Table(Table::new()));
}
let categories = document["categories"]
.as_table_like_mut()
.expect("categories were validated");
for (name, checked) in normalized_tags {
// Keep manually written keys and their formatting, including surrounding spaces.
let key = categories
.iter()
.find(|(key, _)| key.trim() == name)
.map(|(key, _)| key.to_owned())
.unwrap_or_else(|| name.to_owned());
if !categories.contains_key(&key) {
categories.insert(&key, value(Array::new()));
}
let channels = categories
.get_mut(&key)
.and_then(Item::as_array_mut)
.expect("category was validated");
if checked {
let mut found = false;
for entry in channels.iter_mut() {
if resolve_channel(entry.as_str().expect("channel was validated"), &mappings)?
== channel
{
// Keep manually written duplicates and entry comments.
if entry.as_str() != Some(&handle) {
let decor = entry.decor().clone();
*entry = handle.as_str().into();
*entry.decor_mut() = decor;
}
found = true;
}
}
if !found {
channels.push(handle.as_str());
}
} else {
remove_category_channels(channels, &mappings, |identity| identity == channel)?;
}
}
let categories = parse_categories(&document, &mappings)?;
save(path, original.as_deref(), document.to_string().as_bytes())?;
Ok(Config {
channels: mappings,
categories,
pinned_categories,
keys,
})
}
pub fn set_category_tags(
path: &Path,
source: &str,
expected_channels: &[String],
tags: &BTreeMap<String, bool>,
) -> io::Result<Config> {
let source = category_name(source)?;
let tags = normalize_tags(tags)?;
let (original, mut document) = read_document(path)?;
let mappings = parse_channels(&document)?;
let parsed_categories = parse_categories(&document, &mappings)?;
let pinned_categories = parse_pins(&document)?;
let keys = keys::Bindings::from_toml(document.get("keys"))?;
let targets = parsed_categories
.get(source)
.ok_or_else(|| invalid(format!("Unknown source category: {source:?}")))?;
if targets.is_empty() {
return Err(invalid("Cannot edit tags for an empty source category"));
}
let expected: BTreeSet<_> = expected_channels.iter().map(String::as_str).collect();
if targets.len() != expected.len()
|| targets
.iter()
.any(|channel| !expected.contains(channel.as_str()))
{
return Err(invalid(
"Source category changed since opening the editor; please reopen it",
));
}
if !tags.values().any(|checked| *checked)
&& targets
.iter()
.any(|channel| !mappings.values().any(|mapped| mapped == channel))
{
return Err(invalid(
"Cannot clear every category: a source channel is not registered in [channels]",
));
}
let categories = document["categories"]
.as_table_like_mut()
.expect("source category was validated");
let source_entries = categories
.iter()
.find(|(name, _)| name.trim() == source)
.and_then(|(_, item)| item.as_array())
.expect("source category was validated");
let mut additions = BTreeMap::new();
for entry in source_entries.iter() {
let raw = entry.as_str().expect("channel was validated").trim();
let identity = resolve_channel(raw, &mappings)?;
let readable = raw
.strip_prefix("https://www.youtube.com/")
.or_else(|| raw.strip_prefix("https://youtube.com/"))
.filter(|address| address.starts_with('@'))
.unwrap_or(raw);
let addition = additions
.entry(identity)
.or_insert_with(|| readable.to_owned());
if readable.starts_with('@') && !addition.starts_with('@') {
addition.clear();
addition.push_str(readable);
}
}
for (handle, identity) in &mappings {
if let Some(addition) = additions.get_mut(identity)
&& !addition.starts_with('@')
{
addition.clear();
addition.push_str(handle);
}
}
// The checked set is exact, including categories added by another editor.
for (name, item) in categories.iter_mut() {
let entries = item.as_array_mut().expect("category was validated");
if tags.get(name.trim()).copied().unwrap_or(false) {
let present: BTreeSet<_> = entries
.iter()
.map(|entry| {
resolve_channel(entry.as_str().expect("channel was validated"), &mappings)
})
.collect::<io::Result<_>>()?;
for (identity, readable) in &additions {
if !present.contains(identity) {
entries.push(readable.as_str());
}
}
} else {
remove_category_channels(entries, &mappings, |identity| {
additions.contains_key(identity)
})?;
}
}
for (name, checked) in tags {
if checked && !parsed_categories.contains_key(name) {
let mut entries = Array::new();
for readable in additions.values() {
entries.push(readable.as_str());
}
categories.insert(name, value(entries));
}
}
let categories = parse_categories(&document, &mappings)?;
save(path, original.as_deref(), document.to_string().as_bytes())?;
Ok(Config {
channels: mappings,
categories,
pinned_categories,
keys,
})
}
fn normalize_tags(tags: &BTreeMap<String, bool>) -> io::Result<BTreeMap<&str, bool>> {
let mut normalized = BTreeMap::new();
for (name, checked) in tags {
let name = category_name(name)?;
if normalized.insert(name, *checked).is_some() {
return Err(invalid(format!(
"Duplicate category name after trimming: {name:?}"
)));
}
}
Ok(normalized)
}
fn remove_category_channels(
channels: &mut Array,
mappings: &BTreeMap<String, String>,
remove: impl Fn(&str) -> bool,
) -> io::Result<()> {
for index in (0..channels.len()).rev() {
if !remove(&resolve_channel(
channels
.get(index)
.unwrap()
.as_str()
.expect("channel was validated"),
mappings,
)?) {
continue;
}
let removed = channels.remove(index);
// Array comments can be attached to the next entry, not their own line.
let prefix = removed
.decor()
.prefix()
.and_then(|raw| raw.as_str())
.unwrap_or("");
let suffix = removed
.decor()
.suffix()
.and_then(|raw| raw.as_str())
.unwrap_or("");
if prefix.contains('#') || suffix.contains('#') {
if let Some(next) = channels.get_mut(index) {
let next_prefix = next
.decor()
.prefix()
.and_then(|raw| raw.as_str())
.unwrap_or("");
let preserved = format!("{prefix}{suffix}{next_prefix}");
next.decor_mut().set_prefix(preserved);
} else {
let trailing = channels.trailing().as_str().unwrap_or("");
let preserved = format!("{prefix}{suffix}{trailing}");
channels.set_trailing(preserved);
}
}
}
Ok(())
}
pub fn set_category_pinned(path: &Path, name: &str, pinned: bool) -> io::Result<Config> {
let name = category_name(name)?;
let (original, mut document) = read_document(path)?;
let channels = parse_channels(&document)?;
let categories = parse_categories(&document, &channels)?;
let pins = parse_pins(&document)?;
let keys = keys::Bindings::from_toml(document.get("keys"))?;
if !categories.contains_key(name) {
return Err(invalid(format!("Unknown category: {name:?}")));
}
if !document.contains_key("settings") {
document.insert("settings", Item::Table(Table::new()));
}
let settings = document["settings"]
.as_table_like_mut()
.expect("settings were validated");
if !settings.contains_key("pinned_categories") {
settings.insert("pinned_categories", value(Array::new()));
}
let array = settings
.get_mut("pinned_categories")
.and_then(Item::as_array_mut)
.expect("pins were validated");
if let Some(index) = pins.iter().position(|pin| pin == name) {
if !pinned {
array.remove(index);
}
} else if pinned {
array.push(name);
}
let pinned_categories = parse_pins(&document)?;
save(path, original.as_deref(), document.to_string().as_bytes())?;
Ok(Config {
channels,
categories,
pinned_categories,
keys,
})
}
pub fn create_category(path: &Path, name: &str, channels: &[String]) -> io::Result<Config> {
let name = category_name(name)?;
let (original, mut document) = read_document(path)?;
let mappings = parse_channels(&document)?;
let mut categories = parse_categories(&document, &mappings)?;
let pinned_categories = parse_pins(&document)?;
let keys = keys::Bindings::from_toml(document.get("keys"))?;
if categories.contains_key(name) {
return Err(invalid(format!("Category already exists: {name:?}")));
}
let mut labels = BTreeMap::new();
// As in saved_channels, use the first mapped handle for each identity.
for (handle, identity) in &mappings {
labels.entry(identity.as_str()).or_insert(handle.as_str());
}
let identities: BTreeSet<_> = channels
.iter()
.map(|channel| resolve_channel(channel, &mappings))
.collect::<io::Result<_>>()?;
let mut entries = Array::new();
for identity in &identities {
let readable = labels.get(identity.as_str()).copied().unwrap_or_else(|| {
identity
.strip_prefix("https://www.youtube.com/")
.filter(|address| address.starts_with('@'))
.unwrap_or(identity)
});
entries.push(readable);
}
if !document.contains_key("categories") {
document.insert("categories", Item::Table(Table::new()));
}
document["categories"]
.as_table_like_mut()
.expect("categories were validated")
.insert(name, value(entries));
categories.insert(name.to_owned(), identities.into_iter().collect());
save(path, original.as_deref(), document.to_string().as_bytes())?;
Ok(Config {
channels: mappings,
categories,
pinned_categories,
keys,
})
}
pub fn rename_category(path: &Path, old: &str, new: &str) -> io::Result<Config> {
let old = category_name(old)?;
let new = category_name(new)?;
let (original, mut document) = read_document(path)?;
let channels = parse_channels(&document)?;
let categories = parse_categories(&document, &channels)?;
let pinned_categories = parse_pins(&document)?;
let keys = keys::Bindings::from_toml(document.get("keys"))?;
if !categories.contains_key(old) {
return Err(invalid(format!("Unknown source category: {old:?}")));
}
if old == new {
return Ok(Config {
channels,
categories,
pinned_categories,
keys,
});
}
if categories.contains_key(new) {
return Err(invalid(format!("Category already exists: {new:?}")));
}
// KeyMut only edits formatting. Move entries through formatted insertion to
// rename the key without changing its position or any membership formatting.
match &mut document["categories"] {
Item::Table(table) => {
let names: Vec<_> = table.iter().map(|(name, _)| name.to_owned()).collect();
for name in names {
let (mut key, item) = table.remove_entry(&name).expect("category was validated");
if key.get().trim() == old {
key = toml_edit::Key::new(new)
.with_leaf_decor(key.leaf_decor().clone())
.with_dotted_decor(key.dotted_decor().clone());
}
table.insert_formatted(&key, item);
}
}
Item::Value(toml_edit::Value::InlineTable(table)) => {
let names: Vec<_> = table.iter().map(|(name, _)| name.to_owned()).collect();
for name in names {
let (mut key, item) = table.remove_entry(&name).expect("category was validated");
if key.get().trim() == old {
key = toml_edit::Key::new(new)
.with_leaf_decor(key.leaf_decor().clone())
.with_dotted_decor(key.dotted_decor().clone());
}
table.insert_formatted(&key, item);
}
}
_ => unreachable!("categories were validated"),
}
if let Some(array) = document
.get_mut("settings")
.and_then(Item::as_table_like_mut)
.and_then(|settings| settings.get_mut("pinned_categories"))
.and_then(Item::as_array_mut)
{
for pin in array.iter_mut() {
if pin.as_str().expect("pin was validated").trim() == old {
let decor = pin.decor().clone();
*pin = new.into();
*pin.decor_mut() = decor;
}
}
}
let categories = parse_categories(&document, &channels)?;
let pinned_categories = parse_pins(&document)?;
save(path, original.as_deref(), document.to_string().as_bytes())?;
Ok(Config {
channels,
categories,
pinned_categories,
keys,
})
}
fn parse_pins(document: &DocumentMut) -> io::Result<Vec<String>> {
if document.contains_key("pinned_categories") {
return Err(invalid("Move top-level pinned_categories into [settings]"));
}
let Some(settings) = document.get("settings") else {
return Ok(Vec::new());
};
let settings = settings
.as_table_like()
.ok_or_else(|| invalid("settings must be a TOML table"))?;
let Some(item) = settings.get("pinned_categories") else {
return Ok(Vec::new());
};
let array = item
.as_array()
.ok_or_else(|| invalid("pinned_categories must be an array"))?;
let mut pins = Vec::with_capacity(array.len());
for item in array.iter() {
let name = category_name(
item.as_str()
.ok_or_else(|| invalid("Pinned categories must be strings"))?,
)?;
if pins.iter().any(|pin| pin == name) {
return Err(invalid(format!("Duplicate pinned category: {name:?}")));
}
pins.push(name.to_owned());
}
Ok(pins)
}
fn invalid(message: impl Into<String>) -> io::Error {
io::Error::new(io::ErrorKind::InvalidData, message.into())
}
fn category_name(name: &str) -> io::Result<&str> {
if name.chars().any(char::is_control) || name.trim().is_empty() {
return Err(invalid(
"Category names must be nonempty and contain no control characters",
));
}
Ok(name.trim())
}
pub(crate) fn normalize_channel(input: &str) -> io::Result<String> {
if input.chars().any(char::is_control) {
return Err(invalid(
"Channel addresses must contain no control characters",
));
}
let input = input.trim();
let url_address = input
.strip_prefix("https://www.youtube.com/")
.or_else(|| input.strip_prefix("https://youtube.com/"));
let address = url_address.unwrap_or(input);
if let Some(handle) = address.strip_prefix('@') {
// Validate address safety, not creation rules: existing handles can have edge separators.
let length = handle.chars().count();
if length == 0
|| length > 30
|| !handle
.chars()
.all(|c| c.is_alphanumeric() || matches!(c, '_' | '-' | '.'))
{
return Err(invalid("Invalid YouTube channel handle"));
}
return Ok(format!("https://www.youtube.com/@{handle}"));
}
let id = match url_address {
Some(address) => address
.strip_prefix("channel/")
.ok_or_else(|| invalid("Expected a YouTube channel URL"))?,
None => address,
};
if id.len() != 24
|| !id.starts_with("UC")
|| !id
.bytes()
.all(|c| c.is_ascii_alphanumeric() || c == b'_' || c == b'-')
{
return Err(invalid(
"Expected a YouTube @handle or canonical UC channel ID",
));
}
Ok(format!("https://www.youtube.com/channel/{id}"))
}
fn normalize_handle(input: &str) -> io::Result<String> {
let mut address = normalize_channel(input)?;
let prefix = "https://www.youtube.com/";
if !address[prefix.len()..].starts_with('@') {
return Err(invalid("Expected a YouTube @handle"));
}
address.drain(..prefix.len());
Ok(address)
}
fn parse_channels(document: &DocumentMut) -> io::Result<BTreeMap<String, String>> {
let mut mappings = BTreeMap::new();
let Some(item) = document.get("channels") else {
return Ok(mappings);
};
let channels = item
.as_table_like()
.ok_or_else(|| invalid("channels must be a TOML table"))?;
for (handle, address) in channels.iter() {
let handle = normalize_handle(handle)?;
let address = normalize_channel(
address
.as_str()
.ok_or_else(|| invalid("Channel mappings must be strings"))?,
)?;
if !address.starts_with("https://www.youtube.com/channel/") {
return Err(invalid(
"Channel mappings require a canonical UC channel ID",
));
}
if mappings.insert(handle, address).is_some() {
return Err(invalid("Duplicate channel handle after normalization"));
}
}
Ok(mappings)
}
fn resolve_channel(input: &str, mappings: &BTreeMap<String, String>) -> io::Result<String> {
let address = normalize_channel(input)?;
match address
.strip_prefix("https://www.youtube.com/")
.and_then(|handle| mappings.get(handle))
{
Some(mapped) => Ok(mapped.clone()),
None => Ok(address),
}
}
fn read_bytes(path: &Path) -> io::Result<Option<Vec<u8>>> {
match fs::read(path) {
Ok(bytes) => Ok(Some(bytes)),
Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None),
Err(error) => Err(error),
}
}
fn read_document(path: &Path) -> io::Result<(Option<Vec<u8>>, DocumentMut)> {
let bytes = read_bytes(path)?;
let document = match &bytes {
Some(bytes) => std::str::from_utf8(bytes)
.map_err(|error| invalid(format!("Config is not UTF-8: {error}")))?
.parse::<DocumentMut>()
.map_err(|error| invalid(format!("Invalid config TOML: {error}")))?,
None => DocumentMut::new(),
};
Ok((bytes, document))
}
fn parse_categories(
document: &DocumentMut,
mappings: &BTreeMap<String, String>,
) -> io::Result<BTreeMap<String, Vec<String>>> {
let mut parsed = BTreeMap::new();
let Some(item) = document.get("categories") else {
return Ok(parsed);
};
let categories = item
.as_table_like()
.ok_or_else(|| invalid("categories must be a TOML table"))?;
for (name, channels) in categories.iter() {
let name = category_name(name)?;
let channels = channels
.as_array()
.ok_or_else(|| invalid(format!("Category {name:?} must be an array of channels")))?;
let mut normalized = Vec::with_capacity(channels.len());
for channel in channels.iter() {
let channel = resolve_channel(
channel
.as_str()
.ok_or_else(|| invalid("Channels must be strings"))?,
mappings,
)?;
if !normalized.contains(&channel) {
normalized.push(channel);
}
}
if parsed.insert(name.to_owned(), normalized).is_some() {
return Err(invalid(format!(
"Duplicate category name after trimming: {name:?}"
)));
}
}
Ok(parsed)
}
static NEXT_TEMP: AtomicU64 = AtomicU64::new(0);
fn save(path: &Path, original: Option<&[u8]>, contents: &[u8]) -> io::Result<()> {
let parent = path
.parent()
.filter(|p| !p.as_os_str().is_empty())
.unwrap_or(Path::new("."));
let filename = path
.file_name()
.ok_or_else(|| invalid("Config path has no filename"))?;
fs::create_dir_all(parent)?;
let (temporary, mut file) = loop {
let mut name = OsString::from(".");
name.push(filename);
name.push(format!(
".{}.{}.tmp",
std::process::id(),
NEXT_TEMP.fetch_add(1, Ordering::Relaxed)
));
let temporary = parent.join(name);
let mut options = OpenOptions::new();
options.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.mode(0o600);
}
match options.open(&temporary) {
Ok(file) => break (temporary, file),
Err(error) if error.kind() == io::ErrorKind::AlreadyExists => continue,
Err(error) => return Err(error),
}
};
let result = (|| {
if original.is_some() {
file.set_permissions(fs::metadata(path)?.permissions())?;
}
file.write_all(contents)?;
file.sync_all()?;
if read_bytes(path)?.as_deref() != original {
return Err(io::Error::other(
"Config changed while saving; please retry",
));
}
if original.is_none() {
// Unlike rename, hard_link cannot overwrite a file created by a racing editor.
fs::hard_link(&temporary, path)?;
} else {
// std has no compare-and-swap rename; check immediately before replacement.
fs::rename(&temporary, path)?;
}
Ok(())
})();
drop(file);
let _ = fs::remove_file(&temporary);
result
}
#[cfg(test)]
mod tests {
use super::*;
struct TestDirectory(PathBuf);
impl TestDirectory {
fn new() -> Self {
loop {
let path = std::env::temp_dir().join(format!(
"tubular-config-test-{}-{}",
std::process::id(),
NEXT_TEMP.fetch_add(1, Ordering::Relaxed)
));
match fs::create_dir(&path) {
Ok(()) => return Self(path),
Err(error) if error.kind() == io::ErrorKind::AlreadyExists => continue,
Err(error) => panic!("Cannot create test directory: {error}"),
}
}
}
}
impl Drop for TestDirectory {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}
#[test]
fn existing_edge_separator_handles_can_be_saved_and_reloaded() {
let channel = "https://www.youtube.com/channel/UC4QobU6STFB0P71PMvOGN5A";
for handle in ["@hontame_", "@_creator", "@.creator-"] {
let directory = TestDirectory::new();
let path = directory.0.join("config.toml");
let tags = BTreeMap::from([("Immersion".to_owned(), true)]);
let saved = set_channel_tags(&path, &tags, handle, channel).unwrap();
assert_eq!(saved.channels[handle], channel);
assert_eq!(saved.categories["Immersion"], [channel]);
let reloaded = load(&path).unwrap();
assert_eq!(reloaded.channels, saved.channels);
assert_eq!(reloaded.categories, saved.categories);
let unchecked = BTreeMap::from([("Immersion".to_owned(), false)]);
set_channel_tags(
&path,
&unchecked,
&format!("https://www.youtube.com/{handle}"),
channel,
)
.unwrap();
let reloaded = load(&path).unwrap();
assert!(reloaded.categories["Immersion"].is_empty());
assert_eq!(reloaded.channels[handle], channel);
assert_eq!(reloaded.channels.len(), 1);
}
}
#[test]
fn key_remaps_survive_all_saves_and_section_orders() {
use crate::keys::{Action, Context};
use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
let directory = TestDirectory::new();
let path = directory.0.join("config.toml");
let key_section = "# custom controls\n[keys]\napp.quit = ['x', 'ctrl+x'] # quit note\nview.refresh = []\nview.collection.remove.selected = []\n\n[keys.selector]\npin = 'f2' # pin note\n";
let sections = [
("keys", key_section),
("settings", "[settings]\npinned_categories = []\n"),
(
"channels",
"[channels]\n'@example' = 'UC4QobU6STFB0P71PMvOGN5A'\n",
),
("categories", "[categories]\nA = ['@example']\nB = []\n"),
];
for order in [[0, 1, 2, 3], [3, 1, 0, 2], [2, 3, 1, 0]] {
let original = order
.iter()
.map(|index| sections[*index].1)
.collect::<String>();
fs::write(&path, original).unwrap();
let loaded = load(&path).unwrap();
for modifiers in [KeyModifiers::NONE, KeyModifiers::CONTROL] {
assert_eq!(
loaded.keys.resolve(
Context::Normal,
KeyEvent::new(KeyCode::Char('x'), modifiers)
),
Some(Action::Quit),
);
}
assert_eq!(
loaded.keys.resolve(
Context::Normal,
KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE)
),
None,
);
assert_eq!(
loaded.keys.resolve(
Context::SelectorNormal,
KeyEvent::new(KeyCode::F(2), KeyModifiers::NONE)
),
Some(Action::SelectorPin),
);
assert_eq!(
loaded.keys.resolve(
Context::Normal,
KeyEvent::new(KeyCode::Char('r'), KeyModifiers::NONE)
),
None
);
let tags = BTreeMap::from([("A".to_owned(), true), ("B".to_owned(), true)]);
let channel_saved =
set_channel_tags(&path, &tags, "@example", "UC4QobU6STFB0P71PMvOGN5A").unwrap();
let tags = BTreeMap::from([("A".to_owned(), false), ("B".to_owned(), true)]);
let category_saved =
set_category_tags(&path, "A", &channel_saved.categories["A"], &tags).unwrap();
let pin_saved = set_category_pinned(&path, "B", true).unwrap();
for config in [
channel_saved,
category_saved,
pin_saved,
load(&path).unwrap(),
] {
assert_eq!(
config.keys.resolve(
Context::Normal,
KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE)
),
Some(Action::Quit)
);
assert_eq!(
config.keys.resolve(
Context::Normal,
KeyEvent::new(KeyCode::Char('r'), KeyModifiers::NONE)
),
None
);
assert_eq!(
config.keys.resolve(
Context::SelectorNormal,
KeyEvent::new(KeyCode::F(2), KeyModifiers::NONE)
),
Some(Action::SelectorPin)
);
}
let saved = fs::read_to_string(&path).unwrap();
assert!(
saved.contains(key_section),
"key formatting changed: {saved}"
);
let document = saved.parse::<DocumentMut>().unwrap();
assert_eq!(
document.iter().map(|(name, _)| name).collect::<Vec<_>>(),
order
.iter()
.map(|index| sections[*index].0)
.collect::<Vec<_>>(),
);
}
}
#[test]
fn invalid_key_overrides_refuse_every_save_without_modifying_bytes() {
let directory = TestDirectory::new();
let path = directory.0.join("config.toml");
let tags = BTreeMap::from([("A".to_owned(), true), ("B".to_owned(), true)]);
let expected = vec!["https://www.youtube.com/channel/UC4QobU6STFB0P71PMvOGN5A".to_owned()];
for invalid_keys in [
"[keys]\napp.quit = 42\n",
"[keys]\nunknown.action = 'x'\n",
"[keys]\napp.quit = 'not-a-key'\n",
"[keys]\nview.refresh = 'q'\n",
"keys = 'invalid'\n",
] {
let original = format!(
"{invalid_keys}[settings]\npinned_categories = []\n[channels]\n'@example' = 'UC4QobU6STFB0P71PMvOGN5A'\n[categories]\nA = ['@example']\nB = []\n"
);
fs::write(&path, &original).unwrap();
assert_eq!(load(&path).unwrap_err().kind(), io::ErrorKind::InvalidData);
let channel_error =
set_channel_tags(&path, &tags, "@new", "UCabcdefghijklmnopqrstuv").unwrap_err();
assert_eq!(channel_error.kind(), io::ErrorKind::InvalidData);
assert_eq!(fs::read(&path).unwrap(), original.as_bytes());
let category_error = set_category_tags(&path, "A", &expected, &tags).unwrap_err();
assert_eq!(category_error.kind(), io::ErrorKind::InvalidData);
assert_eq!(fs::read(&path).unwrap(), original.as_bytes());
let pin_error = set_category_pinned(&path, "B", true).unwrap_err();
assert_eq!(pin_error.kind(), io::ErrorKind::InvalidData);
assert_eq!(fs::read(&path).unwrap(), original.as_bytes());
let create_error = create_category(&path, "New", &expected).unwrap_err();
assert_eq!(create_error.kind(), io::ErrorKind::InvalidData);
assert_eq!(fs::read(&path).unwrap(), original.as_bytes());
}
}
#[test]
fn settings_can_move_between_sections_and_pin_edits_preserve_their_order() {
let directory = TestDirectory::new();
let path = directory.0.join("config.toml");
let sections = [
(
"settings",
"[settings]\npinned_categories = ['A']\ncustom = 42\n",
),
(
"channels",
"[channels]\n'@example' = 'UC4QobU6STFB0P71PMvOGN5A'\n",
),
("categories", "[categories]\nA = []\nB = ['@example']\n"),
];
for order in [
[0, 1, 2],
[0, 2, 1],
[1, 0, 2],
[1, 2, 0],
[2, 0, 1],
[2, 1, 0],
] {
let original = order
.iter()
.map(|index| sections[*index].1)
.collect::<String>();
fs::write(&path, original).unwrap();
let config = load(&path).unwrap();
assert_eq!(config.pinned_categories, ["A"]);
assert_eq!(
config.categories["B"],
["https://www.youtube.com/channel/UC4QobU6STFB0P71PMvOGN5A"]
);
assert_eq!(
set_category_pinned(&path, "B", true)
.unwrap()
.pinned_categories,
["A", "B"]
);
assert_eq!(
set_category_pinned(&path, "A", false)
.unwrap()
.pinned_categories,
["B"]
);
let saved = load(&path).unwrap();
assert_eq!(saved.pinned_categories, ["B"]);
assert_eq!(saved.categories, config.categories);
assert_eq!(saved.channels, config.channels);
let contents = fs::read_to_string(&path).unwrap();
let document = contents.parse::<DocumentMut>().unwrap();
assert_eq!(document["settings"]["custom"].as_integer(), Some(42));
assert_eq!(
document.iter().map(|(name, _)| name).collect::<Vec<_>>(),
order
.iter()
.map(|index| sections[*index].0)
.collect::<Vec<_>>(),
);
}
for original in [
"[categories]\nA = []\n",
"[settings]\ncustom = 42\n[categories]\nA = []\n",
] {
fs::write(&path, original).unwrap();
set_category_pinned(&path, "A", true).unwrap();
assert_eq!(load(&path).unwrap().pinned_categories, ["A"]);
}
}
#[test]
fn category_pins_persist_without_changing_memberships_or_manual_preferences() {
let directory = TestDirectory::new();
let path = directory.0.join("config.toml");
fs::write(&path, "# favorites\n[settings]\npinned_categories = ['Japanese'] # pin note\n[other]\nvalue = 42\n[categories]\nJapanese = []\nChinese = []\n").unwrap();
let pinned = set_category_pinned(&path, "Chinese", true).unwrap();
assert_eq!(pinned.pinned_categories, ["Japanese", "Chinese"]);
let tags = BTreeMap::from([("Japanese".into(), true)]);
let saved = set_channel_tags(&path, &tags, "@example", "UC4QobU6STFB0P71PMvOGN5A").unwrap();
assert_eq!(saved.pinned_categories, ["Japanese", "Chinese"]);
assert_eq!(
saved.categories["Japanese"],
["https://www.youtube.com/channel/UC4QobU6STFB0P71PMvOGN5A"]
);
let unpinned = set_category_pinned(&path, "Japanese", false).unwrap();
assert_eq!(unpinned.pinned_categories, ["Chinese"]);
assert_eq!(load(&path).unwrap().pinned_categories, ["Chinese"]);
let content = fs::read_to_string(&path).unwrap();
assert!(content.contains("# favorites") && content.contains("# pin note"));
assert_eq!(
content.parse::<DocumentMut>().unwrap()["other"]["value"].as_integer(),
Some(42)
);
assert!(set_category_pinned(&path, "Missing", true).is_err());
assert_eq!(fs::read_to_string(&path).unwrap(), content);
for invalid in [
"[settings]\npinned_categories = true",
"[settings]\npinned_categories = [4]",
"[settings]\npinned_categories = ['A', ' A ']",
"settings = []",
"pinned_categories = ['A']",
] {
fs::write(&path, format!("{invalid}\n[categories]\nA = []\n")).unwrap();
let before = fs::read(&path).unwrap();
assert!(set_category_pinned(&path, "A", true).is_err());
assert!(
set_channel_tags(&path, &tags, "@example", "UC4QobU6STFB0P71PMvOGN5A").is_err()
);
assert_eq!(fs::read(&path).unwrap(), before);
}
}
#[test]
fn category_creation_deduplicates_aliases_and_preserves_original_config() {
let directory = TestDirectory::new();
let path = directory.0.join("config.toml");
let id = "UC4QobU6STFB0P71PMvOGN5A";
let url = format!("https://www.youtube.com/channel/{id}");
let original = "# manual preferences\n[other]\nvalue = 42\n[settings]\npinned_categories = [' Source '] # favorite\nplayer = 'mpv'\n[keys.app]\nquit = ['q'] # key note\n[channels]\n'@zeta' = 'UC4QobU6STFB0P71PMvOGN5A' # mapping note\n'@alpha' = 'UC4QobU6STFB0P71PMvOGN5A'\n[categories]\n# category note\n' Source ' = [\n '@zeta', # entry note\n '@manual',\n '@manual',\n] # array note\nEmpty = []\n";
fs::write(&path, original).unwrap();
let before = load(&path).unwrap();
let created = create_category(
&path,
" Combined ",
&[
id.to_owned(),
url.clone(),
"@zeta".to_owned(),
"https://youtube.com/@alpha".to_owned(),
"@manual".to_owned(),
"https://www.youtube.com/@manual".to_owned(),
"UCabcdefghijklmnopqrstuv".to_owned(),
],
)
.unwrap();
assert_eq!(
created.categories["Combined"],
[
"https://www.youtube.com/@manual".to_owned(),
url,
"https://www.youtube.com/channel/UCabcdefghijklmnopqrstuv".to_owned(),
]
);
assert_eq!(created.channels, before.channels);
assert_eq!(created.categories["Source"], before.categories["Source"]);
assert_eq!(created.categories["Empty"], before.categories["Empty"]);
assert_eq!(created.pinned_categories, before.pinned_categories);
let contents = fs::read_to_string(&path).unwrap();
let mut document = contents.parse::<DocumentMut>().unwrap();
assert_eq!(
document["categories"]["Combined"]
.as_array()
.unwrap()
.iter()
.map(|entry| entry.as_str().unwrap())
.collect::<Vec<_>>(),
[
"@manual",
"@alpha",
"https://www.youtube.com/channel/UCabcdefghijklmnopqrstuv",
]
);
document["categories"]
.as_table_like_mut()
.unwrap()
.remove("Combined");
assert_eq!(document.to_string(), original);
assert_eq!(load(&path).unwrap().categories, created.categories);
assert_eq!(fs::read_dir(&directory.0).unwrap().count(), 1);
}
#[test]
fn category_creation_uses_latest_mappings_and_supports_inline_unicode_and_empty() {
let directory = TestDirectory::new();
let path = directory.0.join("config.toml");
let original = "categories = { Source = ['@first', '@first'], Empty = [] } # categories note\nsettings = { pinned_categories = ['Source'], custom = 42 }\nchannels = { '@mapped' = 'UC4QobU6STFB0P71PMvOGN5A' }\n";
fs::write(&path, original).unwrap();
let opened = load(&path).unwrap();
let latest = original.replace("UC4QobU6STFB0P71PMvOGN5A", "UCabcdefghijklmnopqrstuv");
fs::write(&path, &latest).unwrap();
let created = create_category(
&path,
" 日本語 🎵 ",
&["@mapped".to_owned(), "UCabcdefghijklmnopqrstuv".to_owned()],
)
.unwrap();
assert_ne!(created.channels, opened.channels);
assert_eq!(
created.categories["日本語 🎵"],
["https://www.youtube.com/channel/UCabcdefghijklmnopqrstuv"]
);
assert_eq!(created.pinned_categories, ["Source"]);
let contents = fs::read_to_string(&path).unwrap();
let document = contents.parse::<DocumentMut>().unwrap();
let before_document = latest.parse::<DocumentMut>().unwrap();
assert!(document["categories"].is_inline_table());
assert_eq!(
document["categories"]["日本語 🎵"][0].as_str(),
Some("@mapped")
);
for section in ["settings", "channels"] {
assert_eq!(
document[section].to_string(),
before_document[section].to_string()
);
}
for name in ["Source", "Empty"] {
assert_eq!(
document["categories"][name].to_string(),
before_document["categories"][name].to_string()
);
}
assert!(contents.contains("# categories note"));
let empty = create_category(&path, "New empty", &[]).unwrap();
assert!(empty.categories["New empty"].is_empty());
assert_eq!(empty.pinned_categories, ["Source"]);
let missing = directory.0.join("nested/config.toml");
let empty = create_category(&missing, "Empty", &[]).unwrap();
assert!(empty.categories["Empty"].is_empty());
assert!(empty.channels.is_empty());
assert!(empty.pinned_categories.is_empty());
assert_eq!(load(&missing).unwrap().categories, empty.categories);
}
#[test]
fn category_creation_rejects_invalid_names_channels_and_configs_without_writes() {
let directory = TestDirectory::new();
let path = directory.0.join("config.toml");
let original = "[categories]\n' Source ' = ['@manual']\nEmpty = []\n";
fs::write(&path, original).unwrap();
for name in [
"",
" ",
"Bad\nName",
"Bad\u{7f}Name",
"Source",
" Source ",
"Empty",
] {
assert!(create_category(&path, name, &[]).is_err(), "{name:?}");
assert_eq!(fs::read(&path).unwrap(), original.as_bytes());
}
// Even an identical membership snapshot must not overwrite a category.
assert!(create_category(&path, "Source", &["@manual".to_owned()]).is_err());
assert_eq!(fs::read(&path).unwrap(), original.as_bytes());
for channel in [
"",
"bad",
"@bad handle",
"@manual\n",
"https://example.com/@manual",
"https://www.youtube.com/@manual/videos",
] {
assert!(
create_category(&path, "New", &["@valid".to_owned(), channel.to_owned()]).is_err(),
"{channel:?}"
);
assert_eq!(fs::read(&path).unwrap(), original.as_bytes());
}
for malformed in [
"[categories\n",
"categories = 3\n",
"[categories]\nSource = [7]\n",
"[categories]\nSource = []\n' Source ' = []\n",
"[channels]\n'@mapped' = '@other'\n",
"[settings]\npinned_categories = [7]\n",
"[keys.app]\nunknown = ['q']\n",
] {
fs::write(&path, malformed).unwrap();
assert!(create_category(&path, "New", &[]).is_err());
assert_eq!(fs::read(&path).unwrap(), malformed.as_bytes());
}
let missing = directory.0.join("missing/config.toml");
assert!(create_category(&missing, "Bad\nName", &[]).is_err());
assert!(create_category(&missing, "New", &["@bad handle".to_owned()]).is_err());
assert!(!missing.parent().unwrap().exists());
assert_eq!(fs::read_dir(&directory.0).unwrap().count(), 1);
}
#[test]
fn category_rename_preserves_memberships_pins_comments_and_section_order() {
let directory = TestDirectory::new();
let path = directory.0.join("config.toml");
let original = "# manual preferences\n[other]\nvalue = 42\n[settings]\npinned_categories = [\n ' Source ', # favorite\n 'Keep',\n] # pin note\nplayer = 'mpv'\n[keys.app]\nquit = ['q'] # key note\n[channels]\n'@mapped' = 'UC4QobU6STFB0P71PMvOGN5A' # mapping note\n[categories]\nBefore = []\n# category note\n' Source ' = [\n '@mapped', # entry note\n '@manual',\n '@manual',\n] # array note\nKeep = ['@manual']\n";
fs::write(&path, original).unwrap();
let before = load(&path).unwrap();
let before_document = original.parse::<DocumentMut>().unwrap();
let renamed = rename_category(&path, " Source ", " Renamed group ").unwrap();
assert!(!renamed.categories.contains_key("Source"));
assert_eq!(
renamed.categories["Renamed group"],
before.categories["Source"]
);
assert_eq!(renamed.categories["Keep"], before.categories["Keep"]);
assert_eq!(renamed.channels, before.channels);
assert_eq!(renamed.subscriptions(), before.subscriptions());
assert_eq!(renamed.pinned_categories, ["Renamed group", "Keep"]);
let contents = fs::read_to_string(&path).unwrap();
let document = contents.parse::<DocumentMut>().unwrap();
assert_eq!(
document.iter().map(|(name, _)| name).collect::<Vec<_>>(),
["other", "settings", "keys", "channels", "categories"]
);
assert_eq!(
document["categories"]
.as_table_like()
.unwrap()
.iter()
.map(|(name, _)| name)
.collect::<Vec<_>>(),
["Before", "Renamed group", "Keep"]
);
assert_eq!(
document["categories"]["Renamed group"].to_string(),
before_document["categories"][" Source "].to_string()
);
for section in ["other", "keys", "channels"] {
assert_eq!(
document[section].to_string(),
before_document[section].to_string()
);
}
assert_eq!(document["settings"]["player"].as_str(), Some("mpv"));
for comment in [
"# manual preferences",
"# favorite",
"# pin note",
"# category note",
"# entry note",
"# array note",
"# key note",
"# mapping note",
] {
assert!(contents.contains(comment), "lost {comment}");
}
assert_eq!(load(&path).unwrap().categories, renamed.categories);
assert_eq!(fs::read_dir(&directory.0).unwrap().count(), 1);
}
#[test]
fn category_rename_preserves_inline_tables_and_reads_latest_memberships() {
let directory = TestDirectory::new();
let path = directory.0.join("config.toml");
let original = "categories = { Before = [], Source = ['@first', '@first'], 'Keep' = [] } # categories note\nsettings = { pinned_categories = ['Source'], custom = 42 }\n";
fs::write(&path, original).unwrap();
let opened = load(&path).unwrap();
let latest = original.replace("'@first', '@first'", "'@first', '@late', '@first'");
fs::write(&path, &latest).unwrap();
let renamed = rename_category(&path, "Source", "Renamed").unwrap();
assert_eq!(opened.categories["Source"].len(), 1);
assert_eq!(
renamed.categories["Renamed"],
[
"https://www.youtube.com/@first",
"https://www.youtube.com/@late",
]
);
assert!(!renamed.categories.contains_key("Source"));
assert_eq!(renamed.pinned_categories, ["Renamed"]);
let contents = fs::read_to_string(&path).unwrap();
assert_eq!(
contents,
latest
.replace("Source =", "Renamed =")
.replace("'Source'", "\"Renamed\"")
);
let document = contents.parse::<DocumentMut>().unwrap();
assert!(document["categories"].is_inline_table());
assert_eq!(document["settings"]["custom"].as_integer(), Some(42));
assert_eq!(fs::read_dir(&directory.0).unwrap().count(), 1);
}
#[test]
fn category_rename_rejects_invalid_missing_or_conflicting_names_without_writes() {
let directory = TestDirectory::new();
let path = directory.0.join("config.toml");
let original = "[settings]\npinned_categories = ['Source', 'Keep']\n[categories]\n' Source ' = ['@manual']\nKeep = []\n";
fs::write(&path, original).unwrap();
for (old, new) in [
("Source", ""),
("Source", " \t "),
("Source", "Bad\nName"),
("", "New"),
("Missing", "New"),
("Missing", "Missing"),
("Source", "Keep"),
("Source", " Keep "),
] {
assert!(
rename_category(&path, old, new).is_err(),
"{old:?} -> {new:?}"
);
assert_eq!(fs::read(&path).unwrap(), original.as_bytes());
}
let unchanged = rename_category(&path, " Source ", "Source").unwrap();
assert_eq!(unchanged.categories, load(&path).unwrap().categories);
assert_eq!(fs::read(&path).unwrap(), original.as_bytes());
for invalid in [
"[settings]\npinned_categories = [7]\n[categories]\nSource = []\n",
"[settings]\npinned_categories = ['Source', 'New']\n[categories]\nSource = []\n",
"[keys.global]\nunknown = ['q']\n[categories]\nSource = []\n",
"[categories]\nSource = [7]\n",
"[categories]\nSource = []\n' Source ' = []\n",
] {
fs::write(&path, invalid).unwrap();
assert!(rename_category(&path, "Source", "New").is_err());
assert_eq!(fs::read(&path).unwrap(), invalid.as_bytes());
}
let missing = directory.0.join("missing/config.toml");
assert!(rename_category(&missing, "Source", "New").is_err());
assert!(!missing.parent().unwrap().exists());
assert_eq!(fs::read_dir(&directory.0).unwrap().count(), 1);
}
#[test]
fn category_tags_apply_exact_set_and_preserve_unrelated_entries_and_preferences() {
let directory = TestDirectory::new();
let path = directory.0.join("config.toml");
let first = "UC4QobU6STFB0P71PMvOGN5A";
let second = "UCabcdefghijklmnopqrstuv";
let direct = "UC0123456789012345678901";
let first_url = format!("https://www.youtube.com/channel/{first}");
let second_url = format!("https://www.youtube.com/channel/{second}");
let direct_url = format!("https://www.youtube.com/channel/{direct}");
let manual_url = "https://www.youtube.com/@manual".to_owned();
let original = format!(
"# manual preferences\n[settings]\npinned_categories = ['Source'] # pin note\nplayer = 'mpv'\n[channels]\n'@alias' = '{first}' # alias mapping\n'@second' = '{second_url}'\n[other]\nvalue = 42\n[categories]\n' Source ' = [\n '@alias', # source channel\n '{first_url}',\n '{second}',\n 'https://youtube.com/@manual',\n '@manual',\n '{direct}', # direct channel\n] # source note\nKeep = ['@alias', '@unrelated'] # keep note\nDrop = [\n '@manual', # removed channel note\n 'https://youtube.com/@alias',\n '@unrelated', # unrelated note\n]\nUntouched = ['@unrelated']\n"
);
fs::write(&path, &original).unwrap();
let opened = load(&path).unwrap();
let before = original.parse::<DocumentMut>().unwrap();
// This category was not present in the dialog's tags.
fs::write(
&path,
format!("{original}Late = ['@alias', '@unrelated'] # late note\n"),
)
.unwrap();
let tags = BTreeMap::from([
("Source".to_owned(), false),
("Keep".to_owned(), true),
("Drop".to_owned(), false),
(" New ".to_owned(), true),
]);
let saved =
set_category_tags(&path, " Source ", &opened.categories["Source"], &tags).unwrap();
assert!(saved.categories["Source"].is_empty());
for name in ["Drop", "Late", "Untouched"] {
assert_eq!(
saved.categories[name],
["https://www.youtube.com/@unrelated"]
);
}
assert_eq!(
saved.categories["Keep"],
[
first_url.clone(),
"https://www.youtube.com/@unrelated".to_owned(),
manual_url.clone(),
direct_url.clone(),
second_url.clone(),
]
);
assert_eq!(
saved.categories["New"],
[manual_url, direct_url, first_url, second_url]
);
assert_eq!(saved.channels, opened.channels);
assert_eq!(saved.pinned_categories, ["Source"]);
let content = fs::read_to_string(&path).unwrap();
let document = content.parse::<DocumentMut>().unwrap();
assert_eq!(
document["channels"].to_string(),
before["channels"].to_string()
);
assert_eq!(
document["settings"]["pinned_categories"].to_string(),
before["settings"]["pinned_categories"].to_string()
);
assert_eq!(document["other"]["value"].as_integer(), Some(42));
assert_eq!(document["settings"]["player"].as_str(), Some("mpv"));
let new = document["categories"]["New"].as_array().unwrap();
let raw: Vec<_> = new.iter().map(|entry| entry.as_str().unwrap()).collect();
assert_eq!(raw, ["@manual", direct, "@alias", "@second"]);
let keep = document["categories"]["Keep"].as_array().unwrap();
assert_eq!(keep.get(0).unwrap().as_str(), Some("@alias"));
assert_eq!(keep.get(1).unwrap().as_str(), Some("@unrelated"));
for comment in [
"# manual preferences",
"# pin note",
"# alias mapping",
"# source channel",
"# direct channel",
"# source note",
"# keep note",
"# removed channel note",
"# unrelated note",
"# late note",
] {
assert!(content.contains(comment), "lost {comment}");
}
let reloaded = load(&path).unwrap();
assert_eq!(reloaded.categories, saved.categories);
assert_eq!(reloaded.subscriptions(), saved.subscriptions());
assert_eq!(fs::read_dir(&directory.0).unwrap().count(), 1);
}
#[test]
fn category_tags_compare_identity_sets_not_source_order_or_alias_spelling() {
let directory = TestDirectory::new();
let path = directory.0.join("config.toml");
let id = "UC4QobU6STFB0P71PMvOGN5A";
let url = format!("https://www.youtube.com/channel/{id}");
let registry = format!("[channels]\n'@old' = '{id}'\n'@alias' = '{url}'\n");
fs::write(
&path,
format!("{registry}[categories]\nSource = ['@old', '@manual']\n"),
)
.unwrap();
let expected = load(&path).unwrap().categories.remove("Source").unwrap();
fs::write(
&path,
format!(
"{registry}[categories]\nSource = ['https://youtube.com/@manual', '{url}', '@alias'] # alias note\n"
),
)
.unwrap();
let tags = BTreeMap::from([("Source".to_owned(), true), ("New".to_owned(), true)]);
let saved = set_category_tags(&path, "Source", &expected, &tags).unwrap();
assert_eq!(
saved.categories["Source"],
["https://www.youtube.com/@manual".to_owned(), url.clone()]
);
assert_eq!(
saved.categories["New"],
["https://www.youtube.com/@manual".to_owned(), url]
);
let content = fs::read_to_string(&path).unwrap();
let document = content.parse::<DocumentMut>().unwrap();
let source = document["categories"]["Source"].as_array().unwrap();
assert_eq!(source.len(), 3);
assert_eq!(source.get(2).unwrap().as_str(), Some("@alias"));
let new = document["categories"]["New"].as_array().unwrap();
assert_eq!(new.get(1).unwrap().as_str(), Some("@alias"));
assert!(content.contains("# alias note"));
set_category_tags(&path, "Source", &expected, &tags).unwrap();
assert_eq!(fs::read_to_string(&path).unwrap(), content);
}
#[test]
fn category_tags_reject_stale_sources_invalid_files_and_tags_without_writing() {
let directory = TestDirectory::new();
let path = directory.0.join("config.toml");
let tags = BTreeMap::from([("New".to_owned(), true)]);
let expected = vec!["https://www.youtube.com/@manual".to_owned()];
for contents in [
"[categories]\nOther = ['@manual']\n",
"[categories]\nSource = []\n",
"[categories]\nSource = ['@different']\n",
"[categories]\nSource = ['@manual', '@different']\n",
"[channels]\n'@manual' = 'UC4QobU6STFB0P71PMvOGN5A'\n[categories]\nSource = ['@manual']\n",
"[categories\n",
"categories = true\n",
"[categories]\nSource = ['@manual']\nInvalid = [7]\n",
"[categories]\nSource = ['@manual']\n' Source ' = []\n",
"channels = true\n[categories]\nSource = ['@manual']\n",
"[channels]\n'@manual' = '@different'\n[categories]\nSource = ['@manual']\n",
"[settings]\npinned_categories = [7]\n[categories]\nSource = ['@manual']\n",
] {
fs::write(&path, contents).unwrap();
assert!(set_category_tags(&path, "Source", &expected, &tags).is_err());
assert_eq!(fs::read_to_string(&path).unwrap(), contents);
assert_eq!(fs::read_dir(&directory.0).unwrap().count(), 1);
}
fs::write(&path, [0xff]).unwrap();
assert!(set_category_tags(&path, "Source", &expected, &tags).is_err());
assert_eq!(fs::read(&path).unwrap(), [0xff]);
let valid = "[categories]\nSource = ['@manual']\n";
fs::write(&path, valid).unwrap();
for invalid_tags in [
BTreeMap::from([(" ".to_owned(), true)]),
BTreeMap::from([("bad\nname".to_owned(), true)]),
BTreeMap::from([("New".to_owned(), true), (" New ".to_owned(), false)]),
] {
assert!(set_category_tags(&path, "Source", &expected, &invalid_tags).is_err());
assert_eq!(fs::read_to_string(&path).unwrap(), valid);
}
assert!(set_category_tags(&path, "bad\nname", &expected, &tags).is_err());
assert_eq!(fs::read_to_string(&path).unwrap(), valid);
let missing = directory.0.join("missing/config.toml");
assert!(set_category_tags(&missing, "Source", &expected, &tags).is_err());
assert!(!missing.parent().unwrap().exists());
}
#[test]
fn category_tags_only_clear_all_tags_when_every_target_remains_registered() {
let directory = TestDirectory::new();
let path = directory.0.join("config.toml");
let id = "UC4QobU6STFB0P71PMvOGN5A";
let registry = format!(
"[settings]\npinned_categories = ['Source']\n[channels]\n'@mapped' = '{id}'\n[categories]\n"
);
for source in ["'@mapped', '@manual'", "'UCabcdefghijklmnopqrstuv'"] {
let original = format!("{registry}Source = [{source}]\n");
fs::write(&path, &original).unwrap();
let opened = load(&path).unwrap();
for tags in [
BTreeMap::new(),
BTreeMap::from([("Source".to_owned(), false)]),
] {
let error = set_category_tags(&path, "Source", &opened.categories["Source"], &tags)
.unwrap_err();
assert!(error.to_string().contains("not registered in [channels]"));
assert_eq!(fs::read_to_string(&path).unwrap(), original);
}
}
let original = format!(
"{registry}Source = ['@mapped', '{id}'] # source note\nOther = ['https://youtube.com/@mapped', '@unrelated']\n"
);
fs::write(&path, &original).unwrap();
let opened = load(&path).unwrap();
let saved = set_category_tags(
&path,
"Source",
&opened.categories["Source"],
&BTreeMap::new(),
)
.unwrap();
assert!(saved.categories["Source"].is_empty());
assert_eq!(
saved.categories["Other"],
["https://www.youtube.com/@unrelated"]
);
assert_eq!(saved.channels, opened.channels);
assert_eq!(saved.pinned_categories, ["Source"]);
assert_eq!(
saved.subscriptions(),
[
"https://www.youtube.com/@unrelated",
"https://www.youtube.com/channel/UC4QobU6STFB0P71PMvOGN5A",
]
);
assert!(fs::read_to_string(&path).unwrap().contains("# source note"));
}
#[test]
fn edits_preserve_manual_config_and_refuse_invalid_or_changed_files() {
let directory = TestDirectory::new();
let path = directory.0.join("nested/config.toml");
let id = "UC4QobU6STFB0P71PMvOGN5A";
assert!(load(&path).unwrap().categories.is_empty());
assert!(!path.parent().unwrap().exists());
let tags = BTreeMap::from([(" Music ".to_owned(), true)]);
let config = set_channel_tags(&path, &tags, "@example", id).unwrap();
assert_eq!(
config.channels["@example"],
format!("https://www.youtube.com/channel/{id}")
);
assert_eq!(
config.categories["Music"],
[format!("https://www.youtube.com/channel/{id}")]
);
let manual = "# manual preferences\nplayer = 'mpv' # keep me\n[other]\nvalue = 42\n[categories]\n# favorites\n' Music ' = ['@example', 'https://www.youtube.com/@example'] # channel note\nEmpty = []\n";
fs::write(&path, manual).unwrap();
let tags = BTreeMap::from([("Music".to_owned(), true)]);
let config = set_channel_tags(&path, &tags, "https://youtube.com/@example", id).unwrap();
assert_eq!(config.categories["Music"].len(), 1);
assert!(config.categories["Empty"].is_empty());
let id = "UCabcdefghijklmnopqrstuv";
let config = set_channel_tags(&path, &tags, "@another", id).unwrap();
assert_eq!(
config.categories["Music"][1],
format!("https://www.youtube.com/channel/{id}")
);
let saved = fs::read_to_string(&path).unwrap();
for comment in [
"# manual preferences",
"# keep me",
"# favorites",
"# channel note",
] {
assert!(saved.contains(comment));
}
assert_eq!(
saved.parse::<DocumentMut>().unwrap()["other"]["value"].as_integer(),
Some(42)
);
for malformed in [
"[categories\n",
"categories = 3\n",
"[categories]\nMusic = [3]\n",
"[categories]\nMusic = ['https://example.com/@example']\n",
"[categories]\nMusic = []\n' Music ' = []\n",
"channels = 3\n",
"[channels]\n'@example' = 3\n",
"[channels]\n'@example' = 'https://example.com/channel/UCabcdefghijklmnopqrstuv'\n",
"[channels]\n'@example' = '@another'\n",
"[channels]\n'not-a-handle' = 'UCabcdefghijklmnopqrstuv'\n",
"[channels]\n'@example' = 'UCabcdefghijklmnopqrstuv'\n'https://youtube.com/@example' = 'UCabcdefghijklmnopqrstuv'\n",
] {
fs::write(&path, malformed).unwrap();
assert!(load(&path).is_err());
assert!(set_channel_tags(&path, &tags, "@example", id).is_err());
assert_eq!(fs::read_to_string(&path).unwrap(), malformed);
}
fs::write(&path, manual).unwrap();
for tags in [
BTreeMap::from([(" ".to_owned(), true)]),
BTreeMap::from([("bad\nname".to_owned(), false)]),
BTreeMap::from([("Music".to_owned(), true), (" Music ".to_owned(), false)]),
] {
assert!(set_channel_tags(&path, &tags, "@example", id).is_err());
assert_eq!(fs::read_to_string(&path).unwrap(), manual);
}
assert!(
set_channel_tags(&path, &tags, "https://www.youtube.com/@example/videos", id).is_err()
);
assert_eq!(fs::read_to_string(&path).unwrap(), manual);
assert!(save(&path, Some(b"previous contents"), b"replacement").is_err());
assert_eq!(fs::read_to_string(&path).unwrap(), manual);
assert_eq!(fs::read_dir(path.parent().unwrap()).unwrap().count(), 1);
}
#[test]
fn handles_resolve_to_stable_ids_and_resaving_replaces_opaque_entries() {
let directory = TestDirectory::new();
let path = directory.0.join("config.toml");
let id = "UC4QobU6STFB0P71PMvOGN5A";
let url = format!("https://www.youtube.com/channel/{id}");
let manual = format!(
"[channels]\n'@old' = '{url}'\n[categories]\nJapanese = [\n '{url}', # keep this entry comment\n '@old',\n]\nOther = ['@old']\nDirect = ['@RustVideos']\n"
);
fs::write(&path, manual).unwrap();
let config = load(&path).unwrap();
assert_eq!(config.categories["Japanese"], std::slice::from_ref(&url));
assert_eq!(
config.categories["Direct"],
["https://www.youtube.com/@RustVideos"]
);
let tags = BTreeMap::from([("Japanese".to_owned(), true)]);
let config = set_channel_tags(&path, &tags, "@jawed", id).unwrap();
assert_eq!(config.categories["Japanese"], std::slice::from_ref(&url));
assert_eq!(config.categories["Other"], std::slice::from_ref(&url));
let saved = fs::read_to_string(&path).unwrap();
let document = saved.parse::<DocumentMut>().unwrap();
let entries = document["categories"]["Japanese"].as_array().unwrap();
assert!(entries.iter().all(|entry| entry.as_str() == Some("@jawed")));
assert_eq!(entries.len(), 2);
assert_eq!(document["channels"]["@jawed"].as_str(), Some(url.as_str()));
assert_eq!(document["channels"]["@old"].as_str(), Some(url.as_str()));
assert!(saved.contains("# keep this entry comment"));
set_channel_tags(&path, &tags, "@jawed", id).unwrap();
assert_eq!(fs::read_to_string(&path).unwrap(), saved);
assert!(set_channel_tags(&path, &tags, "@jawed", "UCabcdefghijklmnopqrstuv").is_err());
assert_eq!(fs::read_to_string(&path).unwrap(), saved);
assert_eq!(load(&path).unwrap().categories["Japanese"], [url]);
}
#[test]
fn overlapping_tags_remove_stable_aliases_without_touching_other_channels() {
let directory = TestDirectory::new();
let path = directory.0.join("config.toml");
let id = "UC4QobU6STFB0P71PMvOGN5A";
let url = format!("https://www.youtube.com/channel/{id}");
let other_id = "UCabcdefghijklmnopqrstuv";
let other_url = format!("https://www.youtube.com/channel/{other_id}");
let manual = format!(
"# subscriptions\n[channels]\n'@old' = '{url}' # old alias\n'@other' = '{other_url}' # other mapping\n[categories]\n' First ' = [\n '@other', # unrelated creator\n '{id}',\n '@old',\n 'https://youtube.com/@old',\n '{url}',\n] # first tag\nSecond = ['@other']\nOmitted = ['@old'] # untouched tag\n"
);
fs::write(&path, &manual).unwrap();
let tags = BTreeMap::from([
("First".to_owned(), true),
("Second".to_owned(), true),
("New empty".to_owned(), false),
]);
let config = set_channel_tags(&path, &tags, "@current", id).unwrap();
assert_eq!(config.categories["First"], [other_url.clone(), url.clone()]);
assert_eq!(
config.categories["Second"],
[other_url.clone(), url.clone()]
);
assert!(config.categories["New empty"].is_empty());
let saved = fs::read_to_string(&path).unwrap();
let document = saved.parse::<DocumentMut>().unwrap();
let entries = document["categories"][" First "].as_array().unwrap();
assert_eq!(entries.len(), 5);
assert!(
entries
.iter()
.skip(1)
.all(|entry| entry.as_str() == Some("@current"))
);
assert_eq!(
document["categories"]["Omitted"]
.as_array()
.unwrap()
.get(0)
.unwrap()
.as_str(),
Some("@old")
);
assert_eq!(document["channels"]["@old"].as_str(), Some(url.as_str()));
assert_eq!(
document["channels"]["@other"].as_str(),
Some(other_url.as_str())
);
// Restore manually written aliases so deselection exercises every accepted spelling.
fs::write(&path, &manual).unwrap();
let tags = BTreeMap::from([
("First".to_owned(), false),
("Second".to_owned(), true),
("New empty".to_owned(), false),
]);
let config = set_channel_tags(&path, &tags, "@current", id).unwrap();
assert_eq!(config.categories["First"], std::slice::from_ref(&other_url));
assert_eq!(
config.categories["Second"],
[other_url.clone(), url.clone()]
);
assert_eq!(config.categories["Omitted"], std::slice::from_ref(&url));
assert!(config.categories["New empty"].is_empty());
let saved = fs::read_to_string(&path).unwrap();
for comment in [
"# subscriptions",
"# old alias",
"# other mapping",
"# unrelated creator",
"# first tag",
"# untouched tag",
] {
assert!(saved.contains(comment), "lost {comment}");
}
let tags = BTreeMap::from([
("First".to_owned(), false),
("Second".to_owned(), false),
("Omitted".to_owned(), false),
]);
set_channel_tags(&path, &tags, "@current", id).unwrap();
let config = load(&path).unwrap();
assert_eq!(config.categories["First"], std::slice::from_ref(&other_url));
assert_eq!(config.categories["Second"], [other_url]);
assert!(config.categories["Omitted"].is_empty());
assert!(config.categories["New empty"].is_empty());
assert!(
fs::read_to_string(&path)
.unwrap()
.contains("# unrelated creator")
);
}
#[test]
fn saved_channels_use_readable_aliases_and_include_every_subscription_once() {
let directory = TestDirectory::new();
let path = directory.0.join("config.toml");
let mapped_id = "UC4QobU6STFB0P71PMvOGN5A";
let untagged_id = "UCabcdefghijklmnopqrstuv";
let legacy_id = "UCzyxwvutsrqponmlkjihgfe";
let mapped = format!("https://www.youtube.com/channel/{mapped_id}");
let untagged = format!("https://www.youtube.com/channel/{untagged_id}");
let legacy = format!("https://www.youtube.com/channel/{legacy_id}");
fs::write(
&path,
format!(
"[channels]\n'@zAlias' = '{mapped_id}'\n'https://youtube.com/@aAlias' = '{mapped}'\n'@untagged' = '{untagged_id}'\n[categories]\nFirst = ['@zAlias', '{mapped_id}', '@manual', '{legacy_id}']\nSecond = ['@aAlias', 'https://youtube.com/@manual', '{legacy}']\n"
),
)
.unwrap();
let config = load(&path).unwrap();
let expected = vec![
("@aAlias".to_owned(), mapped.clone()),
("@untagged".to_owned(), untagged.clone()),
(
"https://www.youtube.com/@manual".to_owned(),
"https://www.youtube.com/@manual".to_owned(),
),
(legacy.clone(), legacy.clone()),
];
assert_eq!(config.saved_channels(), expected);
assert_eq!(load(&path).unwrap().saved_channels(), expected);
let mut identities: Vec<_> = config
.saved_channels()
.into_iter()
.map(|(_, identity)| identity)
.collect();
identities.sort_unstable();
assert_eq!(identities, config.subscriptions());
assert!(
config
.categories
.values()
.flatten()
.all(|id| id != &untagged)
);
assert_eq!(config.channels["@zAlias"], mapped);
}
#[test]
fn saved_channels_support_empty_and_registry_only_configs() {
assert!(Config::default().saved_channels().is_empty());
let directory = TestDirectory::new();
let path = directory.0.join("config.toml");
let identity = "https://www.youtube.com/channel/UC4QobU6STFB0P71PMvOGN5A";
fs::write(
&path,
format!("[channels]\n'@zAlias' = '{identity}'\n'@aAlias' = '{identity}'\n"),
)
.unwrap();
let config = load(&path).unwrap();
assert!(config.categories.is_empty());
assert_eq!(
config.saved_channels(),
vec![("@aAlias".to_owned(), identity.to_owned())]
);
assert_eq!(config.subscriptions(), [identity]);
}
#[test]
fn subscriptions_include_untagged_registry_and_manual_channels_after_tag_removal() {
let directory = TestDirectory::new();
let path = directory.0.join("config.toml");
let id = "UC4QobU6STFB0P71PMvOGN5A";
let url = format!("https://www.youtube.com/channel/{id}");
let other_id = "UCabcdefghijklmnopqrstuv";
let other_url = format!("https://www.youtube.com/channel/{other_id}");
let registry =
format!("[channels]\n'https://youtube.com/@old' = '{id}'\n'@alias' = '{url}'\n");
fs::write(&path, &registry).unwrap();
let config = load(&path).unwrap();
assert!(config.categories.is_empty());
assert_eq!(config.channels["@old"], url);
assert_eq!(config.subscriptions(), std::slice::from_ref(&url));
let config = set_channel_tags(&path, &BTreeMap::new(), "@untagged", other_id).unwrap();
assert!(config.categories.is_empty());
assert_eq!(config.subscriptions(), [url.clone(), other_url.clone()]);
assert_eq!(load(&path).unwrap().subscriptions(), config.subscriptions());
fs::write(
&path,
format!(
"{registry}[categories]\nTagged = ['@old', '@alias', '{url}', '{id}']\nManual = ['@RustVideos', 'https://youtube.com/@RustVideos', '{other_id}', '{other_url}']\n"
),
)
.unwrap();
let expected = vec![
"https://www.youtube.com/@RustVideos".to_owned(),
url.clone(),
other_url,
];
let config = load(&path).unwrap();
assert_eq!(config.categories["Tagged"], std::slice::from_ref(&url));
assert_eq!(config.subscriptions(), expected);
let tags = BTreeMap::from([("Tagged".to_owned(), false)]);
let config = set_channel_tags(&path, &tags, "@current", id).unwrap();
assert!(config.categories["Tagged"].is_empty());
assert_eq!(config.subscriptions(), expected);
assert_eq!(load(&path).unwrap().subscriptions(), expected);
}
#[test]
fn paths_and_channel_addresses_are_strict() {
let home = Some(OsString::from("/home/test"));
let fallback = PathBuf::from("/home/test/.config/tubular/config.toml");
for xdg in [
None,
Some(OsString::new()),
Some(OsString::from("relative")),
] {
assert_eq!(resolve_path(xdg, home.clone()).unwrap(), fallback);
}
assert_eq!(
resolve_path(Some("/config".into()), None).unwrap(),
PathBuf::from("/config/tubular/config.toml")
);
assert!(resolve_path(None, None).is_err());
assert!(resolve_path(None, Some(OsString::new())).is_err());
for channel in [
"",
"@",
"@bad/name",
"@bad?query",
"@bad#fragment",
"@bad\n",
"https://www.youtube.com.evil/@example",
"http://www.youtube.com/@example",
"https://www.youtube.com/channel/UCshort",
"channel/UCshort",
"https://www.youtube.com/@example/videos",
"https://www.youtube.com/@example?x=1",
"https://www.youtube.com/@example/../other",
"https://www.youtube.com/@example//",
] {
assert!(normalize_channel(channel).is_err(), "accepted {channel:?}");
}
assert_eq!(
normalize_channel("@example").unwrap(),
"https://www.youtube.com/@example"
);
assert_eq!(
normalize_channel("https://youtube.com/channel/UCabcdefghijklmnopqrstuv").unwrap(),
"https://www.youtube.com/channel/UCabcdefghijklmnopqrstuv"
);
assert!(category_name(" \t ").is_err());
}
}