flarer 0.1.0

Rust client and CLI for Cloudflare's Browser Rendering REST API (content, screenshot, PDF, snapshot, markdown, scrape, JSON extraction, links, crawl).
Documentation
//! Interactive prompts using `inquire`.

use inquire::validator::Validation;
use inquire::{Confirm, Select, Text};
use strum::{IntoEnumIterator, VariantNames};

use crate::cli::schema_builder::build_response_format_interactive;
use crate::{Account, Flarer, JsonOptions, ResponseFormat, Tool};

const BANNER: &str = r"
    Welcome to

    FFFFFFFF  LL          AAAA      RRRRRR    EEEEEEEE  RRRRRR
    FF        LL         AA  AA     RR   RR   EE        RR   RR
    FFFFFF    LL        AA    AA    RRRRRR    EEEEEE    RRRRRR
    FF        LL        AAAAAAAA    RR  RR    EE        RR  RR
    FF        LLLLLLLL  AA    AA    RR   RR   EEEEEEEE  RR   RR

    CLI tool to interact with Cloudflare's Browser Rendering API.
";

/// Result of the interactive flow used by the binary to drive the client.
#[derive(Debug, Clone)]
pub struct InteractiveConfig {
    /// Target URL.
    pub url: String,
    /// Selected tool.
    pub tool: Tool,
    /// JSON-tool options (only populated when `tool == Tool::Json`).
    pub json_options: JsonOptions,
}

/// Print the banner and build a [`Flarer`], either from env or from prompted
/// credentials.
pub async fn interactive_init() -> crate::Result<Flarer> {
    println!("{BANNER}");
    let account = match Account::from_env() {
        Ok(a) => a,
        Err(_) => prompt_for_account()?,
    };
    let flarer = Flarer::builder().account(account).build()?;
    flarer.verify().await?;
    Ok(flarer)
}

fn prompt_for_account() -> crate::Result<Account> {
    let id = Text::new("Enter your Cloudflare Account ID:")
        .with_help_message("https://developers.cloudflare.com/workers-ai/get-started/rest-api/")
        .prompt()
        .map_err(|e| crate::FlarerError::Config(e.to_string()))?;
    let token = Text::new("Enter your Cloudflare API Token:")
        .with_help_message("https://developers.cloudflare.com/workers-ai/get-started/rest-api/")
        .prompt()
        .map_err(|e| crate::FlarerError::Config(e.to_string()))?;
    Ok(Account::new(id, token))
}

/// Prompt the user for a URL, tool, and (for `Json`) prompt + schema.
pub async fn interactive_prompt() -> crate::Result<InteractiveConfig> {
    let url = url_entry_with_retry()?;
    let tool = select_tool()?;

    let json_options = if matches!(tool, Tool::Json) {
        json_options_handler().await?
    } else {
        JsonOptions::default()
    };

    Ok(InteractiveConfig {
        url,
        tool,
        json_options,
    })
}

fn url_entry_with_retry() -> crate::Result<String> {
    loop {
        let raw = Text::new("Enter URL:")
            .with_help_message("e.g. https://example.com")
            .with_validator(|input: &str| {
                if url::Url::parse(input).is_ok() {
                    Ok(Validation::Valid)
                } else {
                    Ok(Validation::Invalid(
                        "Must be a valid absolute URL (https://...)".into(),
                    ))
                }
            })
            .prompt()
            .map_err(|e| crate::FlarerError::Config(e.to_string()))?;
        if url::Url::parse(&raw).is_ok() {
            return Ok(raw);
        }
        let retry = Confirm::new("Invalid URL. Try again?")
            .with_default(true)
            .prompt()
            .map_err(|e| crate::FlarerError::Config(e.to_string()))?;
        if !retry {
            return Err(crate::FlarerError::Config("URL entry cancelled".into()));
        }
    }
}

fn select_tool() -> crate::Result<Tool> {
    let names: Vec<&'static str> = Tool::VARIANTS.to_vec();
    let chosen = Select::new("Select tool:", names)
        .with_help_message("Use arrow keys to navigate")
        .prompt()
        .map_err(|e| crate::FlarerError::Config(e.to_string()))?;
    Tool::iter()
        .find(|t| <&'static str>::from(*t) == chosen)
        .ok_or_else(|| crate::FlarerError::Unexpected(format!("unknown tool {}", chosen)))
}

async fn json_options_handler() -> crate::Result<JsonOptions> {
    let choice = Select::new(
        "Configure JSON tool:",
        vec![
            "Add a prompt only",
            "Define a structured response format only",
            "Both",
            "Neither",
        ],
    )
    .with_help_message("Use arrow keys to navigate")
    .prompt()
    .map_err(|e| crate::FlarerError::Config(e.to_string()))?;

    let mut options = JsonOptions::default();
    match choice {
        "Add a prompt only" => {
            options.prompt = Some(prompt_text()?);
        }
        "Define a structured response format only" => {
            options.response_format = Some(prompt_response_format().await?);
        }
        "Both" => {
            options.prompt = Some(prompt_text()?);
            options.response_format = Some(prompt_response_format().await?);
        }
        _ => {}
    }
    Ok(options)
}

fn prompt_text() -> crate::Result<String> {
    Text::new("Enter your prompt:")
        .with_help_message("Free-form instruction for the LLM extractor")
        .prompt()
        .map_err(|e| crate::FlarerError::Config(e.to_string()))
}

async fn prompt_response_format() -> crate::Result<ResponseFormat> {
    let parsing = Select::new(
        "How would you like to provide the response_format?",
        vec![
            "Paste a JSON schema",
            "Load a schema.json file",
            "Build it interactively step by step",
        ],
    )
    .with_help_message("Use arrow keys to navigate")
    .prompt()
    .map_err(|e| crate::FlarerError::Config(e.to_string()))?;

    match parsing {
        "Paste a JSON schema" => paste_format(),
        "Load a schema.json file" => json_file_input(),
        "Build it interactively step by step" => build_response_format_interactive().await,
        _ => Err(crate::FlarerError::Unexpected(
            "unknown response_format option".into(),
        )),
    }
}

fn paste_format() -> crate::Result<ResponseFormat> {
    let raw = Text::new("Enter your JSON response_format:")
        .with_help_message("Press Enter to submit")
        .with_validator(
            |input: &str| match serde_json::from_str::<serde_json::Value>(input) {
                Ok(_) => Ok(Validation::Valid),
                Err(e) => Ok(Validation::Invalid(format!("Invalid JSON: {}", e).into())),
            },
        )
        .prompt()
        .map_err(|e| crate::FlarerError::Config(e.to_string()))?;
    Ok(serde_json::from_str(&raw)?)
}

fn json_file_input() -> crate::Result<ResponseFormat> {
    loop {
        let path = Text::new("Path to schema.json:")
            .with_help_message("e.g. ./schema.json")
            .prompt()
            .map_err(|e| crate::FlarerError::Config(e.to_string()))?;
        if !std::path::Path::new(&path).exists() {
            println!("File does not exist. Try again.");
            continue;
        }
        let content = std::fs::read_to_string(&path)?;
        return Ok(serde_json::from_str(&content)?);
    }
}