feat: group global configuration under settings

This commit is contained in:
2026-10-11 17:17:50 +02:00
parent f5c1386315
commit eee610105e
3 changed files with 111 additions and 19 deletions
+104 -15
View File
@@ -344,11 +344,18 @@ pub fn set_category_pinned(path: &Path, name: &str, pinned: bool) -> io::Result<
if !categories.contains_key(name) {
return Err(invalid(format!("Unknown category: {name:?}")));
}
if !document.contains_key("pinned_categories") {
document.insert("pinned_categories", value(Array::new()));
if !document.contains_key("settings") {
document.insert("settings", Item::Table(Table::new()));
}
let array = document["pinned_categories"]
.as_array_mut()
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 {
@@ -367,7 +374,16 @@ pub fn set_category_pinned(path: &Path, name: &str, pinned: bool) -> io::Result<
}
fn parse_pins(document: &DocumentMut) -> io::Result<Vec<String>> {
let Some(item) = document.get("pinned_categories") else {
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
@@ -636,11 +652,82 @@ mod tests {
}
}
#[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\npinned_categories = ['Japanese'] # pin note\n[other]\nvalue = 42\n[categories]\nJapanese = []\nChinese = []\n").unwrap();
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)]);
@@ -662,9 +749,11 @@ mod tests {
assert!(set_category_pinned(&path, "Missing", true).is_err());
assert_eq!(fs::read_to_string(&path).unwrap(), content);
for invalid in [
"pinned_categories = true",
"pinned_categories = [4]",
"pinned_categories = ['A', ' A ']",
"[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();
@@ -688,7 +777,7 @@ mod tests {
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\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"
"# 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();
@@ -737,11 +826,11 @@ mod tests {
before["channels"].to_string()
);
assert_eq!(
document["pinned_categories"].to_string(),
before["pinned_categories"].to_string()
document["settings"]["pinned_categories"].to_string(),
before["settings"]["pinned_categories"].to_string()
);
assert_eq!(document["other"]["value"].as_integer(), Some(42));
assert_eq!(document["player"].as_str(), Some("mpv"));
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"]);
@@ -828,7 +917,7 @@ mod tests {
"[categories]\nSource = ['@manual']\n' Source ' = []\n",
"channels = true\n[categories]\nSource = ['@manual']\n",
"[channels]\n'@manual' = '@different'\n[categories]\nSource = ['@manual']\n",
"pinned_categories = [7]\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());
@@ -861,7 +950,7 @@ mod tests {
let path = directory.0.join("config.toml");
let id = "UC4QobU6STFB0P71PMvOGN5A";
let registry = format!(
"pinned_categories = ['Source']\n[channels]\n'@mapped' = '{id}'\n[categories]\n"
"[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");
+4 -3
View File
File diff suppressed because one or more lines are too long