tail-fin-cli-core 0.6.2

Shared CLI/daemon helpers for tail-fin: Ctx, browser session builders, cookie-path resolution, JSON output. Consumed by tail-fin-cli and tail-fin-daemon.
Documentation
//! Shared helpers for the tail-fin CLI and daemon.
//!
//! This crate holds the CLI-adjacent plumbing that both the standalone
//! `tail-fin` binary and the `tfd` daemon need: building a [`BrowserSession`]
//! from a `--connect` host, resolving cookie file paths, and emitting
//! JSON/list responses. Site-specific logic does **not** live here — each
//! adapter crate owns its own Site impl + command handlers.
//!
//! [`BrowserSession`]: night_fury_core::BrowserSession

use std::path::PathBuf;

use tail_fin_common::TailFinError;

/// Build a user-facing error for missing connection mode.
pub fn no_mode_error(service: &str, cmd: &str) -> TailFinError {
    TailFinError::Api(format!(
        "No connection mode specified for {service}.\n\
         \x20 Use --connect to use browser mode:\n\
         \x20   tail-fin --connect 127.0.0.1:9222 {service} {cmd}\n\
         \x20 Or --cookies to use saved cookies:\n\
         \x20   tail-fin --cookies {service} {cmd}\n\
         \x20 Some adapters (e.g. spotify) auto-launch a stealth browser when no mode is given."
    ))
}

/// Connection-mode context shared across CLI subcommands and the REPL.
pub struct Ctx {
    pub connect: Option<String>,
    pub cookies: Option<String>,
    pub headed: bool,
}

/// Default cookies path for a given site: `~/.tail-fin/<site>-cookies.txt`.
pub fn default_cookies_path(site: &str) -> PathBuf {
    let home = std::env::var("HOME").unwrap_or_else(|_| ".".into());
    PathBuf::from(home)
        .join(".tail-fin")
        .join(format!("{}-cookies.txt", site))
}

/// Resolve the cookies file path from the `--cookies` flag value.
/// `"auto"` expands to [`default_cookies_path`]; anything else is verbatim.
pub fn resolve_cookies_path(cookies_flag: &str, site: &str) -> PathBuf {
    if cookies_flag == "auto" {
        default_cookies_path(site)
    } else {
        PathBuf::from(cookies_flag)
    }
}

/// Connect to an existing Chrome instance via CDP at `ws://{host}`.
pub async fn browser_session(
    host: &str,
    headed: bool,
) -> Result<night_fury_core::BrowserSession, TailFinError> {
    Ok(night_fury_core::BrowserSession::builder()
        .connect_to(format!("ws://{}", host))
        .headed(headed)
        .build()
        .await?)
}

/// Launch a fresh headless (or headed) browser — no existing Chrome required.
pub async fn launch_browser(headed: bool) -> Result<night_fury_core::BrowserSession, TailFinError> {
    Ok(night_fury_core::BrowserSession::builder()
        .headed(headed)
        .build()
        .await?)
}

/// Auto-launch a stealth browser session when no connection mode is
/// specified. Adapters that support browser-only mode use this as their
/// fallback path. Emits a stderr notice before launching.
pub async fn auto_launch_stealth(
    url: &str,
    headed: bool,
) -> Result<night_fury_core::BrowserSession, TailFinError> {
    eprintln!("No connection mode specified. Launching stealth browser...");
    Ok(night_fury_core::BrowserSession::builder()
        .headed(headed)
        .cloudflare_timeout(std::time::Duration::from_secs(30))
        .launch_stealth(url)
        .await?)
}

/// Launch a stealth browser navigated to `url` with anti-detection.
pub async fn launch_stealth_session(
    url: &str,
    headed: bool,
) -> Result<night_fury_core::BrowserSession, TailFinError> {
    Ok(night_fury_core::BrowserSession::builder()
        .headed(headed)
        .launch_stealth(url)
        .await?)
}

/// Require `--connect` and return the host, or a friendly error pointing at
/// the correct invocation.
pub fn require_browser(
    connect: &Option<String>,
    service: &str,
    action_name: &str,
) -> Result<String, TailFinError> {
    match connect {
        Some(host) => Ok(host.clone()),
        None => Err(TailFinError::Api(format!(
            "`{service} {action_name}` requires browser mode (--connect).\n\
             \x20 Use: tail-fin --connect 127.0.0.1:9222 {service} {action_name} ..."
        ))),
    }
}

/// Browser-only adapter: reject `--cookies`, require `--connect`, return a
/// ready-to-use [`BrowserSession`].
///
/// [`BrowserSession`]: night_fury_core::BrowserSession
pub async fn require_browser_session(
    ctx: &Ctx,
    service: &str,
) -> Result<night_fury_core::BrowserSession, TailFinError> {
    if ctx.cookies.is_some() {
        return Err(TailFinError::Api(format!(
            "{service} cookie mode is not supported.\n\
             \x20 Use --connect for browser mode."
        )));
    }
    let host = match ctx.connect.as_deref() {
        Some(h) => h,
        None => {
            return Err(TailFinError::Api(format!(
                "{service} requires --connect.\n\
                 \x20 Example: tail-fin --connect 127.0.0.1:9222 {service} ..."
            )));
        }
    };
    browser_session(host, ctx.headed).await
}

/// Print a serializable value as pretty JSON to stdout.
pub fn print_json(value: &(impl serde::Serialize + ?Sized)) -> Result<(), TailFinError> {
    println!("{}", serde_json::to_string_pretty(value)?);
    Ok(())
}

/// Print a list result as `{ "<key>": items, "count": N }` JSON.
pub fn print_list(
    key: &str,
    items: &impl serde::Serialize,
    count: usize,
) -> Result<(), TailFinError> {
    println!(
        "{}",
        serde_json::to_string_pretty(&serde_json::json!({
            key: items,
            "count": count,
        }))?
    );
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn resolve_cookies_path_auto_ends_with_site_cookies_txt() {
        let p = resolve_cookies_path("auto", "twitter");

        assert_eq!(
            p.file_name().and_then(|n| n.to_str()),
            Some("twitter-cookies.txt"),
            "unexpected filename in: {}",
            p.display()
        );

        assert_eq!(
            p.parent()
                .and_then(|pp| pp.file_name())
                .and_then(|n| n.to_str()),
            Some(".tail-fin"),
            "unexpected parent directory in: {}",
            p.display()
        );
    }

    #[test]
    fn resolve_cookies_path_explicit_is_verbatim() {
        let p = resolve_cookies_path("/explicit/cookies.txt", "twitter");
        assert_eq!(p.to_string_lossy(), "/explicit/cookies.txt");
    }

    #[test]
    fn require_browser_errors_when_connect_missing() {
        let err = require_browser(&None, "twitter", "timeline").unwrap_err();
        let msg = err.to_string();
        assert!(
            msg.contains("--connect"),
            "error should mention --connect; got: {msg}"
        );
        assert!(
            msg.contains("twitter timeline"),
            "error should mention the service/action; got: {msg}"
        );
    }

    #[test]
    fn require_browser_returns_host_when_present() {
        let host = require_browser(&Some("127.0.0.1:9222".to_string()), "twitter", "timeline")
            .expect("should succeed when --connect is provided");
        assert_eq!(host, "127.0.0.1:9222");
    }
}