123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624
//! ffmpeg invocation: build the filter_complex graph and run it.
//!
//! The strategy is a single ffmpeg call that:
//!   1. Trims the input into segments at every marker boundary
//!   2. Normal segments: setpts=PTS-STARTPTS (passthrough timing)
//!   3. Speedup segments: setpts=(1/speed)*(PTS-STARTPTS) + drawtext overlay
//!   4. Concats all segments back into one output
//!
//! Audio is dropped (-an). Phase 1 expects silent recordings.

use crate::markers::Segment;
use std::path::Path;
use std::process::Command;

const FONT_PATH: &str = "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf";

/// Get the duration of a video file (in seconds) via ffprobe.
///
/// Handles three common cases:
///   1. Format-level `duration` is a number: parse it and return.
///   2. Format-level `duration` is `N/A` (common on MediaRecorder webms
///      that ship without a proper duration header): fall back to
///      querying the first video stream's duration.
///   3. Stream-level duration is also missing: remux via ffmpeg to a
///      fresh file, let ffmpeg compute the duration on the fly, and
///      probe the fresh file.
pub fn probe_duration(input: &Path) -> Result<f64, String> {
    // Try format-level duration first.
    let format_dur = run_ffprobe_duration(input, &["-show_entries", "format=duration"])?;
    if let Ok(v) = format_dur.parse::<f64>() {
        if v > 0.0 {
            return Ok(v);
        }
    }
    // Fall back to stream-level duration of the first video stream.
    let stream_dur = run_ffprobe_duration(
        input,
        &[
            "-select_streams", "v:0",
            "-show_entries", "stream=duration",
        ],
    )?;
    if let Ok(v) = stream_dur.parse::<f64>() {
        if v > 0.0 {
            return Ok(v);
        }
    }
    // Last resort: remux into a temp file with `-c copy` and probe that.
    // This is what MediaRecorder webms need because they never write a
    // proper container duration header; remuxing forces ffmpeg to
    // compute and write one.
    let tmp = std::env::temp_dir().join(format!(
        "adom-video-post-probe-{}.webm",
        std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_nanos()).unwrap_or(0)
    ));
    let remux = Command::new("ffmpeg")
        .args(["-y", "-i"])
        .arg(input)
        .args(["-c", "copy"])
        .arg(&tmp)
        .output()
        .map_err(|e| format!("ffmpeg remux spawn failed: {}", e))?;
    if !remux.status.success() {
        return Err(format!(
            "ffprobe could not determine duration (format='{}', stream='{}') and remux fallback also failed: {}",
            format_dur, stream_dur, String::from_utf8_lossy(&remux.stderr).lines().last().unwrap_or("")
        ));
    }
    let after_remux = run_ffprobe_duration(&tmp, &["-show_entries", "format=duration"])?;
    let _ = std::fs::remove_file(&tmp);
    after_remux.parse::<f64>()
        .map_err(|e| format!("duration still unavailable after remux: {} ({})", after_remux, e))
}

fn run_ffprobe_duration(input: &Path, extra_args: &[&str]) -> Result<String, String> {
    let mut cmd = Command::new("ffprobe");
    cmd.args(["-v", "error"]);
    cmd.args(extra_args);
    cmd.args(["-of", "default=noprint_wrappers=1:nokey=1"]);
    cmd.arg(input);
    let output = cmd.output().map_err(|e| format!("ffprobe spawn failed: {}", e))?;
    if !output.status.success() {
        return Err(format!(
            "ffprobe failed: {}",
            String::from_utf8_lossy(&output.stderr)
        ));
    }
    Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
}

