Skip to main content

fslite_command/
remote.rs

1//! Translates each [`Command`] into an HTTP request against `fslite-server`'s
2//! actual route table (see `crates/fslite-server/src/routes/*.rs`) and parses
3//! the response back into a typed [`CommandOutput`].
4//!
5//! `fslite-server`'s `GET /content/{*path}` route does not echo a file's
6//! [`Revision`] in any response header, so [`Command::Read`] cannot recover
7//! it from the content response alone. `fslite-server` already went through
8//! its own full review cycle and fix wave before this task started, so
9//! rather than reopening it to add a new header, this executor issues an
10//! extra `stat` call before every `read` to populate
11//! `CommandOutput::Content::revision`, at the cost of one additional round
12//! trip per remote read.
13
14use async_trait::async_trait;
15use base64::Engine;
16use fslite_core::{
17    BatchResult, ByteRange, ErrorCode, FsError, FsResult, LinkTarget, Node, Page, PageRequest,
18    RequestContext, Revision, SearchMatch, VirtualPath, WorkspaceId,
19};
20use reqwest::{Client, StatusCode};
21use serde::de::DeserializeOwned;
22
23use crate::executor::Executor;
24use crate::{Command, CommandOutput};
25
26/// Executes commands against a running `fslite-server` over HTTP.
27pub struct RemoteExecutor {
28    base_url: String,
29    token: String,
30    client: Client,
31}
32
33impl RemoteExecutor {
34    /// Points a new executor at `base_url` (e.g. `http://127.0.0.1:8080`),
35    /// authenticating every request with a bearer `token`.
36    pub fn new(base_url: impl Into<String>, token: impl Into<String>) -> Self {
37        Self {
38            base_url: base_url.into(),
39            token: token.into(),
40            client: Client::new(),
41        }
42    }
43
44    fn url(&self, workspace_id: WorkspaceId, suffix: &str) -> String {
45        format!("{}/v1/workspaces/{workspace_id}{suffix}", self.base_url)
46    }
47
48    /// Attaches the bearer token, sends `builder`, and maps a non-2xx
49    /// response into a typed [`FsError`] via [`Self::error_from_response`].
50    async fn send_checked(&self, builder: reqwest::RequestBuilder) -> FsResult<reqwest::Response> {
51        let response = builder
52            .bearer_auth(&self.token)
53            .send()
54            .await
55            .map_err(map_reqwest_err)?;
56        if response.status().is_success() {
57            Ok(response)
58        } else {
59            Err(Self::error_from_response(response).await)
60        }
61    }
62
63    /// Sends `builder` and deserializes a successful JSON response body.
64    async fn send_json<T: DeserializeOwned>(
65        &self,
66        builder: reqwest::RequestBuilder,
67    ) -> FsResult<T> {
68        let response = self.send_checked(builder).await?;
69        response.json::<T>().await.map_err(map_reqwest_err)
70    }
71
72    /// Issues a `stat` call directly. Used both for `Command::Stat` and as
73    /// the extra round trip `Command::Read` needs to recover `Revision`.
74    async fn stat(
75        &self,
76        ctx: &RequestContext,
77        path: &VirtualPath,
78        follow_symlinks: bool,
79    ) -> FsResult<Node> {
80        let builder = self
81            .client
82            .get(self.url(ctx.workspace_id, &format!("/fs{}", path.as_str())))
83            .query(&[("follow_symlinks", follow_symlinks.to_string())]);
84        self.send_json(builder).await
85    }
86
87    async fn error_from_response(response: reqwest::Response) -> FsError {
88        #[derive(serde::Deserialize)]
89        struct Envelope {
90            error: ErrorBody,
91        }
92        #[derive(serde::Deserialize)]
93        struct ErrorBody {
94            code: String,
95            message: String,
96            details: serde_json::Value,
97        }
98
99        let status = response.status();
100        match response.json::<Envelope>().await {
101            Ok(envelope) => {
102                let code = code_from_str(&envelope.error.code);
103                FsError::new(code, envelope.error.message, envelope.error.details)
104            }
105            Err(_) => FsError::internal_storage_failure(format!(
106                "unrecognized error response, status {status}"
107            )),
108        }
109    }
110}
111
112fn map_reqwest_err(err: reqwest::Error) -> FsError {
113    FsError::internal_storage_failure(err.to_string())
114}
115
116fn code_from_str(raw: &str) -> ErrorCode {
117    // Deserialize through `ErrorCode`'s own `Deserialize` impl (snake_case
118    // variant names) rather than hand-maintaining a second name table.
119    serde_json::from_value(serde_json::Value::String(raw.to_string()))
120        .unwrap_or(ErrorCode::InternalStorageFailure)
121}
122
123fn revision_query(expected_revision: Option<Revision>) -> Vec<(&'static str, String)> {
124    match expected_revision {
125        Some(revision) => vec![("expected_revision", revision.get().to_string())],
126        None => Vec::new(),
127    }
128}
129
130fn page_query(page: &PageRequest) -> Vec<(&'static str, String)> {
131    let mut pairs = vec![("limit", page.limit.to_string())];
132    if let Some(cursor) = &page.cursor {
133        pairs.push(("cursor", cursor.clone()));
134    }
135    pairs
136}
137
138fn page_body(page: &PageRequest) -> serde_json::Value {
139    serde_json::json!({ "cursor": page.cursor, "limit": page.limit })
140}
141
142/// Parses a `Content-Range: bytes {start}-{end}/{total}` response header
143/// into an inclusive-start, exclusive-end `(start, end)` pair.
144fn parse_content_range_bounds(header: &str) -> Option<(u64, u64)> {
145    let range = header.strip_prefix("bytes ")?;
146    let (range, _total) = range.split_once('/')?;
147    let (start, end_inclusive) = range.split_once('-')?;
148    let start: u64 = start.parse().ok()?;
149    let end_inclusive: u64 = end_inclusive.parse().ok()?;
150    Some((start, end_inclusive + 1))
151}
152
153/// The wire shape of `GET /fs/{*path}/link-target`'s response body.
154#[derive(serde::Deserialize)]
155struct LinkTargetWire {
156    target: String,
157}
158
159/// The wire shape of one item in `POST /search/content`'s response page
160/// (mirrors `fslite_server::dto::SearchMatchDto`, which is not exported
161/// outside that crate).
162#[derive(serde::Deserialize)]
163struct SearchMatchWire {
164    node: Node,
165    path: VirtualPath,
166    range: ByteRange,
167    preview_base64: String,
168}
169
170/// The wire shape of `POST /batch`'s response body.
171#[derive(serde::Deserialize)]
172struct BatchResponse {
173    results: Vec<BatchResult>,
174}
175
176#[async_trait]
177impl Executor for RemoteExecutor {
178    async fn execute(&self, ctx: &RequestContext, command: Command) -> FsResult<CommandOutput> {
179        match command {
180            Command::WorkspaceUsage => {
181                let builder = self.client.get(self.url(ctx.workspace_id, "/usage"));
182                Ok(CommandOutput::Usage(self.send_json(builder).await?))
183            }
184
185            Command::Stat { path, options } => Ok(CommandOutput::Node(
186                self.stat(ctx, &path, options.follow_symlinks).await?,
187            )),
188
189            Command::Exists { path, options } => {
190                // Reuses `Self::stat`'s real GET request (which returns a
191                // full JSON error envelope on failure) rather than issuing a
192                // bodyless HEAD request. A HEAD response never carries a
193                // body, so a non-2xx/non-404 status previously had no error
194                // envelope to parse and fell back to a generic
195                // `InternalStorageFailure` — flattening every real domain
196                // error (e.g. a broken symlink's `ErrorCode::BrokenLink`)
197                // into the same code, unlike `LocalExecutor`'s `fs.exists`
198                // (which itself calls `stat` and only maps `NotFound`
199                // specifically to `false`, propagating every other error —
200                // see `fslite-sqlite`'s `directory::exists`). Mirroring that
201                // exact mapping here keeps the two executors in agreement.
202                match self.stat(ctx, &path, options.follow_symlinks).await {
203                    Ok(_) => Ok(CommandOutput::Exists(true)),
204                    Err(err) if err.code() == ErrorCode::NotFound => {
205                        Ok(CommandOutput::Exists(false))
206                    }
207                    Err(err) => Err(err),
208                }
209            }
210
211            Command::ReadDir { path, page } => {
212                let builder = self
213                    .client
214                    .get(self.url(
215                        ctx.workspace_id,
216                        &format!("/directories{}/children", path.as_str()),
217                    ))
218                    .query(&page_query(&page));
219                Ok(CommandOutput::Nodes(self.send_json(builder).await?))
220            }
221
222            Command::Tree {
223                path,
224                options,
225                page,
226            } => {
227                let mut query = page_query(&page);
228                if let Some(max_depth) = options.max_depth {
229                    query.push(("max_depth", max_depth.to_string()));
230                }
231                query.push(("follow_symlinks", options.follow_symlinks.to_string()));
232                let builder = self
233                    .client
234                    .get(self.url(
235                        ctx.workspace_id,
236                        &format!("/directories{}/tree", path.as_str()),
237                    ))
238                    .query(&query);
239                Ok(CommandOutput::Tree(self.send_json(builder).await?))
240            }
241
242            Command::Mkdir { path, options } => {
243                let body = serde_json::json!({
244                    "parents": options.parents,
245                    "exist_ok": options.exist_ok,
246                    "expected_revision": options.expected_revision.map(Revision::get),
247                });
248                let builder = self
249                    .client
250                    .put(self.url(ctx.workspace_id, &format!("/fs{}", path.as_str())))
251                    .query(&[("type", "directory")])
252                    .json(&body);
253                Ok(CommandOutput::Node(self.send_json(builder).await?))
254            }
255
256            Command::Read { path, options } => {
257                // `fslite-server`'s `GET /content/{*path}` route has no
258                // `follow_symlinks` query parameter at all: it always reads
259                // through `ReadOptions::default()` (`follow_symlinks: true`)
260                // server-side, regardless of what this executor's own
261                // `stat` pre-fetch below is asked to honor (see
262                // `crates/fslite-server/src/routes/content.rs`). Silently
263                // proceeding when the caller explicitly asked not to follow
264                // symlinks would return a `CommandOutput::Content` whose
265                // `revision`/`logical_length` (taken from the *unfollowed*
266                // stat) don't correspond to `bytes` (always read from the
267                // *followed* target) — a silent correctness bug, not just a
268                // limitation. This fails loudly instead. `fslite-server` is
269                // out of scope to extend with a new query parameter in this
270                // task, so there is no way to honor the request correctly.
271                if !options.follow_symlinks {
272                    return Err(FsError::internal_storage_failure(
273                        "RemoteExecutor cannot honor follow_symlinks=false for read: \
274                         fslite-server's content route always follows symlinks",
275                    ));
276                }
277                let node = self.stat(ctx, &path, true).await?;
278                let mut builder = self
279                    .client
280                    .get(self.url(ctx.workspace_id, &format!("/content{}", path.as_str())));
281                if let Some(range) = options.range {
282                    builder = builder.header(
283                        reqwest::header::RANGE,
284                        format!("bytes={}-{}", range.start, range.end.saturating_sub(1)),
285                    );
286                }
287                let response = builder
288                    .bearer_auth(&self.token)
289                    .send()
290                    .await
291                    .map_err(map_reqwest_err)?;
292                // `fslite-server` returns a bodyless, envelope-free `416
293                // Range Not Satisfiable` for this one specific case (every
294                // other error path in that crate, including a reversed
295                // range, goes through the JSON `ApiError` envelope — see
296                // `crates/fslite-server/src/routes/content.rs`). Without
297                // this special case it would fall through to
298                // `error_from_response`'s generic "unrecognized error
299                // response" branch and get misreported as
300                // `ErrorCode::InternalStorageFailure` instead of the
301                // `ErrorCode::InvalidRange` `LocalExecutor` would surface
302                // directly from the same domain condition.
303                if response.status() == StatusCode::RANGE_NOT_SATISFIABLE {
304                    return Err(FsError::invalid_range(format!(
305                        "range not satisfiable for {}",
306                        path.as_str()
307                    )));
308                }
309                if !response.status().is_success() {
310                    return Err(Self::error_from_response(response).await);
311                }
312                let content_range_bounds = response
313                    .headers()
314                    .get(reqwest::header::CONTENT_RANGE)
315                    .and_then(|value| value.to_str().ok())
316                    .and_then(parse_content_range_bounds);
317                let bytes = response.bytes().await.map_err(map_reqwest_err)?.to_vec();
318                let range = content_range_bounds
319                    .map(|(start, end)| ByteRange::new(start, end))
320                    .unwrap_or_else(|| ByteRange::new(0, bytes.len() as u64));
321                Ok(CommandOutput::Content {
322                    logical_length: node.logical_size,
323                    revision: node.revision,
324                    range,
325                    bytes,
326                })
327            }
328
329            Command::Write {
330                path,
331                bytes,
332                options,
333            } => {
334                let mut query = vec![("create", options.create.to_string())];
335                query.extend(revision_query(options.expected_revision));
336                let builder = self
337                    .client
338                    .put(self.url(ctx.workspace_id, &format!("/content{}", path.as_str())))
339                    .query(&query)
340                    .body(bytes);
341                Ok(CommandOutput::Node(self.send_json(builder).await?))
342            }
343
344            Command::WriteAt {
345                path,
346                offset,
347                bytes,
348                options,
349            } => {
350                let mut query = vec![("offset", offset.to_string())];
351                query.extend(revision_query(options.expected_revision));
352                let builder = self
353                    .client
354                    .patch(self.url(ctx.workspace_id, &format!("/content{}", path.as_str())))
355                    .query(&query)
356                    .body(bytes);
357                Ok(CommandOutput::Node(self.send_json(builder).await?))
358            }
359
360            Command::Append {
361                path,
362                bytes,
363                options,
364            } => {
365                let mut query = vec![("action", "append".to_string())];
366                query.extend(revision_query(options.expected_revision));
367                let builder = self
368                    .client
369                    .post(self.url(ctx.workspace_id, &format!("/content{}", path.as_str())))
370                    .query(&query)
371                    .body(bytes);
372                Ok(CommandOutput::Node(self.send_json(builder).await?))
373            }
374
375            Command::Truncate {
376                path,
377                length,
378                options,
379            } => {
380                let body = serde_json::json!({
381                    "length": length,
382                    "expected_revision": options.expected_revision.map(Revision::get),
383                });
384                let builder = self
385                    .client
386                    .post(self.url(ctx.workspace_id, &format!("/content{}", path.as_str())))
387                    .query(&[("action", "truncate")])
388                    .json(&body);
389                Ok(CommandOutput::Node(self.send_json(builder).await?))
390            }
391
392            Command::Touch { path, options } => {
393                let body = serde_json::json!({
394                    "op": "touch",
395                    "create": options.create,
396                    "expected_revision": options.expected_revision.map(Revision::get),
397                });
398                let builder = self
399                    .client
400                    .patch(self.url(ctx.workspace_id, &format!("/fs{}", path.as_str())))
401                    .json(&body);
402                Ok(CommandOutput::Node(self.send_json(builder).await?))
403            }
404
405            Command::Copy { from, to, options } => {
406                let body = serde_json::json!({
407                    "to": to.as_str(),
408                    "recursive": options.recursive,
409                    "overwrite": options.overwrite,
410                    "expected_revision": options.expected_revision.map(Revision::get),
411                });
412                let builder = self
413                    .client
414                    .post(self.url(ctx.workspace_id, &format!("/fs{}", from.as_str())))
415                    .query(&[("action", "copy")])
416                    .json(&body);
417                Ok(CommandOutput::Node(self.send_json(builder).await?))
418            }
419
420            Command::Move { from, to, options } => {
421                let body = serde_json::json!({
422                    "to": to.as_str(),
423                    "overwrite": options.overwrite,
424                    "expected_revision": options.expected_revision.map(Revision::get),
425                });
426                let builder = self
427                    .client
428                    .post(self.url(ctx.workspace_id, &format!("/fs{}", from.as_str())))
429                    .query(&[("action", "move")])
430                    .json(&body);
431                Ok(CommandOutput::Node(self.send_json(builder).await?))
432            }
433
434            Command::Remove { path, options } => {
435                let mut query = vec![("recursive", options.recursive.to_string())];
436                query.extend(revision_query(options.expected_revision));
437                let builder = self
438                    .client
439                    .delete(self.url(ctx.workspace_id, &format!("/fs{}", path.as_str())))
440                    .query(&query);
441                self.send_checked(builder).await?;
442                Ok(CommandOutput::Unit)
443            }
444
445            Command::Symlink {
446                target,
447                link,
448                options,
449            } => {
450                let body = serde_json::json!({
451                    "target": target.as_str(),
452                    "parents": options.parents,
453                    "exist_ok": options.exist_ok,
454                    "expected_revision": options.expected_revision.map(Revision::get),
455                });
456                let builder = self
457                    .client
458                    .put(self.url(ctx.workspace_id, &format!("/fs{}", link.as_str())))
459                    .query(&[("type", "symlink")])
460                    .json(&body);
461                Ok(CommandOutput::Node(self.send_json(builder).await?))
462            }
463
464            Command::ReadLink { path } => {
465                let builder = self.client.get(self.url(
466                    ctx.workspace_id,
467                    &format!("/fs{}/link-target", path.as_str()),
468                ));
469                let wire: LinkTargetWire = self.send_json(builder).await?;
470                let target = LinkTarget::parse(&wire.target)
471                    .map_err(|err| FsError::internal_storage_failure(err.message().to_string()))?;
472                Ok(CommandOutput::LinkTarget(target))
473            }
474
475            Command::Trash { path, options } => {
476                let body = serde_json::json!({
477                    "expected_revision": options.expected_revision.map(Revision::get),
478                });
479                let builder = self
480                    .client
481                    .post(self.url(ctx.workspace_id, &format!("/fs{}", path.as_str())))
482                    .query(&[("action", "trash")])
483                    .json(&body);
484                Ok(CommandOutput::Trash(self.send_json(builder).await?))
485            }
486
487            Command::ListTrash { page } => {
488                let builder = self
489                    .client
490                    .get(self.url(ctx.workspace_id, "/trash"))
491                    .query(&page_query(&page));
492                Ok(CommandOutput::TrashList(self.send_json(builder).await?))
493            }
494
495            Command::Restore {
496                trash,
497                destination,
498                options,
499            } => {
500                let body = serde_json::json!({
501                    "destination": destination.as_ref().map(VirtualPath::as_str),
502                    "expected_revision": options.expected_revision.map(Revision::get),
503                });
504                let builder = self
505                    .client
506                    .post(self.url(ctx.workspace_id, &format!("/trash/{trash}/restore")))
507                    .json(&body);
508                Ok(CommandOutput::Node(self.send_json(builder).await?))
509            }
510
511            Command::Purge { trash } => {
512                let builder = self
513                    .client
514                    .delete(self.url(ctx.workspace_id, &format!("/trash/{trash}")));
515                self.send_checked(builder).await?;
516                Ok(CommandOutput::Unit)
517            }
518
519            Command::SetAttribute {
520                path,
521                key,
522                value,
523                options,
524            } => {
525                let body = serde_json::json!({
526                    "op": "set_attribute",
527                    "key": key,
528                    "value_base64": base64::engine::general_purpose::STANDARD.encode(&value),
529                    "expected_revision": options.expected_revision.map(Revision::get),
530                });
531                let builder = self
532                    .client
533                    .patch(self.url(ctx.workspace_id, &format!("/fs{}", path.as_str())))
534                    .json(&body);
535                Ok(CommandOutput::Node(self.send_json(builder).await?))
536            }
537
538            Command::RemoveAttribute { path, key, options } => {
539                let body = serde_json::json!({
540                    "op": "remove_attribute",
541                    "key": key,
542                    "expected_revision": options.expected_revision.map(Revision::get),
543                });
544                let builder = self
545                    .client
546                    .patch(self.url(ctx.workspace_id, &format!("/fs{}", path.as_str())))
547                    .json(&body);
548                Ok(CommandOutput::Node(self.send_json(builder).await?))
549            }
550
551            Command::Glob { pattern, page } => {
552                let mut query = vec![("pattern", pattern)];
553                query.extend(page_query(&page));
554                let builder = self
555                    .client
556                    .get(self.url(ctx.workspace_id, "/search/glob"))
557                    .query(&query);
558                Ok(CommandOutput::Nodes(self.send_json(builder).await?))
559            }
560
561            Command::Find { query, page } => {
562                let body = serde_json::json!({ "query": query, "page": page_body(&page) });
563                let builder = self
564                    .client
565                    .post(self.url(ctx.workspace_id, "/search/find"))
566                    .json(&body);
567                Ok(CommandOutput::Nodes(self.send_json(builder).await?))
568            }
569
570            Command::SearchContent { query, page } => {
571                let body = serde_json::json!({
572                    "root": query.root,
573                    "needle_base64": base64::engine::general_purpose::STANDARD.encode(&query.needle),
574                    "page": page_body(&page),
575                });
576                let builder = self
577                    .client
578                    .post(self.url(ctx.workspace_id, "/search/content"))
579                    .json(&body);
580                let wire: Page<SearchMatchWire> = self.send_json(builder).await?;
581                let items = wire
582                    .items
583                    .into_iter()
584                    .map(|item| -> FsResult<SearchMatch> {
585                        let preview = base64::engine::general_purpose::STANDARD
586                            .decode(&item.preview_base64)
587                            .map_err(|err| FsError::internal_storage_failure(err.to_string()))?;
588                        Ok(SearchMatch {
589                            node: item.node,
590                            path: item.path,
591                            range: item.range,
592                            preview,
593                        })
594                    })
595                    .collect::<FsResult<Vec<_>>>()?;
596                Ok(CommandOutput::SearchMatches(Page::new(
597                    items,
598                    wire.next_cursor,
599                )))
600            }
601
602            Command::Changes { after, page } => {
603                let mut query = page_query(&page);
604                if let Some(after) = &after {
605                    query.push(("after", after.as_str().to_string()));
606                }
607                let builder = self
608                    .client
609                    .get(self.url(ctx.workspace_id, "/changes"))
610                    .query(&query);
611                Ok(CommandOutput::Changes(self.send_json(builder).await?))
612            }
613
614            Command::Batch(operations) => {
615                let body = serde_json::json!({ "operations": operations });
616                let builder = self
617                    .client
618                    .post(self.url(ctx.workspace_id, "/batch"))
619                    .json(&body);
620                let response: BatchResponse = self.send_json(builder).await?;
621                Ok(CommandOutput::Batch(response.results))
622            }
623        }
624    }
625}