#![deny(missing_docs)]
use airl_ir::Module;
use airl_patch::{Impact, Patch};
use airl_project::constraints::{Constraint, ConstraintViolation};
use airl_project::diff::ModuleDiff;
use airl_project::queries::{BuiltinUsage, DeadCodeReport, EffectSurface};
use airl_project::{CallEdge, EffectSummary, FuncSummary};
use serde::{Deserialize, Serialize};
use std::time::Duration;
use thiserror::Error;
#[derive(Debug, Error)]
pub enum SdkError {
#[error("HTTP transport error: {0}")]
Transport(String),
#[error("API error {status} ({code}): {message}")]
Api {
status: u16,
code: String,
message: String,
},
#[error("response parse error: {0}")]
Parse(#[from] serde_json::Error),
}
#[derive(Debug, Deserialize)]
struct ApiErrorBody {
error: String,
code: String,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct ProjectInfo {
pub name: String,
pub version: String,
pub function_count: usize,
pub history_length: usize,
}
#[derive(Debug, Clone, Deserialize)]
pub struct ModuleResponse {
pub module: Module,
pub version: String,
}
#[derive(Debug, Clone, Deserialize)]
pub struct DiagnosticResponse {
pub severity: String,
pub node_id: Option<String>,
pub message: String,
}
#[derive(Debug, Clone, Deserialize)]
pub struct TypeCheckResponse {
pub success: bool,
pub errors: Vec<DiagnosticResponse>,
pub warnings: Vec<DiagnosticResponse>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct InterpretResponse {
pub success: bool,
pub stdout: String,
pub exit_code: i32,
pub error: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct CompileResponse {
pub success: bool,
pub stdout: String,
pub exit_code: i32,
pub compile_time_ms: u64,
pub error: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct PatchResultResponse {
pub success: bool,
pub new_version: String,
pub impact: Impact,
}
#[derive(Debug, Clone, Deserialize)]
pub struct PatchPreviewResponse {
pub would_succeed: bool,
pub validation_error: Option<String>,
pub type_errors: Vec<String>,
pub impact: Impact,
}
#[derive(Debug, Clone, Deserialize)]
pub struct ConstraintsResponse {
pub ok: bool,
pub violations: Vec<ConstraintViolation>,
}
#[derive(Debug, Clone, Copy, Serialize)]
pub struct InterpretLimits {
pub max_steps: u64,
pub max_call_depth: u32,
}
impl Default for InterpretLimits {
fn default() -> Self {
Self {
max_steps: 1_000_000,
max_call_depth: 1000,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ProjectionLang {
TypeScript,
Python,
Json,
Pseudocode,
}
impl ProjectionLang {
fn as_str(self) -> &'static str {
match self {
ProjectionLang::TypeScript => "typescript",
ProjectionLang::Python => "python",
ProjectionLang::Json => "json",
ProjectionLang::Pseudocode => "pseudocode",
}
}
}
#[derive(Debug, Clone, Deserialize)]
pub struct TextProjectionResponse {
pub language: String,
pub text: String,
}
pub struct Client {
base_url: String,
agent: ureq::Agent,
auth_token: Option<String>,
}
impl Client {
pub fn new(base_url: impl Into<String>) -> Self {
let agent = ureq::AgentBuilder::new()
.timeout(Duration::from_secs(30))
.build();
Self {
base_url: base_url.into().trim_end_matches('/').to_string(),
agent,
auth_token: None,
}
}
pub fn with_auth_token(mut self, token: impl Into<String>) -> Self {
self.auth_token = Some(token.into());
self
}
pub fn with_timeout(mut self, timeout: Duration) -> Self {
self.agent = ureq::AgentBuilder::new().timeout(timeout).build();
self
}
pub fn create_project(
&self,
name: impl Into<String>,
module_json: impl Into<String>,
) -> Result<ProjectInfo, SdkError> {
self.post(
"/project/create",
&serde_json::json!({
"name": name.into(),
"module_json": module_json.into(),
}),
)
}
pub fn get_project(&self) -> Result<ProjectInfo, SdkError> {
self.get("/project")
}
pub fn get_module(&self) -> Result<ModuleResponse, SdkError> {
self.get("/module")
}
pub fn apply_patch(&self, patch: &Patch) -> Result<PatchResultResponse, SdkError> {
self.post("/patch/apply", patch)
}
pub fn preview_patch(&self, patch: &Patch) -> Result<PatchPreviewResponse, SdkError> {
self.post("/patch/preview", patch)
}
pub fn undo_patch(&self) -> Result<PatchResultResponse, SdkError> {
self.post("/patch/undo", &serde_json::json!({}))
}
pub fn typecheck(&self) -> Result<TypeCheckResponse, SdkError> {
self.post("/typecheck", &serde_json::json!({}))
}
pub fn check_constraints(
&self,
constraints: &[Constraint],
) -> Result<ConstraintsResponse, SdkError> {
self.post(
"/constraints/check",
&serde_json::json!({ "constraints": constraints }),
)
}
pub fn diff(&self, other_module_json: impl Into<String>) -> Result<ModuleDiff, SdkError> {
self.post(
"/diff",
&serde_json::json!({ "other_module_json": other_module_json.into() }),
)
}
pub fn interpret(&self, limits: InterpretLimits) -> Result<InterpretResponse, SdkError> {
self.post("/interpret", &limits)
}
pub fn interpret_default(&self) -> Result<InterpretResponse, SdkError> {
self.interpret(InterpretLimits::default())
}
pub fn compile(&self) -> Result<CompileResponse, SdkError> {
self.post("/compile", &serde_json::json!({}))
}
pub fn compile_wasm(&self) -> Result<Vec<u8>, SdkError> {
let url = format!("{}/compile/wasm", self.base_url);
let mut req = self.agent.post(&url);
if let Some(ref token) = self.auth_token {
req = req.set("Authorization", &format!("Bearer {token}"));
}
match req.send_string("{}") {
Ok(resp) => {
let mut bytes = Vec::new();
resp.into_reader()
.read_to_end(&mut bytes)
.map_err(|e| SdkError::Transport(e.to_string()))?;
Ok(bytes)
}
Err(ureq::Error::Status(code, resp)) => Err(api_err(code, resp)),
Err(e) => Err(SdkError::Transport(e.to_string())),
}
}
pub fn find_functions(&self, pattern: &str) -> Result<Vec<FuncSummary>, SdkError> {
#[derive(Deserialize)]
struct Resp {
functions: Vec<FuncSummary>,
}
let path = format!("/query/functions?pattern={}", url_encode(pattern));
let resp: Resp = self.get(&path)?;
Ok(resp.functions)
}
pub fn get_call_graph(&self, func: &str) -> Result<Vec<CallEdge>, SdkError> {
#[derive(Deserialize)]
struct Resp {
edges: Vec<CallEdge>,
}
let path = format!("/query/call-graph?func={}", url_encode(func));
let resp: Resp = self.get(&path)?;
Ok(resp.edges)
}
pub fn get_effects(&self, func: &str) -> Result<EffectSummary, SdkError> {
let path = format!("/query/effects?func={}", url_encode(func));
self.get(&path)
}
pub fn find_dead_code(&self, entry: &str) -> Result<DeadCodeReport, SdkError> {
let path = format!("/query/dead-code?entry={}", url_encode(entry));
self.get(&path)
}
pub fn builtin_usage(&self) -> Result<BuiltinUsage, SdkError> {
self.get("/query/builtin-usage")
}
pub fn effect_surface(&self) -> Result<EffectSurface, SdkError> {
self.get("/query/effect-surface")
}
pub fn project_to_text(
&self,
lang: ProjectionLang,
) -> Result<TextProjectionResponse, SdkError> {
self.post(
"/project/text",
&serde_json::json!({ "language": lang.as_str() }),
)
}
fn get<R: for<'de> Deserialize<'de>>(&self, path: &str) -> Result<R, SdkError> {
let url = format!("{}{}", self.base_url, path);
let mut req = self.agent.get(&url);
if let Some(ref token) = self.auth_token {
req = req.set("Authorization", &format!("Bearer {token}"));
}
match req.call() {
Ok(resp) => {
let text = resp
.into_string()
.map_err(|e| SdkError::Transport(e.to_string()))?;
Ok(serde_json::from_str(&text)?)
}
Err(ureq::Error::Status(code, resp)) => Err(api_err(code, resp)),
Err(e) => Err(SdkError::Transport(e.to_string())),
}
}
fn post<B: Serialize, R: for<'de> Deserialize<'de>>(
&self,
path: &str,
body: &B,
) -> Result<R, SdkError> {
let url = format!("{}{}", self.base_url, path);
let mut req = self
.agent
.post(&url)
.set("Content-Type", "application/json");
if let Some(ref token) = self.auth_token {
req = req.set("Authorization", &format!("Bearer {token}"));
}
let body_str = serde_json::to_string(body)?;
match req.send_string(&body_str) {
Ok(resp) => {
let text = resp
.into_string()
.map_err(|e| SdkError::Transport(e.to_string()))?;
Ok(serde_json::from_str(&text)?)
}
Err(ureq::Error::Status(code, resp)) => Err(api_err(code, resp)),
Err(e) => Err(SdkError::Transport(e.to_string())),
}
}
}
fn api_err(status: u16, resp: ureq::Response) -> SdkError {
let body_text = resp.into_string().unwrap_or_default();
match serde_json::from_str::<ApiErrorBody>(&body_text) {
Ok(body) => SdkError::Api {
status,
code: body.code,
message: body.error,
},
Err(_) => SdkError::Api {
status,
code: "UNKNOWN".to_string(),
message: body_text,
},
}
}
fn url_encode(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for b in s.bytes() {
match b {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
out.push(b as char);
}
_ => out.push_str(&format!("%{b:02X}")),
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_client_builder() {
let client = Client::new("http://localhost:9090/")
.with_auth_token("secret")
.with_timeout(Duration::from_secs(5));
assert_eq!(client.base_url, "http://localhost:9090");
assert_eq!(client.auth_token.as_deref(), Some("secret"));
}
#[test]
fn test_url_encode() {
assert_eq!(url_encode("hello"), "hello");
assert_eq!(url_encode("hello world"), "hello%20world");
assert_eq!(url_encode("a/b?c=d&e"), "a%2Fb%3Fc%3Dd%26e");
assert_eq!(url_encode("abc-123_.~"), "abc-123_.~");
}
#[test]
fn test_projection_lang_str() {
assert_eq!(ProjectionLang::TypeScript.as_str(), "typescript");
assert_eq!(ProjectionLang::Python.as_str(), "python");
assert_eq!(ProjectionLang::Json.as_str(), "json");
assert_eq!(ProjectionLang::Pseudocode.as_str(), "pseudocode");
}
#[test]
fn test_interpret_limits_default() {
let limits = InterpretLimits::default();
assert_eq!(limits.max_steps, 1_000_000);
assert_eq!(limits.max_call_depth, 1000);
}
#[test]
fn test_unreachable_server_error() {
let client = Client::new("http://127.0.0.1:1").with_timeout(Duration::from_millis(200));
let err = client.get_project().unwrap_err();
assert!(matches!(err, SdkError::Transport(_)));
}
}