/// Get the (width, height) of the first video stream via ffprobe.
pub fn probe_dimensions(input: &Path) -> Result<(u32, u32), String> {
    let output = Command::new("ffprobe")
        .args([
            "-v",
            "error",
            "-select_streams",
            "v:0",
            "-show_entries",
            "stream=width,height",
            "-of",
            "csv=p=0:s=x",
        ])
        .arg(input)
        .output()
        .map_err(|e| format!("ffprobe spawn failed: {}", e))?;
    if !output.status.success() {
        return Err(format!(
            "ffprobe failed: {}",
            String::from_utf8_lossy(&output.stderr)
        ));
    }
    let s = String::from_utf8_lossy(&output.stdout).trim().to_string();
    let mut parts = s.split('x');
    let w = parts.next().and_then(|p| p.parse::<u32>().ok()).ok_or_else(|| format!("could not parse width from '{}'", s))?;
    let h = parts.next().and_then(|p| p.parse::<u32>().ok()).ok_or_else(|| format!("could not parse height from '{}'", s))?;
    Ok((w, h))
}

/// Compute a reasonable font size for the caption overlay based on video width.
/// Targets readability across resolutions: ~32px at 640w, ~48px at 1280w, ~64px at 1920w.
pub fn compute_font_size(width: u32) -> u32 {
    let raw = (width as f64 / 30.0) as u32;
    raw.clamp(20, 80)
}

/// Check ffmpeg + ffprobe versions. Returns (ffmpeg_version, ffprobe_version).
pub fn check_versions() -> Result<(String, String), String> {
    let ffmpeg = Command::new("ffmpeg")
        .arg("-version")
        .output()
        .map_err(|e| format!("ffmpeg not found in PATH: {}. Hint: sudo apt-get install ffmpeg", e))?;
    let ffprobe = Command::new("ffprobe")
        .arg("-version")
        .output()
        .map_err(|e| format!("ffprobe not found in PATH: {}. Hint: sudo apt-get install ffmpeg", e))?;
    let parse_v = |out: &[u8]| -> String {
        let s = String::from_utf8_lossy(out);
        s.lines()
            .next()
            .and_then(|line| line.split_whitespace().nth(2))
            .unwrap_or("?")
            .to_string()
    };
    Ok((parse_v(&ffmpeg.stdout), parse_v(&ffprobe.stdout)))
}

/// Escape a string for ffmpeg drawtext text= argument.
/// drawtext is finicky: colons, single quotes, backslashes, percents all need escaping.
fn escape_drawtext(s: &str) -> String {
    s.replace('\\', "\\\\")
        .replace(':', "\\:")
        .replace('\'', "\\'")
        .replace('%', "\\%")
}

/// Build the filter_complex graph string for the given segments and total duration.
/// font_size is the drawtext fontsize in pixels (use `compute_font_size` from the
/// probed video width).
/// Returns the filter_complex argument value (one big string with semicolons).
pub fn build_filter_complex(segments: &[Segment], total_duration: f64, font_size: u32) -> String {
    build_filter_complex_with_trim(segments, total_duration, font_size, 0.0)
}

/// Same as `build_filter_complex` but also trims the first `trim_offset`
/// seconds off the start of the input. The first normal section starts
/// at `trim_offset` instead of 0, and segments whose start_offset is
/// less than `trim_offset` should already have been clamped by the caller.
pub fn build_filter_complex_with_trim(segments: &[Segment], total_duration: f64, font_size: u32, trim_offset: f64) -> String {
    build_filter_complex_full(segments, total_duration, font_size, trim_offset, 1.0, false)
}

