tail-fin-daemon 0.5.0

Long-running browser-session daemon for tail-fin (tfd binary). Keeps Chrome tabs warm across invocations via a Unix-socket protocol; registers Site implementations through a runtime Arc<dyn Site> registry.
Documentation
use night_fury_core::BrowserSession;
use night_fury_daemon_core::protocol::Response;
use serde_json::Value;
use tail_fin_sa::{Period, SeekingAlphaApi, SeekingAlphaClient};

pub async fn handle(session: &BrowserSession, id: &str, cmd: &str, params: &Value) -> Response {
    // BrowserSession is Clone; clone it to construct a fresh client for this call.
    let client = SeekingAlphaClient::new(session.clone());
    match cmd {
        "sa.quote" => match required_str(params, "ticker") {
            Ok(ticker) => to_response(id, client.quote(&ticker).await),
            Err(e) => Response::err(id, e),
        },
        "sa.income-statement" => match (required_str(params, "ticker"), parse_period(params)) {
            (Ok(ticker), Ok(period)) => {
                to_response(id, client.income_statement(&ticker, period).await)
            }
            (Err(e), _) | (_, Err(e)) => Response::err(id, e),
        },
        "sa.balance-sheet" => match (required_str(params, "ticker"), parse_period(params)) {
            (Ok(ticker), Ok(period)) => {
                to_response(id, client.balance_sheet(&ticker, period).await)
            }
            (Err(e), _) | (_, Err(e)) => Response::err(id, e),
        },
        "sa.cash-flow" => match (required_str(params, "ticker"), parse_period(params)) {
            (Ok(ticker), Ok(period)) => to_response(id, client.cash_flow(&ticker, period).await),
            (Err(e), _) | (_, Err(e)) => Response::err(id, e),
        },
        "sa.news" => handle_articles(id, &client, params, "news").await,
        "sa.analysis" => handle_articles(id, &client, params, "analysis").await,
        "sa.article" => match required_str(params, "id") {
            Ok(aid) => to_response(id, client.article(&aid).await),
            Err(e) => Response::err(id, e),
        },
        other => Response::err(id, format!("unknown sa cmd: {other}")),
    }
}

async fn handle_articles(
    id: &str,
    client: &SeekingAlphaClient,
    params: &Value,
    kind: &str,
) -> Response {
    let ticker = match required_str(params, "ticker") {
        Ok(t) => t,
        Err(e) => return Response::err(id, e),
    };
    let count = params.get("count").and_then(|v| v.as_u64()).unwrap_or(20) as usize;
    let since = params.get("since").and_then(|v| v.as_i64());
    let until = params.get("until").and_then(|v| v.as_i64());

    let result = if kind == "news" {
        client.news(&ticker, count, since, until).await
    } else {
        client.analysis(&ticker, count, since, until).await
    };
    to_response(id, result)
}

fn required_str(params: &Value, key: &str) -> Result<String, String> {
    params
        .get(key)
        .and_then(|v| v.as_str())
        .map(String::from)
        .ok_or_else(|| format!("missing required param '{key}'"))
}

fn parse_period(params: &Value) -> Result<Period, String> {
    let s = params
        .get("period")
        .and_then(|v| v.as_str())
        .unwrap_or("annual");
    match s.to_lowercase().as_str() {
        "annual" | "a" => Ok(Period::Annual),
        "quarterly" | "q" => Ok(Period::Quarterly),
        other => Err(format!("invalid period '{other}' (annual|quarterly)")),
    }
}

fn to_response<T: serde::Serialize>(
    id: &str,
    result: Result<T, tail_fin_common::TailFinError>,
) -> Response {
    match result {
        Ok(value) => match serde_json::to_value(&value) {
            Ok(json) => Response::ok(id, json),
            Err(e) => Response::err(id, format!("serialize error: {e}")),
        },
        Err(e) => Response::err(id, e.to_string()),
    }
}