raffi 0.21.0

A simple fuzzel launcher based on YAML file
Documentation
use std::process::{Command, Stdio};

use fuzzy_matcher::skim::SkimMatcherV2;
use fuzzy_matcher::FuzzyMatcher;
use iced::Task;
use serde::Deserialize;

use super::state::Message;
use crate::TextSnippet;

#[derive(Debug, Deserialize)]
struct SnippetCommandItem {
    title: String,
    arg: Option<String>,
}

#[derive(Debug, Deserialize)]
struct SnippetCommandResponse {
    items: Vec<SnippetCommandItem>,
}

pub(super) fn execute_text_snippet_command(
    command: String,
    args: Vec<String>,
    generation: u64,
) -> Task<Message> {
    Task::perform(
        async move {
            crate::debug_log!("snippets: executing command={command:?} args={args:?}");
            let output = Command::new(&command)
                .args(&args)
                .stdin(Stdio::null())
                .stderr(Stdio::null())
                .output();

            match output {
                Ok(output) if output.status.success() => {
                    let stdout = String::from_utf8_lossy(&output.stdout);
                    match serde_json::from_str::<SnippetCommandResponse>(&stdout) {
                        Ok(response) => {
                            crate::debug_log!(
                                "snippets: got {} items from {command:?}",
                                response.items.len()
                            );
                            Ok(response
                                .items
                                .into_iter()
                                .map(|item| TextSnippet {
                                    name: item.title,
                                    value: item.arg.unwrap_or_default(),
                                })
                                .collect())
                        }
                        Err(error) => Err(format!("Invalid JSON: {}", error)),
                    }
                }
                Ok(output) => Err(format!("Command exited with status {}", output.status)),
                Err(error) => Err(format!("Failed to execute: {}", error)),
            }
        },
        move |result| Message::TextSnippetCommandResult(generation, result),
    )
}

pub(super) fn filter_snippets(snippets: &[TextSnippet], query: &str) -> Vec<usize> {
    if query.is_empty() {
        return (0..snippets.len()).collect();
    }

    let matcher = SkimMatcherV2::default();
    let mut scored: Vec<(usize, i64)> = snippets
        .iter()
        .enumerate()
        .filter_map(|(index, snippet)| {
            matcher
                .fuzzy_match(&snippet.name, query)
                .map(|score| (index, score))
        })
        .collect();
    scored.sort_by(|a, b| b.1.cmp(&a.1));
    let result: Vec<usize> = scored.into_iter().map(|(index, _)| index).collect();
    crate::debug_log!("snippets: filter query={query:?} results={}", result.len());
    result
}

#[cfg(test)]
mod tests {
    use super::filter_snippets;
    use crate::TextSnippet;

    #[test]
    fn test_filter_snippets_empty_query_returns_all() {
        let snippets = vec![
            TextSnippet {
                name: "Alpha".to_string(),
                value: "a".to_string(),
            },
            TextSnippet {
                name: "Beta".to_string(),
                value: "b".to_string(),
            },
            TextSnippet {
                name: "Gamma".to_string(),
                value: "c".to_string(),
            },
        ];

        let result = filter_snippets(&snippets, "");
        assert_eq!(result, vec![0, 1, 2]);
    }

    #[test]
    fn test_filter_snippets_partial_query() {
        let snippets = vec![
            TextSnippet {
                name: "Personal Email".to_string(),
                value: "user@example.com".to_string(),
            },
            TextSnippet {
                name: "Work Email".to_string(),
                value: "user@company.com".to_string(),
            },
            TextSnippet {
                name: "Phone Number".to_string(),
                value: "+1234567890".to_string(),
            },
        ];

        let result = filter_snippets(&snippets, "work");
        assert!(!result.is_empty());
        assert!(result.contains(&1));
    }

    #[test]
    fn test_filter_snippets_no_match() {
        let snippets = vec![
            TextSnippet {
                name: "Alpha".to_string(),
                value: "a".to_string(),
            },
            TextSnippet {
                name: "Beta".to_string(),
                value: "b".to_string(),
            },
        ];

        let result = filter_snippets(&snippets, "zzzzz");
        assert!(result.is_empty());
    }
}