1use std::collections::HashMap;
14
15use crate::types::{MemoryConfiguration, SectionOverride, SystemMessageConfig};
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
28pub enum ClientMode {
29 #[default]
31 CopilotCli,
32 Empty,
34}
35
36fn is_valid_tool_name(name: &str) -> bool {
39 !name.is_empty()
40 && name
41 .chars()
42 .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
43}
44
45fn validate_name(kind: &str, name: &str) -> Result<(), crate::Error> {
46 if name == "*" {
47 return Ok(());
48 }
49 if !is_valid_tool_name(name) {
50 return Err(crate::Error::with_message(
51 crate::ErrorKind::InvalidConfig,
52 format!(
53 "Invalid {kind} tool name '{name}': tool names must match \
54 /^[a-zA-Z0-9_-]+$/ or be the wildcard '*'."
55 ),
56 ));
57 }
58 Ok(())
59}
60
61#[derive(Debug, Clone, Default)]
82pub struct ToolSet {
83 items: Vec<String>,
84}
85
86impl ToolSet {
87 pub fn new() -> Self {
89 Self::default()
90 }
91
92 pub fn add_builtin(mut self, name: &str) -> Result<Self, crate::Error> {
95 validate_name("builtin", name)?;
96 self.items.push(format!("builtin:{name}"));
97 Ok(self)
98 }
99
100 pub fn add_builtin_many<I, S>(mut self, names: I) -> Result<Self, crate::Error>
102 where
103 I: IntoIterator<Item = S>,
104 S: AsRef<str>,
105 {
106 for name in names {
107 let name = name.as_ref();
108 validate_name("builtin", name)?;
109 self.items.push(format!("builtin:{name}"));
110 }
111 Ok(self)
112 }
113
114 pub fn add_custom(mut self, name: &str) -> Result<Self, crate::Error> {
117 validate_name("custom", name)?;
118 self.items.push(format!("custom:{name}"));
119 Ok(self)
120 }
121
122 pub fn add_mcp(mut self, tool_name: &str) -> Result<Self, crate::Error> {
125 validate_name("mcp", tool_name)?;
126 self.items.push(format!("mcp:{tool_name}"));
127 Ok(self)
128 }
129
130 pub fn to_vec(&self) -> Vec<String> {
132 self.items.clone()
133 }
134
135 pub fn into_vec(self) -> Vec<String> {
137 self.items
138 }
139
140 pub fn len(&self) -> usize {
142 self.items.len()
143 }
144
145 pub fn is_empty(&self) -> bool {
147 self.items.is_empty()
148 }
149}
150
151impl From<ToolSet> for Vec<String> {
152 fn from(value: ToolSet) -> Self {
153 value.into_vec()
154 }
155}
156
157pub const BUILTIN_TOOLS_ISOLATED: &[&str] = &[
169 "ask_user",
170 "task_complete",
171 "exit_plan_mode",
172 "task",
173 "read_agent",
174 "write_agent",
175 "list_agents",
176 "send_inbox",
177 "context_board",
178 "skill",
179];
180
181pub(crate) fn validate_tool_filter_list(
185 field: &str,
186 list: Option<&[String]>,
187) -> Result<(), crate::Error> {
188 let Some(list) = list else { return Ok(()) };
189 for item in list {
190 if item == "*" {
191 return Err(crate::Error::with_message(
192 crate::ErrorKind::InvalidConfig,
193 format!(
194 "{field} contains a bare '*' which matches no tool. Use \
195 source-qualified wildcards instead: \
196 ToolSet::new().add_builtin(\"*\").add_mcp(\"*\").add_custom(\"*\")."
197 ),
198 ));
199 }
200 }
201 Ok(())
202}
203
204pub(crate) fn system_message_for_mode(
208 mode: ClientMode,
209 supplied: Option<SystemMessageConfig>,
210) -> Option<SystemMessageConfig> {
211 if mode != ClientMode::Empty {
212 return supplied;
213 }
214 let strip_env = || {
215 let mut sections = HashMap::new();
216 sections.insert(
217 "environment_context".to_string(),
218 SectionOverride {
219 action: Some("remove".to_string()),
220 content: None,
221 },
222 );
223 sections
224 };
225 let Some(supplied) = supplied else {
226 return Some(SystemMessageConfig {
227 mode: Some("customize".to_string()),
228 content: None,
229 sections: Some(strip_env()),
230 });
231 };
232 let mode_str = supplied.mode.as_deref().unwrap_or("append");
233 match mode_str {
234 "replace" => Some(supplied),
235 "customize" => {
236 if supplied
237 .sections
238 .as_ref()
239 .is_some_and(|s| s.contains_key("environment_context"))
240 {
241 Some(supplied)
242 } else {
243 let mut sections = supplied.sections.unwrap_or_default();
244 sections.insert(
245 "environment_context".to_string(),
246 SectionOverride {
247 action: Some("remove".to_string()),
248 content: None,
249 },
250 );
251 Some(SystemMessageConfig {
252 mode: Some("customize".to_string()),
253 content: supplied.content,
254 sections: Some(sections),
255 })
256 }
257 }
258 _ => Some(SystemMessageConfig {
262 mode: Some("customize".to_string()),
263 content: supplied.content,
264 sections: Some(strip_env()),
265 }),
266 }
267}
268
269pub(crate) fn memory_for_mode(
276 mode: ClientMode,
277 supplied: Option<MemoryConfiguration>,
278) -> Option<MemoryConfiguration> {
279 match supplied {
280 Some(config) => Some(config),
281 None if mode == ClientMode::Empty => Some(MemoryConfiguration::disabled()),
282 None => None,
283 }
284}
285
286#[cfg(test)]
287mod tests {
288 use super::*;
289
290 #[test]
291 fn tool_set_emits_source_qualified_patterns() {
292 let v = ToolSet::new()
293 .add_builtin("bash")
294 .unwrap()
295 .add_builtin("*")
296 .unwrap()
297 .add_custom("foo")
298 .unwrap()
299 .add_custom("*")
300 .unwrap()
301 .add_mcp("github-list_issues")
302 .unwrap()
303 .add_mcp("*")
304 .unwrap()
305 .to_vec();
306 assert_eq!(
307 v,
308 vec![
309 "builtin:bash",
310 "builtin:*",
311 "custom:foo",
312 "custom:*",
313 "mcp:github-list_issues",
314 "mcp:*",
315 ]
316 );
317 }
318
319 #[test]
320 fn tool_set_add_builtin_many() {
321 let v = ToolSet::new()
322 .add_builtin_many(BUILTIN_TOOLS_ISOLATED)
323 .unwrap()
324 .into_vec();
325 assert_eq!(v.len(), BUILTIN_TOOLS_ISOLATED.len());
326 assert_eq!(v[0], format!("builtin:{}", BUILTIN_TOOLS_ISOLATED[0]));
327 }
328
329 #[test]
330 fn tool_set_rejects_invalid_names() {
331 for bad in ["bash!", "with space", "colon:name", "", "wild*card"] {
332 assert!(
333 ToolSet::new().add_builtin(bad).is_err(),
334 "expected '{bad}' to be rejected"
335 );
336 assert!(ToolSet::new().add_custom(bad).is_err());
337 assert!(ToolSet::new().add_mcp(bad).is_err());
338 }
339 }
340
341 #[test]
342 fn tool_set_accepts_wildcard_and_underscores_and_dashes() {
343 assert!(ToolSet::new().add_builtin("*").is_ok());
344 assert!(ToolSet::new().add_mcp("github-list_issues").is_ok());
345 assert!(ToolSet::new().add_custom("A_b-9").is_ok());
346 }
347
348 #[test]
349 fn into_vec_is_idempotent_with_to_vec() {
350 let ts = ToolSet::new().add_builtin("bash").unwrap();
351 assert_eq!(ts.to_vec(), vec!["builtin:bash"]);
352 assert_eq!(ts.into_vec(), vec!["builtin:bash"]);
353 }
354
355 #[test]
356 fn into_vec_string_conversion() {
357 let v: Vec<String> = ToolSet::new().add_mcp("*").unwrap().into();
358 assert_eq!(v, vec!["mcp:*"]);
359 }
360
361 #[test]
362 fn validate_tool_filter_list_rejects_bare_star() {
363 let bad = vec!["*".to_string()];
364 assert!(validate_tool_filter_list("availableTools", Some(&bad)).is_err());
365 }
366
367 #[test]
368 fn validate_tool_filter_list_allows_qualified_star() {
369 let ok = vec!["builtin:*".to_string(), "mcp:*".to_string()];
370 assert!(validate_tool_filter_list("availableTools", Some(&ok)).is_ok());
371 }
372
373 #[test]
374 fn validate_tool_filter_list_none_is_ok() {
375 assert!(validate_tool_filter_list("availableTools", None).is_ok());
376 }
377
378 #[test]
379 fn builtin_tools_isolated_contents() {
380 assert!(BUILTIN_TOOLS_ISOLATED.contains(&"ask_user"));
381 assert!(BUILTIN_TOOLS_ISOLATED.contains(&"task_complete"));
382 assert!(BUILTIN_TOOLS_ISOLATED.contains(&"skill"));
383 assert!(!BUILTIN_TOOLS_ISOLATED.contains(&"bash"));
384 assert!(!BUILTIN_TOOLS_ISOLATED.contains(&"edit"));
385 assert!(!BUILTIN_TOOLS_ISOLATED.contains(&"web_fetch"));
386 }
387
388 #[test]
389 fn client_mode_default_is_copilot_cli() {
390 assert_eq!(ClientMode::default(), ClientMode::CopilotCli);
391 }
392
393 #[test]
394 fn system_message_copilot_cli_passes_through_unchanged() {
395 let cfg = SystemMessageConfig {
396 mode: Some("append".to_string()),
397 content: Some("hello".to_string()),
398 sections: None,
399 };
400 let out = system_message_for_mode(ClientMode::CopilotCli, Some(cfg.clone()));
401 let out = out.unwrap();
402 assert_eq!(out.mode.as_deref(), Some("append"));
403 assert_eq!(out.content.as_deref(), Some("hello"));
404 }
405
406 #[test]
407 fn system_message_empty_none_injects_strip() {
408 let out = system_message_for_mode(ClientMode::Empty, None).unwrap();
409 assert_eq!(out.mode.as_deref(), Some("customize"));
410 let sections = out.sections.unwrap();
411 let env = sections.get("environment_context").unwrap();
412 assert_eq!(env.action.as_deref(), Some("remove"));
413 }
414
415 #[test]
416 fn system_message_empty_append_promoted_to_customize() {
417 let cfg = SystemMessageConfig {
418 mode: Some("append".to_string()),
419 content: Some("hi".to_string()),
420 sections: None,
421 };
422 let out = system_message_for_mode(ClientMode::Empty, Some(cfg)).unwrap();
423 assert_eq!(out.mode.as_deref(), Some("customize"));
424 assert_eq!(out.content.as_deref(), Some("hi"));
425 let sections = out.sections.unwrap();
426 assert!(sections.contains_key("environment_context"));
427 }
428
429 #[test]
430 fn system_message_empty_replace_passes_through() {
431 let cfg = SystemMessageConfig {
432 mode: Some("replace".to_string()),
433 content: Some("verbatim".to_string()),
434 sections: None,
435 };
436 let out = system_message_for_mode(ClientMode::Empty, Some(cfg.clone())).unwrap();
437 assert_eq!(out.mode.as_deref(), Some("replace"));
438 assert_eq!(out.content.as_deref(), Some("verbatim"));
439 assert!(out.sections.is_none());
440 }
441
442 #[test]
443 fn system_message_empty_customize_with_env_context_preserved() {
444 let mut sections = HashMap::new();
445 sections.insert(
446 "environment_context".to_string(),
447 SectionOverride {
448 action: Some("replace".to_string()),
449 content: Some("custom env".to_string()),
450 },
451 );
452 let cfg = SystemMessageConfig {
453 mode: Some("customize".to_string()),
454 content: None,
455 sections: Some(sections),
456 };
457 let out = system_message_for_mode(ClientMode::Empty, Some(cfg)).unwrap();
458 let env = out.sections.unwrap().remove("environment_context").unwrap();
459 assert_eq!(env.action.as_deref(), Some("replace"));
460 assert_eq!(env.content.as_deref(), Some("custom env"));
461 }
462
463 #[test]
464 fn system_message_empty_customize_without_env_context_gets_strip() {
465 let mut sections = HashMap::new();
466 sections.insert(
467 "other_section".to_string(),
468 SectionOverride {
469 action: Some("replace".to_string()),
470 content: Some("body".to_string()),
471 },
472 );
473 let cfg = SystemMessageConfig {
474 mode: Some("customize".to_string()),
475 content: None,
476 sections: Some(sections),
477 };
478 let out = system_message_for_mode(ClientMode::Empty, Some(cfg)).unwrap();
479 let secs = out.sections.unwrap();
480 assert!(secs.contains_key("other_section"));
481 let env = secs.get("environment_context").unwrap();
482 assert_eq!(env.action.as_deref(), Some("remove"));
483 }
484
485 #[test]
486 fn memory_copilot_cli_leaves_unset_when_not_supplied() {
487 assert_eq!(memory_for_mode(ClientMode::CopilotCli, None), None);
488 }
489
490 #[test]
491 fn memory_copilot_cli_preserves_supplied() {
492 assert_eq!(
493 memory_for_mode(ClientMode::CopilotCli, Some(MemoryConfiguration::enabled())),
494 Some(MemoryConfiguration::enabled())
495 );
496 }
497
498 #[test]
499 fn memory_empty_defaults_to_disabled() {
500 assert_eq!(
501 memory_for_mode(ClientMode::Empty, None),
502 Some(MemoryConfiguration::disabled())
503 );
504 }
505
506 #[test]
507 fn memory_empty_preserves_supplied() {
508 assert_eq!(
509 memory_for_mode(ClientMode::Empty, Some(MemoryConfiguration::enabled())),
510 Some(MemoryConfiguration::enabled())
511 );
512 }
513}