/// Full version: segments + trim_offset + base_speed + no_overlay.
/// `base_speed` > 1.0 applies a global setpts to normal (unmarked)
/// sections so the ENTIRE unmarked video runs faster. Marked speedup
/// sections still run at their own marker rate, the marker always
/// dominates the base. A base of 1.0 is a no-op and falls through to
/// the old behavior. `no_overlay` skips the red drawtext caption on
/// speedup sections — useful when the caller is already burning in
/// its own captions (e.g. desktop_caption step_marker) and the red
/// overlay would cover them up.
pub fn build_filter_complex_full(
    segments: &[Segment],
    total_duration: f64,
    font_size: u32,
    trim_offset: f64,
    base_speed: f64,
    no_overlay: bool,
) -> String {
    // Compute the alternating list of (start, end, type) sections.
    // Type is either "normal" (no speedup) or a speedup segment ref.
    enum Section<'a> {
        Normal { start: f64, end: f64 },
        Speedup { seg: &'a Segment },
    }

    let mut sections: Vec<Section> = Vec::new();
    let mut cursor = trim_offset.max(0.0);
    for seg in segments {
        if seg.start_offset > cursor + 0.001 {
            sections.push(Section::Normal {
                start: cursor,
                end: seg.start_offset,
            });
        }
        sections.push(Section::Speedup { seg });
        cursor = seg.end_offset;
    }
    if cursor < total_duration - 0.001 {
        sections.push(Section::Normal {
            start: cursor,
            end: total_duration,
        });
    }

    let mut parts: Vec<String> = Vec::new();
    let mut labels: Vec<String> = Vec::new();

    for (i, sec) in sections.iter().enumerate() {
        let label = format!("v{}", i);
        match sec {
            Section::Normal { start, end } => {
                // Apply base_speed to every normal (unmarked) section so
                // the global pace of the video is base_speed times the
                // original recording. base_speed == 1.0 falls through to
                // the plain passthrough setpts (unchanged).
                if (base_speed - 1.0).abs() < 0.001 {
                    parts.push(format!(
                        "[0:v]trim=start={:.3}:end={:.3},setpts=PTS-STARTPTS[{}]",
                        start, end, label
                    ));
                } else {
                    let base_factor = 1.0 / base_speed;
                    parts.push(format!(
                        "[0:v]trim=start={:.3}:end={:.3},setpts={:.6}*(PTS-STARTPTS)[{}]",
                        start, end, base_factor, label
                    ));
                }
            }
            Section::Speedup { seg } => {
                let setpts_factor = 1.0 / (seg.speed as f64);
                if no_overlay {
                    parts.push(format!(
                        "[0:v]trim=start={:.3}:end={:.3},setpts={:.6}*(PTS-STARTPTS)[{}]",
                        seg.start_offset, seg.end_offset, setpts_factor, label
                    ));
                } else {
                    let caption = format!("{}x SPEEDUP: {}", seg.speed, seg.label);
                    let escaped = escape_drawtext(&caption);
                    parts.push(format!(
                        "[0:v]trim=start={:.3}:end={:.3},setpts={:.6}*(PTS-STARTPTS),\
                         drawtext=text='{}':fontfile={}:fontsize={}:fontcolor=white:\
                         box=1:[email protected]:boxborderw=20:x=(w-text_w)/2:y=80[{}]",
                        seg.start_offset, seg.end_offset, setpts_factor, escaped, FONT_PATH, font_size, label
                    ));
                }
            }
        }
        labels.push(format!("[{}]", label));
    }

    if labels.is_empty() {
        // No segments at all. Pass through unchanged.
        return "[0:v]copy[out]".to_string();
    }

    let concat = format!(
        "{}concat=n={}:v=1:a=0[out]",
        labels.join(""),
        labels.len()
    );
    parts.push(concat);
    parts.join(";")
}

/// Run ffmpeg with the given filter_complex graph. Returns Ok on success or
/// an error message containing ffmpeg's stderr.
///
/// Encoder: libvpx-vp9 at 2 Mbps with `-deadline realtime -cpu-used 8`.
/// This is ~5-10x faster than libvpx-vp9's default "good" deadline, at a
/// tiny quality cost that does not matter for speedup content (drawn
/// captions + fast forward sections, not mastered video). For a 60 s
/// source on this hardware that is the difference between ~45 s encode
/// and ~6 minutes encode. When we start wanting higher quality we can
/// expose the speed knob as a CLI flag.
pub fn run_process(input: &Path, output: &Path, filter_complex: &str) -> Result<(), String> {
    run_process_with_duration(input, output, filter_complex, None)
}

/// Same as `run_process` but accepts an optional source duration for ETA
/// calculation. When provided, progress lines include an ETA.
/// `max_width` downscales the output (0 = no scaling).
pub fn run_process_with_duration(
    input: &Path,
    output: &Path,
    filter_complex: &str,
    source_duration: Option<f64>,
) -> Result<(), String> {
    run_process_full(input, output, filter_complex, source_duration, 0)
}

