browsing 0.1.4

Lightweight MCP/API for browser automation: navigate, get content (text), screenshot. Parallelism via RwLock.
Documentation
//! CLI interface for browsing

use anyhow::Result;
use browsing::browser::profile::BrowserProfile;
use browsing::{Browser, Config};
use clap::{Parser, Subcommand};
use std::path::PathBuf;
use tracing::info;

#[derive(Parser)]
#[command(name = "browsing")]
#[command(about = "Autonomous web browsing for AI agents", long_about = None)]
#[command(version)]
struct Cli {
    #[command(subcommand)]
    command: Commands,

    #[arg(short, long, global = true)]
    config: Option<PathBuf>,

    #[arg(short, long, global = true)]
    verbose: bool,
}

#[derive(Subcommand)]
enum Commands {
    #[command(about = "Run an autonomous browsing task")]
    Run {
        #[arg(help = "Task description for the agent")]
        task: String,

        #[arg(short, long, help = "Starting URL")]
        url: Option<String>,

        #[arg(long, help = "Maximum number of steps", default_value = "100")]
        max_steps: u32,

        #[arg(long, help = "Run browser in headless mode")]
        headless: bool,

        #[arg(long, help = "Enable vision capabilities")]
        vision: bool,
    },

    #[command(about = "Launch a browser and connect to it")]
    Launch {
        #[arg(long, help = "Run browser in headless mode")]
        headless: bool,

        #[arg(long, help = "User data directory")]
        user_data_dir: Option<PathBuf>,
    },

    #[command(about = "Connect to an existing browser via CDP URL")]
    Connect {
        #[arg(help = "CDP WebSocket URL (e.g., ws://localhost:9222/devtools/browser/...)")]
        cdp_url: String,
    },

    #[command(about = "Render a webpage URL as minimal text output")]
    Render {
        #[arg(help = "Webpage URL to render")]
        url: String,

        #[arg(long, help = "Run browser with UI (disables headless mode)")]
        headed: bool,

        #[arg(
            long,
            help = "Maximum number of rendered characters",
            default_value = "4000"
        )]
        max_chars: usize,
    },
}

fn normalize_rendered_text(text: &str) -> String {
    text.split_whitespace().collect::<Vec<_>>().join(" ")
}

fn truncate_rendered_text(text: &str, max_chars: usize) -> String {
    if text.chars().count() <= max_chars {
        return text.to_string();
    }

    let mut truncated = String::new();
    for c in text.chars().take(max_chars) {
        truncated.push(c);
    }
    truncated.push_str("...");
    truncated
}

fn format_rendered_content(raw_text: &str, max_chars: usize) -> String {
    let normalized = normalize_rendered_text(raw_text);
    if normalized.is_empty() {
        "(No text content extracted from page)".to_string()
    } else {
        truncate_rendered_text(&normalized, max_chars)
    }
}

