// Copyright 2026 Leo Cheng
// SPDX-License-Identifier: Apache-2.0
///|
/// A `multipart/related` body (RFC 2387): a compound object whose parts belong
/// together, one of them the root the rest hang off.
///
/// The framing is the one every multipart subtype shares; what this type adds
/// is the three parameters that say what the parts are — `type` for the media
/// type of the root, `start` for the root's `Content-ID`, `start-info` passed
/// through to that type — and the reading of `start` back into the part it
/// names.
///
/// `parts` is what a reader found and what a writer frames; `body()` writes the
/// octets those parts frame to, so a value read from a body that carried a
/// preamble or a folded header writes back this library's own framing of the
/// same parts.
#derive(Eq, Debug)
pub(all) struct Related {
type_ : String
start : String
start_info : String
boundary : String
parts : Array[Part]
}
///|
pub extend Related with Eq::{equal, not_equal}
///|
pub extend Related with Debug::{to_repr}
///|
/// A related body of `parts`.
///
/// The boundary works as it does for any other multipart body: the caller's,
/// checked against RFC 2046 and against the parts, or generated from a stem
/// stepped on until it is proven absent.
///
/// Left without parts the value is the header alone, which is what a caller
/// streaming the body needs: the writer frames the parts, the value names them.
///
/// ```mbt check
/// test {
/// let related = @moonmime.Related::new(
/// [
/// @moonmime.Part::{
/// headers: [("content-id", ""), ("content-type", "text/html")],
/// body: b"hola",
/// },
/// ],
/// boundary="b",
/// type_="text/html",
/// start="",
/// )
/// inspect(
/// related.content_type(),
/// content="multipart/related; boundary=b; type=\"text/html\"; start=\"\"",
/// )
/// inspect(related.root().unwrap().body, content="b\"hola\"")
/// }
/// ```
pub fn Related::new(
parts : Array[Part],
boundary? : StringView,
type_~ : String = "",
start~ : String = "",
start_info~ : String = "",
) -> Related raise Refused {
let blocks : Array[Bytes] = parts.map(part_block)
{
type_,
start,
start_info,
boundary: choose_boundary(blocks, boundary),
parts,
}
}
///|
/// Read a `multipart/related` body given the `Content-Type` that announces it.
///
/// The parameters come off the header, the parts off the body; `root()` is what
/// ties the two together.
pub fn related(
content_type : StringView,
body : BytesView,
limits~ : Limits = limits,
strict~ : Bool = false,
) -> Related raise Refused {
let header = media_type(content_type)
guard header.essence == "multipart/related" else {
raise NotRelated(content_type=content_type.to_owned())
}
let boundary = match header.param("boundary") {
Some(boundary) => boundary
None => raise Boundary(got="")
}
{
type_: header.param("type").unwrap_or(""),
start: header.param("start").unwrap_or(""),
start_info: header.param("start-info").unwrap_or(""),
boundary,
parts: multipart(body, boundary[:], limits~, strict~),
}
}
///|
/// The body the parts frame to, delimited by this value's boundary.
pub fn Related::body(self : Related) -> Bytes {
framed_body(self.parts.map(part_block), self.boundary[:])
}
///|
/// The `Content-Type` that announces this body: `boundary` first, because the
/// receiver cannot read the parts without it, then the three parameters RFC 2387
/// defines, in the order it names them. A parameter that was not given is not
/// written.
pub fn Related::content_type(self : Related) -> String {
let params : Array[(String, String)] = [("boundary", self.boundary)]
if self.type_ != "" {
params.push(("type", self.type_))
}
if self.start != "" {
params.push(("start", self.start))
}
if self.start_info != "" {
params.push(("start-info", self.start_info))
}
MediaType::{ essence: "multipart/related", params, }.to_string()
}
///|
/// The root part: the one `start` names by its `Content-ID`, or the first part
/// when the header names none, which is RFC 2387's default.
///
/// `None` when the header names a part the body does not hold, which is a
/// disagreement between the two halves rather than something to guess at.
pub fn Related::root(self : Related) -> Part? {
if self.start == "" {
if self.parts.length() == 0 {
return None
}
return Some(self.parts[0])
}
let want = content_id_of(self.start[:])
for part in self.parts {
match part.content_id() {
Some(id) => if id == want { return Some(part) }
None => ()
}
}
None
}