alfrusco 0.4.3

Utilities for building Alfred workflows with Rust.
Documentation
use alfrusco::{config, AsyncRunnable, Item, Workflow, WorkflowError};
use clap::Parser;
use serde::{Deserialize, Serialize};

#[derive(Parser, Debug)]
struct RandomUserWorkflow {
    pub keyword: Vec<String>,
    #[arg(short, long, env)]
    pub name: Option<String>,
    /// Override API base URL (for testing)
    #[arg(long, default_value = "https://randomuser.me")]
    pub api_url: String,
}

#[tokio::main]
pub async fn main() {
    env_logger::init();
    let command = RandomUserWorkflow::parse();
    alfrusco::execute_async(&config::AlfredEnvProvider, command, &mut std::io::stdout()).await;
}

#[async_trait::async_trait]
impl AsyncRunnable for RandomUserWorkflow {
    type Error = RandomUserError;

    async fn run_async(self, wf: &mut Workflow) -> Result<(), RandomUserError> {
        if let Some(name) = self.name {
            wf.append_item(Item::new(format!("NAME DEFINED AS: '{name}'")));
            return Ok(());
        }

        let query = self.keyword.join(" ");
        wf.set_filter_keyword(query.clone());

        let url = format!(
            "{}/api/?inc=gender,name&results=50&seed=alfrusco",
            self.api_url
        );
        let response = reqwest::get(&url).await?;
        let response: RandomUserResponse = response.json().await?;
        wf.append_items(
            response
                .results
                .into_iter()
                .map(|r| {
                    let title = format!("{} {} {}", r.name.title, r.name.first, r.name.last);
                    Item::new(&title)
                        .valid(false)
                        .autocomplete("workflow:nonsense")
                        .var("NAME", title)
                })
                .collect(),
        );
        Ok(())
    }
}

#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RandomUserResponse {
    pub results: Vec<RandomUser>,
    pub info: serde_json::Value,
}

#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RandomUser {
    pub name: RandomUserName,
}

#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RandomUserName {
    pub title: String,
    pub first: String,
    pub last: String,
}

#[derive(Debug)]
pub enum RandomUserError {
    Reqwest(reqwest::Error),
    Json(serde_json::Error),
}

impl From<reqwest::Error> for RandomUserError {
    fn from(e: reqwest::Error) -> Self {
        Self::Reqwest(e)
    }
}

impl From<serde_json::Error> for RandomUserError {
    fn from(e: serde_json::Error) -> Self {
        Self::Json(e)
    }
}

impl WorkflowError for RandomUserError {}

impl std::fmt::Display for RandomUserError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            RandomUserError::Reqwest(e) => write!(f, "Reqwest error: {e}"),
            RandomUserError::Json(e) => write!(f, "JSON error: {e}"),
        }
    }
}

impl std::error::Error for RandomUserError {
    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
        match self {
            RandomUserError::Reqwest(e) => Some(e),
            RandomUserError::Json(e) => Some(e),
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use wiremock::matchers::{method, path};
    use wiremock::{Mock, MockServer, ResponseTemplate};

    const MOCK_RESPONSE: &str = r#"{
        "results": [
            {"gender": "male", "name": {"title": "Mr", "first": "Fletcher", "last": "Hall"}},
            {"gender": "female", "name": {"title": "Ms", "first": "Jane", "last": "Doe"}}
        ],
        "info": {"seed": "alfrusco", "results": 2, "page": 1, "version": "1.4"}
    }"#;

    #[test]
    fn test_random_user_deserialization() {
        let response: RandomUserResponse = serde_json::from_str(MOCK_RESPONSE).unwrap();
        assert_eq!(response.results.len(), 2);
        assert_eq!(response.results[0].name.title, "Mr");
        assert_eq!(response.results[0].name.first, "Fletcher");
        assert_eq!(response.results[0].name.last, "Hall");
    }

    #[tokio::test]
    async fn test_random_user_response() {
        let server = MockServer::start().await;
        Mock::given(method("GET"))
            .and(path("/api/"))
            .respond_with(
                ResponseTemplate::new(200).set_body_raw(MOCK_RESPONSE, "application/json"),
            )
            .mount(&server)
            .await;

        let command = RandomUserWorkflow {
            keyword: vec![],
            name: None,
            api_url: server.uri(),
        };

        let mut buffer = Vec::new();
        let dir = tempfile::tempdir().unwrap().keep();
        alfrusco::execute_async(&config::TestingProvider(dir), command, &mut buffer).await;
        let output = String::from_utf8(buffer).unwrap();
        assert!(output.contains("\"title\":\"Mr Fletcher Hall\""));
        assert!(output.contains("\"title\":\"Ms Jane Doe\""));
    }

    #[tokio::test]
    async fn test_random_user_name_override() {
        let command = RandomUserWorkflow {
            keyword: vec![],
            name: Some("Alice".to_string()),
            api_url: String::new(),
        };

        let mut buffer = Vec::new();
        let dir = tempfile::tempdir().unwrap().keep();
        alfrusco::execute_async(&config::TestingProvider(dir), command, &mut buffer).await;
        let output = String::from_utf8(buffer).unwrap();
        assert!(output.contains("NAME DEFINED AS: 'Alice'"));
    }
}