Skip to main content

hara_native/file/
interface.rs

1//! Provider-neutral mounted filesystem interface.
2//!
3//! `std.native.File` remains promise-based at the Hara boundary. Providers use
4//! typed local futures here so browser implementations may retain non-`Send`
5//! host handles while native implementations schedule blocking I/O elsewhere.
6
7#[path = "legacy.rs"]
8mod legacy;
9
10use self::legacy::LegacyFilesystem;
11use crate::file::{
12    CopyOptions, DeleteOptions, FileEntry, FileError, FileProvider, FileType, MkdirOptions,
13    MoveOptions, WriteOptions,
14};
15use std::collections::{BTreeMap, BTreeSet};
16use std::future::Future;
17use std::pin::Pin;
18use std::rc::Rc;
19use std::sync::atomic::{AtomicBool, Ordering};
20use std::sync::Arc;
21use std::time::Instant;
22
23pub type FilesystemFuture<'a, T> = Pin<Box<dyn Future<Output = Result<T, FileError>> + 'a>>;
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
26pub enum FilesystemCapability {
27    Read,
28    Write,
29    Entries,
30    Mkdir,
31    Delete,
32    Copy,
33    Move,
34    Append,
35    AtomicMove,
36    PreserveModified,
37    RevisionCheck,
38    Transactions,
39    Watch,
40}
41
42impl FilesystemCapability {
43    pub fn keyword(self) -> &'static str {
44        match self {
45            Self::Read => "read",
46            Self::Write => "write",
47            Self::Entries => "entries",
48            Self::Mkdir => "mkdir",
49            Self::Delete => "delete",
50            Self::Copy => "copy",
51            Self::Move => "move",
52            Self::Append => "append",
53            Self::AtomicMove => "atomic-move",
54            Self::PreserveModified => "preserve-modified",
55            Self::RevisionCheck => "revision-check",
56            Self::Transactions => "transactions",
57            Self::Watch => "watch",
58        }
59    }
60}
61
62#[derive(Debug, Clone, PartialEq, Eq, Default)]
63pub struct FilesystemCapabilities {
64    values: BTreeSet<FilesystemCapability>,
65}
66
67impl FilesystemCapabilities {
68    pub fn new(values: impl IntoIterator<Item = FilesystemCapability>) -> Self {
69        Self {
70            values: values.into_iter().collect(),
71        }
72    }
73
74    pub fn read_only() -> Self {
75        Self::new([FilesystemCapability::Read, FilesystemCapability::Entries])
76    }
77
78    pub fn legacy_read_write() -> Self {
79        Self::new([
80            FilesystemCapability::Read,
81            FilesystemCapability::Write,
82            FilesystemCapability::Entries,
83            FilesystemCapability::Mkdir,
84            FilesystemCapability::Delete,
85            FilesystemCapability::Copy,
86            FilesystemCapability::Move,
87            FilesystemCapability::Append,
88            FilesystemCapability::PreserveModified,
89        ])
90    }
91
92    pub fn contains(&self, capability: FilesystemCapability) -> bool {
93        self.values.contains(&capability)
94    }
95
96    pub fn iter(&self) -> impl Iterator<Item = FilesystemCapability> + '_ {
97        self.values.iter().copied()
98    }
99}
100
101#[derive(Debug, Clone, PartialEq, Eq)]
102pub struct FilesystemDescriptor {
103    kind: String,
104    display: String,
105    read_only: bool,
106    capabilities: FilesystemCapabilities,
107    revision: Option<String>,
108    extensions: BTreeMap<String, String>,
109}
110
111impl FilesystemDescriptor {
112    pub fn new(
113        kind: impl Into<String>,
114        display: impl Into<String>,
115        read_only: bool,
116        capabilities: FilesystemCapabilities,
117    ) -> Self {
118        Self {
119            kind: kind.into(),
120            display: display.into(),
121            read_only,
122            capabilities,
123            revision: None,
124            extensions: BTreeMap::new(),
125        }
126    }
127
128    pub fn legacy(kind: impl Into<String>, display: impl Into<String>) -> Self {
129        Self::new(
130            kind,
131            display,
132            false,
133            FilesystemCapabilities::legacy_read_write(),
134        )
135    }
136
137    pub fn with_revision(mut self, revision: impl Into<String>) -> Self {
138        self.revision = Some(revision.into());
139        self
140    }
141
142    pub fn with_extension(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
143        self.extensions.insert(key.into(), value.into());
144        self
145    }
146
147    pub fn kind(&self) -> &str {
148        &self.kind
149    }
150
151    pub fn display(&self) -> &str {
152        &self.display
153    }
154
155    pub fn read_only(&self) -> bool {
156        self.read_only
157    }
158
159    pub fn capabilities(&self) -> &FilesystemCapabilities {
160        &self.capabilities
161    }
162
163    pub fn revision(&self) -> Option<&str> {
164        self.revision.as_deref()
165    }
166
167    pub fn extensions(&self) -> &BTreeMap<String, String> {
168        &self.extensions
169    }
170}
171
172#[derive(Debug, Clone)]
173pub struct FilesystemCallContext {
174    deadline: Option<Instant>,
175    cancelled: Arc<AtomicBool>,
176    trace_id: Option<Arc<str>>,
177}
178
179impl Default for FilesystemCallContext {
180    fn default() -> Self {
181        Self {
182            deadline: None,
183            cancelled: Arc::new(AtomicBool::new(false)),
184            trace_id: None,
185        }
186    }
187}
188
189impl FilesystemCallContext {
190    pub fn with_deadline(mut self, deadline: Instant) -> Self {
191        self.deadline = Some(deadline);
192        self
193    }
194
195    pub fn with_trace_id(mut self, trace_id: impl Into<Arc<str>>) -> Self {
196        self.trace_id = Some(trace_id.into());
197        self
198    }
199
200    pub fn deadline(&self) -> Option<Instant> {
201        self.deadline
202    }
203
204    pub fn trace_id(&self) -> Option<&str> {
205        self.trace_id.as_deref()
206    }
207
208    pub fn cancel(&self) -> bool {
209        !self.cancelled.swap(true, Ordering::AcqRel)
210    }
211
212    pub fn cancelled(&self) -> bool {
213        self.cancelled.load(Ordering::Acquire)
214    }
215
216    pub fn check(&self) -> Result<(), FileError> {
217        if self.cancelled() {
218            return Err(FileError::Io("filesystem operation cancelled".into()));
219        }
220        if self
221            .deadline
222            .is_some_and(|deadline| Instant::now() >= deadline)
223        {
224            return Err(FileError::Io("filesystem operation timed out".into()));
225        }
226        Ok(())
227    }
228}
229
230#[derive(Debug, Clone, PartialEq, Eq)]
231pub struct FilesystemPageRequest {
232    pub token: Option<String>,
233    pub limit: usize,
234}
235
236impl Default for FilesystemPageRequest {
237    fn default() -> Self {
238        Self {
239            token: None,
240            limit: 256,
241        }
242    }
243}
244
245#[derive(Debug, Clone, PartialEq, Eq)]
246pub struct FilesystemEntry {
247    pub path: String,
248    pub name: String,
249    pub kind: FileType,
250    pub size: Option<u64>,
251    pub modified_at: Option<i64>,
252    pub id: Option<String>,
253    pub revision: Option<String>,
254    pub capabilities: Option<FilesystemCapabilities>,
255    pub extensions: BTreeMap<String, String>,
256}
257
258impl From<FileEntry> for FilesystemEntry {
259    fn from(entry: FileEntry) -> Self {
260        Self {
261            path: entry.path,
262            name: entry.name,
263            kind: entry.kind,
264            size: entry.size,
265            modified_at: Some(entry.modified_at),
266            id: None,
267            revision: None,
268            capabilities: None,
269            extensions: BTreeMap::new(),
270        }
271    }
272}
273
274#[derive(Debug, Clone, PartialEq, Eq)]
275pub struct FilesystemEntryPage {
276    pub entries: Vec<FilesystemEntry>,
277    pub next_token: Option<String>,
278}
279
280#[derive(Debug, Clone, PartialEq, Eq, Default)]
281pub struct FilesystemMutationContext {
282    pub expected_revision: Option<String>,
283    pub expected_target_revision: Option<String>,
284}
285
286impl FilesystemMutationContext {
287    pub fn required(&self) -> bool {
288        self.expected_revision.is_some() || self.expected_target_revision.is_some()
289    }
290}
291
292#[derive(Debug, Clone, PartialEq, Eq)]
293pub struct FilesystemMutation {
294    pub path: String,
295    pub revision: Option<String>,
296    pub mount_revision: Option<String>,
297    pub extensions: BTreeMap<String, String>,
298}
299
300impl FilesystemMutation {
301    pub fn path(path: impl Into<String>) -> Self {
302        Self {
303            path: path.into(),
304            revision: None,
305            mount_revision: None,
306            extensions: BTreeMap::new(),
307        }
308    }
309}
310
311pub trait SynchronousFileProvider: FileProvider {}
312
313pub trait IFilesystem {
314    fn descriptor(&self) -> FilesystemDescriptor;
315
316    fn capabilities(&self) -> FilesystemCapabilities {
317        self.descriptor().capabilities().clone()
318    }
319
320    fn stat<'a>(
321        &'a self,
322        context: FilesystemCallContext,
323        path: String,
324    ) -> FilesystemFuture<'a, FilesystemEntry>;
325
326    fn read<'a>(
327        &'a self,
328        context: FilesystemCallContext,
329        path: String,
330    ) -> FilesystemFuture<'a, Vec<u8>>;
331
332    fn write<'a>(
333        &'a self,
334        context: FilesystemCallContext,
335        path: String,
336        bytes: Vec<u8>,
337        options: WriteOptions,
338        mutation: FilesystemMutationContext,
339    ) -> FilesystemFuture<'a, FilesystemMutation>;
340
341    fn entries_page<'a>(
342        &'a self,
343        context: FilesystemCallContext,
344        path: String,
345        request: FilesystemPageRequest,
346    ) -> FilesystemFuture<'a, FilesystemEntryPage>;
347
348    fn mkdir<'a>(
349        &'a self,
350        context: FilesystemCallContext,
351        path: String,
352        options: MkdirOptions,
353        mutation: FilesystemMutationContext,
354    ) -> FilesystemFuture<'a, FilesystemMutation>;
355
356    fn delete<'a>(
357        &'a self,
358        context: FilesystemCallContext,
359        path: String,
360        options: DeleteOptions,
361        mutation: FilesystemMutationContext,
362    ) -> FilesystemFuture<'a, FilesystemMutation>;
363
364    fn copy<'a>(
365        &'a self,
366        context: FilesystemCallContext,
367        source: String,
368        target: String,
369        options: CopyOptions,
370        mutation: FilesystemMutationContext,
371    ) -> FilesystemFuture<'a, FilesystemMutation>;
372
373    fn move_entry<'a>(
374        &'a self,
375        context: FilesystemCallContext,
376        source: String,
377        target: String,
378        options: MoveOptions,
379        mutation: FilesystemMutationContext,
380    ) -> FilesystemFuture<'a, FilesystemMutation>;
381
382    fn close<'a>(&'a self, context: FilesystemCallContext) -> FilesystemFuture<'a, ()>;
383}
384
385#[path = "providers.rs"]
386pub mod providers;
387#[cfg(not(target_arch = "wasm32"))]
388pub mod sftp;
389
390#[derive(Clone)]
391pub struct FilesystemHandle {
392    filesystem: Rc<dyn IFilesystem>,
393}
394
395impl std::fmt::Debug for FilesystemHandle {
396    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
397        formatter
398            .debug_struct("FilesystemHandle")
399            .field("descriptor", &self.descriptor())
400            .finish()
401    }
402}
403
404impl FilesystemHandle {
405    pub fn new<F: IFilesystem + 'static>(filesystem: F) -> Self {
406        Self {
407            filesystem: Rc::new(filesystem),
408        }
409    }
410
411    pub fn from_legacy<P: SynchronousFileProvider + 'static>(
412        provider: P,
413        descriptor: FilesystemDescriptor,
414    ) -> Self {
415        Self::new(LegacyFilesystem::new(provider, descriptor))
416    }
417
418    pub fn descriptor(&self) -> FilesystemDescriptor {
419        self.filesystem.descriptor()
420    }
421
422    pub fn capabilities(&self) -> FilesystemCapabilities {
423        self.filesystem.capabilities()
424    }
425
426    pub fn as_filesystem(&self) -> &dyn IFilesystem {
427        self.filesystem.as_ref()
428    }
429}
430
431#[cfg(test)]
432mod tests {
433    use super::*;
434
435    #[test]
436    fn call_context_cancellation_is_shared() {
437        let context = FilesystemCallContext::default();
438        let second = context.clone();
439        assert!(context.cancel());
440        assert!(second.cancelled());
441        assert!(matches!(second.check(), Err(FileError::Io(_))));
442    }
443
444    #[test]
445    fn descriptors_expose_only_explicit_redacted_fields() {
446        let descriptor = FilesystemDescriptor::new(
447            "github",
448            "hara-lang/hara@main",
449            false,
450            FilesystemCapabilities::new([
451                FilesystemCapability::Read,
452                FilesystemCapability::Entries,
453                FilesystemCapability::RevisionCheck,
454            ]),
455        )
456        .with_revision("commit-sha")
457        .with_extension("provider/ref", "heads/main");
458        assert_eq!(descriptor.kind(), "github");
459        assert_eq!(descriptor.display(), "hara-lang/hara@main");
460        assert_eq!(descriptor.revision(), Some("commit-sha"));
461        assert!(descriptor
462            .capabilities()
463            .contains(FilesystemCapability::RevisionCheck));
464    }
465}