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
30 changes: 24 additions & 6 deletions src/server/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,23 @@ use rocket::response::Redirect;
#[cfg(feature = "server")]
use crate::{BunnylolCommandRegistry, BunnylolConfig, ConfigReloader, History, utils};

/// Combine the `cmd` query parameter with an optional `args` parameter into the
/// full command string the registry should process.
///
/// The landing-page cards send the command in a hidden `cmd` field and the
/// user's arguments in a separate `args` field; this stitches them back into
/// `"cmd args"`. Empty/whitespace-only `args` yields the bare command, so
/// existing single-parameter requests (e.g. `/?cmd=gh facebook/bunnylol.rs`,
/// `/?cmd=%s`) are unchanged. Returns `None` when there is no command.
#[cfg(feature = "server")]
fn combine_command_args(cmd: Option<&str>, args: Option<&str>) -> Option<String> {
let cmd = cmd?;
match args {
Some(args) if !args.trim().is_empty() => Some(format!("{} {}", cmd, args.trim())),
_ => Some(cmd.to_string()),
}
}

#[cfg(feature = "server")]
mod server_impl {
use super::*;
Expand All @@ -44,27 +61,28 @@ mod server_impl {
}
}

// http://localhost:8000/?cmd=gh
#[rocket::get("/?<cmd>")]
// http://localhost:8000/?cmd=gh (optionally &args=... from the card forms)
#[rocket::get("/?<cmd>&<args>")]
pub(super) fn search(
cmd: Option<&str>,
args: Option<&str>,
config: &State<ConfigReloader>,
client_ip: ClientIP,
) -> Result<Redirect, rocket::response::content::RawHtml<String>> {
let config = config.current();

match cmd {
match combine_command_args(cmd, args) {
Some(cmd_str) => {
println!("bunnylol command: {}", cmd_str);

let command = utils::get_command_from_query_string(cmd_str);
let redirect_url = BunnylolCommandRegistry::process_command(command, cmd_str);
let command = utils::get_command_from_query_string(&cmd_str);
let redirect_url = BunnylolCommandRegistry::process_command(command, &cmd_str);
println!("redirecting to: {}", redirect_url);

// Track command in history if enabled
if config.history.enabled
&& let Some(history) = History::new(&config)
&& let Err(e) = history.add(cmd_str, &client_ip.0)
&& let Err(e) = history.add(&cmd_str, &client_ip.0)
{
eprintln!("Warning: Failed to save command to history: {}", e);
}
Expand Down
87 changes: 74 additions & 13 deletions src/server/web.rs
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,13 @@ fn collect_user_bindings(config: &BunnylolConfig) -> Vec<BindingData> {

#[component]
fn BindingCard(binding: BindingData) -> impl IntoView {
// The card opens commands through the server's `/?cmd=` redirect. Links are
// relative to this server, so they work regardless of host/proxy.
let command_url = format!("/?cmd={}", binding.command);
// The argument form is a JS-free GET form. The command travels in a hidden
// `cmd` field (so it can't be edited away); the visible field carries only
// the user's `args`, which the server combines into `cmd args`.
let cmd_value = binding.command.clone();
view! {
<div
class="binding-card"
Expand All @@ -185,19 +192,21 @@ fn BindingCard(binding: BindingData) -> impl IntoView {
style:transition="transform 0.2s, box-shadow 0.2s"
style:border="2px solid var(--border-light)"
>
<div
style:font-family="'JetBrains Mono', monospace"
style:font-size="1.4em"
style:font-weight="700"
style:color="var(--accent-blue)"
style:margin-bottom="10px"
style:background="var(--bg-white)"
style:padding="8px 12px"
style:border-radius="4px"
style:display="inline-block"
>
{binding.command}
</div>
<a href=command_url style:text-decoration="none" style:display="inline-block">
<div
style:font-family="'JetBrains Mono', monospace"
style:font-size="1.4em"
style:font-weight="700"
style:color="var(--accent-blue)"
style:margin-bottom="10px"
style:background="var(--bg-white)"
style:padding="8px 12px"
style:border-radius="4px"
style:display="inline-block"
>
{binding.command}
</div>
</a>
<div
style:color="var(--text-dark)"
style:margin-bottom="15px"
Expand Down Expand Up @@ -229,6 +238,58 @@ fn BindingCard(binding: BindingData) -> impl IntoView {
{binding.example}
</div>
</div>
// Argument form: the command is fixed (hidden field); the user types
// only arguments and submits (button or Enter) to open a specific
// target. The `×` is a JS-free reset that clears the args field.
<form action="/" method="get" style:margin-top="15px" style:display="flex" style:gap="8px">
<input type="hidden" name="cmd" value=cmd_value />
<div style:position="relative" style:flex="1" style:display="flex">
<input
type="text"
name="args"
placeholder="arguments (optional)"
aria-label="arguments"
style:flex="1"
style:font-family="'JetBrains Mono', monospace"
style:font-size="0.9em"
style:padding="8px 28px 8px 10px"
style:border="1px solid var(--border-light)"
style:border-radius="4px"
style:min-width="0"
/>
<button
type="reset"
aria-label="clear"
title="clear"
style:position="absolute"
style:right="6px"
style:top="50%"
style:transform="translateY(-50%)"
style:border="none"
style:background="transparent"
style:color="var(--text-light)"
style:cursor="pointer"
style:font-size="1.1em"
style:line-height="1"
style:padding="2px 4px"
>
"×"
</button>
</div>
<button
type="submit"
style:font-family="'JetBrains Mono', monospace"
style:font-weight="600"
style:color="var(--bg-white)"
style:background="var(--accent-blue)"
style:border="none"
style:border-radius="4px"
style:padding="8px 14px"
style:cursor="pointer"
>
"open"
</button>
</form>
</div>
}
}
Expand Down
133 changes: 133 additions & 0 deletions tests/cards_feature_test.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
/*
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

//! Integration test for the interactive `serve` cards feature.
//!
//! This is the test the task asks the solution to ship alongside the feature:
//! it exercises the end-to-end behavior of a card's arguments form — typing
//! arguments and submitting opens the combined `cmd args` target through the
//! server's redirect. It spawns the real `bunnylol serve` binary and drives it
//! over HTTP (no browser, no internal symbols).

use std::fs;
use std::io::{Read, Write};
use std::net::{TcpListener, TcpStream};
use std::path::Path;
use std::process::{Child, Command, Stdio};
use std::time::Duration;

fn free_port() -> u16 {
TcpListener::bind(("127.0.0.1", 0))
.expect("bind test port")
.local_addr()
.expect("read test port")
.port()
}

fn write_config(xdg_dir: &Path, port: u16) {
fs::create_dir_all(xdg_dir.join("bunnylol")).expect("create config dir");
fs::write(
xdg_dir.join("bunnylol/config.toml"),
format!(
"default_search = \"google\"\n\n[history]\nenabled = false\n\n[server]\nport = {port}\naddress = \"127.0.0.1\"\nlog_level = \"critical\"\n"
),
)
.expect("write config");
}

struct Server {
child: Child,
}

impl Drop for Server {
fn drop(&mut self) {
let _ = self.child.kill();
let _ = self.child.wait();
}
}

fn http_get(port: u16, path: &str) -> std::io::Result<String> {
let mut stream = TcpStream::connect(("127.0.0.1", port))?;
stream.set_read_timeout(Some(Duration::from_secs(2)))?;
stream.write_all(
format!("GET {path} HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n").as_bytes(),
)?;
let mut response = String::new();
stream.read_to_string(&mut response)?;
Ok(response)
}

fn location(response: &str) -> String {
response
.lines()
.find_map(|line| {
let (name, value) = line.split_once(':')?;
name.eq_ignore_ascii_case("location")
.then(|| value.trim().to_string())
})
.expect("redirect should include a Location header")
}

fn start_server() -> (Server, u16, std::path::PathBuf) {
let mut dir = std::env::temp_dir();
dir.push(format!(
"bunnylol-cards-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
let port = free_port();
write_config(&dir, port);
let child = Command::new(assert_cmd::cargo::cargo_bin!("bunnylol"))
.env("XDG_CONFIG_HOME", &dir)
.args([
"serve",
"--port",
&port.to_string(),
"--address",
"127.0.0.1",
])
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.expect("spawn bunnylol server");
let mut server = Server { child };
for _ in 0..50 {
if let Ok(r) = http_get(port, "/health")
&& r.contains("ok")
{
return (server, port, dir);
}
if server.child.try_wait().expect("status").is_some() {
panic!("server exited early");
}
std::thread::sleep(Duration::from_millis(100));
}
panic!("server did not become ready");
}

#[test]
fn test_card_args_open_combined_target() {
let (server, port, dir) = start_server();
// Typing "hello" into the `g` card and opening combines into `g hello`.
let resp = http_get(port, "/?cmd=g&args=hello").expect("request");
assert_eq!(location(&resp), "https://google.com/search?q=hello");
drop(server);
fs::remove_dir_all(&dir).ok();
}

#[test]
fn test_card_open_bare_command() {
let (server, port, dir) = start_server();
// Clicking the title (no args) opens the bare command.
let resp = http_get(port, "/?cmd=g").expect("request");
assert_eq!(location(&resp), "https://google.com/search?q=");
drop(server);
fs::remove_dir_all(&dir).ok();
}
Loading