rusty-fmp 0.5.3

JSON CLI and client library for Financial Modeling Prep
Documentation
//! CLI output rendering.

use serde_json::Value;

use crate::error::{Error, Result};

#[derive(Debug)]
pub(super) struct EmptyResultContext {
    endpoint: &'static str,
    symbol: String,
    search_query: String,
}

#[derive(Debug)]
pub(super) struct CommandPayload {
    #[cfg(test)]
    pub(super) endpoint: &'static str,
    #[cfg(test)]
    pub(super) query: Value,
    pub(super) data: Value,
    empty_result_context: Option<EmptyResultContext>,
}

impl CommandPayload {
    #[cfg(test)]
    pub(super) fn new(endpoint: &'static str, query: Value, data: Value) -> Self {
        Self {
            endpoint,
            query,
            data,
            empty_result_context: None,
        }
    }

    #[cfg(not(test))]
    pub(super) fn new(_endpoint: &'static str, _query: Value, data: Value) -> Self {
        Self {
            data,
            empty_result_context: None,
        }
    }

    pub(super) fn symbol_lookup(
        mut self,
        endpoint: &'static str,
        symbol: impl Into<String>,
    ) -> Self {
        let symbol = symbol.into();
        self.empty_result_context = Some(EmptyResultContext {
            endpoint,
            search_query: symbol.clone(),
            symbol,
        });
        self
    }

    pub(super) fn symbol_lookup_with_search_query(
        mut self,
        endpoint: &'static str,
        symbol: impl Into<String>,
        search_query: impl Into<String>,
    ) -> Self {
        self.empty_result_context = Some(EmptyResultContext {
            endpoint,
            symbol: symbol.into(),
            search_query: search_query.into(),
        });
        self
    }

    pub(super) fn reject_strict_empty(&self) -> Result<()> {
        if let Some(context) = &self.empty_result_context
            && is_empty_payload(&self.data)
        {
            return Err(Error::EmptyResult {
                symbol: context.symbol.clone(),
                search_query: context.search_query.clone(),
                endpoint: context.endpoint,
            });
        }

        Ok(())
    }
}

fn is_empty_payload(data: &Value) -> bool {
    match data {
        Value::Null => true,
        Value::Array(values) => values.is_empty(),
        _ => false,
    }
}

pub(super) fn render_output(payload: CommandPayload) -> Result<Option<String>> {
    Ok(Some(serde_json::to_string(&payload.data)?))
}

#[cfg(test)]
mod tests {
    use serde_json::json;

    use super::CommandPayload;

    #[test]
    fn strict_empty_rejects_null_symbol_lookup() {
        let payload = CommandPayload::new("/quote", json!({ "symbol": "FAKE" }), json!(null))
            .symbol_lookup("/quote", "FAKE");

        let error = payload.reject_strict_empty().unwrap_err();

        assert_eq!(error.kind(), "empty_result");
        assert!(error.to_string().contains("fmp-agent search FAKE"));
    }

    #[test]
    fn batch_symbol_lookup_suggests_first_symbol_search() {
        let payload =
            CommandPayload::new("/quote", json!({ "symbols": ["FAKE", "NOPE"] }), json!([]))
                .symbol_lookup_with_search_query("/quote", "FAKE,NOPE", "FAKE");

        let message = payload.reject_strict_empty().unwrap_err().to_string();

        assert!(message.contains("symbol FAKE,NOPE"));
        assert!(message.contains("fmp-agent search FAKE"));
        assert!(!message.contains("fmp-agent search FAKE,NOPE"));
    }

    #[test]
    fn strict_empty_allows_non_empty_symbol_lookup() {
        let payload = CommandPayload::new(
            "/quote",
            json!({ "symbol": "AAPL" }),
            json!({ "symbol": "AAPL" }),
        )
        .symbol_lookup("/quote", "AAPL");

        payload.reject_strict_empty().unwrap();
    }

    #[test]
    fn strict_empty_allows_empty_untagged_payload() {
        let payload = CommandPayload::new("/search-symbol", json!({ "query": "FAKE" }), json!([]));

        payload.reject_strict_empty().unwrap();
    }
}