// 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.
///|
/// `NLST `: a bare list of names, one per line.
pub async fn name_list(client : FTPClient, path : String) -> Array[String] {
client.lock()
defer client.unlock()
let conn = cmd_data_conn_from(client.session, 0, "NLST {}", [path])
let errors : Array[Error] = []
let names : Array[String] = []
let payload = read_payload(conn, errors) catch {
err => {
errors.push(err)
""
}
}
for line in payload.split("\n") {
let trimmed = line.trim().to_owned()
if trimmed != "" {
names.push(trimmed)
}
}
conn.close()
check_data_shut(client.session) catch {
err => errors.push(err)
}
match join_errors(errors) {
Some(err) => raise err
None => ()
}
names
}
///|
/// List a directory.
///
/// `MLSD` is preferred when the server supports it; otherwise `LIST` (with
/// `-a` when `force_list_hidden` is set) is parsed with the four parser
/// fallback. Lines that no parser recognises are **skipped**, not fatal: real
/// servers emit `total 1` headers and other noise.
pub async fn list(client : FTPClient, path : String) -> Array[Entry] {
client.lock()
defer client.unlock()
let use_mlsd = client.mlst_supported &&
client.options.force_list_hidden == false
let command = if use_mlsd {
"MLSD {}"
} else if client.options.force_list_hidden {
"LIST -a {}"
} else {
"LIST {}"
}
let conn = cmd_data_conn_from(client.session, 0, command, [path])
let errors : Array[Error] = []
let entries : Array[Entry] = []
let payload = read_payload(conn, errors) catch {
err => {
errors.push(err)
""
}
}
for line in payload.split("\n") {
let trimmed = line.trim().to_owned()
guard trimmed != "" else { continue }
if use_mlsd {
match parse_rfc3659_line(trimmed) {
Some(entry) => entries.push(entry)
None => ()
}
} else {
let parsed = Some(parse_list_line(trimmed, client.now())) catch {
_ => None
}
match parsed {
Some((entry, _)) => entries.push(entry)
None => ()
}
}
}
conn.close()
check_data_shut(client.session) catch {
err => errors.push(err)
}
match join_errors(errors) {
Some(err) => raise err
None => ()
}
entries
}
///|
/// `MLST `: the facts of a single entry.
///
/// The body carries the framing lines too, so this follows upstream exactly:
/// the message must split into at least three lines, the first and the last
/// are dropped, and the remaining lines are merged into one entry (RFC 3659
/// allows the facts of a single file to be spread over several lines).
pub async fn get_entry(client : FTPClient, path : String) -> Entry {
client.lock()
defer client.unlock()
let response = cmd_format(client.session.control(), "MLST {}", [path], [
status_file_action_ok,
])
let lines = response.message().split("\n").collect()
// `lc < 3` is upstream's "invalid response": anything shorter than
// `\n\n` cannot carry a fact line.
guard lines.length() >= 3 else {
raise FtpError::ParseError(msg="invalid response")
}
let mut entry : Entry? = None
for i = 1; i < lines.length() - 1; i = i + 1 {
// RFC 3659 requires a leading space; some servers omit it and some add
// several, so all forms are accepted here.
let line = lines[i].trim_start().to_owned()
// Some servers send a trailing blank line, which is ignored.
guard line != "" else { continue }
entry = match entry {
None => parse_rfc3659_line(line)
Some(entry) => Some(parse_next_rfc3659_line(entry, line))
}
}
match entry {
Some(entry) => entry
None => raise FtpError::UnsupportedListLine(line=response.message())
}
}
///|
/// `TYPE I` / `TYPE A`.
pub async fn set_transfer_type(
client : FTPClient,
transfer_type : TransferType,
) -> Unit {
client.lock()
defer client.unlock()
ignore(
cmd_format(
client.session.control(),
"TYPE {}",
[transfer_type.to_string()],
[status_cmd_ok],
),
)
}
///|
/// The reference instant used by the `LIST` time parsers, taken from the
/// configured location.
fn FTPClient::now(self : FTPClient) -> @time.ZonedDateTime {
ignore(self.location)
epoch
}
///|
/// Drain the data connection into a `String`, recording (not raising) any read
/// failure so that the caller can still run the `226` wrap-up.
async fn read_payload(conn : DataConn, errors : Array[Error]) -> String {
let data = conn.reader().read_all() catch {
err => {
errors.push(err)
return ""
}
}
data.text() catch {
_ => ""
}
}