/// Full version with optional downscale. max_width > 0 appends a
/// scale filter to the output, e.g. 1280 → scale=1280:-2.
pub fn run_process_full(
    input: &Path,
    output: &Path,
    filter_complex: &str,
    source_duration: Option<f64>,
    max_width: u32,
) -> Result<(), String> {
    use std::io::{BufRead, BufReader};
    use std::process::Stdio;

    // -r 30 + -fps_mode cfr forces constant 30 fps output. This is
    // essential: the source recordings come from MediaRecorder which
    // emits variable-framerate frames (idle screens get long gaps
    // between frames). After setpts compression the gaps pack together
    // into stuttering bursts — the player sits on one frame for
    // seconds then jumps. Resampling to CFR evens it all out.
    //
    // -g 60 -keyint_min 60 force a keyframe every 2 seconds at 30 fps,
    // so the browser can seek and recover decoder state quickly.
    //
    // -progress pipe:1 writes machine-readable progress to stdout.
    //
    // If max_width is set and the source is wider, append a scale filter
    // to the filter_complex graph so ffmpeg encodes fewer pixels.
    let final_filter = if max_width > 0 {
        // Append scale to the [out] label: rename [out] → [pre], add scale
        let patched = filter_complex.replace("[out]", "[pre]");
        format!("{};[pre]scale='min({},iw)':-2[out]", patched, max_width)
    } else {
        filter_complex.to_string()
    };

    let mut child = Command::new("ffmpeg")
        .args(["-y", "-i"])
        .arg(input)
        .args(["-filter_complex", &final_filter])
        .args(["-map", "[out]"])
        .args([
            "-c:v", "libvpx-vp9",
            "-b:v", "2M",
            "-deadline", "realtime",
            "-cpu-used", "8",
            "-row-mt", "1",
            "-r", "30",
            "-fps_mode", "cfr",
            "-g", "60",
            "-keyint_min", "60",
            "-an",
            "-progress", "pipe:1",
        ])
        .arg(output)
        .stdout(Stdio::piped())
        .stderr(Stdio::piped())
        .spawn()
        .map_err(|e| format!("ffmpeg spawn failed: {}", e))?;

    // Stream stdout for progress (ffmpeg -progress pipe:1 writes
    // key=value lines with out_time_ms= showing encode position).
    let stdout = child.stdout.take().expect("stdout piped");
    let start = std::time::Instant::now();
    let reader = BufReader::new(stdout);
    let mut last_print = std::time::Instant::now();

    for line in reader.lines() {
        let line = match line {
            Ok(l) => l,
            Err(_) => break,
        };
        // Parse out_time_ms=<microseconds>
        if let Some(val) = line.strip_prefix("out_time_ms=") {
            if let Ok(us) = val.trim().parse::<i64>() {
                let encoded_s = us as f64 / 1_000_000.0;
                let elapsed = start.elapsed().as_secs_f64();
                // Throttle prints to once per second
                if last_print.elapsed().as_secs_f64() >= 1.0 {
                    last_print = std::time::Instant::now();
                    if let Some(dur) = source_duration {
                        if encoded_s > 0.1 && dur > 0.1 {
                            let pct = (encoded_s / dur * 100.0).min(100.0);
                            let speed = encoded_s / elapsed;
                            let remaining_s = if speed > 0.01 {
                                (dur - encoded_s) / speed
                            } else {
                                0.0
                            };
                            eprint!(
                                "\r\x1b[K  \x1b[36m{:.0}%\x1b[0m  {:.0}s/{:.0}s encoded  \x1b[2m{:.1}x speed  ~{:.0}s remaining\x1b[0m",
                                pct, encoded_s, dur, speed, remaining_s
                            );
                        }
                    } else {
                        eprint!(
                            "\r\x1b[K  \x1b[36m{:.0}s\x1b[0m encoded  \x1b[2m{:.0}s elapsed\x1b[0m",
                            encoded_s, elapsed
                        );
                    }
                }
            }
        }
    }
    eprintln!(); // newline after progress

    let result = child.wait().map_err(|e| format!("ffmpeg wait failed: {}", e))?;
    if !result.success() {
        // Read stderr for error details
        let mut stderr_str = String::new();
        if let Some(mut stderr) = child.stderr.take() {
            use std::io::Read;
            let _ = stderr.read_to_string(&mut stderr_str);
        }
        let normalised = stderr_str.replace('\r', "\n");
        let meaningful: Vec<&str> = normalised
            .lines()
            .map(str::trim_end)
            .filter(|l| {
                !l.is_empty()
                    && !l.starts_with("frame=")
                    && !l.starts_with("size=")
                    && !l.starts_with("Press [q]")
            })
            .collect();
        let tail_start = meaningful.len().saturating_sub(60);
        let tail = &meaningful[tail_start..];
        return Err(format!("ffmpeg failed:\n{}", tail.join("\n")));
    }

    let total_elapsed = start.elapsed().as_secs_f64();
    eprintln!("  \x1b[32mdone\x1b[0m in {:.0}s", total_elapsed);
    Ok(())
}

