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::(); 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::().unwrap(); assert_eq!( document.iter().map(|(name, _)| name).collect::>(), order .iter() .map(|index| sections[*index].0) .collect::>(), ); } } #[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::(); 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::().unwrap(); assert_eq!(document["settings"]["custom"].as_integer(), Some(42)); assert_eq!( document.iter().map(|(name, _)| name).collect::>(), order .iter() .map(|index| sections[*index].0) .collect::>(), ); } 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::().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::().unwrap(); assert_eq!( document["categories"]["Combined"] .as_array() .unwrap() .iter() .map(|entry| entry.as_str().unwrap()) .collect::>(), [ "@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::().unwrap(); let before_document = latest.parse::().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::().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::().unwrap(); assert_eq!( document.iter().map(|(name, _)| name).collect::>(), ["other", "settings", "keys", "channels", "categories"] ); assert_eq!( document["categories"] .as_table_like() .unwrap() .iter() .map(|(name, _)| name) .collect::>(), ["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::().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::().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::().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::().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::().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::().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::().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, ®istry).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()); }