Skip to main content

airl_sdk/
lib.rs

1//! AIRL SDK - Typed Rust client for the AIRL HTTP API.
2//!
3//! Provides a [`Client`] type wrapping every endpoint exposed by `airl-api`,
4//! with optional Bearer token authentication and structured error types.
5//!
6//! # Example
7//!
8//! ```no_run
9//! use airl_sdk::Client;
10//!
11//! let client = Client::new("http://127.0.0.1:9090");
12//!
13//! // Create a project from a JSON IR string
14//! let info = client.create_project("my-app", "{...}").unwrap();
15//! println!("project: {} version={}", info.name, info.version);
16//!
17//! // Type check
18//! let tc = client.typecheck().unwrap();
19//! assert!(tc.success);
20//!
21//! // Interpret
22//! let output = client.interpret_default().unwrap();
23//! print!("{}", output.stdout);
24//! ```
25//!
26//! # Authentication
27//!
28//! If the server is started with `serve_with_auth`, provide a token:
29//!
30//! ```no_run
31//! use airl_sdk::Client;
32//!
33//! let client = Client::new("http://127.0.0.1:9090")
34//!     .with_auth_token("my-secret-token");
35//! ```
36
37#![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// ---------------------------------------------------------------------------
50// Error type
51// ---------------------------------------------------------------------------
52
53/// Errors returned by the AIRL SDK client.
54#[derive(Debug, Error)]
55pub enum SdkError {
56    /// Network-level error (connection refused, DNS, timeout, etc.).
57    #[error("HTTP transport error: {0}")]
58    Transport(String),
59    /// Server returned a non-success status with a structured error body.
60    #[error("API error {status} ({code}): {message}")]
61    Api {
62        /// HTTP status code.
63        status: u16,
64        /// Short error code from the API.
65        code: String,
66        /// Human-readable error message.
67        message: String,
68    },
69    /// Response body could not be parsed as the expected type.
70    #[error("response parse error: {0}")]
71    Parse(#[from] serde_json::Error),
72}
73
74/// Structured error body returned by the AIRL API on non-2xx responses.
75#[derive(Debug, Deserialize)]
76struct ApiErrorBody {
77    error: String,
78    code: String,
79}
80
81// ---------------------------------------------------------------------------
82// Request/response types (mirror the wire format)
83// ---------------------------------------------------------------------------
84
85/// Summary information about a loaded project.
86#[derive(Debug, Clone, Deserialize, Serialize)]
87pub struct ProjectInfo {
88    /// Project name.
89    pub name: String,
90    /// Content-addressed version hash of the current module.
91    pub version: String,
92    /// Number of functions in the module.
93    pub function_count: usize,
94    /// Number of patches in the undo history.
95    pub history_length: usize,
96}
97
98/// Result of [`Client::get_module`]: the current module plus its version.
99#[derive(Debug, Clone, Deserialize)]
100pub struct ModuleResponse {
101    /// The current module state.
102    pub module: Module,
103    /// Content-addressed version hash.
104    pub version: String,
105}
106
107/// One entry from the type checker output.
108#[derive(Debug, Clone, Deserialize)]
109pub struct DiagnosticResponse {
110    /// Severity level: `"error"` or `"warning"`.
111    pub severity: String,
112    /// IR node ID where the diagnostic originated, if any.
113    pub node_id: Option<String>,
114    /// Human-readable message.
115    pub message: String,
116}
117
118/// Response from [`Client::typecheck`].
119#[derive(Debug, Clone, Deserialize)]
120pub struct TypeCheckResponse {
121    /// `true` if no errors were found.
122    pub success: bool,
123    /// Type errors (prevent execution).
124    pub errors: Vec<DiagnosticResponse>,
125    /// Non-fatal warnings.
126    pub warnings: Vec<DiagnosticResponse>,
127}
128
129/// Response from [`Client::interpret`].
130#[derive(Debug, Clone, Deserialize)]
131pub struct InterpretResponse {
132    /// `true` if interpretation succeeded.
133    pub success: bool,
134    /// Captured standard output.
135    pub stdout: String,
136    /// Exit code (0 = success).
137    pub exit_code: i32,
138    /// Runtime error message, if any.
139    pub error: Option<String>,
140}
141
142/// Response from [`Client::compile`].
143#[derive(Debug, Clone, Deserialize)]
144pub struct CompileResponse {
145    /// `true` if compilation and execution succeeded.
146    pub success: bool,
147    /// Captured standard output.
148    pub stdout: String,
149    /// Exit code (0 = success).
150    pub exit_code: i32,
151    /// Compilation time in milliseconds (excludes execution).
152    pub compile_time_ms: u64,
153    /// Compile error message, if any.
154    pub error: Option<String>,
155}
156
157/// Response from [`Client::apply_patch`] or [`Client::undo_patch`].
158#[derive(Debug, Clone, Deserialize)]
159pub struct PatchResultResponse {
160    /// `true` if the patch was applied.
161    pub success: bool,
162    /// Version hash after the patch.
163    pub new_version: String,
164    /// Analysis of which functions/types were affected.
165    pub impact: Impact,
166}
167
168/// Response from [`Client::preview_patch`].
169#[derive(Debug, Clone, Deserialize)]
170pub struct PatchPreviewResponse {
171    /// `true` if the patch would succeed if applied.
172    pub would_succeed: bool,
173    /// Structural validation error, if any.
174    pub validation_error: Option<String>,
175    /// Type errors that would arise after applying the patch.
176    pub type_errors: Vec<String>,
177    /// Analysis of which functions/types would be affected.
178    pub impact: Impact,
179}
180
181/// Response from [`Client::check_constraints`].
182#[derive(Debug, Clone, Deserialize)]
183pub struct ConstraintsResponse {
184    /// `true` if no constraints were violated.
185    pub ok: bool,
186    /// List of violations, one per constraint that failed.
187    pub violations: Vec<ConstraintViolation>,
188}
189
190/// Execution limits for [`Client::interpret`].
191#[derive(Debug, Clone, Copy, Serialize)]
192pub struct InterpretLimits {
193    /// Maximum number of evaluation steps before aborting.
194    pub max_steps: u64,
195    /// Maximum call-stack depth.
196    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/// Language for [`Client::project_to_text`].
209#[derive(Debug, Clone, Copy, PartialEq, Eq)]
210pub enum ProjectionLang {
211    /// Project the module to TypeScript source.
212    TypeScript,
213    /// Project the module to Python source.
214    Python,
215    /// Raw JSON (pretty-printed IR).
216    Json,
217    /// Pseudocode function-signature summary (API fallback).
218    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/// Response from [`Client::project_to_text`].
233#[derive(Debug, Clone, Deserialize)]
234pub struct TextProjectionResponse {
235    /// Language identifier echoed back from the request.
236    pub language: String,
237    /// Rendered source code.
238    pub text: String,
239}
240
241// ---------------------------------------------------------------------------
242// Client
243// ---------------------------------------------------------------------------
244
245/// Typed client for the AIRL HTTP API.
246///
247/// Construct with [`Client::new`] and chain optional configuration
248/// methods like [`Client::with_auth_token`] and [`Client::with_timeout`].
249pub struct Client {
250    base_url: String,
251    agent: ureq::Agent,
252    auth_token: Option<String>,
253}
254
255impl Client {
256    /// Create a new client pointing at the given API server base URL
257    /// (e.g. `"http://127.0.0.1:9090"`).
258    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    /// Set a Bearer token to send with every request.
270    /// Required when the server was started via `serve_with_auth`.
271    pub fn with_auth_token(mut self, token: impl Into<String>) -> Self {
272        self.auth_token = Some(token.into());
273        self
274    }
275
276    /// Override the default 30-second request timeout.
277    pub fn with_timeout(mut self, timeout: Duration) -> Self {
278        self.agent = ureq::AgentBuilder::new().timeout(timeout).build();
279        self
280    }
281
282    // -- Project management --
283
284    /// Create a project from a JSON IR string. Equivalent to `POST /project/create`.
285    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    /// Get project metadata. Equivalent to `GET /project`.
300    pub fn get_project(&self) -> Result<ProjectInfo, SdkError> {
301        self.get("/project")
302    }
303
304    /// Fetch the current module IR. Equivalent to `GET /module`.
305    pub fn get_module(&self) -> Result<ModuleResponse, SdkError> {
306        self.get("/module")
307    }
308
309    // -- Patch operations --
310
311    /// Apply a semantic patch. Equivalent to `POST /patch/apply`.
312    ///
313    /// The server expects the patch fields at the request body root
314    /// (thanks to `#[serde(flatten)]` on the request struct), so we send
315    /// the patch as-is.
316    pub fn apply_patch(&self, patch: &Patch) -> Result<PatchResultResponse, SdkError> {
317        self.post("/patch/apply", patch)
318    }
319
320    /// Preview a patch (dry-run). Equivalent to `POST /patch/preview`.
321    pub fn preview_patch(&self, patch: &Patch) -> Result<PatchPreviewResponse, SdkError> {
322        self.post("/patch/preview", patch)
323    }
324
325    /// Undo the most recent patch. Equivalent to `POST /patch/undo`.
326    pub fn undo_patch(&self) -> Result<PatchResultResponse, SdkError> {
327        self.post("/patch/undo", &serde_json::json!({}))
328    }
329
330    // -- Build & run --
331
332    /// Type-check the current module. Equivalent to `POST /typecheck`.
333    pub fn typecheck(&self) -> Result<TypeCheckResponse, SdkError> {
334        self.post("/typecheck", &serde_json::json!({}))
335    }
336
337    /// Check the module against architectural constraints.
338    /// Equivalent to `POST /constraints/check`.
339    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    /// Diff the current module against another module (passed as JSON).
350    /// Equivalent to `POST /diff`.
351    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    /// Interpret the module with custom limits. Equivalent to `POST /interpret`.
359    pub fn interpret(&self, limits: InterpretLimits) -> Result<InterpretResponse, SdkError> {
360        self.post("/interpret", &limits)
361    }
362
363    /// Interpret the module with default limits (1M steps, 1000 call depth).
364    pub fn interpret_default(&self) -> Result<InterpretResponse, SdkError> {
365        self.interpret(InterpretLimits::default())
366    }
367
368    /// Cranelift JIT compile and run the module. Equivalent to `POST /compile`.
369    pub fn compile(&self) -> Result<CompileResponse, SdkError> {
370        self.post("/compile", &serde_json::json!({}))
371    }
372
373    /// Compile the module to a WASM binary. Equivalent to `POST /compile/wasm`.
374    /// Returns raw bytes.
375    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    // -- Queries --
395
396    /// Find functions whose name contains `pattern` (substring match).
397    /// Equivalent to `GET /query/functions?pattern=<p>`.
398    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    /// Get call-graph edges for a function.
409    /// Equivalent to `GET /query/call-graph?func=<name>`.
410    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    /// Get the declared effect set for a function.
421    /// Equivalent to `GET /query/effects?func=<name>`.
422    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    /// Find functions unreachable from an entry point (default `"main"`).
428    /// Equivalent to `GET /query/dead-code?entry=<name>`.
429    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    /// Count calls to each `std::...` builtin across the module.
435    /// Equivalent to `GET /query/builtin-usage`.
436    pub fn builtin_usage(&self) -> Result<BuiltinUsage, SdkError> {
437        self.get("/query/builtin-usage")
438    }
439
440    /// Get the effect surface of the module.
441    /// Equivalent to `GET /query/effect-surface`.
442    pub fn effect_surface(&self) -> Result<EffectSurface, SdkError> {
443        self.get("/query/effect-surface")
444    }
445
446    // -- Projections --
447
448    /// Render the module in a target language.
449    /// Equivalent to `POST /project/text { language }`.
450    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    // -- Low-level helpers --
461
462    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
507/// Convert a ureq error response into an [`SdkError::Api`].
508fn 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
524/// Minimal URL-encoding for query string values (alphanumeric + `-_.~` pass through).
525fn 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        // Use a port very unlikely to be bound. Should fail with Transport error
577        // (not hang — the default timeout handles that).
578        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}