Skip to main content

tail_fin_cli_core/
lib.rs

1//! Shared helpers for the tail-fin CLI and daemon.
2//!
3//! This crate holds the CLI-adjacent plumbing that both the standalone
4//! `tail-fin` binary and the `tfd` daemon need: building a [`BrowserSession`]
5//! from a `--connect` host, resolving cookie file paths, and emitting
6//! JSON/list responses. Site-specific logic does **not** live here — each
7//! adapter crate owns its own Site impl + command handlers.
8//!
9//! [`BrowserSession`]: night_fury_core::BrowserSession
10
11use std::path::PathBuf;
12
13use tail_fin_common::TailFinError;
14
15/// Build a user-facing error for missing connection mode.
16pub fn no_mode_error(service: &str, cmd: &str) -> TailFinError {
17    TailFinError::Api(format!(
18        "No connection mode specified for {service}.\n\
19         \x20 Use --connect to use browser mode:\n\
20         \x20   tail-fin --connect 127.0.0.1:9222 {service} {cmd}\n\
21         \x20 Or --cookies to use saved cookies:\n\
22         \x20   tail-fin --cookies {service} {cmd}\n\
23         \x20 Some adapters (e.g. spotify) auto-launch a stealth browser when no mode is given."
24    ))
25}
26
27/// Connection-mode context shared across CLI subcommands and the REPL.
28pub struct Ctx {
29    pub connect: Option<String>,
30    pub cookies: Option<String>,
31    pub headed: bool,
32}
33
34/// Default cookies path for a given site: `~/.tail-fin/<site>-cookies.txt`.
35pub fn default_cookies_path(site: &str) -> PathBuf {
36    let home = std::env::var("HOME").unwrap_or_else(|_| ".".into());
37    PathBuf::from(home)
38        .join(".tail-fin")
39        .join(format!("{}-cookies.txt", site))
40}
41
42/// Resolve the cookies file path from the `--cookies` flag value.
43/// `"auto"` expands to [`default_cookies_path`]; anything else is verbatim.
44pub fn resolve_cookies_path(cookies_flag: &str, site: &str) -> PathBuf {
45    if cookies_flag == "auto" {
46        default_cookies_path(site)
47    } else {
48        PathBuf::from(cookies_flag)
49    }
50}
51
52/// Connect to an existing Chrome instance via CDP at `ws://{host}`.
53pub async fn browser_session(
54    host: &str,
55    headed: bool,
56) -> Result<night_fury_core::BrowserSession, TailFinError> {
57    Ok(night_fury_core::BrowserSession::builder()
58        .connect_to(format!("ws://{}", host))
59        .headed(headed)
60        .build()
61        .await?)
62}
63
64/// Launch a fresh headless (or headed) browser — no existing Chrome required.
65pub async fn launch_browser(headed: bool) -> Result<night_fury_core::BrowserSession, TailFinError> {
66    Ok(night_fury_core::BrowserSession::builder()
67        .headed(headed)
68        .build()
69        .await?)
70}
71
72/// Auto-launch a stealth browser session when no connection mode is
73/// specified. Adapters that support browser-only mode use this as their
74/// fallback path. Emits a stderr notice before launching.
75pub async fn auto_launch_stealth(
76    url: &str,
77    headed: bool,
78) -> Result<night_fury_core::BrowserSession, TailFinError> {
79    eprintln!("No connection mode specified. Launching stealth browser...");
80    Ok(night_fury_core::BrowserSession::builder()
81        .headed(headed)
82        .cloudflare_timeout(std::time::Duration::from_secs(30))
83        .launch_stealth(url)
84        .await?)
85}
86
87/// Launch a stealth browser navigated to `url` with anti-detection.
88pub async fn launch_stealth_session(
89    url: &str,
90    headed: bool,
91) -> Result<night_fury_core::BrowserSession, TailFinError> {
92    Ok(night_fury_core::BrowserSession::builder()
93        .headed(headed)
94        .launch_stealth(url)
95        .await?)
96}
97
98/// Require `--connect` and return the host, or a friendly error pointing at
99/// the correct invocation.
100pub fn require_browser(
101    connect: &Option<String>,
102    service: &str,
103    action_name: &str,
104) -> Result<String, TailFinError> {
105    match connect {
106        Some(host) => Ok(host.clone()),
107        None => Err(TailFinError::Api(format!(
108            "`{service} {action_name}` requires browser mode (--connect).\n\
109             \x20 Use: tail-fin --connect 127.0.0.1:9222 {service} {action_name} ..."
110        ))),
111    }
112}
113
114/// Browser-only adapter: reject `--cookies`, require `--connect`, return a
115/// ready-to-use [`BrowserSession`].
116///
117/// [`BrowserSession`]: night_fury_core::BrowserSession
118pub async fn require_browser_session(
119    ctx: &Ctx,
120    service: &str,
121) -> Result<night_fury_core::BrowserSession, TailFinError> {
122    if ctx.cookies.is_some() {
123        return Err(TailFinError::Api(format!(
124            "{service} cookie mode is not supported.\n\
125             \x20 Use --connect for browser mode."
126        )));
127    }
128    let host = match ctx.connect.as_deref() {
129        Some(h) => h,
130        None => {
131            return Err(TailFinError::Api(format!(
132                "{service} requires --connect.\n\
133                 \x20 Example: tail-fin --connect 127.0.0.1:9222 {service} ..."
134            )));
135        }
136    };
137    browser_session(host, ctx.headed).await
138}
139
140/// Print a serializable value as pretty JSON to stdout.
141pub fn print_json(value: &(impl serde::Serialize + ?Sized)) -> Result<(), TailFinError> {
142    println!("{}", serde_json::to_string_pretty(value)?);
143    Ok(())
144}
145
146/// Print a list result as `{ "<key>": items, "count": N }` JSON.
147pub fn print_list(
148    key: &str,
149    items: &impl serde::Serialize,
150    count: usize,
151) -> Result<(), TailFinError> {
152    println!(
153        "{}",
154        serde_json::to_string_pretty(&serde_json::json!({
155            key: items,
156            "count": count,
157        }))?
158    );
159    Ok(())
160}
161
162#[cfg(test)]
163mod tests {
164    use super::*;
165
166    #[test]
167    fn resolve_cookies_path_auto_ends_with_site_cookies_txt() {
168        let p = resolve_cookies_path("auto", "twitter");
169
170        assert_eq!(
171            p.file_name().and_then(|n| n.to_str()),
172            Some("twitter-cookies.txt"),
173            "unexpected filename in: {}",
174            p.display()
175        );
176
177        assert_eq!(
178            p.parent()
179                .and_then(|pp| pp.file_name())
180                .and_then(|n| n.to_str()),
181            Some(".tail-fin"),
182            "unexpected parent directory in: {}",
183            p.display()
184        );
185    }
186
187    #[test]
188    fn resolve_cookies_path_explicit_is_verbatim() {
189        let p = resolve_cookies_path("/explicit/cookies.txt", "twitter");
190        assert_eq!(p.to_string_lossy(), "/explicit/cookies.txt");
191    }
192
193    #[test]
194    fn require_browser_errors_when_connect_missing() {
195        let err = require_browser(&None, "twitter", "timeline").unwrap_err();
196        let msg = err.to_string();
197        assert!(
198            msg.contains("--connect"),
199            "error should mention --connect; got: {msg}"
200        );
201        assert!(
202            msg.contains("twitter timeline"),
203            "error should mention the service/action; got: {msg}"
204        );
205    }
206
207    #[test]
208    fn require_browser_returns_host_when_present() {
209        let host = require_browser(&Some("127.0.0.1:9222".to_string()), "twitter", "timeline")
210            .expect("should succeed when --connect is provided");
211        assert_eq!(host, "127.0.0.1:9222");
212    }
213}