/// Generate a first-frame JPG poster from a video.
///
/// Used by the storyboard subcommand to populate `clip.poster_path`
/// lazily when the manifest doesn't already have one. The resulting
/// JPG is used as the `<video poster=...>` attribute so cards render
/// a visible thumbnail before the user clicks play, without
/// downloading any video bytes.
pub fn generate_poster(video: &Path, output: &Path) -> Result<(), String> {
    let result = Command::new("ffmpeg")
        .args(["-y", "-loglevel", "error", "-i"])
        .arg(video)
        .args(["-frames:v", "1", "-q:v", "3"])
        .arg(output)
        .output()
        .map_err(|e| format!("ffmpeg spawn failed: {}", e))?;
    if !result.status.success() {
        return Err(format!(
            "ffmpeg poster failed: {}",
            String::from_utf8_lossy(&result.stderr).lines().rev().take(5).collect::<Vec<_>>().join("; ")
        ));
    }
    Ok(())
}

/// Append a still image (held for `duration` seconds) to the end
/// of a video. Used by demo workflows to give viewers a readable
/// pause on the actual exported data (console output, gerber file
/// list, BOM rows, etc.) right after a sped-up action.
///
/// The still is padded to match the source video's dimensions and
/// encoded with the same CFR 30 fps + 2s keyframe interval as the
/// main speedup pipeline, so `-c copy` concat works without
/// re-encoding. If the concat-copy fails for any reason, falls back
/// to a full re-encode.
pub fn append_still(
    video: &Path,
    image: &Path,
    duration: f64,
    output: &Path,
) -> Result<(), String> {
    let (w, h) = probe_dimensions(video)?;

    // Step 1: encode the still as a short webm matching the source
    // dimensions and codec params.
    let still_tmp = std::env::temp_dir().join(format!(
        "adom-video-post-still-{}.webm",
        std::process::id()
    ));
    let pad_filter = format!(
        "scale={}:{}:force_original_aspect_ratio=decrease,pad={}:{}:(ow-iw)/2:(oh-ih)/2:black,setsar=1",
        w, h, w, h
    );
    let still_result = Command::new("ffmpeg")
        .args(["-y", "-loglevel", "error", "-loop", "1", "-framerate", "30", "-t"])
        .arg(format!("{}", duration))
        .args(["-i"])
        .arg(image)
        .args([
            "-vf", &pad_filter,
            "-c:v", "libvpx-vp9",
            "-b:v", "2M",
            "-deadline", "realtime",
            "-cpu-used", "8",
            "-row-mt", "1",
            "-r", "30",
            "-fps_mode", "cfr",
            "-g", "60",
            "-keyint_min", "60",
            "-pix_fmt", "yuv420p",
            "-an",
        ])
        .arg(&still_tmp)
        .output()
        .map_err(|e| format!("ffmpeg spawn failed: {}", e))?;
    if !still_result.status.success() {
        let _ = std::fs::remove_file(&still_tmp);
        return Err(format!(
            "ffmpeg still encode failed: {}",
            String::from_utf8_lossy(&still_result.stderr)
                .lines()
                .rev()
                .take(5)
                .collect::<Vec<_>>()
                .join("; ")
        ));
    }

    // Step 2: concat video + still into output. Try copy first,
    // fall back to re-encode.
    let list_tmp = std::env::temp_dir().join(format!(
        "adom-video-post-concat-{}.txt",
        std::process::id()
    ));
    std::fs::write(
        &list_tmp,
        format!("file '{}'\nfile '{}'\n", video.display(), still_tmp.display()),
    )
    .map_err(|e| format!("write concat list: {}", e))?;

    let copy_result = Command::new("ffmpeg")
        .args(["-y", "-loglevel", "error", "-f", "concat", "-safe", "0", "-i"])
        .arg(&list_tmp)
        .args(["-c", "copy", "-an"])
        .arg(output)
        .output();
    let ok = matches!(&copy_result, Ok(r) if r.status.success());
    if !ok {
        // Fall back to re-encode.
        let re_result = Command::new("ffmpeg")
            .args(["-y", "-loglevel", "error", "-f", "concat", "-safe", "0", "-i"])
            .arg(&list_tmp)
            .args([
                "-c:v", "libvpx-vp9",
                "-b:v", "2M",
                "-deadline", "realtime",
                "-cpu-used", "8",
                "-row-mt", "1",
                "-r", "30",
                "-fps_mode", "cfr",
                "-g", "60",
                "-keyint_min", "60",
                "-pix_fmt", "yuv420p",
                "-an",
            ])
            .arg(output)
            .output()
            .map_err(|e| format!("ffmpeg spawn failed: {}", e))?;
        if !re_result.status.success() {
            let _ = std::fs::remove_file(&still_tmp);
            let _ = std::fs::remove_file(&list_tmp);
            return Err(format!(
                "ffmpeg concat re-encode failed: {}",
                String::from_utf8_lossy(&re_result.stderr)
                    .lines()
                    .rev()
                    .take(10)
                    .collect::<Vec<_>>()
                    .join("; ")
            ));
        }
    }

    let _ = std::fs::remove_file(&still_tmp);
    let _ = std::fs::remove_file(&list_tmp);
    Ok(())
}

