collet 0.1.0

Relentless agentic coding orchestrator with zero-drop agent loops
Documentation
//! Channel → project mapping from `[[channel_map]]` config entries.

use std::collections::HashMap;

pub use crate::config::ChannelMappingEntry;

/// Indexed channel map for O(1) lookups.
#[derive(Debug, Clone)]
pub struct ChannelMap {
    mappings: Vec<ChannelMappingEntry>,
    /// (platform, channel) → index into `mappings`.
    index: HashMap<(String, String), usize>,
}

impl ChannelMap {
    pub fn new(entries: Vec<ChannelMappingEntry>) -> Self {
        let mut index = HashMap::new();
        for (i, entry) in entries.iter().enumerate() {
            index.insert((entry.platform.to_lowercase(), entry.channel.clone()), i);
        }
        Self {
            mappings: entries,
            index,
        }
    }

    /// Look up the project directory for a platform + channel pair.
    pub fn resolve(&self, platform: &str, channel: &str) -> Option<&ChannelMappingEntry> {
        self.index
            .get(&(platform.to_lowercase(), channel.to_string()))
            .and_then(|&idx| self.mappings.get(idx))
    }

    /// List all mappings for a given platform.
    pub fn for_platform(&self, platform: &str) -> Vec<&ChannelMappingEntry> {
        let p = platform.to_lowercase();
        self.mappings
            .iter()
            .filter(|e| e.platform.to_lowercase() == p)
            .collect()
    }

    /// List all mappings.
    pub fn all(&self) -> &[ChannelMappingEntry] {
        &self.mappings
    }

    /// Find a project by name (case-insensitive, partial match).
    pub fn find_by_name(&self, name: &str) -> Option<&ChannelMappingEntry> {
        let lower = name.to_lowercase();
        self.mappings.iter().find(|e| {
            e.name.to_lowercase() == lower
                || e.project
                    .as_ref()
                    .is_some_and(|p| p.to_lowercase().ends_with(&lower))
        })
    }

    pub fn is_empty(&self) -> bool {
        self.mappings.is_empty()
    }
}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

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

    fn sample_entries() -> Vec<ChannelMappingEntry> {
        vec![
            ChannelMappingEntry {
                platform: "discord".to_string(),
                channel: "999888".to_string(),
                project: Some("/home/user/my-app".to_string()),
                name: "my-app".to_string(),
                agent: Some("architect".to_string()),
            },
            ChannelMappingEntry {
                platform: "telegram".to_string(),
                channel: "-100123".to_string(),
                project: Some("/home/user/backend".to_string()),
                name: "backend".to_string(),
                agent: None,
            },
        ]
    }

    #[test]
    fn resolve_existing() {
        let map = ChannelMap::new(sample_entries());
        let entry = map.resolve("discord", "999888").unwrap();
        assert_eq!(entry.project, Some("/home/user/my-app".to_string()));
        assert_eq!(entry.agent, Some("architect".to_string()));
    }

    #[test]
    fn resolve_missing() {
        let map = ChannelMap::new(sample_entries());
        assert!(map.resolve("slack", "C01").is_none());
    }

    #[test]
    fn filter_by_platform() {
        let map = ChannelMap::new(sample_entries());
        let tg = map.for_platform("telegram");
        assert_eq!(tg.len(), 1);
        assert_eq!(tg[0].name, "backend");
        assert_eq!(tg[0].agent, None);
    }
}