feat: rename categories from views and selectors
This commit is contained in:
+212
@@ -384,6 +384,83 @@ pub fn set_category_pinned(path: &Path, name: &str, pinned: bool) -> io::Result<
|
||||
})
|
||||
}
|
||||
|
||||
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]"));
|
||||
@@ -912,6 +989,141 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[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();
|
||||
|
||||
Reference in New Issue
Block a user