about summary refs log tree commit diff stats
diff options
context:
space:
mode:
-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
+    }
+}