// Copyright 2026 PaiGack
// Licensed under the Apache License, Version 2.0.
// Ported from jlaffaye/ftp (ISC License), see LICENSE-THIRD-PARTY.
// Layer: IO — uses the `moonbitlang/async` stack from the root moon.pkg.
// The thin one-command-per-function wrappers: navigation, file operations and
// session lifecycle.
//
// This file merges `nav`, `fsops` and `lifecycle`: every block here is the same
// shape — lock the client, send one (or two) commands through
// `cmd_expect` / `cmd_format`, check the expected status code — so three files
// of 50-65 lines each only made the same pattern look like three separate
// features. `extract_quoted` stays next to `current_dir`, its only caller.
///|
/// `CWD `, expecting `250`.
pub async fn change_dir(client : FTPClient, dir : String) -> Unit {
client.lock()
defer client.unlock()
ignore(
cmd_format(client.session.control(), "CWD {}", [dir], [
status_file_action_ok,
]),
)
}
///|
/// `CDUP`, expecting `250`.
pub async fn change_dir_to_parent(client : FTPClient) -> Unit {
client.lock()
defer client.unlock()
ignore(cmd_expect(client.session.control(), "CDUP", [status_file_action_ok]))
}
///|
/// `PWD`, returning the path inside the quoted part of the `257` reply.
pub async fn current_dir(client : FTPClient) -> String {
client.lock()
defer client.unlock()
let response = cmd_expect(client.session.control(), "PWD", [status_dir_create])
match extract_quoted(response.message()) {
Some(path) => path
None => raise FtpError::ParseError(msg="invalid PWD response")
}
}
///|
/// Extract the substring between the first pair of double quotes, which is how
/// `257 "/incoming" created.` encodes the path.
pub fn extract_quoted(message : String) -> String? {
let start = match message.find("\"") {
Some(start) => start
None => return None
}
let tail = message[start + 1:].to_owned()
let end = match tail.find("\"") {
Some(end) => end
None => return None
}
Some(tail[0:end].to_owned())
}
///|
/// `MKD `, expecting `257`.
pub async fn make_dir(client : FTPClient, dir : String) -> Unit {
client.lock()
defer client.unlock()
ignore(
cmd_format(client.session.control(), "MKD {}", [dir], [status_dir_create]),
)
}
///|
/// `RMD `, expecting `250`.
pub async fn remove_dir(client : FTPClient, dir : String) -> Unit {
client.lock()
defer client.unlock()
ignore(
cmd_format(client.session.control(), "RMD {}", [dir], [
status_file_action_ok,
]),
)
}
///|
/// Delete a non-empty directory recursively, the port of upstream
/// `RemoveDirRecur`.
///
/// The sequence is upstream's, and it is a `cd`-in / `cd ..`-out dance rather
/// than a walk of the tree, because a plain FTP server has no way to name a
/// child relative to a directory the client is not currently *in*: every
/// `LIST` / `RMD` argument is resolved against the session's working directory.
///
/// 1. `CWD path`, then `PWD` to learn the absolute name the server assigned —
/// not the caller's string, which may be relative or a symlink.
/// 2. `LIST` that absolute path and recurse into every folder child, `DELE`
/// every file child. `.` and `..` are skipped.
/// 3. `CDUP` out, then `RMD` the directory itself — bottom up, so a directory
/// is only removed once it is empty.
///
/// The client lock is deliberately **not** held across the recursion: `list` /
/// `delete` / `remove_dir` each take it themselves and the lock is not
/// reentrant, so wrapping the body would deadlock on the first child.
pub async fn remove_dir_recur(client : FTPClient, path : String) -> Unit {
change_dir(client, path)
let current_dir = current_dir(client)
let entries = list(client, current_dir)
for entry in entries {
if entry.name == "." || entry.name == ".." {
continue
}
if entry.type_ is EntryType::Folder {
remove_dir_recur(client, current_dir + "/" + entry.name)
} else {
// A bare name is enough: the recursion just `CWD`-ed into
// `current_dir`, and upstream relies on that too.
delete(client, entry.name)
}
}
change_dir_to_parent(client)
remove_dir(client, current_dir)
}
///|
/// `DELE `, expecting `250`.
pub async fn delete(client : FTPClient, path : String) -> Unit {
client.lock()
defer client.unlock()
ignore(
cmd_format(client.session.control(), "DELE {}", [path], [
status_file_action_ok,
]),
)
}
///|
/// Rename in two steps, `RNFR` -> `350` then `RNTO` -> `250`.
pub async fn rename(client : FTPClient, from : String, to : String) -> Unit {
client.lock()
defer client.unlock()
ignore(
cmd_format(client.session.control(), "RNFR {}", [from], [
status_file_action_pending_further_info,
]),
)
ignore(
cmd_format(client.session.control(), "RNTO {}", [to], [
status_file_action_ok,
]),
)
}
///|
/// `NOOP`, expecting `200`.
pub async fn no_op(client : FTPClient) -> Unit {
client.lock()
defer client.unlock()
ignore(cmd_expect(client.session.control(), "NOOP", [status_cmd_ok]))
}
///|
/// `REIN`, expecting `220`: drops the authentication state but keeps the
/// control connection.
pub async fn logout(client : FTPClient) -> Unit {
client.lock()
defer client.unlock()
ignore(
cmd_expect(client.session.control(), "REIN", [
status_service_ready_for_new_user,
]),
)
}
///|
/// `QUIT` and mark the connection as closed.
///
/// The socket is **not** closed here: `Control` has no `close`, and no path in
/// this module calls `@socket.Tcp::close` (see the "connection and shutdown
/// semantics" note in `README.mbt.md`). Upstream's `Quit` closes `c.conn`
/// explicitly and joins that error with the command's, which is why the shape
/// below -- collect into `errors`, then `finish_quit` -- survives even though
/// only the command can fail. Sending `QUIT` therefore does not make the next
/// read observe EOF; the server closing its side and this process releasing
/// the handle do.
///
/// A second call is a no-op, visible through `is_closed`.
pub async fn quit(client : FTPClient) -> Unit {
client.lock()
defer client.unlock()
guard client.closed == false else { return }
client.set_closed(true)
let errors : Array[Error] = []
let response = cmd_expect(client.session.control(), "QUIT", [
status_closing_control_connection, status_cmd_ok, status_service_ready_for_new_user,
]) catch {
err => {
errors.push(err)
return finish_quit(errors)
}
}
ignore(response)
finish_quit(errors)
}
///|
/// Raise the errors collected by `quit` as one `FtpErrors::MultipleErrors`,
/// or return normally when there are none.
///
/// The list is always empty or a single `QUIT` failure today -- there is no
/// socket to close, so there is no second error to join. It stays a list
/// because that is upstream's `errors.Join` shape and because the error
/// aggregation rule (`docs/porting.md` section 1.4 point 3) applies to it as
/// soon as a second step appears.
fn finish_quit(errors : Array[Error]) -> Unit raise {
match join_errors(errors) {
Some(err) => raise err
None => ()
}
}
///|
/// Whether the client already sent `QUIT`.
pub fn FTPClient::is_closed(self : FTPClient) -> Bool {
self.closed
}