nomad-cli 0.1.1

Nomad CLI - extract structured web content
Documentation
use std::io::Write;

use crate::cli::BrowseArgs;
use crate::client::{self, Envelope};

pub fn run(args: BrowseArgs) -> Result<(), Box<dyn std::error::Error>> {
    // Resolve format string to numeric ID
    let fmt_id = resolve_format(&args.format);

    // Check if daemon is available
    if !args.oneshot && client::daemon_alive(args.port) {
        browse_via_daemon(&args, fmt_id)
    } else {
        browse_oneshot(&args, fmt_id)
    }
}

fn browse_via_daemon(args: &BrowseArgs, fmt: u8) -> Result<(), Box<dyn std::error::Error>> {
    let url = format!("http://127.0.0.1:{}/api/navigate", args.port);
    let body = serde_json::json!({"url": args.url, "format": fmt});
    let client = reqwest::blocking::Client::new();
    let resp = client.post(&url).json(&body).send()?;
    let env: Envelope = resp.json()?;

    if !env.success {
        let err = env.error.unwrap_or_else(|| "request failed".into());
        eprintln!("API error: {err}");
        // Fall back to oneshot
        return browse_oneshot(args, fmt);
    }

    let output = format_envelope(&env.data, fmt);
    write_output(&output, args.output.as_deref())
}

fn browse_oneshot(args: &BrowseArgs, fmt: u8) -> Result<(), Box<dyn std::error::Error>> {
    let resolved = nomad_core::omnibox::resolve(&args.url);

    // Fetch via blocking HTTP client
    let client = nomad_core::net::build_blocking_client()
        .ok_or_else(|| "failed to init HTTP client (no TLS?)")?;
    let resp = client.get(&resolved).send()?;
    let body = resp.bytes()?;
    let final_url = resolved;

    // Parse
    let meta_level = nomad_common::ecs::MetadataLevel::Minimal;
    let (mut ents, meta_extracted, content_flags, form_entries) =
        nomad_core::parse::parse_html_full_with_level(&body, meta_level)?;

    // CSS
    let html = String::from_utf8_lossy(&body);
    nomad_core::css::parse_and_apply_css(&html, &mut ents);

    // Layout
    let (total_w, total_h) = nomad_core::layout::solve(&mut ents, &nomad_core::layout::LayoutConfig::default());
    ents.shrink_to_fit();

    // Build frame
    let frame = nomad_common::ecs::EcsFrame {
        tab_id: 0,
        viewport: nomad_common::types::Viewport { x: 0., y: 0., width: 800., height: 600. },
        scroll_offset: (0., 0.),
        total_extent: (total_w, total_h),
        entities: ents,
        content_flags,
        form_entries,
    };
    let meta = nomad_common::ecs::PageMetadata {
        title: meta_extracted.title,
        url: final_url,
        description: String::new(),
        og_title: String::new(),
        og_image: String::new(),
        og_description: String::new(),
        canonical_url: String::new(),
        json_ld: Vec::new(),
    };

    let mode = nomad_core::formatter::OutputMode::from_u8(fmt);
    let bytes = nomad_core::formatter::FormatEngine::format(&frame, Some(&meta), mode);
    write_output(&bytes, args.output.as_deref())
}

fn format_envelope(data: &serde_json::Value, fmt: u8) -> Vec<u8> {
    if fmt == 0 {
        // RawBincode: hex-encoded in JSON response
        if let Some(hex) = data.get("body_hex").and_then(|v| v.as_str()) {
            return hex::decode(hex).unwrap_or_default();
        }
    }
    if let Some(raw) = data.get("raw").and_then(|v| v.as_str()) {
        return raw.as_bytes().to_vec();
    }
    if let Some(d) = data.get("data") {
        return serde_json::to_string_pretty(d).unwrap_or_default().into_bytes();
    }
    serde_json::to_string_pretty(data).unwrap_or_default().into_bytes()
}

fn write_output(data: &[u8], path: Option<&str>) -> Result<(), Box<dyn std::error::Error>> {
    match path {
        Some(p) => Ok(std::fs::write(p, data)?),
        None => {
            if data.first().copied() == Some(0) || data.iter().any(|b| *b == 0 && *b < 32) {
                // Binary format — write to stdout as bytes
                std::io::stdout().write_all(data)?;
            } else {
                // Text format — try to print as UTF-8
                let s = String::from_utf8_lossy(data);
                print!("{s}");
            }
            Ok(())
        }
    }
}

fn resolve_format(fmt: &str) -> u8 {
    if let Ok(n) = fmt.parse::<u8>() {
        return n.clamp(0, 10);
    }
    match fmt.to_lowercase().as_str() {
        "raw" => 0, "json" => 1, "markdown" => 2, "md" => 2,
        "agent" => 3, "summary" => 4, "minimal" => 5,
        "section" => 6, "a11y" => 7, "tree" => 8, "reader" => 9,
        "form" => 10,
        _ => 4,
    }
}