Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
146 changes: 137 additions & 9 deletions src/config/user_bindings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 <unknown>`) 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<String>,
#[serde(default)]
description: Option<String>,
#[serde(default, rename = "override")]
override_builtin: bool,
nested: HashMap<String, UserBinding>,
},
/// Maps a name to a URL or URL template.
Url {
url: String,
Expand All @@ -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(),
}
}

Expand All @@ -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)"),
}
}
}
Expand All @@ -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
Expand Down Expand Up @@ -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<String, UserBinding>,
parent_name: &str,
full_args: &str,
) -> Option<ResolvedBinding> {
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.
Expand All @@ -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<String> = Vec::new();
match binding {
UserBinding::Url {
Expand Down Expand Up @@ -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::<Vec<_>>()
.join(", ");
parts.push(format!("nested = {{ {} }}", nested_body));
}
}
format!("{} = {{ {} }}", format_toml_key(name), parts.join(", "))
format!("{{ {} }}", parts.join(", "))
}

fn format_toml_key(key: &str) -> String {
Expand Down
25 changes: 23 additions & 2 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -402,21 +402,42 @@ fn print_user_bindings_table(cfg: &BunnylolConfig) {

let rows: Vec<UserBindingRow> = 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()) {
"ignored"
} 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();

Expand Down
91 changes: 82 additions & 9 deletions src/server/web.rs
Original file line number Diff line number Diff line change
Expand Up @@ -153,21 +153,42 @@ impl From<BunnylolCommandInfo> for BindingData {
/// showing them here would be misleading. Bindings with `override = true`
/// are kept.
fn collect_user_bindings(config: &BunnylolConfig) -> Vec<BindingData> {
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<BindingData> = 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",
};
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());
Expand Down Expand Up @@ -526,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(
Expand Down
Loading