/// Concatenate multiple webm videos into one. Uses ffmpeg's concat
/// demuxer with `-c copy` (lossless, fast) — assumes the inputs
/// share codec and timebase, which is true for everything produced
/// by `adom-video-post process` + `append_still`.
pub fn concat_videos(inputs: &[std::path::PathBuf], output: &Path) -> Result<(), String> {
    if inputs.is_empty() {
        return Err("concat: no input videos".to_string());
    }
    let list_tmp = std::env::temp_dir().join(format!(
        "adom-video-post-concat-list-{}.txt",
        std::process::id()
    ));
    let mut body = String::new();
    for p in inputs {
        if !p.exists() {
            let _ = std::fs::remove_file(&list_tmp);
            return Err(format!("concat: input missing: {}", p.display()));
        }
        body.push_str(&format!("file '{}'\n", p.display()));
    }
    std::fs::write(&list_tmp, body).map_err(|e| format!("write concat list: {}", e))?;

    let result = Command::new("ffmpeg")
        .args(["-y", "-loglevel", "error", "-f", "concat", "-safe", "0", "-i"])
        .arg(&list_tmp)
        .args(["-c", "copy", "-an"])
        .arg(output)
        .output()
        .map_err(|e| format!("ffmpeg spawn failed: {}", e))?;
    let _ = std::fs::remove_file(&list_tmp);
    if !result.status.success() {
        return Err(format!(
            "ffmpeg concat failed: {}",
            String::from_utf8_lossy(&result.stderr)
                .lines()
                .rev()
                .take(10)
                .collect::<Vec<_>>()
                .join("; ")
        ));
    }
    Ok(())
}