use super::super::{
args::CliOptions,
constants::{BILLING_CHECKOUT_ROUTE, VERSION},
errors::{AgentErrorPayload, CliError, CliFailure, Result, agent_error, internal_error},
};
use reqwest::{Method, blocking::Client};
use serde_json::{Value, json};
pub(crate) fn api_request(
cli: &CliOptions,
method: Method,
route: &str,
token: Option<&str>,
body: Option<Value>,
) -> Result<Value> {
let client = Client::builder()
.user_agent(format!("tovuk-cli/{VERSION}"))
.build()
.map_err(|error| internal_error(error.to_string()))?;
let url = format!("{}{}", cli.api_url, route);
let mut request = client
.request(method, url)
.header("accept", "application/json");
if let Some(token) = token {
request = request.bearer_auth(token);
}
if let Some(body) = body {
request = request.json(&body);
}
let response = request.send().map_err(|error| {
agent_error(
"api_unreachable",
format!("Could not reach Tovuk API: {error}"),
"Retry the command. If it keeps failing, check Tovuk status before changing your project.",
cli.output.json,
)
})?;
let status = response.status();
let text = response.text().unwrap_or_default();
let data = parse_json_text(&text);
if status.is_success() {
return Ok(data);
}
let mut payload = agent_payload_from_json(&data).unwrap_or_else(|| AgentErrorPayload {
code: "api_error".to_owned(),
message: format!("Tovuk API returned HTTP {}.", status.as_u16()),
agent_instruction: Some(
"Retry the command. If it keeps failing, check Tovuk status before changing your project."
.to_owned(),
),
docs_url: None,
checkout_url: None,
});
enrich_agent_error_payload(cli, route, token, &mut payload);
Err(CliError::new(CliFailure {
payload,
json: cli.output.json,
exit_code: if status.is_server_error() { 2 } else { 1 },
}))
}
fn parse_json_text(text: &str) -> Value {
if text.trim().is_empty() {
Value::Null
} else {
serde_json::from_str(text).unwrap_or(Value::Null)
}
}
fn agent_payload_from_json(value: &Value) -> Option<AgentErrorPayload> {
let object = value.as_object()?;
Some(AgentErrorPayload {
code: object.get("code")?.as_str()?.to_owned(),
message: object.get("message")?.as_str()?.to_owned(),
agent_instruction: object
.get("agent_instruction")
.and_then(Value::as_str)
.map(str::to_owned),
docs_url: object
.get("docs_url")
.and_then(Value::as_str)
.map(str::to_owned),
checkout_url: object
.get("checkout_url")
.and_then(Value::as_str)
.map(str::to_owned),
})
}
fn enrich_agent_error_payload(
cli: &CliOptions,
route: &str,
token: Option<&str>,
payload: &mut AgentErrorPayload,
) {
if payload.code != "payment_required"
|| payload
.checkout_url
.as_deref()
.is_some_and(|value| !value.is_empty())
|| token.is_none()
|| route == BILLING_CHECKOUT_ROUTE
{
return;
}
if let Some(url) = create_checkout_url(cli, token, &payload.message) {
payload.checkout_url = Some(url);
}
}
pub(crate) fn payment_required_agent_error(
cli: &CliOptions,
token: &str,
message: impl Into<String>,
instruction: impl Into<String>,
) -> CliError {
let mut payload = AgentErrorPayload {
code: "payment_required".to_owned(),
message: message.into(),
agent_instruction: Some(instruction.into()),
docs_url: None,
checkout_url: None,
};
enrich_agent_error_payload(cli, "local:preflight", Some(token), &mut payload);
CliError::new(CliFailure {
payload,
json: cli.output.json,
exit_code: 1,
})
}
fn create_checkout_url(cli: &CliOptions, token: Option<&str>, reason: &str) -> Option<String> {
let token = token?;
let response = api_request(
cli,
Method::POST,
BILLING_CHECKOUT_ROUTE,
Some(token),
Some(json!({
"reason": if reason.is_empty() { "Plan limit reached." } else { reason },
"target_plan": "pro",
})),
)
.ok()?;
response
.get("checkout")
.and_then(|checkout| checkout.get("url"))
.and_then(Value::as_str)
.map(str::to_owned)
}