Skip to main content

github_copilot_sdk/
session_fs.rs

1//! Session filesystem provider — virtualizable filesystem layer over JSON-RPC.
2//!
3//! When [`ClientOptions::session_fs`] is set, the SDK tells the CLI to delegate
4//! all per-session filesystem operations (`readFile`, `writeFile`, `stat`, ...)
5//! to a [`SessionFsProvider`] registered on each session. This lets host
6//! applications sandbox sessions, project files into in-memory or remote
7//! storage, and apply permission policies before bytes move.
8//!
9//! # Concurrency
10//!
11//! Each inbound `sessionFs.*` request is dispatched on its own spawned task,
12//! so provider implementations MUST be safe for concurrent invocation across
13//! distinct paths. Use internal synchronization (e.g. [`tokio::sync::Mutex`]
14//! keyed by path) if your backing store needs ordering.
15//!
16//! # Errors
17//!
18//! Provider methods return [`Result<T, FsError>`]. The SDK adapts these into
19//! the schema's `{ ..., error: Option<SessionFsError> }` payload, mapping
20//! [`FsError::NotFound`] to the wire's `ENOENT` and everything else to
21//! `UNKNOWN`. A [`From<std::io::Error>`] conversion is provided so handlers
22//! backed by [`tokio::fs`](https://docs.rs/tokio/latest/tokio/fs/index.html)
23//! can propagate `io::Error` with `?`.
24//!
25//! # Example
26//!
27//! ```no_run
28//! use std::sync::Arc;
29//! use async_trait::async_trait;
30//! use github_copilot_sdk::types::{SessionFsProvider, FsError, FileInfo, DirEntry};
31//!
32//! struct MyProvider;
33//!
34//! #[async_trait]
35//! impl SessionFsProvider for MyProvider {
36//!     async fn read_file(&self, path: &str) -> Result<String, FsError> {
37//!         std::fs::read_to_string(path)
38//!             .map_err(FsError::from)
39//!     }
40//! }
41//! ```
42
43use std::collections::HashMap;
44
45use async_trait::async_trait;
46
47pub use crate::generated::api_types::SessionFsSqliteQueryType;
48use crate::generated::api_types::{
49    SessionFsError, SessionFsErrorCode, SessionFsReaddirWithTypesEntry,
50    SessionFsReaddirWithTypesEntryType, SessionFsSetProviderConventions, SessionFsStatResult,
51};
52
53/// Optional capabilities declared by a session filesystem provider.
54#[non_exhaustive]
55#[derive(Debug, Clone, Default)]
56pub struct SessionFsCapabilities {
57    /// Whether the provider supports SQLite query/exists operations.
58    pub sqlite: bool,
59}
60
61impl SessionFsCapabilities {
62    /// Create a new capabilities struct with default values.
63    pub fn new() -> Self {
64        Self::default()
65    }
66
67    /// Enable SQLite support.
68    pub fn with_sqlite(mut self, sqlite: bool) -> Self {
69        self.sqlite = sqlite;
70        self
71    }
72}
73
74/// Configuration for a custom session filesystem provider.
75///
76/// When set on [`ClientOptions::session_fs`](crate::ClientOptions::session_fs),
77/// the SDK calls `sessionFs.setProvider` during [`Client::start`](crate::Client::start)
78/// to tell the CLI to route per-session filesystem operations to the SDK.
79#[non_exhaustive]
80#[derive(Debug, Clone)]
81pub struct SessionFsConfig {
82    /// Initial working directory for sessions (the user's project directory).
83    pub initial_cwd: String,
84    /// Path within each session's SessionFs where the runtime stores
85    /// session-scoped files (events, workspace, checkpoints, etc.).
86    pub session_state_path: String,
87    /// Path conventions used by this filesystem provider.
88    pub conventions: SessionFsConventions,
89    /// Optional capabilities such as SQLite support.
90    pub capabilities: Option<SessionFsCapabilities>,
91}
92
93impl SessionFsConfig {
94    /// Build a new config with the required fields.
95    pub fn new(
96        initial_cwd: impl Into<String>,
97        session_state_path: impl Into<String>,
98        conventions: SessionFsConventions,
99    ) -> Self {
100        Self {
101            initial_cwd: initial_cwd.into(),
102            session_state_path: session_state_path.into(),
103            conventions,
104            capabilities: None,
105        }
106    }
107
108    /// Set the capabilities on this config and return it (builder pattern).
109    pub fn with_capabilities(mut self, capabilities: SessionFsCapabilities) -> Self {
110        self.capabilities = Some(capabilities);
111        self
112    }
113}
114
115/// Path conventions used by a session filesystem provider.
116///
117/// Hand-authored consumer-facing enum (rather than reusing
118/// [`SessionFsSetProviderConventions`]) to avoid exposing the generated
119/// catch-all `Unknown` variant on the input side. The SDK rejects unknown
120/// conventions at validation time with a typed error.
121#[derive(Debug, Clone, Copy, PartialEq, Eq)]
122pub enum SessionFsConventions {
123    /// POSIX-style paths (`/foo/bar`).
124    Posix,
125    /// Windows-style paths (`C:\foo\bar`).
126    Windows,
127}
128
129impl SessionFsConventions {
130    pub(crate) fn into_wire(self) -> SessionFsSetProviderConventions {
131        match self {
132            Self::Posix => SessionFsSetProviderConventions::Posix,
133            Self::Windows => SessionFsSetProviderConventions::Windows,
134        }
135    }
136}
137
138/// Error returned by a [`SessionFsProvider`] method.
139///
140/// The SDK maps this onto the wire schema's [`SessionFsError`]:
141/// [`FsError::NotFound`] becomes `ENOENT`, everything else becomes `UNKNOWN`.
142#[non_exhaustive]
143#[derive(Debug, Clone, thiserror::Error)]
144pub enum FsError {
145    /// File or directory does not exist.
146    #[error("not found: {0}")]
147    NotFound(String),
148
149    /// Any other filesystem error (permission denied, I/O error, etc.).
150    ///
151    /// The wire mapping always uses `UNKNOWN` as the code; the message is
152    /// preserved for diagnostics.
153    #[error("{0}")]
154    Other(String),
155}
156
157impl FsError {
158    pub(crate) fn into_wire(self) -> SessionFsError {
159        match self {
160            Self::NotFound(message) => SessionFsError {
161                code: SessionFsErrorCode::ENOENT,
162                message: Some(message),
163            },
164            Self::Other(message) => SessionFsError {
165                code: SessionFsErrorCode::UNKNOWN,
166                message: Some(message),
167            },
168        }
169    }
170}
171
172impl From<std::io::Error> for FsError {
173    fn from(err: std::io::Error) -> Self {
174        match err.kind() {
175            std::io::ErrorKind::NotFound => Self::NotFound(err.to_string()),
176            _ => Self::Other(err.to_string()),
177        }
178    }
179}
180
181/// File or directory metadata returned by [`SessionFsProvider::stat`].
182///
183/// The SDK adapts this into the wire's [`SessionFsStatResult`].
184#[non_exhaustive]
185#[derive(Debug, Clone)]
186pub struct FileInfo {
187    /// Whether the path is a regular file.
188    pub is_file: bool,
189    /// Whether the path is a directory.
190    pub is_directory: bool,
191    /// File size in bytes.
192    pub size: i64,
193    /// ISO 8601 timestamp of last modification.
194    pub mtime: String,
195    /// ISO 8601 timestamp of creation.
196    pub birthtime: String,
197}
198
199impl FileInfo {
200    /// Build a metadata record. The mtime/birthtime arguments are caller-
201    /// supplied ISO 8601 strings — the SDK does not format timestamps for
202    /// you.
203    pub fn new(
204        is_file: bool,
205        is_directory: bool,
206        size: i64,
207        mtime: impl Into<String>,
208        birthtime: impl Into<String>,
209    ) -> Self {
210        Self {
211            is_file,
212            is_directory,
213            size,
214            mtime: mtime.into(),
215            birthtime: birthtime.into(),
216        }
217    }
218
219    pub(crate) fn into_wire(self) -> SessionFsStatResult {
220        SessionFsStatResult {
221            is_file: self.is_file,
222            is_directory: self.is_directory,
223            size: self.size,
224            mtime: self.mtime,
225            birthtime: self.birthtime,
226            error: None,
227        }
228    }
229}
230
231/// Kind of entry returned by [`SessionFsProvider::readdir_with_types`].
232///
233/// The wire schema's `Unknown` forward-compat variant is intentionally absent
234/// from this consumer-facing enum — providers must classify each entry as
235/// either a file or a directory.
236#[derive(Debug, Clone, Copy, PartialEq, Eq)]
237pub enum DirEntryKind {
238    /// Regular file.
239    File,
240    /// Directory.
241    Directory,
242}
243
244impl DirEntryKind {
245    fn into_wire(self) -> SessionFsReaddirWithTypesEntryType {
246        match self {
247            Self::File => SessionFsReaddirWithTypesEntryType::File,
248            Self::Directory => SessionFsReaddirWithTypesEntryType::Directory,
249        }
250    }
251}
252
253/// Single entry in a directory listing returned by
254/// [`SessionFsProvider::readdir_with_types`].
255#[non_exhaustive]
256#[derive(Debug, Clone)]
257pub struct DirEntry {
258    /// Entry name (basename, not full path).
259    pub name: String,
260    /// Whether the entry is a file or a directory.
261    pub kind: DirEntryKind,
262}
263
264impl DirEntry {
265    /// Build a new directory entry.
266    pub fn new(name: impl Into<String>, kind: DirEntryKind) -> Self {
267        Self {
268            name: name.into(),
269            kind,
270        }
271    }
272
273    pub(crate) fn into_wire(self) -> SessionFsReaddirWithTypesEntry {
274        SessionFsReaddirWithTypesEntry {
275            name: self.name,
276            r#type: self.kind.into_wire(),
277        }
278    }
279}
280
281/// Implementor-supplied filesystem backing for a session.
282///
283/// Each method takes a path using the conventions declared in
284/// [`SessionFsConfig::conventions`] and returns the operation's result. The
285/// SDK adapts every `Result<_, FsError>` into the JSON-RPC response shape
286/// expected by the GitHub Copilot CLI.
287///
288/// # Concurrency
289///
290/// Implementations MUST be `Send + Sync` and safe for concurrent invocation
291/// across distinct paths. The SDK dispatches each inbound `sessionFs.*`
292/// request on its own spawned task. Use internal synchronization (e.g.
293/// [`tokio::sync::Mutex`] keyed by path) if your backing store requires
294/// ordering.
295///
296/// # Forward compatibility
297///
298/// Methods on this trait have default implementations that return
299/// `Err(FsError::Other("operation not supported".into()))`. When the CLI
300/// schema grows new `sessionFs.*` methods, the SDK adds them to this trait
301/// with default impls so existing implementations continue to compile.
302/// Override only the methods relevant to your backing store.
303#[async_trait]
304pub trait SessionFsProvider: Send + Sync + 'static {
305    /// Read the full contents of a file as UTF-8.
306    async fn read_file(&self, path: &str) -> Result<String, FsError> {
307        let _ = path;
308        Err(FsError::Other("read_file not supported".to_string()))
309    }
310
311    /// Write content to a file, creating parent directories if needed.
312    async fn write_file(
313        &self,
314        path: &str,
315        content: &str,
316        mode: Option<i64>,
317    ) -> Result<(), FsError> {
318        let _ = (path, content, mode);
319        Err(FsError::Other("write_file not supported".to_string()))
320    }
321
322    /// Append content to a file, creating parent directories if needed.
323    async fn append_file(
324        &self,
325        path: &str,
326        content: &str,
327        mode: Option<i64>,
328    ) -> Result<(), FsError> {
329        let _ = (path, content, mode);
330        Err(FsError::Other("append_file not supported".to_string()))
331    }
332
333    /// Check whether a path exists.
334    ///
335    /// Returns `Ok(false)` for non-existent paths, not [`FsError::NotFound`].
336    async fn exists(&self, path: &str) -> Result<bool, FsError> {
337        let _ = path;
338        Err(FsError::Other("exists not supported".to_string()))
339    }
340
341    /// Get metadata about a file or directory.
342    async fn stat(&self, path: &str) -> Result<FileInfo, FsError> {
343        let _ = path;
344        Err(FsError::Other("stat not supported".to_string()))
345    }
346
347    /// Create a directory. When `recursive`, missing parents are also created.
348    async fn mkdir(&self, path: &str, recursive: bool, mode: Option<i64>) -> Result<(), FsError> {
349        let _ = (path, recursive, mode);
350        Err(FsError::Other("mkdir not supported".to_string()))
351    }
352
353    /// List entry names in a directory.
354    async fn readdir(&self, path: &str) -> Result<Vec<String>, FsError> {
355        let _ = path;
356        Err(FsError::Other("readdir not supported".to_string()))
357    }
358
359    /// List directory entries with type information.
360    async fn readdir_with_types(&self, path: &str) -> Result<Vec<DirEntry>, FsError> {
361        let _ = path;
362        Err(FsError::Other(
363            "readdir_with_types not supported".to_string(),
364        ))
365    }
366
367    /// Remove a file or directory. When `force`, missing paths are not an
368    /// error. When `recursive`, directory contents are removed as well.
369    async fn rm(&self, path: &str, recursive: bool, force: bool) -> Result<(), FsError> {
370        let _ = (path, recursive, force);
371        Err(FsError::Other("rm not supported".to_string()))
372    }
373
374    /// Rename or move a file or directory.
375    async fn rename(&self, src: &str, dest: &str) -> Result<(), FsError> {
376        let _ = (src, dest);
377        Err(FsError::Other("rename not supported".to_string()))
378    }
379
380    /// Return a reference to the SQLite provider, if this provider supports
381    /// SQLite operations. The default returns `None`. Providers that support
382    /// SQLite should also implement [`SessionFsSqliteProvider`] and override
383    /// this to return `Some(self)`.
384    fn sqlite(&self) -> Option<&dyn SessionFsSqliteProvider> {
385        None
386    }
387}
388
389/// Optional trait for providers that support SQLite operations.
390///
391/// Providers are already session-scoped (created per session by the factory),
392/// so these methods do not take a `session_id` parameter.
393///
394/// To opt in, implement this trait on your provider and override
395/// [`SessionFsProvider::sqlite`] to return `Some(self)`:
396///
397/// ```ignore
398/// impl SessionFsSqliteProvider for MyProvider { /* ... */ }
399///
400/// #[async_trait]
401/// impl SessionFsProvider for MyProvider {
402///     fn sqlite(&self) -> Option<&dyn SessionFsSqliteProvider> {
403///         Some(self)
404///     }
405///     // ... other methods ...
406/// }
407/// ```
408#[async_trait]
409pub trait SessionFsSqliteProvider: Send + Sync {
410    /// Execute a SQLite query against the provider's per-session database.
411    async fn sqlite_query(
412        &self,
413        query_type: SessionFsSqliteQueryType,
414        query: &str,
415        params: Option<&HashMap<String, serde_json::Value>>,
416    ) -> Result<Option<SessionFsSqliteQueryResult>, FsError>;
417
418    /// Check whether the provider has a SQLite database for this session.
419    async fn sqlite_exists(&self) -> Result<bool, FsError>;
420}
421
422/// Result of a SQLite query execution via [`SessionFsSqliteProvider::sqlite_query`].
423///
424/// Same shape as the generated RPC type but without the `error` field,
425/// since providers signal errors by returning `Err`.
426#[derive(Debug, Clone, Default)]
427pub struct SessionFsSqliteQueryResult {
428    /// Column names from the result set.
429    pub columns: Vec<String>,
430    /// For SELECT: array of row objects. For others: empty array.
431    pub rows: Vec<HashMap<String, serde_json::Value>>,
432    /// Number of rows affected (for INSERT/UPDATE/DELETE).
433    pub rows_affected: i64,
434    /// Last inserted row ID (for INSERT).
435    pub last_insert_rowid: Option<i64>,
436}
437
438#[cfg(test)]
439mod tests {
440    use super::*;
441
442    #[test]
443    fn fs_error_maps_io_not_found_to_enoent() {
444        let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "missing.txt");
445        let fs_err: FsError = io_err.into();
446        assert!(matches!(fs_err, FsError::NotFound(_)));
447        let wire = fs_err.into_wire();
448        assert_eq!(wire.code, SessionFsErrorCode::ENOENT);
449    }
450
451    #[test]
452    fn fs_error_maps_other_io_to_unknown() {
453        let io_err = std::io::Error::other("disk full");
454        let fs_err: FsError = io_err.into();
455        assert!(matches!(fs_err, FsError::Other(_)));
456        let wire = fs_err.into_wire();
457        assert_eq!(wire.code, SessionFsErrorCode::UNKNOWN);
458        assert!(wire.message.unwrap().contains("disk full"));
459    }
460
461    #[test]
462    fn conventions_maps_to_wire() {
463        assert_eq!(
464            SessionFsConventions::Posix.into_wire(),
465            SessionFsSetProviderConventions::Posix
466        );
467        assert_eq!(
468            SessionFsConventions::Windows.into_wire(),
469            SessionFsSetProviderConventions::Windows
470        );
471    }
472
473    struct DefaultProvider;
474    #[async_trait]
475    impl SessionFsProvider for DefaultProvider {}
476
477    #[tokio::test]
478    async fn default_impls_return_unsupported() {
479        let p = DefaultProvider;
480        let err = p.read_file("/x").await.unwrap_err();
481        assert!(matches!(err, FsError::Other(ref m) if m.contains("not supported")));
482    }
483}