1#![deny(missing_docs)]
38
39use airl_ir::Module;
40use airl_patch::{Impact, Patch};
41use airl_project::constraints::{Constraint, ConstraintViolation};
42use airl_project::diff::ModuleDiff;
43use airl_project::queries::{BuiltinUsage, DeadCodeReport, EffectSurface};
44use airl_project::{CallEdge, EffectSummary, FuncSummary};
45use serde::{Deserialize, Serialize};
46use std::time::Duration;
47use thiserror::Error;
48
49#[derive(Debug, Error)]
55pub enum SdkError {
56 #[error("HTTP transport error: {0}")]
58 Transport(String),
59 #[error("API error {status} ({code}): {message}")]
61 Api {
62 status: u16,
64 code: String,
66 message: String,
68 },
69 #[error("response parse error: {0}")]
71 Parse(#[from] serde_json::Error),
72}
73
74#[derive(Debug, Deserialize)]
76struct ApiErrorBody {
77 error: String,
78 code: String,
79}
80
81#[derive(Debug, Clone, Deserialize, Serialize)]
87pub struct ProjectInfo {
88 pub name: String,
90 pub version: String,
92 pub function_count: usize,
94 pub history_length: usize,
96}
97
98#[derive(Debug, Clone, Deserialize)]
100pub struct ModuleResponse {
101 pub module: Module,
103 pub version: String,
105}
106
107#[derive(Debug, Clone, Deserialize)]
109pub struct DiagnosticResponse {
110 pub severity: String,
112 pub node_id: Option<String>,
114 pub message: String,
116}
117
118#[derive(Debug, Clone, Deserialize)]
120pub struct TypeCheckResponse {
121 pub success: bool,
123 pub errors: Vec<DiagnosticResponse>,
125 pub warnings: Vec<DiagnosticResponse>,
127}
128
129#[derive(Debug, Clone, Deserialize)]
131pub struct InterpretResponse {
132 pub success: bool,
134 pub stdout: String,
136 pub exit_code: i32,
138 pub error: Option<String>,
140}
141
142#[derive(Debug, Clone, Deserialize)]
144pub struct CompileResponse {
145 pub success: bool,
147 pub stdout: String,
149 pub exit_code: i32,
151 pub compile_time_ms: u64,
153 pub error: Option<String>,
155}
156
157#[derive(Debug, Clone, Deserialize)]
159pub struct PatchResultResponse {
160 pub success: bool,
162 pub new_version: String,
164 pub impact: Impact,
166}
167
168#[derive(Debug, Clone, Deserialize)]
170pub struct PatchPreviewResponse {
171 pub would_succeed: bool,
173 pub validation_error: Option<String>,
175 pub type_errors: Vec<String>,
177 pub impact: Impact,
179}
180
181#[derive(Debug, Clone, Deserialize)]
183pub struct ConstraintsResponse {
184 pub ok: bool,
186 pub violations: Vec<ConstraintViolation>,
188}
189
190#[derive(Debug, Clone, Copy, Serialize)]
192pub struct InterpretLimits {
193 pub max_steps: u64,
195 pub max_call_depth: u32,
197}
198
199impl Default for InterpretLimits {
200 fn default() -> Self {
201 Self {
202 max_steps: 1_000_000,
203 max_call_depth: 1000,
204 }
205 }
206}
207
208#[derive(Debug, Clone, Copy, PartialEq, Eq)]
210pub enum ProjectionLang {
211 TypeScript,
213 Python,
215 Json,
217 Pseudocode,
219}
220
221impl ProjectionLang {
222 fn as_str(self) -> &'static str {
223 match self {
224 ProjectionLang::TypeScript => "typescript",
225 ProjectionLang::Python => "python",
226 ProjectionLang::Json => "json",
227 ProjectionLang::Pseudocode => "pseudocode",
228 }
229 }
230}
231
232#[derive(Debug, Clone, Deserialize)]
234pub struct TextProjectionResponse {
235 pub language: String,
237 pub text: String,
239}
240
241pub struct Client {
250 base_url: String,
251 agent: ureq::Agent,
252 auth_token: Option<String>,
253}
254
255impl Client {
256 pub fn new(base_url: impl Into<String>) -> Self {
259 let agent = ureq::AgentBuilder::new()
260 .timeout(Duration::from_secs(30))
261 .build();
262 Self {
263 base_url: base_url.into().trim_end_matches('/').to_string(),
264 agent,
265 auth_token: None,
266 }
267 }
268
269 pub fn with_auth_token(mut self, token: impl Into<String>) -> Self {
272 self.auth_token = Some(token.into());
273 self
274 }
275
276 pub fn with_timeout(mut self, timeout: Duration) -> Self {
278 self.agent = ureq::AgentBuilder::new().timeout(timeout).build();
279 self
280 }
281
282 pub fn create_project(
286 &self,
287 name: impl Into<String>,
288 module_json: impl Into<String>,
289 ) -> Result<ProjectInfo, SdkError> {
290 self.post(
291 "/project/create",
292 &serde_json::json!({
293 "name": name.into(),
294 "module_json": module_json.into(),
295 }),
296 )
297 }
298
299 pub fn get_project(&self) -> Result<ProjectInfo, SdkError> {
301 self.get("/project")
302 }
303
304 pub fn get_module(&self) -> Result<ModuleResponse, SdkError> {
306 self.get("/module")
307 }
308
309 pub fn apply_patch(&self, patch: &Patch) -> Result<PatchResultResponse, SdkError> {
317 self.post("/patch/apply", patch)
318 }
319
320 pub fn preview_patch(&self, patch: &Patch) -> Result<PatchPreviewResponse, SdkError> {
322 self.post("/patch/preview", patch)
323 }
324
325 pub fn undo_patch(&self) -> Result<PatchResultResponse, SdkError> {
327 self.post("/patch/undo", &serde_json::json!({}))
328 }
329
330 pub fn typecheck(&self) -> Result<TypeCheckResponse, SdkError> {
334 self.post("/typecheck", &serde_json::json!({}))
335 }
336
337 pub fn check_constraints(
340 &self,
341 constraints: &[Constraint],
342 ) -> Result<ConstraintsResponse, SdkError> {
343 self.post(
344 "/constraints/check",
345 &serde_json::json!({ "constraints": constraints }),
346 )
347 }
348
349 pub fn diff(&self, other_module_json: impl Into<String>) -> Result<ModuleDiff, SdkError> {
352 self.post(
353 "/diff",
354 &serde_json::json!({ "other_module_json": other_module_json.into() }),
355 )
356 }
357
358 pub fn interpret(&self, limits: InterpretLimits) -> Result<InterpretResponse, SdkError> {
360 self.post("/interpret", &limits)
361 }
362
363 pub fn interpret_default(&self) -> Result<InterpretResponse, SdkError> {
365 self.interpret(InterpretLimits::default())
366 }
367
368 pub fn compile(&self) -> Result<CompileResponse, SdkError> {
370 self.post("/compile", &serde_json::json!({}))
371 }
372
373 pub fn compile_wasm(&self) -> Result<Vec<u8>, SdkError> {
376 let url = format!("{}/compile/wasm", self.base_url);
377 let mut req = self.agent.post(&url);
378 if let Some(ref token) = self.auth_token {
379 req = req.set("Authorization", &format!("Bearer {token}"));
380 }
381 match req.send_string("{}") {
382 Ok(resp) => {
383 let mut bytes = Vec::new();
384 resp.into_reader()
385 .read_to_end(&mut bytes)
386 .map_err(|e| SdkError::Transport(e.to_string()))?;
387 Ok(bytes)
388 }
389 Err(ureq::Error::Status(code, resp)) => Err(api_err(code, resp)),
390 Err(e) => Err(SdkError::Transport(e.to_string())),
391 }
392 }
393
394 pub fn find_functions(&self, pattern: &str) -> Result<Vec<FuncSummary>, SdkError> {
399 #[derive(Deserialize)]
400 struct Resp {
401 functions: Vec<FuncSummary>,
402 }
403 let path = format!("/query/functions?pattern={}", url_encode(pattern));
404 let resp: Resp = self.get(&path)?;
405 Ok(resp.functions)
406 }
407
408 pub fn get_call_graph(&self, func: &str) -> Result<Vec<CallEdge>, SdkError> {
411 #[derive(Deserialize)]
412 struct Resp {
413 edges: Vec<CallEdge>,
414 }
415 let path = format!("/query/call-graph?func={}", url_encode(func));
416 let resp: Resp = self.get(&path)?;
417 Ok(resp.edges)
418 }
419
420 pub fn get_effects(&self, func: &str) -> Result<EffectSummary, SdkError> {
423 let path = format!("/query/effects?func={}", url_encode(func));
424 self.get(&path)
425 }
426
427 pub fn find_dead_code(&self, entry: &str) -> Result<DeadCodeReport, SdkError> {
430 let path = format!("/query/dead-code?entry={}", url_encode(entry));
431 self.get(&path)
432 }
433
434 pub fn builtin_usage(&self) -> Result<BuiltinUsage, SdkError> {
437 self.get("/query/builtin-usage")
438 }
439
440 pub fn effect_surface(&self) -> Result<EffectSurface, SdkError> {
443 self.get("/query/effect-surface")
444 }
445
446 pub fn project_to_text(
451 &self,
452 lang: ProjectionLang,
453 ) -> Result<TextProjectionResponse, SdkError> {
454 self.post(
455 "/project/text",
456 &serde_json::json!({ "language": lang.as_str() }),
457 )
458 }
459
460 fn get<R: for<'de> Deserialize<'de>>(&self, path: &str) -> Result<R, SdkError> {
463 let url = format!("{}{}", self.base_url, path);
464 let mut req = self.agent.get(&url);
465 if let Some(ref token) = self.auth_token {
466 req = req.set("Authorization", &format!("Bearer {token}"));
467 }
468 match req.call() {
469 Ok(resp) => {
470 let text = resp
471 .into_string()
472 .map_err(|e| SdkError::Transport(e.to_string()))?;
473 Ok(serde_json::from_str(&text)?)
474 }
475 Err(ureq::Error::Status(code, resp)) => Err(api_err(code, resp)),
476 Err(e) => Err(SdkError::Transport(e.to_string())),
477 }
478 }
479
480 fn post<B: Serialize, R: for<'de> Deserialize<'de>>(
481 &self,
482 path: &str,
483 body: &B,
484 ) -> Result<R, SdkError> {
485 let url = format!("{}{}", self.base_url, path);
486 let mut req = self
487 .agent
488 .post(&url)
489 .set("Content-Type", "application/json");
490 if let Some(ref token) = self.auth_token {
491 req = req.set("Authorization", &format!("Bearer {token}"));
492 }
493 let body_str = serde_json::to_string(body)?;
494 match req.send_string(&body_str) {
495 Ok(resp) => {
496 let text = resp
497 .into_string()
498 .map_err(|e| SdkError::Transport(e.to_string()))?;
499 Ok(serde_json::from_str(&text)?)
500 }
501 Err(ureq::Error::Status(code, resp)) => Err(api_err(code, resp)),
502 Err(e) => Err(SdkError::Transport(e.to_string())),
503 }
504 }
505}
506
507fn api_err(status: u16, resp: ureq::Response) -> SdkError {
509 let body_text = resp.into_string().unwrap_or_default();
510 match serde_json::from_str::<ApiErrorBody>(&body_text) {
511 Ok(body) => SdkError::Api {
512 status,
513 code: body.code,
514 message: body.error,
515 },
516 Err(_) => SdkError::Api {
517 status,
518 code: "UNKNOWN".to_string(),
519 message: body_text,
520 },
521 }
522}
523
524fn url_encode(s: &str) -> String {
526 let mut out = String::with_capacity(s.len());
527 for b in s.bytes() {
528 match b {
529 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
530 out.push(b as char);
531 }
532 _ => out.push_str(&format!("%{b:02X}")),
533 }
534 }
535 out
536}
537
538#[cfg(test)]
539mod tests {
540 use super::*;
541
542 #[test]
543 fn test_client_builder() {
544 let client = Client::new("http://localhost:9090/")
545 .with_auth_token("secret")
546 .with_timeout(Duration::from_secs(5));
547 assert_eq!(client.base_url, "http://localhost:9090");
548 assert_eq!(client.auth_token.as_deref(), Some("secret"));
549 }
550
551 #[test]
552 fn test_url_encode() {
553 assert_eq!(url_encode("hello"), "hello");
554 assert_eq!(url_encode("hello world"), "hello%20world");
555 assert_eq!(url_encode("a/b?c=d&e"), "a%2Fb%3Fc%3Dd%26e");
556 assert_eq!(url_encode("abc-123_.~"), "abc-123_.~");
557 }
558
559 #[test]
560 fn test_projection_lang_str() {
561 assert_eq!(ProjectionLang::TypeScript.as_str(), "typescript");
562 assert_eq!(ProjectionLang::Python.as_str(), "python");
563 assert_eq!(ProjectionLang::Json.as_str(), "json");
564 assert_eq!(ProjectionLang::Pseudocode.as_str(), "pseudocode");
565 }
566
567 #[test]
568 fn test_interpret_limits_default() {
569 let limits = InterpretLimits::default();
570 assert_eq!(limits.max_steps, 1_000_000);
571 assert_eq!(limits.max_call_depth, 1000);
572 }
573
574 #[test]
575 fn test_unreachable_server_error() {
576 let client = Client::new("http://127.0.0.1:1").with_timeout(Duration::from_millis(200));
579 let err = client.get_project().unwrap_err();
580 assert!(matches!(err, SdkError::Transport(_)));
581 }
582}