kcode-k1-chat-thread-ktools 0.1.4

Synchronous Ktool dispatch for one K1 chat thread
Documentation
#![forbid(unsafe_code)]
#![doc = include_str!("../Documentation.md")]

use std::sync::{Arc, Mutex, MutexGuard};

use kcode_k1_access_kmap::K1AccessKmap;
use kcode_k1_chat_thread_actions::ChatThreadActions;
pub use kcode_k1_chat_thread_actions::{AccessContext, AccessPolicy, ProfileId};
pub use kcode_k1_ktool_set_launch_node::{GetLaunchNodeKtool, SetLaunchNodeKtool};
use kcode_k1_ktool_social::SocialKtools;

const DIFFERENT_AUTHORIZATION: &str =
    "different Kmap access context, profile ID, or policy is already active";
const NO_GET_LAUNCH_NODE_AUTHORIZATION: &str = "GetLaunchNode authorization is not active";
const NO_LAUNCH_NODE_KTOOLS: &str = "launch-node Ktools are unavailable";
const NO_SET_LAUNCH_NODE_AUTHORIZATION: &str = "SetLaunchNode authorization is not active";
const NO_SOCIAL_AUTHORIZATION: &str = "social authorization is not active";
const NO_SOCIAL_KTOOLS: &str = "social Ktools are unavailable";
const POISONED: &str = "chat-thread Ktool dispatcher lock poisoned";

#[derive(Clone, Eq, PartialEq)]
struct Authorization {
    context: AccessContext,
    profile_id: ProfileId,
    policy: AccessPolicy,
}

#[derive(Debug, Eq, PartialEq)]
enum GetLaunchNodeRoute {
    NotOwned,
    Unavailable,
    AuthorizationRequired,
    Launch,
}

fn get_launch_node_route(
    name: &str,
    configured: bool,
    authorization_active: bool,
) -> GetLaunchNodeRoute {
    if name != "GetLaunchNode" {
        return GetLaunchNodeRoute::NotOwned;
    }
    match (configured, authorization_active) {
        (false, _) => GetLaunchNodeRoute::Unavailable,
        (true, false) => GetLaunchNodeRoute::AuthorizationRequired,
        (true, true) => GetLaunchNodeRoute::Launch,
    }
}

#[derive(Debug, Eq, PartialEq)]
enum SetLaunchNodeRoute {
    NotOwned,
    Fallback,
    AuthorizationRequired,
    Launch,
}

fn set_launch_node_route(
    name: &str,
    configured: bool,
    authorization_active: bool,
) -> SetLaunchNodeRoute {
    if name != "SetLaunchNode" {
        return SetLaunchNodeRoute::NotOwned;
    }
    match (configured, authorization_active) {
        (false, _) => SetLaunchNodeRoute::Fallback,
        (true, false) => SetLaunchNodeRoute::AuthorizationRequired,
        (true, true) => SetLaunchNodeRoute::Launch,
    }
}

pub struct ChatThreadKtools {
    actions: ChatThreadActions,
    social: Option<SocialKtools>,
    get_launch_node: Option<GetLaunchNodeKtool>,
    set_launch_node: Option<SetLaunchNodeKtool>,
    authorization: Option<Authorization>,
}

impl ChatThreadKtools {
    pub fn new(kmap: Arc<K1AccessKmap>) -> Self {
        Self {
            actions: ChatThreadActions::new(kmap),
            social: None,
            get_launch_node: None,
            set_launch_node: None,
            authorization: None,
        }
    }

    pub fn new_with_social(kmap: Arc<K1AccessKmap>, social: SocialKtools) -> Self {
        Self {
            actions: ChatThreadActions::new(kmap),
            social: Some(social),
            get_launch_node: None,
            set_launch_node: None,
            authorization: None,
        }
    }

