Skip to main content

kcode_k1_chat_thread_ktools/
lib.rs

1#![forbid(unsafe_code)]
2#![doc = include_str!("../Documentation.md")]
3
4use std::sync::{Arc, Mutex, MutexGuard};
5
6use kcode_k1_access_kmap::K1AccessKmap;
7use kcode_k1_chat_thread_actions::ChatThreadActions;
8pub use kcode_k1_chat_thread_actions::{AccessContext, AccessPolicy, ProfileId};
9pub use kcode_k1_ktool_set_launch_node::{GetLaunchNodeKtool, SetLaunchNodeKtool};
10use kcode_k1_ktool_social::SocialKtools;
11
12const DIFFERENT_AUTHORIZATION: &str =
13    "different Kmap access context, profile ID, or policy is already active";
14const NO_GET_LAUNCH_NODE_AUTHORIZATION: &str = "GetLaunchNode authorization is not active";
15const NO_LAUNCH_NODE_KTOOLS: &str = "launch-node Ktools are unavailable";
16const NO_SET_LAUNCH_NODE_AUTHORIZATION: &str = "SetLaunchNode authorization is not active";
17const NO_SOCIAL_AUTHORIZATION: &str = "social authorization is not active";
18const NO_SOCIAL_KTOOLS: &str = "social Ktools are unavailable";
19const POISONED: &str = "chat-thread Ktool dispatcher lock poisoned";
20
21#[derive(Clone, Eq, PartialEq)]
22struct Authorization {
23    context: AccessContext,
24    profile_id: ProfileId,
25    policy: AccessPolicy,
26}
27
28#[derive(Debug, Eq, PartialEq)]
29enum GetLaunchNodeRoute {
30    NotOwned,
31    Unavailable,
32    AuthorizationRequired,
33    Launch,
34}
35
36fn get_launch_node_route(
37    name: &str,
38    configured: bool,
39    authorization_active: bool,
40) -> GetLaunchNodeRoute {
41    if name != "GetLaunchNode" {
42        return GetLaunchNodeRoute::NotOwned;
43    }
44    match (configured, authorization_active) {
45        (false, _) => GetLaunchNodeRoute::Unavailable,
46        (true, false) => GetLaunchNodeRoute::AuthorizationRequired,
47        (true, true) => GetLaunchNodeRoute::Launch,
48    }
49}
50
51#[derive(Debug, Eq, PartialEq)]
52enum SetLaunchNodeRoute {
53    NotOwned,
54    Fallback,
55    AuthorizationRequired,
56    Launch,
57}
58
59fn set_launch_node_route(
60    name: &str,
61    configured: bool,
62    authorization_active: bool,
63) -> SetLaunchNodeRoute {
64    if name != "SetLaunchNode" {
65        return SetLaunchNodeRoute::NotOwned;
66    }
67    match (configured, authorization_active) {
68        (false, _) => SetLaunchNodeRoute::Fallback,
69        (true, false) => SetLaunchNodeRoute::AuthorizationRequired,
70        (true, true) => SetLaunchNodeRoute::Launch,
71    }
72}
73
74pub struct ChatThreadKtools {
75    actions: ChatThreadActions,
76    social: Option<SocialKtools>,
77    get_launch_node: Option<GetLaunchNodeKtool>,
78    set_launch_node: Option<SetLaunchNodeKtool>,
79    authorization: Option<Authorization>,
80}
81
82impl ChatThreadKtools {
83    pub fn new(kmap: Arc<K1AccessKmap>) -> Self {
84        Self {
85            actions: ChatThreadActions::new(kmap),
86            social: None,
87            get_launch_node: None,
88            set_launch_node: None,
89            authorization: None,
90        }
91    }
92
93    pub fn new_with_social(kmap: Arc<K1AccessKmap>, social: SocialKtools) -> Self {
94        Self {
95            actions: ChatThreadActions::new(kmap),
96            social: Some(social),
97            get_launch_node: None,
98            set_launch_node: None,
99            authorization: None,
100        }
101    }
102
103    pub fn new_with_social_and_set_launch_node(
104        kmap: Arc<K1AccessKmap>,
105        social: SocialKtools,
106        set_launch_node: SetLaunchNodeKtool,
107    ) -> Self {
108        let get_launch_node = set_launch_node.get_launch_node();
109        Self {
110            actions: ChatThreadActions::new(kmap),
111            social: Some(social),
112            get_launch_node: Some(get_launch_node),
113            set_launch_node: Some(set_launch_node),
114            authorization: None,
115        }
116    }
117
118    pub fn bind_authorization(
119        &mut self,
120        context: AccessContext,
121        profile_id: ProfileId,
122        policy: AccessPolicy,
123    ) -> Result<(), String> {
124        let candidate = Authorization {
125            context,
126            profile_id,
127            policy,
128        };
129        if self
130            .authorization
131            .as_ref()
132            .is_some_and(|active| active != &candidate)
133        {
134            return Err(DIFFERENT_AUTHORIZATION.to_owned());
135        }
136        self.actions.bind_authorization(
137            candidate.context.clone(),
138            candidate.profile_id,
139            candidate.policy.clone(),
140        )?;
141        self.authorization = Some(candidate);
142        Ok(())
143    }
144
145    pub fn clear_authorization(&mut self) {
146        self.actions.clear_authorization();
147        self.authorization = None;
148    }
149
150    pub fn supports(&self, name: &str) -> bool {
151        matches!(
152            name,
153            "CurrentTime"
154                | "KmapCreateNode"
155                | "KmapOpenNode"
156                | "KmapUpdateNode"
157                | "KmapPenalizeNodes"
158                | "KmapConnectNodes"
159        ) || (self.social.is_some() && matches!(name, "ListContacts" | "ListGroups" | "GetGroup"))
160            || (self.get_launch_node.is_some() && name == "GetLaunchNode")
161            || (self.set_launch_node.is_some() && name == "SetLaunchNode")
162    }
163
164    pub fn launch(&mut self, name: &str, arguments: &str) -> Result<String, String> {
165        match get_launch_node_route(
166            name,
167            self.get_launch_node.is_some(),
168            self.authorization.is_some(),
169        ) {
170            GetLaunchNodeRoute::Unavailable => return Err(NO_LAUNCH_NODE_KTOOLS.to_owned()),
171            GetLaunchNodeRoute::AuthorizationRequired => {
172                return Err(NO_GET_LAUNCH_NODE_AUTHORIZATION.to_owned());
173            }
174            GetLaunchNodeRoute::Launch => {
175                let tool = self
176                    .get_launch_node
177                    .as_ref()
178                    .expect("configured GetLaunchNode route has a tool");
179                let context = self
180                    .authorization
181                    .as_ref()
182                    .map(|authorization| &authorization.context)
183                    .expect("authorized GetLaunchNode route has authorization");
184                return tool.launch(context, arguments);
185            }
186            GetLaunchNodeRoute::NotOwned => {}
187        }
188        match set_launch_node_route(
189            name,
190            self.set_launch_node.is_some(),
191            self.authorization.is_some(),
192        ) {
193            SetLaunchNodeRoute::AuthorizationRequired => {
194                return Err(NO_SET_LAUNCH_NODE_AUTHORIZATION.to_owned());
195            }
196            SetLaunchNodeRoute::Launch => {
197                let tool = self
198                    .set_launch_node
199                    .as_ref()
200                    .expect("configured SetLaunchNode route has a tool");
201                let authorization = self
202                    .authorization
203                    .as_ref()
204                    .expect("authorized SetLaunchNode route has authorization");
205                return tool.launch(
206                    &authorization.context,
207                    authorization.profile_id,
208                    authorization.policy.clone(),
209                    arguments,
210                );
211            }
212            SetLaunchNodeRoute::NotOwned | SetLaunchNodeRoute::Fallback => {}
213        }
214        if matches!(name, "ListContacts" | "ListGroups" | "GetGroup") {
215            let social = self
216                .social
217                .as_ref()
218                .ok_or_else(|| NO_SOCIAL_KTOOLS.to_owned())?;
219            let context = self
220                .authorization
221                .as_ref()
222                .map(|authorization| &authorization.context)
223                .ok_or_else(|| NO_SOCIAL_AUTHORIZATION.to_owned())?;
224            return social.launch(context, name, arguments);
225        }
226        self.actions.launch(name, arguments)
227    }
228}
229
230#[derive(Clone)]
231pub struct ChatThreadKtoolExecutor(Arc<Mutex<ChatThreadKtools>>);
232
233impl ChatThreadKtoolExecutor {
234    pub fn new(ktools: ChatThreadKtools) -> Self {
235        Self(Arc::new(Mutex::new(ktools)))
236    }
237
238    fn lock(&self) -> Result<MutexGuard<'_, ChatThreadKtools>, String> {
239        self.0.lock().map_err(|_| POISONED.to_owned())
240    }
241
242    pub fn bind_authorization(
243        &self,
244        context: AccessContext,
245        profile_id: ProfileId,
246        policy: AccessPolicy,
247    ) -> Result<(), String> {
248        self.lock()?.bind_authorization(context, profile_id, policy)
249    }
250
251    pub fn clear_authorization(&self) -> Result<(), String> {
252        self.lock()?.clear_authorization();
253        Ok(())
254    }
255
256    pub fn supports(&self, name: &str) -> Result<bool, String> {
257        Ok(self.lock()?.supports(name))
258    }
259
260    pub fn launch(&self, name: &str, arguments: &str) -> Result<String, String> {
261        self.lock()?.launch(name, arguments)
262    }
263}
264
265#[cfg(test)]
266mod tests {
267    use super::*;
268
269    #[test]
270    fn get_launch_node_owns_only_its_exact_name() {
271        for name in [
272            "GetLaunchNode",
273            "getLaunchNode",
274            "GetLaunchNodes",
275            "WebSearch",
276        ] {
277            let expected = if name == "GetLaunchNode" {
278                GetLaunchNodeRoute::Unavailable
279            } else {
280                GetLaunchNodeRoute::NotOwned
281            };
282            assert_eq!(get_launch_node_route(name, false, false), expected);
283        }
284    }
285
286    #[test]
287    fn get_launch_node_configuration_and_authorization_are_independent() {
288        assert_eq!(
289            get_launch_node_route("GetLaunchNode", false, true),
290            GetLaunchNodeRoute::Unavailable
291        );
292        assert_eq!(
293            get_launch_node_route("GetLaunchNode", true, false),
294            GetLaunchNodeRoute::AuthorizationRequired
295        );
296        assert_eq!(
297            get_launch_node_route("GetLaunchNode", true, true),
298            GetLaunchNodeRoute::Launch
299        );
300    }
301
302    #[test]
303    fn set_launch_node_owns_only_its_exact_name() {
304        for name in [
305            "SetLaunchNode",
306            "setLaunchNode",
307            "SetLaunchNodes",
308            "WebSearch",
309        ] {
310            let expected = if name == "SetLaunchNode" {
311                SetLaunchNodeRoute::Fallback
312            } else {
313                SetLaunchNodeRoute::NotOwned
314            };
315            assert_eq!(set_launch_node_route(name, false, false), expected);
316        }
317    }
318
319    #[test]
320    fn set_launch_node_configuration_and_authorization_are_independent() {
321        assert_eq!(
322            set_launch_node_route("SetLaunchNode", false, true),
323            SetLaunchNodeRoute::Fallback
324        );
325        assert_eq!(
326            set_launch_node_route("SetLaunchNode", true, false),
327            SetLaunchNodeRoute::AuthorizationRequired
328        );
329        assert_eq!(
330            set_launch_node_route("SetLaunchNode", true, true),
331            SetLaunchNodeRoute::Launch
332        );
333    }
334
335    #[test]
336    fn concrete_support_is_narrow() {
337        let names = [
338            "CurrentTime",
339            "KmapCreateNode",
340            "KmapOpenNode",
341            "KmapUpdateNode",
342            "KmapPenalizeNodes",
343            "KmapConnectNodes",
344        ];
345        assert_eq!(names.len(), 6);
346        for name in [
347            "KtoolDocs",
348            "SendMessage",
349            "WebSearch",
350            "GetLaunchNode",
351            "SetLaunchNode",
352        ] {
353            assert!(!matches!(
354                name,
355                "CurrentTime" | "KmapCreateNode" | "KmapOpenNode"
356            ));
357        }
358    }
359
360    #[test]
361    fn complete_package_stays_below_the_managed_limit() {
362        let files = [
363            include_str!("../Cargo.toml"),
364            include_str!("../Documentation.md"),
365            include_str!("lib.rs"),
366        ];
367        let count = files
368            .iter()
369            .flat_map(|file| file.lines())
370            .filter(|line| !line.trim().is_empty())
371            .count();
372        assert!(count < 500, "complete package has {count} nonblank lines");
373    }
374}