robson-core 0.1.0

Rust async agent orchestrator for automated development workflows
Documentation
#[cfg(test)]
mod tests {
    use anyhow::Result;
    use async_trait::async_trait;
    use sea_orm::DatabaseConnection;
    use std::sync::Arc;

    use crate::plugin::{MessageEvent, SensoriumLoop, Worker};

    struct EchoWorker;

    #[async_trait]
    impl Worker for EchoWorker {
        fn name(&self) -> &'static str {
            "echo"
        }

        fn description(&self) -> &'static str {
            "echo worker for tests"
        }

        fn example(&self) -> &'static str {
            "/echo"
        }

        async fn handle(
            &self,
            _db: DatabaseConnection,
            _msg: MessageEvent,
            _args: std::collections::HashMap<String, String>,
        ) -> Result<bool> {
            Ok(true)
        }
    }

    #[test]
    fn register_worker_accepts_valid_pattern() {
        let mut sloop = SensoriumLoop::new();
        let result = sloop.register_worker(r"^hello", Arc::new(EchoWorker));
        assert!(result.is_ok(), "valid pattern should register successfully");
    }

    #[test]
    fn register_worker_rejects_duplicate_pattern() {
        let mut sloop = SensoriumLoop::new();
        sloop
            .register_worker(r"^hello", Arc::new(EchoWorker))
            .unwrap();
        let result = sloop.register_worker(r"^hello", Arc::new(EchoWorker));
        assert!(result.is_err(), "duplicate pattern should return Err");
    }

    #[test]
    fn register_worker_rejects_invalid_regex() {
        let mut sloop = SensoriumLoop::new();
        let result = sloop.register_worker(r"[invalid", Arc::new(EchoWorker));
        assert!(result.is_err(), "invalid regex pattern should return Err");
    }

    #[test]
    fn register_worker_allows_different_patterns() {
        let mut sloop = SensoriumLoop::new();
        sloop
            .register_worker(r"^hello", Arc::new(EchoWorker))
            .unwrap();
        let result = sloop.register_worker(r"^world", Arc::new(EchoWorker));
        assert!(result.is_ok(), "different patterns should both register");
    }

    #[test]
    fn register_worker_rejects_pattern_matching_help() {
        let mut sloop = SensoriumLoop::new();
        // This pattern matches "/help" so it should be rejected
        let result = sloop.register_worker(r"(?i)/help", Arc::new(EchoWorker));
        assert!(result.is_err(), "pattern matching /help should be rejected");
        let err = result.unwrap_err();
        assert!(
            err.to_string().contains("reserved"),
            "error should mention reserved: {err}"
        );
    }

    #[test]
    fn register_worker_rejects_uppercase_help_pattern() {
        let mut sloop = SensoriumLoop::new();
        let result = sloop.register_worker(r"^/HELP$", Arc::new(EchoWorker));
        assert!(
            result.is_err(),
            "uppercase /HELP pattern should be rejected"
        );
    }

    use std::sync::atomic::{AtomicUsize, Ordering};

    struct CountingWorker {
        pub name: &'static str,
        pub call_count: Arc<AtomicUsize>,
    }

    #[async_trait]
    impl Worker for CountingWorker {
        fn name(&self) -> &'static str {
            self.name
        }

        fn description(&self) -> &'static str {
            "counting worker for tests"
        }

        fn example(&self) -> &'static str {
            "/count"
        }

        async fn handle(
            &self,
            _db: DatabaseConnection,
            _msg: MessageEvent,
            _args: std::collections::HashMap<String, String>,
        ) -> Result<bool> {
            self.call_count.fetch_add(1, Ordering::SeqCst);
            Ok(true)
        }
    }

    fn find_matching_worker<'a>(
        workers: &'a [crate::plugin::WorkerRegistration],
        content: &str,
    ) -> Option<&'a crate::plugin::WorkerRegistration> {
        workers.iter().find(|r| r.pattern.is_match(content))
    }

    #[test]
    fn dispatch_selects_first_matching_worker() {
        let counter_a = Arc::new(AtomicUsize::new(0));
        let counter_b = Arc::new(AtomicUsize::new(0));

        let mut sloop = SensoriumLoop::new();
        sloop
            .register_worker(
                r"^/deploy",
                Arc::new(CountingWorker {
                    name: "deploy-worker",
                    call_count: counter_a.clone(),
                }),
            )
            .unwrap();
        sloop
            .register_worker(
                r"^/dev",
                Arc::new(CountingWorker {
                    name: "generic-worker",
                    call_count: counter_b.clone(),
                }),
            )
            .unwrap();

        let workers = sloop.workers();
        let matched = find_matching_worker(workers, "/deploy now");
        assert!(matched.is_some(), "should find a match for '/deploy now'");
        assert_eq!(
            matched.unwrap().worker.name(),
            "deploy-worker",
            "first matching worker should be deploy-worker"
        );
    }

    #[test]
    fn dispatch_returns_none_for_no_match() {
        let counter = Arc::new(AtomicUsize::new(0));
        let mut sloop = SensoriumLoop::new();
        sloop
            .register_worker(
                r"^/deploy",
                Arc::new(CountingWorker {
                    name: "deploy-worker",
                    call_count: counter.clone(),
                }),
            )
            .unwrap();

        let workers = sloop.workers();
        let matched = find_matching_worker(workers, "hello world");
        assert!(matched.is_none(), "should not match when no pattern fits");
    }

    #[test]
    fn dispatch_respects_registration_order() {
        let counter_a = Arc::new(AtomicUsize::new(0));
        let counter_b = Arc::new(AtomicUsize::new(0));

        let mut sloop = SensoriumLoop::new();
        sloop
            .register_worker(
                r"run",
                Arc::new(CountingWorker {
                    name: "worker-a",
                    call_count: counter_a.clone(),
                }),
            )
            .unwrap();
        sloop
            .register_worker(
                r"this",
                Arc::new(CountingWorker {
                    name: "worker-b",
                    call_count: counter_b.clone(),
                }),
            )
            .unwrap();

        let workers = sloop.workers();
        let matched = find_matching_worker(workers, "run this");
        assert!(matched.is_some());
        assert_eq!(
            matched.unwrap().worker.name(),
            "worker-a",
            "first registered worker wins when both patterns match"
        );
    }

    #[test]
    fn dispatch_second_worker_matches_when_first_does_not() {
        let counter_a = Arc::new(AtomicUsize::new(0));
        let counter_b = Arc::new(AtomicUsize::new(0));

        let mut sloop = SensoriumLoop::new();
        sloop
            .register_worker(
                r"^/deploy",
                Arc::new(CountingWorker {
                    name: "deploy-worker",
                    call_count: counter_a.clone(),
                }),
            )
            .unwrap();
        sloop
            .register_worker(
                r"^/status",
                Arc::new(CountingWorker {
                    name: "status-worker",
                    call_count: counter_b.clone(),
                }),
            )
            .unwrap();

        let workers = sloop.workers();
        let matched = find_matching_worker(workers, "/status all");
        assert!(matched.is_some());
        assert_eq!(matched.unwrap().worker.name(), "status-worker");
    }

    #[test]
    fn parse_kv_returns_empty_for_plain_command() {
        let args = crate::plugin::parse_kv("/task list");
        assert!(args.is_empty());
    }

    #[test]
    fn parse_kv_extracts_key_value_pairs() {
        let args =
            crate::plugin::parse_kv("/run-task key=PROJ-123 repo_url=git@github.com:org/repo.git");
        assert_eq!(args.get("key").map(String::as_str), Some("PROJ-123"));
        assert_eq!(
            args.get("repo_url").map(String::as_str),
            Some("git@github.com:org/repo.git")
        );
    }

    #[test]
    fn parse_kv_strips_bot_mention() {
        let args = crate::plugin::parse_kv("<@U123ABC> /run-task key=PROJ-1");
        assert_eq!(args.get("key").map(String::as_str), Some("PROJ-1"));
    }

    #[test]
    fn parse_kv_handles_quoted_values() {
        let args = crate::plugin::parse_kv(
            r#"/run-task key="PROJ-123" repo_url="git@github.com:org/repo""#,
        );
        assert_eq!(args.get("key").map(String::as_str), Some("PROJ-123"));
        assert_eq!(
            args.get("repo_url").map(String::as_str),
            Some("git@github.com:org/repo")
        );
    }

    #[test]
    fn parse_kv_keys_are_lowercased() {
        let args = crate::plugin::parse_kv("/run-task Key=VALUE");
        assert_eq!(args.get("key").map(String::as_str), Some("VALUE"));
    }

    #[test]
    fn parse_kv_empty_string_returns_empty() {
        let args = crate::plugin::parse_kv("");
        assert!(args.is_empty());
    }

    #[test]
    fn build_help_response_lists_workers_inline() {
        let counter = Arc::new(AtomicUsize::new(0));
        let mut sloop = SensoriumLoop::new();
        sloop
            .register_worker(
                r"^/alpha",
                Arc::new(CountingWorker {
                    name: "alpha",
                    call_count: counter.clone(),
                }),
            )
            .unwrap();
        sloop
            .register_worker(
                r"^/beta",
                Arc::new(CountingWorker {
                    name: "beta",
                    call_count: counter.clone(),
                }),
            )
            .unwrap();

        let workers = sloop.workers();
        let response = crate::plugin::build_help_response(workers);
        assert!(
            response.contains("/count \u{2014} counting worker for tests"),
            "expected inline format: got {response}"
        );
    }
}