kinjo 0.1.0

Kinjo: mDNS TUI and commands launch for local network services
Documentation
use std::collections::BTreeSet;

use crate::discovery::{Entry, GroupingMode};

#[derive(Debug, Clone)]
pub struct FilterState {
    pub text_query: String,
    pub enabled_service_types: BTreeSet<String>,
    pub disabled_service_types: BTreeSet<String>,
    pub host_filter: Option<String>,
    pub grouping: GroupingMode,
}

impl Default for FilterState {
    fn default() -> Self {
        Self {
            text_query: String::new(),
            enabled_service_types: BTreeSet::new(),
            disabled_service_types: BTreeSet::new(),
            host_filter: None,
            grouping: GroupingMode::LogicalService,
        }
    }
}

impl FilterState {
    pub fn sync_service_types(&mut self, records: &[Entry]) {
        for record in records {
            if !self.disabled_service_types.contains(&record.service_type) {
                self.enabled_service_types
                    .insert(record.service_type.clone());
            }
        }
    }

    pub fn discovered_types(records: &[Entry]) -> Vec<String> {
        records
            .iter()
            .map(|record| record.service_type.clone())
            .collect::<BTreeSet<_>>()
            .into_iter()
            .collect()
    }

    pub fn toggle_service_type(&mut self, service_type: &str) {
        if self.enabled_service_types.remove(service_type) {
            self.disabled_service_types.insert(service_type.to_string());
        } else {
            self.enabled_service_types.insert(service_type.to_string());
            self.disabled_service_types.remove(service_type);
        }
    }

    pub fn clear_text(&mut self) {
        self.text_query.clear();
    }

    pub fn set_host_filter(&mut self, host: impl Into<String>) {
        self.host_filter = Some(host.into());
    }

    pub fn clear_host_filter(&mut self) {
        self.host_filter = None;
    }

    pub fn is_active(&self) -> bool {
        !self.text_query.trim().is_empty()
            || self.host_filter.is_some()
            || !self.disabled_service_types.is_empty()
    }

    pub fn apply(&self, records: &[Entry]) -> Vec<Entry> {
        records
            .iter()
            .filter(|record| self.enabled_service_types.contains(&record.service_type))
            .filter(|record| match &self.host_filter {
                Some(host) => record.hostname.as_deref() == Some(host.as_str()),
                None => true,
            })
            .filter(|record| {
                self.text_query.trim().is_empty()
                    || fuzzy_match(&record.searchable_text(), self.text_query.trim())
            })
            .cloned()
            .collect()
    }
}

