// 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,
]),
)
}
///|
/// `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 close the connection.
///
/// The errors are aggregated instead of short-circuiting: a failure to send
/// `QUIT` must not hide a failure to close the socket, and the other way
/// round. This mirrors upstream's use of `errors.Join`.
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)
}
///|
/// Close the underlying connection and combine every collected error.
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
}