aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--crates/yt/src/commands/show/mod.rs20
-rw-r--r--crates/yt/src/commands/watch/implm/mod.rs224
-rw-r--r--crates/yt/src/commands/watch/implm/playlist_handler/client_messages.rs (renamed from crates/yt/src/commands/watch/implm/watch/playlist_handler/client_messages.rs)38
-rw-r--r--crates/yt/src/commands/watch/implm/playlist_handler/mod.rs (renamed from crates/yt/src/commands/watch/implm/watch/playlist_handler/mod.rs)7
-rw-r--r--crates/yt/src/commands/watch/implm/watch/mod.rs235
-rw-r--r--crates/yt/src/config/mod.rs23
-rw-r--r--crates/yt/src/config/non_empty_vec.rs73
7 files changed, 358 insertions, 262 deletions
diff --git a/crates/yt/src/commands/show/mod.rs b/crates/yt/src/commands/show/mod.rs
new file mode 100644
index 0000000..fe583c0
--- /dev/null
+++ b/crates/yt/src/commands/show/mod.rs
@@ -0,0 +1,20 @@
+use clap::Subcommand;
+
+mod implm;
+
+#[derive(Subcommand, Debug)]
+pub(super) enum ShowCommand {
+ /// Display the description of the currently playing video
+ Description {},
+
+ /// Display the comments of the currently playing video.
+ Comments {},
+
+ /// Display the thumbnail of the currently playing video.
+ Thumbnail {},
+
+ /// Display general info of the currently playing video.
+ ///
+ /// This is the same as running `yt videos info <hash of current video>`
+ Info {},
+}
diff --git a/crates/yt/src/commands/watch/implm/mod.rs b/crates/yt/src/commands/watch/implm/mod.rs
index 338f80a..6aaa076 100644
--- a/crates/yt/src/commands/watch/implm/mod.rs
+++ b/crates/yt/src/commands/watch/implm/mod.rs
@@ -1,20 +1,234 @@
-use std::sync::Arc;
+use std::{
+ fs,
+ path::PathBuf,
+ sync::{
+ Arc,
+ atomic::{AtomicBool, Ordering},
+ },
+};
-use crate::{app::App, commands::watch::WatchCommand};
+use crate::{
+ app::App,
+ commands::watch::{WatchCommand, implm::playlist_handler::Status},
+ storage::{
+ db::{
+ insert::{Operations, maintenance::clear_stale_downloaded_paths},
+ playlist::Playlist,
+ },
+ notify::wait_for_db_write,
+ },
+};
-use anyhow::Result;
+use anyhow::{Context, Result};
+use libmpv2::{Mpv, events::EventContext};
+use log::{debug, info, trace, warn};
+use tokio::{task, time};
-mod watch;
+mod playlist_handler;
impl WatchCommand {
+ #[allow(clippy::too_many_lines)]
pub(crate) async fn implm(self, app: Arc<App>) -> Result<()> {
let WatchCommand {
provide_ipc_socket,
headless,
} = self;
- watch::watch(app, provide_ipc_socket, headless).await?;
+ clear_stale_downloaded_paths(&app).await?;
+
+ let ipc_socket = if provide_ipc_socket {
+ Some(app.config.paths.mpv_ipc_socket_path.clone())
+ } else {
+ None
+ };
+
+ let (mpv, mut ev_ctx) =
+ init_mpv(&app, ipc_socket, headless).context("Failed to initialize mpv instance")?;
+ let mpv = Arc::new(mpv);
+
+ if provide_ipc_socket {
+ println!("{}", app.config.paths.mpv_ipc_socket_path.display());
+ }
+
+ let should_break = Arc::new(AtomicBool::new(false));
+ let local_app = Arc::clone(&app);
+ let local_mpv = Arc::clone(&mpv);
+ let local_should_break = Arc::clone(&should_break);
+ let progress_handle = task::spawn(async move {
+ loop {
+ if local_should_break.load(Ordering::Relaxed) {
+ trace!("WatchProgressThread: Stopping, as we received exit signal.");
+ break;
+ }
+
+ let mut playlist = Playlist::create(&local_app).await?;
+
+ if let Some(index) = playlist.current_index() {
+ trace!("WatchProgressThread: Saving watch progress for current video");
+
+ let mut ops =
+ Operations::new("WatchProgressThread: save watch progress thread");
+ playlist.save_watch_progress(&local_mpv, index, &mut ops);
+ ops.commit(&local_app).await?;
+ } else {
+ trace!(
+ "WatchProgressThread: Tried to save current watch progress, but no video active."
+ );
+ }
+
+ time::sleep(local_app.config.watch.progress_save_intervall).await;
+ }
+
+ Ok::<(), anyhow::Error>(())
+ });
+
+ let playlist = Playlist::create(&app).await?;
+ playlist.resync_with_mpv(&app, &mpv)?;
+
+ let mut have_warned = (false, 0);
+ 'watchloop: loop {
+ 'waitloop: while let Ok(value) = playlist_handler::status(&app).await {
+ match value {
+ Status::NoMoreAvailable => {
+ break 'watchloop;
+ }
+ Status::NoCached { marked_watch } => {
+ // try again next time.
+ if have_warned.0 {
+ if have_warned.1 != marked_watch {
+ warn!("Now {marked_watch} videos are marked as to be watched.");
+ have_warned.1 = marked_watch;
+ }
+ } else {
+ warn!(
+ "There is nothing to watch yet, but still {marked_watch} videos marked as to be watched. \
+ Will idle, until they become available"
+ );
+ have_warned = (true, marked_watch);
+ }
+ wait_for_db_write(&app).await?;
+
+ // Add the new videos, if they are there.
+ let playlist = Playlist::create(&app).await?;
+ playlist.resync_with_mpv(&app, &mpv)?;
+ }
+ Status::Available { newly_available } => {
+ debug!(
+ "Checked for currently available videos and found {newly_available}!"
+ );
+ have_warned.0 = false;
+
+ // Something just became available!
+ break 'waitloop;
+ }
+ }
+ }
+
+ // TODO(@bpeetz): Is the following assumption correct? <2025-07-10>
+ // We wait until forever for the next event, because we really don't need to do anything
+ // else.
+ if let Some(ev) = ev_ctx.wait_event(f64::MAX) {
+ match ev {
+ Ok(event) => {
+ trace!("Mpv event triggered: {event:#?}");
+ if playlist_handler::handle_mpv_event(&app, &mpv, &event)
+ .await
+ .with_context(|| format!("Failed to handle mpv event: '{event:#?}'"))?
+ {
+ break;
+ }
+ }
+ Err(e) => debug!("Mpv Event errored: {e}"),
+ }
+ }
+ }
+ should_break.store(true, Ordering::Relaxed);
+ progress_handle.await??;
+
+ if provide_ipc_socket {
+ fs::remove_file(&app.config.paths.mpv_ipc_socket_path).with_context(|| {
+ format!(
+ "Failed to clean-up the mpv ipc socket at {}",
+ app.config.paths.mpv_ipc_socket_path.display()
+ )
+ })?;
+ }
Ok(())
}
}
+
+fn init_mpv(app: &App, ipc_socket: Option<PathBuf>, headless: bool) -> Result<(Mpv, EventContext)> {
+ // set some default values, to make things easier (these can be overridden by the config file,
+ // which we load later)
+ let mpv = Mpv::with_initializer(|mpv| {
+ if let Some(socket) = ipc_socket {
+ mpv.set_property(
+ "input-ipc-server",
+ socket
+ .to_str()
+ .expect("This path comes from us, it should never contain not-utf8"),
+ )?;
+ }
+
+ if headless {
+ // Do not provide video output.
+ mpv.set_property("vid", "no")?;
+ } else {
+ // Enable default key bindings, so the user can actually interact with
+ // the player (and e.g. close the window).
+ mpv.set_property("input-default-bindings", "yes")?;
+ mpv.set_property("input-vo-keyboard", "yes")?;
+
+ // Show the on screen controller.
+ mpv.set_property("osc", "yes")?;
+
+ // Don't automatically advance to the next video (or exit the player)
+ mpv.set_option("keep-open", "always")?;
+
+ // Always display an window, even for non-video playback.
+ // As mpv does not have cli access, no window means no control and no user feedback.
+ mpv.set_option("force-window", "yes")?;
+ }
+
+ Ok(())
+ })
+ .context("Failed to initialize mpv")?;
+
+ let config_path = &app.config.paths.mpv_config_path;
+ if config_path.try_exists()? {
+ info!("Found mpv.conf at '{}'!", config_path.display());
+ mpv.command(
+ "load-config-file",
+ &[config_path
+ .to_str()
+ .context("Failed to parse the config path is utf8-stringt")?],
+ )?;
+ } else {
+ warn!(
+ "Did not find a mpv.conf file at '{}'",
+ config_path.display()
+ );
+ }
+
+ let input_path = &app.config.paths.mpv_input_path;
+ if input_path.try_exists()? {
+ info!("Found mpv.input.conf at '{}'!", input_path.display());
+ mpv.command(
+ "load-input-conf",
+ &[input_path
+ .to_str()
+ .context("Failed to parse the input path as utf8 string")?],
+ )?;
+ } else {
+ warn!(
+ "Did not find a mpv.input.conf file at '{}'",
+ input_path.display()
+ );
+ }
+
+ let ev_ctx = EventContext::new(mpv.ctx);
+ ev_ctx.disable_deprecated_events()?;
+
+ Ok((mpv, ev_ctx))
+}
diff --git a/crates/yt/src/commands/watch/implm/watch/playlist_handler/client_messages.rs b/crates/yt/src/commands/watch/implm/playlist_handler/client_messages.rs
index 6c8ebbe..fd7e035 100644
--- a/crates/yt/src/commands/watch/implm/watch/playlist_handler/client_messages.rs
+++ b/crates/yt/src/commands/watch/implm/playlist_handler/client_messages.rs
@@ -23,19 +23,8 @@ async fn run_self_in_external_command(app: &App, args: &[&str]) -> Result<()> {
let binary =
env::current_exe().context("Failed to determine the current executable to re-execute")?;
- let status = Command::new("riverctl")
- .args(["focus-output", "next"])
- .status()
- .await?;
- if !status.success() {
- bail!("focusing the next output failed!");
- }
-
let arguments = [
&[
- "--title",
- "floating please",
- "--command",
binary
.to_str()
.context("Failed to turn the executable path to a utf8-string")?,
@@ -50,25 +39,20 @@ async fn run_self_in_external_command(app: &App, args: &[&str]) -> Result<()> {
]
.concat();
- let status = Command::new("alacritty").args(arguments).status().await?;
- if !status.success() {
- bail!("Falied to start `yt comments`");
- }
-
- let status = Command::new("riverctl")
- .args(["focus-output", "next"])
+ let status = Command::new(app.config.commands.external_spawn.first())
+ .args(app.config.commands.external_spawn.tail())
+ .args(arguments)
.status()
.await?;
-
if !status.success() {
- bail!("focusing the next output failed!");
+ bail!("Falied to start (external) `yt {}`", args.join(" "));
}
Ok(())
}
pub(super) async fn handle_yt_description_external(app: &App) -> Result<()> {
- run_self_in_external_command(app, &["description"]).await?;
+ run_self_in_external_command(app, &["show", "description"]).await?;
Ok(())
}
pub(super) async fn handle_yt_description_local(app: &App, mpv: &Mpv) -> Result<()> {
@@ -83,7 +67,7 @@ pub(super) async fn handle_yt_description_local(app: &App, mpv: &Mpv) -> Result<
}
pub(super) async fn handle_yt_comments_external(app: &App) -> Result<()> {
- run_self_in_external_command(app, &["comments"]).await?;
+ run_self_in_external_command(app, &["show", "comments"]).await?;
Ok(())
}
pub(super) async fn handle_yt_comments_local(app: &App, mpv: &Mpv) -> Result<()> {
@@ -97,3 +81,13 @@ pub(super) async fn handle_yt_comments_local(app: &App, mpv: &Mpv) -> Result<()>
mpv_message(mpv, &comments, Duration::from_secs(6))?;
Ok(())
}
+
+pub(super) async fn handle_yt_info_external(app: &App) -> Result<()> {
+ run_self_in_external_command(app, &["show", "info"]).await?;
+ Ok(())
+}
+
+pub(super) async fn handle_yt_thumbnail_external(app: &App) -> Result<()> {
+ run_self_in_external_command(app, &["show", "thumbnail"]).await?;
+ Ok(())
+}
diff --git a/crates/yt/src/commands/watch/implm/watch/playlist_handler/mod.rs b/crates/yt/src/commands/watch/implm/playlist_handler/mod.rs
index 443fd26..bdb77d2 100644
--- a/crates/yt/src/commands/watch/implm/watch/playlist_handler/mod.rs
+++ b/crates/yt/src/commands/watch/implm/playlist_handler/mod.rs
@@ -179,6 +179,13 @@ pub(crate) async fn handle_mpv_event(app: &App, mpv: &Mpv, event: &Event<'_>) ->
client_messages::handle_yt_description_local(app, mpv).await?;
}
+ &["yt-info-external"] => {
+ client_messages::handle_yt_info_external(app).await?;
+ }
+ &["yt-thumbnail-external"] => {
+ client_messages::handle_yt_thumbnail_external(app).await?;
+ }
+
&["yt-mark-picked"] => {
playlist().await?.mark_current_done(
app,
diff --git a/crates/yt/src/commands/watch/implm/watch/mod.rs b/crates/yt/src/commands/watch/implm/watch/mod.rs
deleted file mode 100644
index 1436d8d..0000000
--- a/crates/yt/src/commands/watch/implm/watch/mod.rs
+++ /dev/null
@@ -1,235 +0,0 @@
-// yt - A fully featured command line YouTube client
-//
-// Copyright (C) 2024 Benedikt Peetz <benedikt.peetz@b-peetz.de>
-// Copyright (C) 2025 Benedikt Peetz <benedikt.peetz@b-peetz.de>
-// SPDX-License-Identifier: GPL-3.0-or-later
-//
-// This file is part of Yt.
-//
-// You should have received a copy of the License along with this program.
-// If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>.
-
-use std::{
- fs,
- path::PathBuf,
- sync::{
- Arc,
- atomic::{AtomicBool, Ordering},
- },
-};
-
-use anyhow::{Context, Result};
-use libmpv2::{Mpv, events::EventContext};
-use log::{debug, info, trace, warn};
-use tokio::{task, time};
-
-use self::playlist_handler::Status;
-use crate::{
- app::App,
- storage::{
- db::{insert::{maintenance::clear_stale_downloaded_paths, Operations}, playlist::Playlist},
- notify::wait_for_db_write,
- },
-};
-
-pub(crate) mod playlist_handler;
-
-fn init_mpv(app: &App, ipc_socket: Option<PathBuf>, headless: bool) -> Result<(Mpv, EventContext)> {
- // set some default values, to make things easier (these can be overridden by the config file,
- // which we load later)
- let mpv = Mpv::with_initializer(|mpv| {
- if let Some(socket) = ipc_socket {
- mpv.set_property(
- "input-ipc-server",
- socket
- .to_str()
- .expect("This path comes from us, it should never contain not-utf8"),
- )?;
- }
-
- if headless {
- // Do not provide video output.
- mpv.set_property("vid", "no")?;
- } else {
- // Enable default key bindings, so the user can actually interact with
- // the player (and e.g. close the window).
- mpv.set_property("input-default-bindings", "yes")?;
- mpv.set_property("input-vo-keyboard", "yes")?;
-
- // Show the on screen controller.
- mpv.set_property("osc", "yes")?;
-
- // Don't automatically advance to the next video (or exit the player)
- mpv.set_option("keep-open", "always")?;
-
- // Always display an window, even for non-video playback.
- // As mpv does not have cli access, no window means no control and no user feedback.
- mpv.set_option("force-window", "yes")?;
- }
-
- Ok(())
- })
- .context("Failed to initialize mpv")?;
-
- let config_path = &app.config.paths.mpv_config_path;
- if config_path.try_exists()? {
- info!("Found mpv.conf at '{}'!", config_path.display());
- mpv.command(
- "load-config-file",
- &[config_path
- .to_str()
- .context("Failed to parse the config path is utf8-stringt")?],
- )?;
- } else {
- warn!(
- "Did not find a mpv.conf file at '{}'",
- config_path.display()
- );
- }
-
- let input_path = &app.config.paths.mpv_input_path;
- if input_path.try_exists()? {
- info!("Found mpv.input.conf at '{}'!", input_path.display());
- mpv.command(
- "load-input-conf",
- &[input_path
- .to_str()
- .context("Failed to parse the input path as utf8 string")?],
- )?;
- } else {
- warn!(
- "Did not find a mpv.input.conf file at '{}'",
- input_path.display()
- );
- }
-
- let ev_ctx = EventContext::new(mpv.ctx);
- ev_ctx.disable_deprecated_events()?;
-
- Ok((mpv, ev_ctx))
-}
-
-pub(crate) async fn watch(app: Arc<App>, provide_ipc_socket: bool, headless: bool) -> Result<()> {
- clear_stale_downloaded_paths(&app).await?;
-
- let ipc_socket = if provide_ipc_socket {
- Some(app.config.paths.mpv_ipc_socket_path.clone())
- } else {
- None
- };
-
- let (mpv, mut ev_ctx) =
- init_mpv(&app, ipc_socket, headless).context("Failed to initialize mpv instance")?;
- let mpv = Arc::new(mpv);
-
- // We now _know_ that the socket is set-up and ready.
- if provide_ipc_socket {
- println!("{}", app.config.paths.mpv_ipc_socket_path.display());
- }
-
- let should_break = Arc::new(AtomicBool::new(false));
-
- let local_app = Arc::clone(&app);
- let local_mpv = Arc::clone(&mpv);
- let local_should_break = Arc::clone(&should_break);
- let progress_handle = task::spawn(async move {
- loop {
- if local_should_break.load(Ordering::Relaxed) {
- trace!("WatchProgressThread: Stopping, as we received exit signal.");
- break;
- }
-
- let mut playlist = Playlist::create(&local_app).await?;
-
- if let Some(index) = playlist.current_index() {
- trace!("WatchProgressThread: Saving watch progress for current video");
-
- let mut ops = Operations::new("WatchProgressThread: save watch progress thread");
- playlist.save_watch_progress(&local_mpv, index, &mut ops);
- ops.commit(&local_app).await?;
- } else {
- trace!(
- "WatchProgressThread: Tried to save current watch progress, but no video active."
- );
- }
-
- time::sleep(local_app.config.watch.watch_progress_save_intervall).await;
- }
-
- Ok::<(), anyhow::Error>(())
- });
-
- // Set up the initial playlist.
- let playlist = Playlist::create(&app).await?;
- playlist.resync_with_mpv(&app, &mpv)?;
-
- let mut have_warned = (false, 0);
- 'watchloop: loop {
- 'waitloop: while let Ok(value) = playlist_handler::status(&app).await {
- match value {
- Status::NoMoreAvailable => {
- break 'watchloop;
- }
- Status::NoCached { marked_watch } => {
- // try again next time.
- if have_warned.0 {
- if have_warned.1 != marked_watch {
- warn!("Now {marked_watch} videos are marked as to be watched.");
- have_warned.1 = marked_watch;
- }
- } else {
- warn!(
- "There is nothing to watch yet, but still {marked_watch} videos marked as to be watched. \
- Will idle, until they become available"
- );
- have_warned = (true, marked_watch);
- }
- wait_for_db_write(&app).await?;
-
- // Add the new videos, if they are there.
- let playlist = Playlist::create(&app).await?;
- playlist.resync_with_mpv(&app, &mpv)?;
- }
- Status::Available { newly_available } => {
- debug!("Checked for currently available videos and found {newly_available}!");
- have_warned.0 = false;
-
- // Something just became available!
- break 'waitloop;
- }
- }
- }
-
- // TODO(@bpeetz): Is the following assumption correct? <2025-07-10>
- // We wait until forever for the next event, because we really don't need to do anything
- // else.
- if let Some(ev) = ev_ctx.wait_event(f64::MAX) {
- match ev {
- Ok(event) => {
- trace!("Mpv event triggered: {event:#?}");
- if playlist_handler::handle_mpv_event(&app, &mpv, &event)
- .await
- .with_context(|| format!("Failed to handle mpv event: '{event:#?}'"))?
- {
- break;
- }
- }
- Err(e) => debug!("Mpv Event errored: {e}"),
- }
- }
- }
-
- should_break.store(true, Ordering::Relaxed);
- progress_handle.await??;
-
- if provide_ipc_socket {
- fs::remove_file(&app.config.paths.mpv_ipc_socket_path).with_context(|| {
- format!(
- "Failed to clean-up the mpv ipc socket at {}",
- app.config.paths.mpv_ipc_socket_path.display()
- )
- })?;
- }
-
- Ok(())
-}
diff --git a/crates/yt/src/config/mod.rs b/crates/yt/src/config/mod.rs
index e02d5f5..947e1f8 100644
--- a/crates/yt/src/config/mod.rs
+++ b/crates/yt/src/config/mod.rs
@@ -2,6 +2,7 @@ use std::sync::atomic::{AtomicBool, Ordering};
use crate::config::support::mk_config;
+mod non_empty_vec;
mod paths;
mod support;
@@ -31,6 +32,9 @@ mk_config! {
use super::paths::ensure_dir;
use super::paths::PREFIX;
+ use super::non_empty_vec::NonEmptyVec;
+ use super::non_empty_vec::non_empty_vec;
+
struct Config {
global: GlobalConfig = {
/// Whether to display colors.
@@ -59,6 +63,25 @@ mk_config! {
/// How long to wait between saving the video watch progress.
watch_progress_save_intervall: Duration =: Duration::from_secs(10),
},
+ commands: CommandsConfig = {
+ /// Which command to execute, when showing the thumbnail.
+ ///
+ /// This command will be executed with the one argument, being the path to the image file to display.
+ image_show: NonEmptyVec<String> =: non_empty_vec!["imv".to_owned()],
+
+ /// Which command to use, when spawing one of the external commands (e.g.
+ /// `yt-comments-external` from mpv).
+ ///
+ /// The command will be called with a series of args that should be executed.
+ /// For example,
+ /// `<your_specified_command> <path_to_yt_binary> --db-path <path_to_current_db_path> comments`
+ external_spawn: NonEmptyVec<String> =: non_empty_vec!["alacritty".to_owned(), "-e".to_owned()],
+
+ /// Which command to use, when opening video urls (like in the `yt select url` case).
+ ///
+ /// This command will be called with one argument, being the url of the video to open.
+ url_opener: NonEmptyVec<String> =: non_empty_vec!["firefox".to_owned()],
+ },
paths: PathsConfig = {
/// Where to store downloaded files.
download_dir: PathBuf =: {
diff --git a/crates/yt/src/config/non_empty_vec.rs b/crates/yt/src/config/non_empty_vec.rs
new file mode 100644
index 0000000..0ca864b
--- /dev/null
+++ b/crates/yt/src/config/non_empty_vec.rs
@@ -0,0 +1,73 @@
+use std::{
+ collections::VecDeque,
+ fmt::{Display, Write},
+};
+
+use anyhow::bail;
+use serde::{Deserialize, Serialize};
+
+macro_rules! non_empty_vec {
+ ($first:expr $(, $($others:expr),+ $(,)?)?) => {{
+ let inner: Vec<_> = vec![$first $(, $($others,)+)?];
+ inner.try_into().expect("Has a first arg")
+ }}
+}
+pub(crate) use non_empty_vec;
+
+/// A vector that is non-empty.
+#[derive(Deserialize, Serialize, Debug, Clone, PartialEq)]
+#[serde(try_from = "Vec<T>")]
+#[serde(into = "Vec<T>")]
+pub(crate) struct NonEmptyVec<T: Clone> {
+ first: T,
+ rest: Vec<T>,
+}
+
+impl<T: Clone> TryFrom<Vec<T>> for NonEmptyVec<T> {
+ type Error = anyhow::Error;
+
+ fn try_from(value: Vec<T>) -> Result<Self, Self::Error> {
+ let mut queue = VecDeque::from(value);
+
+ if let Some(first) = queue.pop_front() {
+ Ok(Self {
+ first,
+ rest: queue.into(),
+ })
+ } else {
+ bail!("You need to have at least one element in a non-empty vector.")
+ }
+ }
+}
+
+impl<T: Clone> From<NonEmptyVec<T>> for Vec<T> {
+ fn from(value: NonEmptyVec<T>) -> Self {
+ let mut base = vec![value.first];
+ base.extend(value.rest);
+ base
+ }
+}
+
+impl<T: Clone> NonEmptyVec<T> {
+ pub(crate) fn first(&self) -> &T {
+ &self.first
+ }
+
+ pub(crate) fn tail(&self) -> &[T] {
+ self.rest.as_ref()
+ }
+
+ pub(crate) fn join(&self, sep: &str) -> String
+ where
+ T: Display,
+ {
+ let mut output = String::new();
+ write!(output, "{}", self.first()).expect("In-memory, does not fail");
+
+ for elem in &self.rest {
+ write!(output, "{sep}{elem}").expect("In-memory, does not fail");
+ }
+
+ output
+ }
+}