pub fn fuzzy_match(haystack: &str, needle: &str) -> bool {
    let mut chars = haystack.chars().flat_map(char::to_lowercase);
    for needle_char in needle.chars().flat_map(char::to_lowercase) {
        if !chars.any(|haystack_char| haystack_char == needle_char) {
            return false;
        }
    }
    true
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn fuzzy_match_accepts_subsequence() {
        assert!(fuzzy_match("Kitchen Printer _ipp._tcp", "kpr"));
        assert!(!fuzzy_match("Kitchen Printer", "zx"));
    }

    #[test]
    fn type_filter_and_text_filter_combine() {
        let ssh = Entry::new("alpha", "_ssh._tcp", "local");
        let http = Entry::new("beta", "_http._tcp", "local");
        let mut filter = FilterState::default();
        filter.sync_service_types(&[ssh.clone(), http.clone()]);
        filter.toggle_service_type("_http._tcp");
        filter.text_query = "alp".to_string();

        let visible = filter.apply(&[ssh, http]);
        assert_eq!(visible.len(), 1);
        assert_eq!(visible[0].name, "alpha");
    }

    #[test]
    fn discovered_types_are_unique_and_sorted() {
        let records = vec![
            Entry::new("printer", "_ipp._tcp", "local"),
            Entry::new("shell", "_ssh._tcp", "local"),
            Entry::new("other printer", "_ipp._tcp", "local"),
        ];

        assert_eq!(
            FilterState::discovered_types(&records),
            vec!["_ipp._tcp".to_string(), "_ssh._tcp".to_string()]
        );
    }

    #[test]
    fn sync_service_types_does_not_reenable_disabled_types() {
        let mut filter = FilterState::default();
        filter.sync_service_types(&[Entry::new("alpha", "_ssh._tcp", "local")]);
        filter.toggle_service_type("_ssh._tcp");

        filter.sync_service_types(&[
            Entry::new("beta", "_ssh._tcp", "local"),
            Entry::new("site", "_http._tcp", "local"),
        ]);

        assert!(!filter.enabled_service_types.contains("_ssh._tcp"));
        assert!(filter.disabled_service_types.contains("_ssh._tcp"));
        assert!(filter.enabled_service_types.contains("_http._tcp"));
    }

    #[test]
    fn toggling_disabled_type_reenables_it() {
        let mut filter = FilterState::default();
        filter.sync_service_types(&[Entry::new("alpha", "_ssh._tcp", "local")]);

        filter.toggle_service_type("_ssh._tcp");
        filter.toggle_service_type("_ssh._tcp");

        assert!(filter.enabled_service_types.contains("_ssh._tcp"));
        assert!(!filter.disabled_service_types.contains("_ssh._tcp"));
    }

    #[test]
    fn host_filter_matches_exact_hostname_and_can_be_cleared() {
        let mut alpha = Entry::new("alpha", "_ssh._tcp", "local");
        alpha.hostname = Some("alpha.local".to_string());
        let mut beta = Entry::new("beta", "_ssh._tcp", "local");
        beta.hostname = Some("beta.local".to_string());
        let records = vec![alpha, beta];

        let mut filter = FilterState::default();
        filter.sync_service_types(&records);
        filter.set_host_filter("alpha.local");

        assert!(filter.is_active());
        assert_eq!(filter.apply(&records)[0].name, "alpha");

        filter.clear_host_filter();
        assert!(!filter.is_active());
        assert_eq!(filter.apply(&records).len(), 2);
    }

    #[test]
    fn fuzzy_match_accepts_empty_needle() {
        assert!(fuzzy_match("anything", ""));
    }

    #[test]
    fn clear_text_resets_query_and_active_state() {
        let mut filter = FilterState {
            text_query: "alpha".to_string(),
            ..Default::default()
        };
        assert!(filter.is_active());

        filter.clear_text();

        assert!(filter.text_query.is_empty());
        assert!(!filter.is_active());
    }

    #[test]
    fn disabling_every_type_hides_all_records() {
        let ssh = Entry::new("alpha", "_ssh._tcp", "local");
        let mut filter = FilterState::default();
        filter.sync_service_types(std::slice::from_ref(&ssh));
        filter.toggle_service_type("_ssh._tcp");

        assert!(filter.is_active());
        assert!(filter.apply(&[ssh]).is_empty());
    }

    #[test]
    fn whitespace_only_query_is_not_active_and_matches_all() {
        let ssh = Entry::new("alpha", "_ssh._tcp", "local");
        let mut filter = FilterState::default();
        filter.sync_service_types(std::slice::from_ref(&ssh));
        filter.text_query = "   ".to_string();

        assert!(!filter.is_active());
        assert_eq!(filter.apply(&[ssh]).len(), 1);
    }

    #[test]
    fn text_query_matches_searchable_txt_and_instance_fields() {
        let mut printer = Entry::new("printer", "_ipp._tcp", "local");
        printer.hostname = Some("print.local".to_string());
        printer.port = Some(631);
        printer
            .txt
            .insert("note".to_string(), "third floor".to_string());
        let mut filter = FilterState::default();
        filter.sync_service_types(&[printer.clone()]);
        filter.text_query = "flr".to_string();

        assert_eq!(filter.apply(&[printer]).len(), 1);
    }
}