From acc5a8d42947d42c765ed42cc270bbfebcbb6329 Mon Sep 17 00:00:00 2001 From: Adrian Rivera Date: Tue, 23 Jun 2026 11:03:19 -0700 Subject: [PATCH 1/3] feat(config): add nested (hierarchical) user bindings --- src/config/user_bindings.rs | 146 +++++++++++++++++++++++++++++++++--- src/server/web.rs | 1 + 2 files changed, 138 insertions(+), 9 deletions(-) diff --git a/src/config/user_bindings.rs b/src/config/user_bindings.rs index 2b63aab..e84ff9c 100644 --- a/src/config/user_bindings.rs +++ b/src/config/user_bindings.rs @@ -5,6 +5,8 @@ * LICENSE file in the root directory of this source tree. */ +use std::collections::HashMap; + use serde::{Deserialize, Serialize}; use super::BunnylolConfig; @@ -42,9 +44,47 @@ use super::BunnylolConfig; /// /// - By default, built-ins win on a name collision. Set `override = true` /// to make a user binding shadow a built-in command. +/// +/// - `Nested` bindings group sub-bindings under a parent name. They are +/// resolved by a two-token lookup: with `aoc = { nested = { r = ... } }`, +/// typing `aoc r` resolves child `r`, `aoc j 5` resolves child `j` with the +/// remainder `5` as its args, and bare `aoc` (or `aoc `) falls back +/// to the parent's own `url` if present. Only the **parent's** `override` +/// flag affects built-in shadowing; child `override` flags are ignored +/// because children are only reachable through the parent. Nesting is one +/// level deep — a child's own `nested` table is not reachable via the +/// two-token dispatch. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(untagged)] pub enum UserBinding { + /// A parent binding with named sub-bindings (one level of nesting). + /// + /// Declared first so serde's untagged matching selects this variant + /// whenever a `nested` table is present (the `Url`/`Command` variants do + /// not deny unknown fields and would otherwise swallow it). A parent may + /// also carry an optional `url` used when invoked bare or with an unknown + /// child. + /// + /// ```toml + /// [user_bindings.aoc] + /// url = "https://adventofcode.com" + /// description = "Advent of code" + /// + /// [user_bindings.aoc.nested.r] + /// url = "https://www.reddit.com/r/adventofcode/" + /// + /// [user_bindings.aoc.nested.j] + /// url = "https://github.com/jrodal98/advent-of-code/blob/master/py2024/day{}/solution.py" + /// ``` + Nested { + #[serde(default)] + url: Option, + #[serde(default)] + description: Option, + #[serde(default, rename = "override")] + override_builtin: bool, + nested: HashMap, + }, /// Maps a name to a URL or URL template. Url { url: String, @@ -68,9 +108,9 @@ impl UserBinding { /// The description shown on the /bindings web page, if any. pub fn description(&self) -> Option<&str> { match self { - UserBinding::Url { description, .. } | UserBinding::Command { description, .. } => { - description.as_deref() - } + UserBinding::Url { description, .. } + | UserBinding::Command { description, .. } + | UserBinding::Nested { description, .. } => description.as_deref(), } } @@ -82,25 +122,31 @@ impl UserBinding { } | UserBinding::Command { override_builtin, .. + } + | UserBinding::Nested { + override_builtin, .. } => *override_builtin, } } - /// Short label for display ("URL" or "CMD"). + /// Short label for display ("URL", "CMD", or "NEST"). pub fn kind_label(&self) -> &'static str { match self { UserBinding::Url { .. } => "URL", UserBinding::Command { .. } => "CMD", + UserBinding::Nested { .. } => "NEST", } } - /// The URL template (for `Url`) or command string (for `Command`), used - /// for displaying the binding's target in the /bindings web page and the - /// CLI `--list` table. + /// The URL template (for `Url`), command string (for `Command`), or the + /// parent URL / a `(N nested)` summary (for `Nested`), used for displaying + /// the binding's target in the /bindings web page and the CLI `--list` + /// table. pub fn display_target(&self) -> &str { match self { UserBinding::Url { url, .. } => url, UserBinding::Command { command, .. } => command, + UserBinding::Nested { url, .. } => url.as_deref().unwrap_or("(nested)"), } } } @@ -121,13 +167,17 @@ impl BunnylolConfig { full_args: &str, ) -> Option<(ResolvedBinding, bool)> { let binding = self.user_bindings.get(name)?; + let overrides = binding.overrides_builtin(); let resolved = match binding { UserBinding::Url { url, .. } => { ResolvedBinding::Url(apply_url_template(url, name, full_args)) } UserBinding::Command { command, .. } => ResolvedBinding::Command(command.clone()), + UserBinding::Nested { url, nested, .. } => { + resolve_nested(url.as_deref(), nested, name, full_args)? + } }; - Some((resolved, binding.overrides_builtin())) + Some((resolved, overrides)) } /// Validate this config's `[user_bindings]` against the set of built-in @@ -156,6 +206,49 @@ impl BunnylolConfig { } } +/// Resolve a `Nested` parent binding via a two-token lookup. +/// +/// The first whitespace-delimited token after `parent_name` selects a child; +/// the remainder is the child's args. On a child hit the child is resolved as +/// an ordinary binding (`Url` → `{}` template, `Command` → rewrite). On a miss +/// (no token, or an unknown child) the parent's own `url` is used, if any; +/// otherwise `None` is returned so the registry falls through to its next tier. +fn resolve_nested( + parent_url: Option<&str>, + nested: &HashMap, + parent_name: &str, + full_args: &str, +) -> Option { + let remainder = full_args + .strip_prefix(parent_name) + .map(|s| s.trim_start()) + .unwrap_or(full_args); + let mut parts = remainder.splitn(2, char::is_whitespace); + let child_key = parts.next().unwrap_or(""); + let child_args = parts.next().unwrap_or("").trim_start(); + + let child = (!child_key.is_empty()) + .then(|| nested.get(child_key)) + .flatten(); + match child { + Some(UserBinding::Url { url, .. }) => Some(ResolvedBinding::Url(apply_url_template( + url, "", child_args, + ))), + Some(UserBinding::Command { command, .. }) => { + Some(ResolvedBinding::Command(command.clone())) + } + // Deeper nesting is not reachable by two-token dispatch; fall back to + // the child's own url, if it declares one. + Some(UserBinding::Nested { url, .. }) => url + .as_deref() + .map(|u| ResolvedBinding::Url(apply_url_template(u, "", child_args))), + // No matching child: fall back to the parent's own url, if any. + None => { + parent_url.map(|u| ResolvedBinding::Url(apply_url_template(u, parent_name, full_args))) + } + } +} + /// Apply a `{}` template substitution to a URL binding. `command` is stripped /// from the front of `full_args`, the remainder is URL-encoded, and /// substituted in. A template with no `{}` is returned as-is. @@ -173,6 +266,17 @@ fn apply_url_template(template: &str, command: &str, full_args: &str) -> String /// Format one `[user_bindings]` entry as its TOML inline-table representation. pub(super) fn format_user_binding_toml(name: &str, binding: &UserBinding) -> String { + format!( + "{} = {}", + format_toml_key(name), + format_user_binding_value(binding) + ) +} + +/// Format a binding's value (the `{ ... }` inline table), recursing into +/// nested children. Kept separate from [`format_user_binding_toml`] so nested +/// children can be rendered without a leading `name = `. +fn format_user_binding_value(binding: &UserBinding) -> String { let mut parts: Vec = Vec::new(); match binding { UserBinding::Url { @@ -201,8 +305,32 @@ pub(super) fn format_user_binding_toml(name: &str, binding: &UserBinding) -> Str parts.push("override = true".to_string()); } } + UserBinding::Nested { + url, + description, + override_builtin, + nested, + } => { + if let Some(u) = url { + parts.push(format!("url = \"{}\"", escape_toml_string(u))); + } + if let Some(d) = description { + parts.push(format!("description = \"{}\"", escape_toml_string(d))); + } + if *override_builtin { + parts.push("override = true".to_string()); + } + let mut children: Vec<(&String, &UserBinding)> = nested.iter().collect(); + children.sort_by_key(|(k, _)| k.to_lowercase()); + let nested_body = children + .into_iter() + .map(|(k, v)| format!("{} = {}", format_toml_key(k), format_user_binding_value(v))) + .collect::>() + .join(", "); + parts.push(format!("nested = {{ {} }}", nested_body)); + } } - format!("{} = {{ {} }}", format_toml_key(name), parts.join(", ")) + format!("{{ {} }}", parts.join(", ")) } fn format_toml_key(key: &str) -> String { diff --git a/src/server/web.rs b/src/server/web.rs index b829f16..6c87ab5 100644 --- a/src/server/web.rs +++ b/src/server/web.rs @@ -162,6 +162,7 @@ fn collect_user_bindings(config: &BunnylolConfig) -> Vec { let default_desc = match binding { crate::config::UserBinding::Url { .. } => "User URL binding", crate::config::UserBinding::Command { .. } => "User command binding", + crate::config::UserBinding::Nested { .. } => "User nested binding", }; BindingData { command: name.clone(), From 0f726de2b14fc0028d0252da2e3049e342d985cf Mon Sep 17 00:00:00 2001 From: Adrian Rivera Date: Wed, 24 Jun 2026 09:29:18 -0700 Subject: [PATCH 2/3] feat(config): expand nested user bindings in --list table The CLI `--list` table only rendered a Nested binding's parent row, so nested children (e.g. `cal g`) were invisible. Expand each Nested parent into its own row plus one row per child, sorted case-insensitively by key. Child rows display as ` ` with a `nested` status, since they are reachable only through the parent and not subject to built-in shadowing. Add an integration test that asserts the parent and child rows appear. --- src/main.rs | 25 +++++++++++++++++++++-- tests/cli_integration.rs | 43 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+), 2 deletions(-) diff --git a/src/main.rs b/src/main.rs index 0ca0785..838b936 100644 --- a/src/main.rs +++ b/src/main.rs @@ -402,7 +402,9 @@ fn print_user_bindings_table(cfg: &BunnylolConfig) { let rows: Vec = entries .into_iter() - .map(|(name, b)| { + .flat_map(|(name, b)| { + // Status reflects how the top-level name interacts with built-ins; + // it is computed once per parent and not applied to child rows. let status = if b.overrides_builtin() { "override" } else if builtins.contains(name.as_str()) { @@ -410,13 +412,32 @@ fn print_user_bindings_table(cfg: &BunnylolConfig) { } else { "active" }; - UserBindingRow { + + let mut rows = vec![UserBindingRow { command: name.clone(), kind: b.kind_label().to_string(), status: status.to_string(), target: b.display_target().to_string(), description: b.description().unwrap_or("—").to_string(), + }]; + + // Expand nested children into their own rows (e.g. `cal g`) so they + // are discoverable. Children sort case-insensitively by key. + if let UserBinding::Nested { nested, .. } = b { + let mut children: Vec<(&String, &UserBinding)> = nested.iter().collect(); + children.sort_by_key(|(k, _)| k.to_lowercase()); + for (child_key, child) in children { + rows.push(UserBindingRow { + command: format!("{} {}", name, child_key), + kind: child.kind_label().to_string(), + status: "nested".to_string(), + target: child.display_target().to_string(), + description: child.description().unwrap_or("—").to_string(), + }); + } } + + rows }) .collect(); diff --git a/tests/cli_integration.rs b/tests/cli_integration.rs index e760f34..883998a 100644 --- a/tests/cli_integration.rs +++ b/tests/cli_integration.rs @@ -357,6 +357,49 @@ ig = { url = "https://example.com/override", override = true } .stdout(predicate::str::contains("override")); } +#[test] +#[cfg(feature = "cli")] +fn test_user_binding_list_expands_nested_children() { + // A Nested parent must render its own row plus one row per child, so + // nested sub-bindings (e.g. `aoc r`) are discoverable in `--list`. + let xdg = write_test_config( + "list-nested", + r#" +[user_bindings] +cal2 = { url = "https://internalfb.com/calendar" } + +[user_bindings.aoc] +url = "https://adventofcode.com" +description = "Advent of Code" + +[user_bindings.aoc.nested.r] +url = "https://www.reddit.com/r/adventofcode/" + +[user_bindings.aoc.nested.repo] +command = "gh jrodal98/advent-of-code" +"#, + ); + + let mut cmd = assert_cmd::cargo::cargo_bin_cmd!("bunnylol"); + cmd.env("XDG_CONFIG_HOME", &xdg) + .arg("--list") + .assert() + .success() + // Parent row: NEST kind, parent fallback url. + .stdout(predicate::str::contains("aoc")) + .stdout(predicate::str::contains("NEST")) + .stdout(predicate::str::contains("https://adventofcode.com")) + // Child rows are expanded as ` ` with the `nested` + // status and their own targets. + .stdout(predicate::str::contains("aoc r")) + .stdout(predicate::str::contains("nested")) + .stdout(predicate::str::contains( + "https://www.reddit.com/r/adventofcode/", + )) + .stdout(predicate::str::contains("aoc repo")) + .stdout(predicate::str::contains("gh jrodal98/advent-of-code")); +} + // ===================================================================== // [aliases] deprecation / migration tests // ===================================================================== From 0c349fc39da9ad511da499df1bbc20fe20a5a8b4 Mon Sep 17 00:00:00 2001 From: Adrian Rivera Date: Wed, 24 Jun 2026 09:44:01 -0700 Subject: [PATCH 3/3] feat(server): render nested user bindings as cards on /bindings The bindings page only rendered a Nested binding's parent card, so nested children were missing from `bunnylol serve`. Expand each Nested parent into its own card plus one card per child (named ` `, e.g. `aoc r`), mirroring the CLI `--list` table. Add a unit test over collect_user_bindings. --- src/server/web.rs | 92 +++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 82 insertions(+), 10 deletions(-) diff --git a/src/server/web.rs b/src/server/web.rs index 6c87ab5..18f1e85 100644 --- a/src/server/web.rs +++ b/src/server/web.rs @@ -153,22 +153,42 @@ impl From for BindingData { /// showing them here would be misleading. Bindings with `override = true` /// are kept. fn collect_user_bindings(config: &BunnylolConfig) -> Vec { + use crate::config::UserBinding; + + // Build a card for a single binding (or nested child). `command` is the + // display name (`"aoc"` for a parent, `"aoc r"` for a child). + fn card(command: String, binding: &UserBinding) -> BindingData { + let default_desc = match binding { + UserBinding::Url { .. } => "User URL binding", + UserBinding::Command { .. } => "User command binding", + UserBinding::Nested { .. } => "User nested binding", + }; + BindingData { + command, + description: binding.description().unwrap_or(default_desc).to_string(), + example: format!("{} — {}", binding.kind_label(), binding.display_target()), + } + } + let builtins = BunnylolCommandRegistry::builtin_binding_names(); let mut rows: Vec = config .user_bindings .iter() .filter(|(name, binding)| binding.overrides_builtin() || !builtins.contains(name.as_str())) - .map(|(name, binding)| { - let default_desc = match binding { - crate::config::UserBinding::Url { .. } => "User URL binding", - crate::config::UserBinding::Command { .. } => "User command binding", - crate::config::UserBinding::Nested { .. } => "User nested binding", - }; - BindingData { - command: name.clone(), - description: binding.description().unwrap_or(default_desc).to_string(), - example: format!("{} — {}", binding.kind_label(), binding.display_target()), + .flat_map(|(name, binding)| { + let mut cards = vec![card(name.clone(), binding)]; + // Expand nested children into their own cards (e.g. `aoc r`) so + // they are discoverable on the bindings page. + if let UserBinding::Nested { nested, .. } = binding { + let mut children: Vec<(&String, &UserBinding)> = nested.iter().collect(); + children.sort_by_key(|(k, _)| k.to_lowercase()); + cards.extend( + children + .into_iter() + .map(|(child_key, child)| card(format!("{} {}", name, child_key), child)), + ); } + cards }) .collect(); rows.sort_by_key(|a| a.command.to_lowercase()); @@ -527,6 +547,58 @@ mod tests { config } + #[test] + fn test_collect_user_bindings_expands_nested_children() { + use std::collections::HashMap; + + let mut nested = HashMap::new(); + nested.insert( + "r".to_string(), + UserBinding::Url { + url: "https://www.reddit.com/r/adventofcode/".to_string(), + description: None, + override_builtin: false, + }, + ); + nested.insert( + "repo".to_string(), + UserBinding::Command { + command: "gh jrodal98/advent-of-code".to_string(), + description: None, + override_builtin: false, + }, + ); + + let mut config = BunnylolConfig::default(); + config.user_bindings.insert( + "aoc".to_string(), + UserBinding::Nested { + url: Some("https://adventofcode.com".to_string()), + description: Some("Advent of Code".to_string()), + override_builtin: false, + nested, + }, + ); + + let rows = collect_user_bindings(&config); + let names: Vec<&str> = rows.iter().map(|r| r.command.as_str()).collect(); + // Parent card plus one card per child (sorted alphabetically overall). + assert_eq!(names, vec!["aoc", "aoc r", "aoc repo"]); + + let parent = rows.iter().find(|r| r.command == "aoc").unwrap(); + assert_eq!(parent.example, "NEST — https://adventofcode.com"); + assert_eq!(parent.description, "Advent of Code"); + + let child_url = rows.iter().find(|r| r.command == "aoc r").unwrap(); + assert_eq!( + child_url.example, + "URL — https://www.reddit.com/r/adventofcode/" + ); + + let child_cmd = rows.iter().find(|r| r.command == "aoc repo").unwrap(); + assert_eq!(child_cmd.example, "CMD — gh jrodal98/advent-of-code"); + } + #[test] fn test_landing_page_cache_invalidates_when_user_bindings_change() { let first = config_with_user_binding(