tail-fin-cli 0.4.0

Multi-site browser automation CLI — attaches to Chrome or auto-launches a stealth browser to drive 14+ sites
use night_fury_cli_core::repl::{CommandHandler, ReplEngine};
use tail_fin_common::TailFinError;

use crate::adapter::CliAdapter;
use crate::session::Ctx;

/// Build the clap `Command` tree used inside the REPL. Unlike the one-shot
/// build_command, this:
/// - Uses `no_binary_name(true)` so argv doesn't need a prepended binary name
/// - Does not re-expose global --connect/--cookies/--headed (those are fixed
///   at REPL launch and live in Ctx)
/// - Does not include --repl itself (we're already in REPL)
fn build_repl_command(adapters: &[Box<dyn CliAdapter>]) -> clap::Command {
    let mut cmd = clap::Command::new("tail-fin")
        .about("Multi-site browser automation CLI (REPL)")
        .no_binary_name(true)
        .subcommand_required(true);

    #[cfg(feature = "gen")]
    {
        cmd = cmd.subcommand(
            clap::Command::new("run")
                .about("Run a dynamically generated site command")
                .arg(clap::Arg::new("site").required(true).help("Site name"))
                .arg(
                    clap::Arg::new("command")
                        .required(true)
                        .help("Command name"),
                )
                .arg(
                    clap::Arg::new("args")
                        .num_args(..)
                        .trailing_var_arg(true)
                        .help("Extra arguments"),
                ),
        );
    }

    for adapter in adapters {
        cmd = cmd.subcommand(adapter.command());
    }

    cmd
}

/// REPL-side command dispatcher.
///
/// Each stdin line is parsed as argv for the REPL clap tree and dispatched to
/// the matching adapter. Parse / dispatch errors are printed but do not end
/// the loop; only `quit` / `exit` / EOF end it.
pub struct ReplHandler {
    pub ctx: Ctx,
    pub adapters: Vec<Box<dyn CliAdapter>>,
}

impl CommandHandler for ReplHandler {
    async fn dispatch(&mut self, line: &str) -> bool {
        let trimmed = line.trim();
        if trimmed.is_empty() {
            return false;
        }
        if matches!(trimmed, "quit" | "exit") {
            return true;
        }

        // MVP: whitespace split. For quoted args, users can fall back to
        // one-shot mode. (A follow-up could pull in `shell_words`.)
        let argv: Vec<String> = trimmed.split_whitespace().map(String::from).collect();

        let cmd = build_repl_command(&self.adapters);
        let matches = match cmd.try_get_matches_from(&argv) {
            Ok(m) => m,
            Err(e) => {
                let _ = e.print();
                return false;
            }
        };

        let Some((subcommand, sub_matches)) = matches.subcommand() else {
            eprintln!("no subcommand (type `--help` for commands, `quit` to exit)");
            return false;
        };

        #[cfg(feature = "gen")]
        if subcommand == "run" {
            let site = sub_matches
                .get_one::<String>("site")
                .cloned()
                .unwrap_or_default();
            let command = sub_matches
                .get_one::<String>("command")
                .cloned()
                .unwrap_or_default();
            let args: Vec<String> = sub_matches
                .get_many::<String>("args")
                .map(|v| v.cloned().collect())
                .unwrap_or_default();
            if let Err(e) = crate::cli::gen::run_dynamic(site, command, args, &self.ctx).await {
                eprintln!("Error: {e}");
            }
            return false;
        }

        for adapter in &self.adapters {
            if adapter.name() == subcommand {
                if let Err(e) = adapter.dispatch(sub_matches, &self.ctx).await {
                    eprintln!("Error: {e}");
                }
                return false;
            }
        }

        eprintln!("unknown command '{subcommand}'");
        false
    }
}

pub async fn run_repl(ctx: Ctx, adapters: Vec<Box<dyn CliAdapter>>) -> Result<(), TailFinError> {
    let mut handler = ReplHandler { ctx, adapters };
    let engine = ReplEngine::new("tail-fin> ");
    engine
        .run(&mut handler)
        .await
        .map_err(|e| TailFinError::Api(e.to_string()))
}

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

    fn empty_handler() -> ReplHandler {
        ReplHandler {
            ctx: Ctx {
                connect: None,
                cookies: None,
                headed: false,
            },
            adapters: Vec::new(),
        }
    }

    #[tokio::test]
    async fn quit_returns_true() {
        let mut h = empty_handler();
        assert!(h.dispatch("quit").await);
    }

    #[tokio::test]
    async fn exit_returns_true() {
        let mut h = empty_handler();
        assert!(h.dispatch("exit").await);
    }

    #[tokio::test]
    async fn empty_line_returns_false() {
        let mut h = empty_handler();
        assert!(!h.dispatch("").await);
        assert!(!h.dispatch("   ").await);
    }

    #[tokio::test]
    async fn unknown_command_returns_false() {
        let mut h = empty_handler();
        // Clap will print a "subcommand not found" error; the REPL should
        // continue rather than quit.
        assert!(!h.dispatch("bogus-nonexistent-cmd").await);
    }

    #[tokio::test]
    async fn invalid_arg_returns_false() {
        let mut h = empty_handler();
        // Clap rejects this; REPL keeps going.
        assert!(!h.dispatch("--no-such-flag").await);
    }
}