mod dispatch;
mod listing;
mod runs;
use std::fs;
use std::net::SocketAddr;
use std::path::Path;
use std::sync::{Arc, OnceLock};
use axum::Json;
use axum::Router;
use axum::routing::post;
use rmcp::model::{CallToolRequestParams, CallToolResult, JsonObject};
use serde_json::{Value, json};
use tempfile::TempDir;
use super::{PreparedTools, PromptForgeServer};
use crate::catalog::{Catalog, CatalogHandle, OnBroken};
use crate::config::Config;
use std::num::NonZeroU32;
use promptforge_core::model::{ModelCatalog, ModelDescriptor, ModelId, ThinkingMode};
fn fixture_model_catalog() -> ModelCatalog {
ModelCatalog::new([ModelDescriptor::new(
ModelId::gateway("claude-sonnet-4-6").expect("the test model alias is valid"),
"A model suited for careful analysis, coding, and general assistance",
NonZeroU32::new(200_000).expect("200000 is non-zero"),
ThinkingMode::Never,
)])
.expect("the test catalog has a single unique model")
}
fn prepared(config: &Config) -> Arc<PreparedTools> {
static SEED: OnceLock<PreparedTools> = OnceLock::new();
let seed = SEED.get_or_init(|| {
PreparedTools::new(&config.gateway, fixture_model_catalog())
.expect("prepare fixture tool model")
});
Arc::new(
seed.rebuild(&config.gateway)
.expect("index fixture live tools"),
)
}
fn echo_prompt(name: &str, description: &str) -> String {
format!(
"---\nname: {name}\ndescription: {description}\npromptforge: 1\n---\n\n\
# Test prompt\n\n## Main\n\n```lua\nreturn args\n```\n"
)
}
fn failing_lua_prompt(name: &str) -> String {
format!(
"---\nname: {name}\ndescription: Fails on entry\npromptforge: 1\n---\n\n\
# Test prompt\n\n## Main\n\n```lua\nreturn {{}}\n```\n"
)
}
fn capability_prompt(name: &str, capability: &str) -> String {
format!(
"---\nname: {name}\ndescription: Live capability fixture\npromptforge: 1\n---\n\n\
# Capability prompt\n\n```lua\ntools.need(\"fetch\", \"{capability}\")\n```\n\n\
## Main\n\n```lua\ntools.add(\"fetch\")\n```\n\n```lua\nreturn \"bound\"\n```\n"
)
}
fn write(root: &Path, relative: &str, contents: &str) {
fs::write(root.join(relative), contents).expect("write the fixture prompt");
}
fn server() -> (TempDir, PromptForgeServer) {
server_with("")
}
fn server_with(server_lines: &str) -> (TempDir, PromptForgeServer) {
let dir = tempfile::tempdir().expect("create a temporary prompts directory");
let root = dir.path();
write(root, "echo.md", &echo_prompt("echo", "Echo the input back"));
write(root, "greet.md", &echo_prompt("greet", "Greet a person"));
write(
root,
"summarize.md",
&echo_prompt("summarize", "Summarize a document"),
);
write(root, "explode.md", &failing_lua_prompt("explode"));
let config = Config::from_toml_str(&format!(
"[server]\ntoken = \"t\"\n{server_lines}\n\n\
[gateway]\nurl = \"http://127.0.0.1:8081/v1\"\nkey = \"gw\"\n\n\
[paths]\nprompts = '{}'\n\n\
[catalog]\ninclude = [\"*.md\"]\n",
root.display()
))
.expect("the fixture configuration parses");
let catalog =
Catalog::resolve(&config, OnBroken::Reject).expect("the fixture catalog resolves");
let tools = prepared(&config);
let server = PromptForgeServer::new(
Arc::new(config),
Arc::new(CatalogHandle::new(catalog)),
tools,
);
(dir, server)
}
fn call(name: &'static str, arguments: Value) -> CallToolRequestParams {
let arguments: JsonObject = match arguments {
Value::Object(map) => map,
other => panic!("arguments must be an object, got {other}"),
};
CallToolRequestParams::new(name).with_arguments(arguments)
}
fn text_of(result: &CallToolResult) -> String {
let [block] = result.content.as_slice() else {
panic!("expected exactly one content block")
};
block
.as_text()
.expect("the content block should be text")
.text
.clone()
}
fn structured_of(result: &CallToolResult) -> Value {
result
.structured_content
.clone()
.expect("every run result carries structured content")
}
struct Gateway {
addr: SocketAddr,
shutdown: Option<tokio::sync::oneshot::Sender<()>>,
task: Option<tokio::task::JoinHandle<std::io::Result<()>>>,
}
impl Gateway {
async fn serve(router: Router) -> Gateway {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("bind an ephemeral port");
let addr = listener.local_addr().expect("read the bound address");
let (shutdown, stop) = tokio::sync::oneshot::channel();
let task = tokio::spawn(async move {
axum::serve(listener, router)
.with_graceful_shutdown(async move {
let _ = stop.await;
})
.await
});
Gateway {
addr,
shutdown: Some(shutdown),
task: Some(task),
}
}
fn addr(&self) -> SocketAddr {
self.addr
}
async fn shutdown(mut self) {
if let Some(shutdown) = self.shutdown.take() {
let _ = shutdown.send(());
}
if let Some(task) = self.task.take() {
task.await
.expect("the gateway task joins")
.expect("the gateway served without error");
}
}
}
impl Drop for Gateway {
fn drop(&mut self) {
if let Some(shutdown) = self.shutdown.take() {
let _ = shutdown.send(());
}
if let Some(task) = self.task.take() {
task.abort();
}
}
}
async fn spawn_text_gateway() -> Gateway {
async fn completions(Json(_body): Json<Value>) -> Json<Value> {
Json(json!({
"choices": [{ "message": { "role": "assistant", "content": "spoken" } }]
}))
}
let router = Router::new().route("/v1/chat/completions", post(completions));
Gateway::serve(router).await
}
fn speaking_server(gateway: SocketAddr) -> (TempDir, PromptForgeServer) {
speaking_server_with(gateway, "")
}
fn speaking_server_with(gateway: SocketAddr, server_lines: &str) -> (TempDir, PromptForgeServer) {
let dir = tempfile::tempdir().expect("create a temporary prompts directory");
write(
dir.path(),
"speak.md",
"---\nname: speak\ndescription: Say something\npromptforge: 1\n---\n\n\
# Test prompt\n\n```lua\n\
models.always(\"writer\", \"A model suited for careful analysis, coding, and general assistance\")\n\
```\n\n## Only\n\nSay something.\n",
);
let config = Config::from_toml_str(&format!(
"[server]\ntoken = \"t\"\n{server_lines}\n\n\
[gateway]\nurl = \"http://{gateway}/v1\"\nkey = \"gw\"\n\n\
[paths]\nprompts = '{}'\n\n\
[catalog]\ninclude = [\"*.md\"]\n",
dir.path().display()
))
.expect("the fixture configuration parses");
let catalog =
Catalog::resolve(&config, OnBroken::Reject).expect("the fixture catalog resolves");
let tools = prepared(&config);
let server = PromptForgeServer::new(
Arc::new(config),
Arc::new(CatalogHandle::new(catalog)),
tools,
);
(dir, server)
}