#[tokio::main]
async fn main() -> Result<()> {
    let cli = Cli::parse();

    browsing::init();

    if cli.verbose {
        unsafe {
            std::env::set_var("RUST_LOG", "browsing=debug,info");
        }
    }

    let _config = if let Some(config_path) = cli.config {
        Config::load_from_file(config_path)?
    } else {
        Config::from_env()
    };

    match cli.command {
        Commands::Run {
            task,
            url,
            max_steps: _,
            headless,
            vision: _,
        } => {
            info!("Starting autonomous browsing task: {}", task);

            println!("\n=== Autonomous Browsing ===");
            println!("Task: {}", task);
            println!("\nNote: Full agent implementation requires an LLM provider.");
            println!("Please implement the ChatModel trait for your LLM.");
            println!("See docs/LIBRARY_USAGE.md for details.");

            // For now, just demonstrate browser capabilities
            let mut profile = BrowserProfile::default();
            profile.headless = Some(headless);

            let mut browser = Browser::new(profile);
            browser.start().await?;
            info!("Browser launched successfully");

            if let Some(start_url) = url {
                browser.navigate(&start_url).await?;
                info!("Navigated to: {}", start_url);

                let current_url = browser.get_current_url().await?;
                let title = browser.get_current_page_title().await?;
                println!("\n✓ Browser ready");
                println!("  URL: {}", current_url);
                println!("  Title: {}", title);
            }

            println!("\nTo use the full agent, implement ChatModel trait for your LLM provider.");
            let _ = browser.stop().await;
        }

        Commands::Launch {
            headless,
            user_data_dir,
        } => {
            let mut profile = BrowserProfile::default();
            profile.headless = Some(headless);
            if let Some(dir) = user_data_dir {
                profile.user_data_dir = Some(dir);
            }

            let mut browser = Browser::new(profile);
            browser.start().await?;

            println!("Browser launched successfully!");
            println!("\nPress Ctrl+C to close the browser...");

            tokio::signal::ctrl_c().await?;
            println!("\nClosing browser...");
            let _ = browser.stop().await;
        }

        Commands::Connect { cdp_url } => {
            info!("Connecting to browser at: {}", cdp_url);
            let profile = BrowserProfile::default();
            let mut browser = Browser::new(profile).with_cdp_url(cdp_url.clone());
            browser.start().await?;

            println!("Connected to browser successfully!");
            println!("CDP URL: {}", cdp_url);
            println!("\nPress Ctrl+C to disconnect...");

            tokio::signal::ctrl_c().await?;
            println!("\nDisconnecting...");
            let _ = browser.stop().await;
        }

        Commands::Render {
            url,
            headed,
            max_chars,
        } => {
            info!("Rendering webpage as text: {}", url);

            let mut profile = BrowserProfile::default();
            profile.headless = Some(!headed);

            let mut browser = Browser::new(profile);
            browser.start().await?;

            let render_result = async {
                browser.navigate(&url).await?;

                let session = browser.get_session_info().await?;
                let page = browser.get_page()?;
                let page_text = page
                    .evaluate("document.body ? document.body.innerText : document.documentElement.innerText")
                    .await?;
                let rendered = format_rendered_content(&page_text, max_chars);

                println!("\n=== Minimal CLI Browser Render ===");
                println!("URL: {}", session.url);
                println!("Title: {}", session.title);
                println!("\n{}", rendered);

                Ok::<(), anyhow::Error>(())
            }
            .await;

            let _ = browser.stop().await;
            render_result?;
        }
    }

    Ok(())
}

#[allow(dead_code)]
fn create_llm_from_config(
    _config: &browsing::config::LlmConfig,
) -> Result<Box<dyn browsing::ChatModel>> {
    Err(anyhow::anyhow!(
        "LLM implementation required. Please implement ChatModel trait for your LLM provider.\n\
         Example: Use watsonx-rs crate with ibm/granite-4-h-small model.\n\
         Set LLM_API_KEY and LLM_MODEL environment variables."
    ))
}

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

    #[test]
    fn test_render_command_parsing() {
        let cli = Cli::try_parse_from([
            "browsing",
            "render",
            "https://example.com",
            "--max-chars",
            "120",
            "--headed",
        ])
        .expect("render command should parse");

        match cli.command {
            Commands::Render {
                url,
                headed,
                max_chars,
            } => {
                assert_eq!(url, "https://example.com");
                assert!(headed);
                assert_eq!(max_chars, 120);
            }
            _ => panic!("expected render command"),
        }
    }

    #[test]
    fn test_format_rendered_content_normalizes_whitespace() {
        let output = format_rendered_content("hello\n\n   world\tfrom   browser", 100);
        assert_eq!(output, "hello world from browser");
    }

    #[test]
    fn test_format_rendered_content_truncates() {
        let output = format_rendered_content("abcdefghijklmnopqrstuvwxyz", 10);
        assert_eq!(output, "abcdefghij...");
    }
}