Skip to main content

kaish_client/
embedded.rs

1//! Embedded client for direct in-process kernel access.
2//!
3//! The `EmbeddedClient` wraps a `Kernel` instance and implements `KernelClient`,
4//! allowing direct access without network overhead. This is ideal for:
5//!
6//! - Embedding kaish in other Rust applications
7//! - Unit testing
8//! - Single-process use cases
9
10use std::collections::HashMap;
11use std::path::{Path, PathBuf};
12use std::sync::Arc;
13use std::sync::atomic::{AtomicU64, Ordering};
14use std::time::{SystemTime, UNIX_EPOCH};
15
16use async_trait::async_trait;
17
18use kaish_kernel::ast::Value;
19use kaish_kernel::interpreter::ExecResult;
20use kaish_kernel::tools::ToolSchema;
21use kaish_kernel::vfs::Filesystem;
22use kaish_kernel::{ExecuteOptions, Kernel, KernelConfig};
23
24use crate::traits::{ClientError, ClientResult, KernelClient};
25
26/// Generate a unique blob ID.
27fn generate_blob_id() -> String {
28    static COUNTER: AtomicU64 = AtomicU64::new(0);
29
30    let timestamp = SystemTime::now()
31        .duration_since(UNIX_EPOCH)
32        .map(|d| d.as_nanos())
33        .unwrap_or(0);
34    let count = COUNTER.fetch_add(1, Ordering::SeqCst);
35
36    format!("{:x}-{:x}", timestamp, count)
37}
38
39/// A client that wraps a `Kernel` directly for in-process access.
40///
41/// # Example
42///
43/// ```ignore
44/// use kaish_client::EmbeddedClient;
45/// use kaish_kernel::{Kernel, KernelConfig};
46///
47/// let kernel = Kernel::new(KernelConfig::transient())?;
48/// let client = EmbeddedClient::new(kernel);
49///
50/// let result = client.execute("X=42").await?;
51/// assert!(result.ok());
52///
53/// let value = client.get_var("X").await?;
54/// assert_eq!(value, Some(Value::Int(42)));
55/// ```
56#[derive(Clone)]
57pub struct EmbeddedClient {
58    kernel: Arc<Kernel>,
59}
60
61impl EmbeddedClient {
62    /// Create a new embedded client wrapping the given kernel.
63    pub fn new(kernel: Kernel) -> Self {
64        Self {
65            kernel: kernel.into_arc(),
66        }
67    }
68
69    /// Create a new embedded client with a transient (non-persistent) kernel.
70    pub fn transient() -> ClientResult<Self> {
71        let kernel = Kernel::new(KernelConfig::transient())
72            .map_err(ClientError::Other)?;
73        Ok(Self::new(kernel))
74    }
75
76    /// Create a new embedded client with default configuration.
77    pub fn with_defaults() -> ClientResult<Self> {
78        let kernel = Kernel::new(KernelConfig::default())
79            .map_err(ClientError::Other)?;
80        Ok(Self::new(kernel))
81    }
82
83    /// Get a reference to the underlying kernel.
84    pub fn kernel(&self) -> &Kernel {
85        &self.kernel
86    }
87
88    /// Execute with a per-statement output callback.
89    ///
90    /// Each statement's result is passed to `on_output` as it completes.
91    /// External commands in interactive mode already stream via `Stdio::inherit()`;
92    /// this callback handles builtins and other captured output.
93    pub async fn execute_streaming(
94        &self,
95        input: &str,
96        on_output: &mut (dyn FnMut(&ExecResult) + Send),
97    ) -> ClientResult<ExecResult> {
98        self.kernel
99            .execute_with_options_streaming(input, ExecuteOptions::default(), on_output)
100            .await
101            .map_err(|e| ClientError::Execution(e.to_string()))
102    }
103
104    /// Execute with full per-call options (timeout, cancel token, vars overlay,
105    /// cwd override). Use [`Self::execute_with_options_streaming`] for the
106    /// per-statement callback variant.
107    pub async fn execute_with_options(
108        &self,
109        input: &str,
110        opts: ExecuteOptions,
111    ) -> ClientResult<ExecResult> {
112        self.kernel
113            .execute_with_options(input, opts)
114            .await
115            .map_err(|e| ClientError::Execution(e.to_string()))
116    }
117
118    /// Execute with options + per-statement output callback.
119    pub async fn execute_with_options_streaming(
120        &self,
121        input: &str,
122        opts: ExecuteOptions,
123        on_output: &mut (dyn FnMut(&ExecResult) + Send),
124    ) -> ClientResult<ExecResult> {
125        self.kernel
126            .execute_with_options_streaming(input, opts, on_output)
127            .await
128            .map_err(|e| ClientError::Execution(e.to_string()))
129    }
130}
131
132#[async_trait(?Send)]
133impl KernelClient for EmbeddedClient {
134    async fn execute(&self, input: &str) -> ClientResult<ExecResult> {
135        self.kernel
136            .execute(input)
137            .await
138            .map_err(|e| ClientError::Execution(e.to_string()))
139    }
140
141    async fn execute_with_vars(
142        &self,
143        input: &str,
144        vars: HashMap<String, Value>,
145    ) -> ClientResult<ExecResult> {
146        self.kernel
147            .execute_with_options(input, ExecuteOptions::new().with_vars(vars))
148            .await
149            .map_err(|e| ClientError::Execution(e.to_string()))
150    }
151
152    async fn get_var(&self, name: &str) -> ClientResult<Option<Value>> {
153        Ok(self.kernel.get_var(name).await)
154    }
155
156    async fn set_var(&self, name: &str, value: Value) -> ClientResult<()> {
157        self.kernel.set_var(name, value).await;
158        Ok(())
159    }
160
161    async fn list_vars(&self) -> ClientResult<Vec<(String, Value)>> {
162        Ok(self.kernel.list_vars().await)
163    }
164
165    async fn tool_schemas(&self) -> ClientResult<Vec<ToolSchema>> {
166        Ok(self.kernel.tool_schemas())
167    }
168
169    async fn has_function(&self, name: &str) -> ClientResult<bool> {
170        Ok(self.kernel.has_function(name).await)
171    }
172
173    async fn cancel(&self) -> ClientResult<()> {
174        self.kernel.cancel();
175        Ok(())
176    }
177
178    async fn cwd(&self) -> ClientResult<String> {
179        Ok(self.kernel.cwd().await.to_string_lossy().to_string())
180    }
181
182    async fn set_cwd(&self, path: &str) -> ClientResult<()> {
183        self.kernel.set_cwd(PathBuf::from(path)).await;
184        Ok(())
185    }
186
187    async fn last_result(&self) -> ClientResult<ExecResult> {
188        Ok(self.kernel.last_result().await)
189    }
190
191    async fn reset(&self) -> ClientResult<()> {
192        self.kernel
193            .reset()
194            .await
195            .map_err(ClientError::Other)
196    }
197
198    async fn ping(&self) -> ClientResult<String> {
199        Ok("pong".to_string())
200    }
201
202    async fn shutdown(&self) -> ClientResult<()> {
203        // For embedded client, shutdown is a no-op since we don't own the kernel lifecycle
204        // The kernel will be dropped when the client is dropped
205        Ok(())
206    }
207
208    async fn read_blob(&self, id: &str) -> ClientResult<Vec<u8>> {
209        let vfs = self.kernel.vfs();
210        let path = PathBuf::from(format!("/v/blobs/{}", id));
211
212        vfs.read(&path)
213            .await
214            .map_err(ClientError::Io)
215    }
216
217    async fn write_blob(&self, content_type: &str, data: &[u8]) -> ClientResult<String> {
218        let vfs = self.kernel.vfs();
219        let id = generate_blob_id();
220        let path = PathBuf::from(format!("/v/blobs/{}", id));
221
222        // Ensure parent directory exists
223        let parent = Path::new("/v/blobs");
224        if let Err(e) = vfs.mkdir(parent).await {
225            // Ignore "already exists" errors
226            if e.kind() != std::io::ErrorKind::AlreadyExists {
227                tracing::warn!("Failed to create blob directory: {}", e);
228            }
229        }
230
231        // Store content type as metadata (could be extended later)
232        tracing::debug!("Creating blob {} with content type {}", id, content_type);
233
234        vfs.write(&path, data)
235            .await
236            .map_err(ClientError::Io)?;
237
238        Ok(id)
239    }
240
241    async fn delete_blob(&self, id: &str) -> ClientResult<bool> {
242        let vfs = self.kernel.vfs();
243        let path = PathBuf::from(format!("/v/blobs/{}", id));
244
245        match vfs.remove(&path).await {
246            Ok(()) => Ok(true),
247            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(false),
248            Err(e) => Err(ClientError::Io(e)),
249        }
250    }
251}
252
253#[cfg(test)]
254mod tests {
255    use super::*;
256
257    #[tokio::test]
258    async fn test_embedded_transient() {
259        let client = EmbeddedClient::transient().expect("failed to create client");
260        let result = client.ping().await.expect("ping failed");
261        assert_eq!(result, "pong");
262    }
263
264    #[tokio::test]
265    async fn test_embedded_execute() {
266        let client = EmbeddedClient::transient().expect("failed to create client");
267        let result = client.execute("echo hello").await.expect("execute failed");
268        assert!(result.ok());
269        assert_eq!(result.text_out().trim(), "hello");
270    }
271
272    #[tokio::test]
273    async fn test_embedded_tool_schemas() {
274        let client = EmbeddedClient::transient().expect("failed to create client");
275        let schemas = client.tool_schemas().await.expect("tool_schemas failed");
276
277        // Several builtins should always be present.
278        assert!(!schemas.is_empty(), "expected at least one tool schema");
279
280        // A known builtin with a known flag exposes its parameters for completion.
281        let echo = schemas
282            .iter()
283            .find(|s| s.name == "echo")
284            .expect("echo builtin should be present");
285        // Flags are recorded without the leading dash; short is "n", long is
286        // "no-newline" (see schema_from_clap).
287        assert!(
288            echo.params
289                .iter()
290                .any(|p| p.matches_flag("n") && p.matches_flag("no-newline")),
291            "echo schema should expose its -n/--no-newline flag for completion"
292        );
293    }
294
295    #[tokio::test]
296    async fn test_embedded_has_function() {
297        let client = EmbeddedClient::transient().expect("failed to create client");
298
299        assert!(
300            !client.has_function("greet").await.expect("has_function failed"),
301            "function should not exist before definition"
302        );
303
304        client
305            .execute("greet() { echo hi; }")
306            .await
307            .expect("defining function failed");
308
309        assert!(
310            client.has_function("greet").await.expect("has_function failed"),
311            "function should exist after definition"
312        );
313    }
314
315    #[tokio::test]
316    async fn test_embedded_cancel_idempotent_when_idle() {
317        // cancel() with nothing in flight must be a harmless no-op, since
318        // frontends fire it from a Ctrl-C handler that may race execution.
319        let client = EmbeddedClient::transient().expect("failed to create client");
320        client.cancel().await.expect("cancel failed");
321        // The kernel remains usable afterward.
322        let result = client.execute("echo ok").await.expect("execute failed");
323        assert!(result.ok());
324        assert_eq!(result.text_out().trim(), "ok");
325    }
326
327    #[tokio::test]
328    async fn test_embedded_variables() {
329        let client = EmbeddedClient::transient().expect("failed to create client");
330
331        // Set via execute
332        client.execute("X=42").await.expect("set failed");
333        let value = client.get_var("X").await.expect("get failed");
334        assert_eq!(value, Some(Value::Int(42)));
335
336        // Set via API
337        client.set_var("Y", Value::String("hello".into())).await.expect("set_var failed");
338        let value = client.get_var("Y").await.expect("get failed");
339        assert_eq!(value, Some(Value::String("hello".into())));
340
341        // List vars
342        let vars = client.list_vars().await.expect("list failed");
343        assert!(vars.iter().any(|(n, _)| n == "X"));
344        assert!(vars.iter().any(|(n, _)| n == "Y"));
345    }
346
347    #[tokio::test]
348    async fn test_embedded_cwd() {
349        let client = EmbeddedClient::transient().expect("failed to create client");
350
351        // Transient kernel uses sandboxed mode with cwd=$HOME
352        let cwd = client.cwd().await.expect("cwd failed");
353        let home = std::env::var("HOME").unwrap_or_else(|_| "/".to_string());
354        assert_eq!(cwd, home);
355
356        client.set_cwd("/tmp").await.expect("set_cwd failed");
357        let cwd = client.cwd().await.expect("cwd failed");
358        assert_eq!(cwd, "/tmp");
359    }
360
361    #[tokio::test]
362    async fn test_embedded_reset() {
363        let client = EmbeddedClient::transient().expect("failed to create client");
364
365        client.execute("X=1").await.expect("set failed");
366        assert!(client.get_var("X").await.expect("get failed").is_some());
367
368        client.reset().await.expect("reset failed");
369        assert!(client.get_var("X").await.expect("get failed").is_none());
370    }
371
372    #[tokio::test]
373    async fn test_embedded_last_result() {
374        let client = EmbeddedClient::transient().expect("failed to create client");
375
376        client.execute("echo test").await.expect("execute failed");
377        let last = client.last_result().await.expect("last_result failed");
378        assert!(last.ok());
379        assert_eq!(last.text_out().trim(), "test");
380    }
381
382    #[tokio::test]
383    async fn test_embedded_blob_write_read() {
384        let client = EmbeddedClient::transient().expect("failed to create client");
385
386        let data = b"hello blob world!";
387        let id = client.write_blob("text/plain", data).await.expect("write_blob failed");
388
389        assert!(!id.is_empty(), "blob id should not be empty");
390
391        let read_data = client.read_blob(&id).await.expect("read_blob failed");
392        assert_eq!(read_data, data);
393    }
394
395    #[tokio::test]
396    async fn test_embedded_blob_delete() {
397        let client = EmbeddedClient::transient().expect("failed to create client");
398
399        let data = b"blob to delete";
400        let id = client.write_blob("application/octet-stream", data).await.expect("write_blob failed");
401
402        // Verify it exists
403        let read_data = client.read_blob(&id).await.expect("read_blob failed");
404        assert_eq!(read_data, data);
405
406        // Delete it
407        let deleted = client.delete_blob(&id).await.expect("delete_blob failed");
408        assert!(deleted, "blob should have been deleted");
409
410        // Verify it's gone
411        let result = client.read_blob(&id).await;
412        assert!(result.is_err(), "blob should not exist after deletion");
413    }
414
415    #[tokio::test]
416    async fn test_embedded_blob_delete_nonexistent() {
417        let client = EmbeddedClient::transient().expect("failed to create client");
418
419        let deleted = client.delete_blob("nonexistent-blob-id").await.expect("delete_blob failed");
420        assert!(!deleted, "deleting nonexistent blob should return false");
421    }
422
423    #[tokio::test]
424    async fn test_embedded_blob_large_data() {
425        let client = EmbeddedClient::transient().expect("failed to create client");
426
427        // Create 1MB of data
428        let data: Vec<u8> = (0..1024 * 1024).map(|i| (i % 256) as u8).collect();
429        let id = client.write_blob("application/octet-stream", &data).await.expect("write_blob failed");
430
431        let read_data = client.read_blob(&id).await.expect("read_blob failed");
432        assert_eq!(read_data.len(), data.len());
433        assert_eq!(read_data, data);
434    }
435}