use crate::cli::commands::{
ApiRunnersCmd, ApiRunnersGroupsCreateCmd, ApiRunnersGroupsDeleteCmd, ApiRunnersGroupsListCmd,
ApiRunnersHostsEnrollCmd, ApiRunnersHostsListCmd, ApiRunnersInventoryListCmd,
ApiRunnersJobsCreateCmd, ApiRunnersSubCommand, ApiTargetOptions, RunnersCmd, RunnersSubCommand,
};
use crate::commands::api_request::{execute_api_request, ApiRequestExecution};
use crate::commands::runners_runtime::{
enrich_runner_job_payload, run_runner_agent, run_runner_group_access,
run_runner_host_preflight, run_runner_host_status, run_runner_logs, run_runner_status,
};
use dialoguer::{theme::ColorfulTheme, Confirm, Select};
use reqwest::Method;
use serde_json::{json, Map, Value};
use std::io::IsTerminal;
pub async fn run_runners(cmd: &RunnersCmd) -> Result<(), String> {
match &cmd.command {
RunnersSubCommand::OrgsList(cmd) => {
execute_without_body("/projects", Method::GET, &cmd.target).await
}
RunnersSubCommand::Groups(cmd) => match &cmd.command {
crate::cli::commands::RunnersGroupsSubCommand::List(list_cmd) => {
run_api_runners(&ApiRunnersCmd {
command: ApiRunnersSubCommand::GroupsList(ApiRunnersGroupsListCmd {
organization_id: list_cmd.organization_id.clone(),
target: list_cmd.target.clone(),
}),
})
.await
}
crate::cli::commands::RunnersGroupsSubCommand::Create(create_cmd) => {
let create_cmd = resolve_runner_group_access_settings(create_cmd)?;
run_api_runners(&ApiRunnersCmd {
command: ApiRunnersSubCommand::GroupsCreate(ApiRunnersGroupsCreateCmd {
organization_id: create_cmd.organization_id.clone(),
name: create_cmd.name.clone(),
visibility: create_cmd.visibility.clone(),
github_installation_id: create_cmd.github_installation_id.clone(),
github_runner_group_id: create_cmd.github_runner_group_id,
sync_state: create_cmd.sync_state.clone(),
sync_error: None,
inherited: create_cmd.inherited,
allows_public_repositories: create_cmd.allows_public_repositories,
interactive: false,
restricted_to_workflows: create_cmd.restricted_to_workflows,
selected_workflows_json: create_cmd.selected_workflows_json.clone(),
metadata_json: create_cmd.metadata_json.clone(),
target: create_cmd.target.clone(),
}),
})
.await
}
crate::cli::commands::RunnersGroupsSubCommand::Update(update_cmd) => {
let update_cmd = resolve_runner_group_access_settings(update_cmd)?;
run_api_runners(&ApiRunnersCmd {
command: ApiRunnersSubCommand::GroupsUpdate(ApiRunnersGroupsCreateCmd {
organization_id: update_cmd.organization_id.clone(),
name: update_cmd.name.clone(),
visibility: update_cmd.visibility.clone(),
github_installation_id: update_cmd.github_installation_id.clone(),
github_runner_group_id: update_cmd.github_runner_group_id,
sync_state: update_cmd.sync_state.clone(),
sync_error: None,
inherited: update_cmd.inherited,
allows_public_repositories: update_cmd.allows_public_repositories,
interactive: false,
restricted_to_workflows: update_cmd.restricted_to_workflows,
selected_workflows_json: update_cmd.selected_workflows_json.clone(),
metadata_json: update_cmd.metadata_json.clone(),
target: update_cmd.target.clone(),
}),
})
.await
}
crate::cli::commands::RunnersGroupsSubCommand::Delete(delete_cmd) => {
run_api_runners(&ApiRunnersCmd {
command: ApiRunnersSubCommand::GroupsDelete(ApiRunnersGroupsDeleteCmd {
runner_group_id: delete_cmd.runner_group_id.clone(),
target: delete_cmd.target.clone(),
}),
})
.await
}
crate::cli::commands::RunnersGroupsSubCommand::Access(access_cmd) => {
run_runner_group_access(access_cmd).await
}
},
RunnersSubCommand::Hosts(cmd) => match &cmd.command {
crate::cli::commands::RunnersHostsSubCommand::List(list_cmd) => {
run_api_runners(&ApiRunnersCmd {
command: ApiRunnersSubCommand::HostsList(ApiRunnersHostsListCmd {
organization_id: list_cmd.organization_id.clone(),
daemon_id: list_cmd.daemon_id.clone(),
status: list_cmd.status.clone(),
target: list_cmd.target.clone(),
}),
})
.await
}
crate::cli::commands::RunnersHostsSubCommand::Enroll(enroll_cmd) => {
run_api_runners(&ApiRunnersCmd {
command: ApiRunnersSubCommand::HostsEnroll(ApiRunnersHostsEnrollCmd {
organization_id: enroll_cmd.organization_id.clone(),
daemon_id: enroll_cmd.daemon_id.clone(),
host_kind: enroll_cmd.host_kind.clone(),
platform: enroll_cmd.platform.clone(),
hostname: enroll_cmd.hostname.clone(),
runner_name_prefix: enroll_cmd.runner_name_prefix.clone(),
display_name: enroll_cmd.display_name.clone(),
arch: enroll_cmd.arch.clone(),
status: enroll_cmd.status.clone(),
labels_json: enroll_cmd.labels_json.clone(),
capabilities_json: enroll_cmd.capabilities_json.clone(),
metadata_json: enroll_cmd.metadata_json.clone(),
target: enroll_cmd.target.clone(),
}),
})
.await
}
crate::cli::commands::RunnersHostsSubCommand::Preflight(preflight_cmd) => {
run_runner_host_preflight(preflight_cmd).await
}
crate::cli::commands::RunnersHostsSubCommand::Status(status_cmd) => {
run_runner_host_status(status_cmd).await
}
},
RunnersSubCommand::Inventory(cmd) => {
run_api_runners(&ApiRunnersCmd {
command: ApiRunnersSubCommand::InventoryList(ApiRunnersInventoryListCmd {
organization_id: cmd.organization_id.clone(),
target: cmd.target.clone(),
}),
})
.await
}
RunnersSubCommand::Status(cmd) => run_runner_status(cmd).await,
RunnersSubCommand::Logs(cmd) => run_runner_logs(cmd).await,
RunnersSubCommand::Sync(cmd) => {
run_api_runners(&ApiRunnersCmd {
command: ApiRunnersSubCommand::JobsCreate(ApiRunnersJobsCreateCmd {
organization_id: cmd.organization_id.clone(),
runner_host_id: cmd.runner_host_id.clone(),
daemon_id: cmd.daemon_id.clone(),
runner_id: None,
job_kind: "sync".to_string(),
priority: None,
max_attempts: None,
run_after: cmd.run_after.clone(),
payload_json: enrich_runner_job_payload(cmd.payload_json.as_deref(), "sync")?,
target: cmd.target.clone(),
}),
})
.await
}
RunnersSubCommand::Deploy(cmd) => {
run_api_runners(&ApiRunnersCmd {
command: ApiRunnersSubCommand::JobsCreate(ApiRunnersJobsCreateCmd {
organization_id: cmd.organization_id.clone(),
runner_host_id: cmd.runner_host_id.clone(),
daemon_id: cmd.daemon_id.clone(),
runner_id: cmd.runner_id.clone(),
job_kind: "deploy".to_string(),
priority: cmd.priority,
max_attempts: None,
run_after: cmd.run_after.clone(),
payload_json: enrich_runner_job_payload(cmd.payload_json.as_deref(), "deploy")?,
target: cmd.target.clone(),
}),
})
.await
}
RunnersSubCommand::Update(cmd) => {
run_api_runners(&ApiRunnersCmd {
command: ApiRunnersSubCommand::JobsCreate(ApiRunnersJobsCreateCmd {
organization_id: cmd.organization_id.clone(),
runner_host_id: cmd.runner_host_id.clone(),
daemon_id: cmd.daemon_id.clone(),
runner_id: cmd.runner_id.clone(),
job_kind: "deploy".to_string(),
priority: cmd.priority,
max_attempts: None,
run_after: cmd.run_after.clone(),
payload_json: enrich_runner_job_payload(cmd.payload_json.as_deref(), "update")?,
target: cmd.target.clone(),
}),
})
.await
}
RunnersSubCommand::Remove(cmd) => {
run_api_runners(&ApiRunnersCmd {
command: ApiRunnersSubCommand::JobsCreate(ApiRunnersJobsCreateCmd {
organization_id: cmd.organization_id.clone(),
runner_host_id: cmd.runner_host_id.clone(),
daemon_id: cmd.daemon_id.clone(),
runner_id: Some(cmd.runner_id.clone()),
job_kind: "remove".to_string(),
priority: cmd.priority,
max_attempts: None,
run_after: cmd.run_after.clone(),
payload_json: enrich_runner_job_payload(cmd.payload_json.as_deref(), "remove")?,
target: cmd.target.clone(),
}),
})
.await
}
RunnersSubCommand::Agent(cmd) => match &cmd.command {
crate::cli::commands::RunnersAgentSubCommand::Serve(serve_cmd) => {
run_runner_agent(serve_cmd).await
}
},
}
}
pub async fn run_api_runners(cmd: &ApiRunnersCmd) -> Result<(), String> {
match &cmd.command {
ApiRunnersSubCommand::HostsList(list_cmd) => {
let path = with_query(
"/runner-hosts",
&[
("organization_id", list_cmd.organization_id.as_deref()),
("daemon_id", list_cmd.daemon_id.as_deref()),
("status", list_cmd.status.as_deref()),
],
);
execute_without_body(&path, Method::GET, &list_cmd.target).await
}
ApiRunnersSubCommand::HostsEnroll(enroll_cmd) => {
let mut body = Map::new();
insert_string(&mut body, "organization_id", &enroll_cmd.organization_id);
insert_optional_string(&mut body, "daemon_id", enroll_cmd.daemon_id.as_deref());
insert_string(&mut body, "host_kind", &enroll_cmd.host_kind);
insert_string(&mut body, "platform", &enroll_cmd.platform);
insert_string(&mut body, "hostname", &enroll_cmd.hostname);
insert_string(
&mut body,
"runner_name_prefix",
&enroll_cmd.runner_name_prefix,
);
insert_optional_string(
&mut body,
"display_name",
enroll_cmd.display_name.as_deref(),
);
insert_optional_string(&mut body, "arch", enroll_cmd.arch.as_deref());
insert_optional_string(&mut body, "status", enroll_cmd.status.as_deref());
insert_optional_json(&mut body, "labels", enroll_cmd.labels_json.as_deref())?;
insert_optional_json(
&mut body,
"capabilities",
enroll_cmd.capabilities_json.as_deref(),
)?;
insert_optional_json(&mut body, "metadata", enroll_cmd.metadata_json.as_deref())?;
execute_with_json_body(
"/runner-hosts",
Method::POST,
Value::Object(body),
&enroll_cmd.target,
)
.await
}
ApiRunnersSubCommand::HostsHeartbeat(heartbeat_cmd) => {
let mut body = Map::new();
insert_optional_string(&mut body, "status", heartbeat_cmd.status.as_deref());
insert_optional_json(&mut body, "labels", heartbeat_cmd.labels_json.as_deref())?;
insert_optional_json(
&mut body,
"capabilities",
heartbeat_cmd.capabilities_json.as_deref(),
)?;
execute_with_json_body(
&format!("/runner-hosts/{}/heartbeat", heartbeat_cmd.runner_host_id),
Method::POST,
Value::Object(body),
&heartbeat_cmd.target,
)
.await
}
ApiRunnersSubCommand::HostsPreflightsList(list_cmd) => {
execute_without_body(
&format!(
"/runner-host-preflights?runner_host_id={}",
list_cmd.runner_host_id
),
Method::GET,
&list_cmd.target,
)
.await
}
ApiRunnersSubCommand::HostsPreflightsUpsert(upsert_cmd) => {
let mut body = Map::new();
insert_string(&mut body, "runner_host_id", &upsert_cmd.runner_host_id);
insert_string(&mut body, "platform", &upsert_cmd.platform);
insert_string(&mut body, "status", &upsert_cmd.status);
if let Some(docker_available) = upsert_cmd.docker_available {
body.insert("docker_available".to_string(), json!(docker_available));
}
insert_optional_string(
&mut body,
"service_manager",
upsert_cmd.service_manager.as_deref(),
);
insert_optional_string(&mut body, "checked_at", upsert_cmd.checked_at.as_deref());
insert_optional_json(&mut body, "checks", upsert_cmd.checks_json.as_deref())?;
execute_with_json_body(
"/runner-host-preflights",
Method::POST,
Value::Object(body),
&upsert_cmd.target,
)
.await
}
ApiRunnersSubCommand::GroupsList(list_cmd) => {
execute_without_body(
&format!("/organizations/{}/runner-groups", list_cmd.organization_id),
Method::GET,
&list_cmd.target,
)
.await
}
ApiRunnersSubCommand::GroupsCreate(create_cmd) => {
let mut body = Map::new();
insert_string(&mut body, "organization_id", &create_cmd.organization_id);
insert_string(&mut body, "name", &create_cmd.name);
insert_string(
&mut body,
"visibility",
&create_cmd
.visibility
.clone()
.unwrap_or_else(|| "selected".to_string()),
);
insert_optional_string(
&mut body,
"github_installation_id",
create_cmd.github_installation_id.as_deref(),
);
if let Some(id) = create_cmd.github_runner_group_id {
body.insert("github_runner_group_id".to_string(), json!(id));
}
insert_optional_string(&mut body, "sync_state", create_cmd.sync_state.as_deref());
insert_optional_string(&mut body, "sync_error", create_cmd.sync_error.as_deref());
body.insert("inherited".to_string(), json!(create_cmd.inherited));
body.insert(
"allows_public_repositories".to_string(),
json!(create_cmd.allows_public_repositories),
);
body.insert(
"restricted_to_workflows".to_string(),
json!(create_cmd.restricted_to_workflows),
);
insert_optional_json(
&mut body,
"selected_workflows",
create_cmd.selected_workflows_json.as_deref(),
)?;
insert_optional_json(&mut body, "metadata", create_cmd.metadata_json.as_deref())?;
execute_with_json_body(
&format!(
"/organizations/{}/runner-groups",
create_cmd.organization_id
),
Method::POST,
Value::Object(body),
&create_cmd.target,
)
.await
}
ApiRunnersSubCommand::GroupsDelete(delete_cmd) => {
execute_without_body(
&format!("/runner-groups/{}", delete_cmd.runner_group_id),
Method::DELETE,
&delete_cmd.target,
)
.await
}
ApiRunnersSubCommand::GroupsUpdate(update_cmd) => {
let mut body = Map::new();
insert_string(&mut body, "organization_id", &update_cmd.organization_id);
insert_string(&mut body, "name", &update_cmd.name);
insert_string(
&mut body,
"visibility",
&update_cmd
.visibility
.clone()
.unwrap_or_else(|| "selected".to_string()),
);
insert_optional_string(
&mut body,
"github_installation_id",
update_cmd.github_installation_id.as_deref(),
);
if let Some(id) = update_cmd.github_runner_group_id {
body.insert("github_runner_group_id".to_string(), json!(id));
}
insert_optional_string(&mut body, "sync_state", update_cmd.sync_state.as_deref());
insert_optional_string(&mut body, "sync_error", update_cmd.sync_error.as_deref());
body.insert("inherited".to_string(), json!(update_cmd.inherited));
body.insert(
"allows_public_repositories".to_string(),
json!(update_cmd.allows_public_repositories),
);
body.insert(
"restricted_to_workflows".to_string(),
json!(update_cmd.restricted_to_workflows),
);
insert_optional_json(
&mut body,
"selected_workflows",
update_cmd.selected_workflows_json.as_deref(),
)?;
insert_optional_json(&mut body, "metadata", update_cmd.metadata_json.as_deref())?;
execute_with_json_body(
&format!(
"/organizations/{}/runner-groups",
update_cmd.organization_id
),
Method::POST,
Value::Object(body),
&update_cmd.target,
)
.await
}
ApiRunnersSubCommand::GroupRepositoriesList(list_cmd) => {
execute_without_body(
&format!("/runner-groups/{}/repositories", list_cmd.runner_group_id),
Method::GET,
&list_cmd.target,
)
.await
}
ApiRunnersSubCommand::GroupRepositoriesCreate(create_cmd) => {
let mut body = Map::new();
if let Some(id) = create_cmd.github_repository_id {
body.insert("github_repository_id".to_string(), json!(id));
}
insert_string(&mut body, "repository_owner", &create_cmd.repository_owner);
insert_string(&mut body, "repository_name", &create_cmd.repository_name);
insert_string(
&mut body,
"repository_full_name",
&create_cmd.repository_full_name,
);
body.insert("is_private".to_string(), json!(create_cmd.is_private));
execute_with_json_body(
&format!("/runner-groups/{}/repositories", create_cmd.runner_group_id),
Method::POST,
Value::Object(body),
&create_cmd.target,
)
.await
}
ApiRunnersSubCommand::InventoryList(list_cmd) => {
execute_without_body(
&format!("/organizations/{}/runners", list_cmd.organization_id),
Method::GET,
&list_cmd.target,
)
.await
}
ApiRunnersSubCommand::InventoryUpsert(upsert_cmd) => {
let mut body = Map::new();
insert_string(&mut body, "organization_id", &upsert_cmd.organization_id);
insert_optional_string(
&mut body,
"runner_host_id",
upsert_cmd.runner_host_id.as_deref(),
);
insert_optional_string(
&mut body,
"runner_group_id",
upsert_cmd.runner_group_id.as_deref(),
);
if let Some(id) = upsert_cmd.github_runner_id {
body.insert("github_runner_id".to_string(), json!(id));
}
insert_string(&mut body, "name", &upsert_cmd.name);
insert_string(&mut body, "platform", &upsert_cmd.platform);
insert_optional_string(&mut body, "os", upsert_cmd.os.as_deref());
insert_optional_string(
&mut body,
"architecture",
upsert_cmd.architecture.as_deref(),
);
insert_optional_string(&mut body, "status", upsert_cmd.status.as_deref());
body.insert("busy".to_string(), json!(upsert_cmd.busy));
insert_optional_json(&mut body, "labels", upsert_cmd.labels_json.as_deref())?;
insert_optional_json(&mut body, "metadata", upsert_cmd.metadata_json.as_deref())?;
execute_with_json_body(
&format!("/organizations/{}/runners", upsert_cmd.organization_id),
Method::POST,
Value::Object(body),
&upsert_cmd.target,
)
.await
}
ApiRunnersSubCommand::JobsList(list_cmd) => {
let path = with_query(
"/runner-jobs",
&[
("organization_id", list_cmd.organization_id.as_deref()),
("runner_host_id", list_cmd.runner_host_id.as_deref()),
("runner_id", list_cmd.runner_id.as_deref()),
("daemon_id", list_cmd.daemon_id.as_deref()),
("status", list_cmd.status.as_deref()),
("phase", list_cmd.phase.as_deref()),
("limit", list_cmd.limit.map(|v| v.to_string()).as_deref()),
],
);
execute_without_body(&path, Method::GET, &list_cmd.target).await
}
ApiRunnersSubCommand::JobsCreate(create_cmd) => {
let mut body = Map::new();
insert_string(&mut body, "organization_id", &create_cmd.organization_id);
insert_optional_string(
&mut body,
"runner_host_id",
create_cmd.runner_host_id.as_deref(),
);
insert_optional_string(&mut body, "daemon_id", create_cmd.daemon_id.as_deref());
insert_optional_string(&mut body, "runner_id", create_cmd.runner_id.as_deref());
insert_string(&mut body, "job_kind", &create_cmd.job_kind);
if let Some(priority) = create_cmd.priority {
body.insert("priority".to_string(), json!(priority));
}
if let Some(max_attempts) = create_cmd.max_attempts {
body.insert("max_attempts".to_string(), json!(max_attempts));
}
insert_optional_string(&mut body, "run_after", create_cmd.run_after.as_deref());
insert_optional_json(&mut body, "payload", create_cmd.payload_json.as_deref())?;
execute_with_json_body(
"/runner-jobs",
Method::POST,
Value::Object(body),
&create_cmd.target,
)
.await
}
ApiRunnersSubCommand::JobsClaim(claim_cmd) => {
let mut body = Map::new();
insert_optional_string(
&mut body,
"runner_host_id",
claim_cmd.runner_host_id.as_deref(),
);
insert_optional_string(&mut body, "daemon_id", claim_cmd.daemon_id.as_deref());
insert_optional_string(&mut body, "locked_by", claim_cmd.locked_by.as_deref());
execute_with_json_body(
"/runner-jobs/claim",
Method::POST,
Value::Object(body),
&claim_cmd.target,
)
.await
}
ApiRunnersSubCommand::JobsUpdate(update_cmd) => {
let mut body = Map::new();
insert_string(&mut body, "status", &update_cmd.status);
insert_optional_string(&mut body, "error_text", update_cmd.error_text.as_deref());
execute_with_json_body(
&format!("/runner-jobs/{}", update_cmd.runner_job_id),
Method::PATCH,
Value::Object(body),
&update_cmd.target,
)
.await
}
}
}
fn resolve_runner_group_access_settings(
cmd: &crate::cli::commands::RunnersGroupsCreateCmd,
) -> Result<crate::cli::commands::RunnersGroupsCreateCmd, String> {
let mut resolved = cmd.clone();
if !resolved.interactive {
if resolved.visibility.is_none() {
return Err(
"Missing `--visibility`. Pass `--interactive` to configure access interactively."
.to_string(),
);
}
return Ok(resolved);
}
if !std::io::stdin().is_terminal() {
return Err(
"`xbp runners groups create --interactive` requires an interactive terminal."
.to_string(),
);
}
let theme = ColorfulTheme::default();
let visibilities = ["selected", "all", "private"];
let default_visibility = resolved
.visibility
.as_deref()
.and_then(|value| {
visibilities
.iter()
.position(|candidate| candidate == &value)
})
.unwrap_or(0);
let selected = Select::with_theme(&theme)
.with_prompt("Runner group visibility")
.items(&visibilities)
.default(default_visibility)
.interact()
.map_err(|error| format!("Failed to read runner group visibility: {error}"))?;
resolved.visibility = Some(visibilities[selected].to_string());
let allow_public = Confirm::with_theme(&theme)
.with_prompt("Allow public repositories to use this runner group?")
.default(resolved.allows_public_repositories)
.interact()
.map_err(|error| format!("Failed to read public repository access: {error}"))?;
if allow_public {
let confirmed = Confirm::with_theme(&theme)
.with_prompt(
"Self-hosted runners on public repositories and public forks are a significant security risk. Continue?",
)
.default(false)
.interact()
.map_err(|error| format!("Failed to confirm public repository access: {error}"))?;
if !confirmed {
return Err(
"Cancelled enabling public repository access for this runner group.".to_string(),
);
}
}
resolved.allows_public_repositories = allow_public;
resolved.restricted_to_workflows = Confirm::with_theme(&theme)
.with_prompt("Restrict this runner group to selected workflows?")
.default(resolved.restricted_to_workflows)
.interact()
.map_err(|error| format!("Failed to read workflow restriction setting: {error}"))?;
Ok(resolved)
}
async fn execute_without_body(
path: &str,
method: Method,
target: &ApiTargetOptions,
) -> Result<(), String> {
execute_api_request(ApiRequestExecution {
path: path.to_string(),
method,
body: None,
body_file: None,
target: target.clone(),
})
.await
}
async fn execute_with_json_body(
path: &str,
method: Method,
body: Value,
target: &ApiTargetOptions,
) -> Result<(), String> {
execute_api_request(ApiRequestExecution {
path: path.to_string(),
method,
body: Some(
serde_json::to_string(&body)
.map_err(|e| format!("Failed to serialize API request body: {}", e))?,
),
body_file: None,
target: target.clone(),
})
.await
}
fn insert_string(map: &mut Map<String, Value>, key: &str, value: &str) {
map.insert(key.to_string(), Value::String(value.to_string()));
}
fn insert_optional_string(map: &mut Map<String, Value>, key: &str, value: Option<&str>) {
if let Some(value) = value {
insert_string(map, key, value);
}
}
fn insert_optional_json(
map: &mut Map<String, Value>,
key: &str,
raw: Option<&str>,
) -> Result<(), String> {
if let Some(raw) = raw {
let parsed: Value = serde_json::from_str(raw)
.map_err(|e| format!("Failed to parse {} as JSON: {}", key, e))?;
map.insert(key.to_string(), parsed);
}
Ok(())
}
fn with_query(path: &str, params: &[(&str, Option<&str>)]) -> String {
let rendered: Vec<String> = params
.iter()
.filter_map(|(key, value)| value.map(|value| format!("{}={}", key, value)))
.collect();
if rendered.is_empty() {
path.to_string()
} else {
format!("{}?{}", path, rendered.join("&"))
}
}