    pub fn new_with_social_and_set_launch_node(
        kmap: Arc<K1AccessKmap>,
        social: SocialKtools,
        set_launch_node: SetLaunchNodeKtool,
    ) -> Self {
        let get_launch_node = set_launch_node.get_launch_node();
        Self {
            actions: ChatThreadActions::new(kmap),
            social: Some(social),
            get_launch_node: Some(get_launch_node),
            set_launch_node: Some(set_launch_node),
            authorization: None,
        }
    }

    pub fn bind_authorization(
        &mut self,
        context: AccessContext,
        profile_id: ProfileId,
        policy: AccessPolicy,
    ) -> Result<(), String> {
        let candidate = Authorization {
            context,
            profile_id,
            policy,
        };
        if self
            .authorization
            .as_ref()
            .is_some_and(|active| active != &candidate)
        {
            return Err(DIFFERENT_AUTHORIZATION.to_owned());
        }
        self.actions.bind_authorization(
            candidate.context.clone(),
            candidate.profile_id,
            candidate.policy.clone(),
        )?;
        self.authorization = Some(candidate);
        Ok(())
    }

    pub fn clear_authorization(&mut self) {
        self.actions.clear_authorization();
        self.authorization = None;
    }

    pub fn supports(&self, name: &str) -> bool {
        matches!(
            name,
            "CurrentTime"
                | "KmapCreateNode"
                | "KmapOpenNode"
                | "KmapUpdateNode"
                | "KmapPenalizeNodes"
                | "KmapConnectNodes"
        ) || (self.social.is_some() && matches!(name, "ListContacts" | "ListGroups" | "GetGroup"))
            || (self.get_launch_node.is_some() && name == "GetLaunchNode")
            || (self.set_launch_node.is_some() && name == "SetLaunchNode")
    }

    pub fn launch(&mut self, name: &str, arguments: &str) -> Result<String, String> {
        match get_launch_node_route(
            name,
            self.get_launch_node.is_some(),
            self.authorization.is_some(),
        ) {
            GetLaunchNodeRoute::Unavailable => return Err(NO_LAUNCH_NODE_KTOOLS.to_owned()),
            GetLaunchNodeRoute::AuthorizationRequired => {
                return Err(NO_GET_LAUNCH_NODE_AUTHORIZATION.to_owned());
            }
            GetLaunchNodeRoute::Launch => {
                let tool = self
                    .get_launch_node
                    .as_ref()
                    .expect("configured GetLaunchNode route has a tool");
                let context = self
                    .authorization
                    .as_ref()
                    .map(|authorization| &authorization.context)
                    .expect("authorized GetLaunchNode route has authorization");
                return tool.launch(context, arguments);
            }
            GetLaunchNodeRoute::NotOwned => {}
        }
        match set_launch_node_route(
            name,
            self.set_launch_node.is_some(),
            self.authorization.is_some(),
        ) {
            SetLaunchNodeRoute::AuthorizationRequired => {
                return Err(NO_SET_LAUNCH_NODE_AUTHORIZATION.to_owned());
            }
            SetLaunchNodeRoute::Launch => {
                let tool = self
                    .set_launch_node
                    .as_ref()
                    .expect("configured SetLaunchNode route has a tool");
                let authorization = self
                    .authorization
                    .as_ref()
                    .expect("authorized SetLaunchNode route has authorization");
                return tool.launch(
                    &authorization.context,
                    authorization.profile_id,
                    authorization.policy.clone(),
                    arguments,
                );
            }
            SetLaunchNodeRoute::NotOwned | SetLaunchNodeRoute::Fallback => {}
        }
        if matches!(name, "ListContacts" | "ListGroups" | "GetGroup") {
            let social = self
                .social
                .as_ref()
                .ok_or_else(|| NO_SOCIAL_KTOOLS.to_owned())?;
            let context = self
                .authorization
                .as_ref()
                .map(|authorization| &authorization.context)
                .ok_or_else(|| NO_SOCIAL_AUTHORIZATION.to_owned())?;
            return social.launch(context, name, arguments);
        }
        self.actions.launch(name, arguments)
    }
}

#[derive(Clone)]
pub struct ChatThreadKtoolExecutor(Arc<Mutex<ChatThreadKtools>>);

impl ChatThreadKtoolExecutor {
    pub fn new(ktools: ChatThreadKtools) -> Self {
        Self(Arc::new(Mutex::new(ktools)))
    }

    fn lock(&self) -> Result<MutexGuard<'_, ChatThreadKtools>, String> {
        self.0.lock().map_err(|_| POISONED.to_owned())
    }

    pub fn bind_authorization(
        &self,
        context: AccessContext,
        profile_id: ProfileId,
        policy: AccessPolicy,
    ) -> Result<(), String> {
        self.lock()?.bind_authorization(context, profile_id, policy)
    }

    pub fn clear_authorization(&self) -> Result<(), String> {
        self.lock()?.clear_authorization();
        Ok(())
    }

    pub fn supports(&self, name: &str) -> Result<bool, String> {
        Ok(self.lock()?.supports(name))
    }

    pub fn launch(&self, name: &str, arguments: &str) -> Result<String, String> {
        self.lock()?.launch(name, arguments)
    }
}

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

    #[test]
    fn get_launch_node_owns_only_its_exact_name() {
        for name in [
            "GetLaunchNode",
            "getLaunchNode",
            "GetLaunchNodes",
            "WebSearch",
        ] {
            let expected = if name == "GetLaunchNode" {
                GetLaunchNodeRoute::Unavailable
            } else {
                GetLaunchNodeRoute::NotOwned
            };
            assert_eq!(get_launch_node_route(name, false, false), expected);
        }
    }

    #[test]
    fn get_launch_node_configuration_and_authorization_are_independent() {
        assert_eq!(
            get_launch_node_route("GetLaunchNode", false, true),
            GetLaunchNodeRoute::Unavailable
        );
        assert_eq!(
            get_launch_node_route("GetLaunchNode", true, false),
            GetLaunchNodeRoute::AuthorizationRequired
        );
        assert_eq!(
            get_launch_node_route("GetLaunchNode", true, true),
            GetLaunchNodeRoute::Launch
        );
    }

    #[test]
    fn set_launch_node_owns_only_its_exact_name() {
        for name in [
            "SetLaunchNode",
            "setLaunchNode",
            "SetLaunchNodes",
            "WebSearch",
        ] {
            let expected = if name == "SetLaunchNode" {
                SetLaunchNodeRoute::Fallback
            } else {
                SetLaunchNodeRoute::NotOwned
            };
            assert_eq!(set_launch_node_route(name, false, false), expected);
        }
    }

    #[test]
    fn set_launch_node_configuration_and_authorization_are_independent() {
        assert_eq!(
            set_launch_node_route("SetLaunchNode", false, true),
            SetLaunchNodeRoute::Fallback
        );
        assert_eq!(
            set_launch_node_route("SetLaunchNode", true, false),
            SetLaunchNodeRoute::AuthorizationRequired
        );
        assert_eq!(
            set_launch_node_route("SetLaunchNode", true, true),
            SetLaunchNodeRoute::Launch
        );
    }

    #[test]
    fn concrete_support_is_narrow() {
        let names = [
            "CurrentTime",
            "KmapCreateNode",
            "KmapOpenNode",
            "KmapUpdateNode",
            "KmapPenalizeNodes",
            "KmapConnectNodes",
        ];
        assert_eq!(names.len(), 6);
        for name in [
            "KtoolDocs",
            "SendMessage",
            "WebSearch",
            "GetLaunchNode",
            "SetLaunchNode",
        ] {
            assert!(!matches!(
                name,
                "CurrentTime" | "KmapCreateNode" | "KmapOpenNode"
            ));
        }
    }

    #[test]
    fn complete_package_stays_below_the_managed_limit() {
        let files = [
            include_str!("../Cargo.toml"),
            include_str!("../Documentation.md"),
            include_str!("lib.rs"),
        ];
        let count = files
            .iter()
            .flat_map(|file| file.lines())
            .filter(|line| !line.trim().is_empty())
            .count();
        assert!(count < 500, "complete package has {count} nonblank lines");
    }
}