1use serde_json::Value;
7use std::collections::HashMap;
8use std::path::PathBuf;
9
10use turul_mcp_protocol::roots::{
12 HasRootAnnotations, HasRootFiltering, HasRootMetadata, HasRootPermissions, ListRootsRequest,
13 RootsListChangedNotification,
14};
15
16pub struct RootBuilder {
18 uri: String,
19 name: Option<String>,
20 description: Option<String>,
21 meta: Option<HashMap<String, Value>>,
22 read_only: bool,
24 max_depth: Option<usize>,
25 allowed_extensions: Option<Vec<String>>,
27 excluded_patterns: Option<Vec<String>>,
28 tags: Option<Vec<String>>,
29}
30
31impl RootBuilder {
32 pub fn new(uri: impl Into<String>) -> Self {
34 Self {
35 uri: uri.into(),
36 name: None,
37 description: None,
38 meta: None,
39 read_only: true, max_depth: None,
41 allowed_extensions: None,
42 excluded_patterns: None,
43 tags: None,
44 }
45 }
46
47 pub fn from_path(path: impl Into<PathBuf>) -> Self {
49 let path = path.into();
50 let uri = format!("file://{}", path.display());
51 Self::new(uri)
52 }
53
54 pub fn name(mut self, name: impl Into<String>) -> Self {
56 self.name = Some(name.into());
57 self
58 }
59
60 pub fn description(mut self, description: impl Into<String>) -> Self {
62 self.description = Some(description.into());
63 self
64 }
65
66 pub fn meta(mut self, meta: HashMap<String, Value>) -> Self {
68 self.meta = Some(meta);
69 self
70 }
71
72 pub fn meta_value(mut self, key: impl Into<String>, value: Value) -> Self {
74 if self.meta.is_none() {
75 self.meta = Some(HashMap::new());
76 }
77 self.meta.as_mut().unwrap().insert(key.into(), value);
78 self
79 }
80
81 pub fn read_only(mut self, read_only: bool) -> Self {
83 self.read_only = read_only;
84 self
85 }
86
87 pub fn read_write(mut self) -> Self {
89 self.read_only = false;
90 self
91 }
92
93 pub fn max_depth(mut self, depth: usize) -> Self {
95 self.max_depth = Some(depth);
96 self
97 }
98
99 pub fn allowed_extensions(mut self, extensions: Vec<String>) -> Self {
101 self.allowed_extensions = Some(extensions);
102 self
103 }
104
105 pub fn allow_extension(mut self, extension: impl Into<String>) -> Self {
107 if self.allowed_extensions.is_none() {
108 self.allowed_extensions = Some(Vec::new());
109 }
110 self.allowed_extensions
111 .as_mut()
112 .unwrap()
113 .push(extension.into());
114 self
115 }
116
117 pub fn excluded_patterns(mut self, patterns: Vec<String>) -> Self {
119 self.excluded_patterns = Some(patterns);
120 self
121 }
122
123 pub fn exclude_pattern(mut self, pattern: impl Into<String>) -> Self {
125 if self.excluded_patterns.is_none() {
126 self.excluded_patterns = Some(Vec::new());
127 }
128 self.excluded_patterns
129 .as_mut()
130 .unwrap()
131 .push(pattern.into());
132 self
133 }
134
135 pub fn tags(mut self, tags: Vec<String>) -> Self {
137 self.tags = Some(tags);
138 self
139 }
140
141 pub fn tag(mut self, tag: impl Into<String>) -> Self {
143 if self.tags.is_none() {
144 self.tags = Some(Vec::new());
145 }
146 self.tags.as_mut().unwrap().push(tag.into());
147 self
148 }
149
150 pub fn build(self) -> Result<DynamicRoot, String> {
152 if !self.uri.starts_with("file://") {
154 return Err("Root URI must start with 'file://'".to_string());
155 }
156
157 Ok(DynamicRoot {
158 uri: self.uri,
159 name: self.name,
160 description: self.description,
161 meta: self.meta,
162 read_only: self.read_only,
163 max_depth: self.max_depth,
164 allowed_extensions: self.allowed_extensions,
165 excluded_patterns: self.excluded_patterns,
166 tags: self.tags,
167 })
168 }
169}
170
171#[derive(Debug)]
173pub struct DynamicRoot {
174 uri: String,
175 name: Option<String>,
176 description: Option<String>,
177 meta: Option<HashMap<String, Value>>,
178 read_only: bool,
179 max_depth: Option<usize>,
180 allowed_extensions: Option<Vec<String>>,
181 excluded_patterns: Option<Vec<String>>,
182 tags: Option<Vec<String>>,
183}
184
185impl HasRootMetadata for DynamicRoot {
187 fn uri(&self) -> &str {
188 &self.uri
189 }
190
191 fn name(&self) -> Option<&str> {
192 self.name.as_deref()
193 }
194
195 fn description(&self) -> Option<&str> {
196 self.description.as_deref()
197 }
198}
199
200impl HasRootPermissions for DynamicRoot {
201 fn can_read(&self, _path: &str) -> bool {
202 true }
204
205 fn can_write(&self, _path: &str) -> bool {
206 !self.read_only
207 }
208
209 fn max_depth(&self) -> Option<usize> {
210 self.max_depth
211 }
212}
213
214impl HasRootFiltering for DynamicRoot {
215 fn allowed_extensions(&self) -> Option<&[String]> {
216 self.allowed_extensions.as_deref()
217 }
218
219 fn excluded_patterns(&self) -> Option<&[String]> {
220 self.excluded_patterns.as_deref()
221 }
222}
223
224impl HasRootAnnotations for DynamicRoot {
225 fn annotations(&self) -> Option<&HashMap<String, Value>> {
226 self.meta.as_ref()
227 }
228
229 fn tags(&self) -> Option<&[String]> {
230 self.tags.as_deref()
231 }
232}
233
234pub struct ListRootsRequestBuilder {
238 meta: Option<HashMap<String, Value>>,
239}
240
241impl ListRootsRequestBuilder {
242 pub fn new() -> Self {
243 Self { meta: None }
244 }
245
246 pub fn meta(mut self, meta: HashMap<String, Value>) -> Self {
248 self.meta = Some(meta);
249 self
250 }
251
252 pub fn meta_value(mut self, key: impl Into<String>, value: Value) -> Self {
254 if self.meta.is_none() {
255 self.meta = Some(HashMap::new());
256 }
257 self.meta.as_mut().unwrap().insert(key.into(), value);
258 self
259 }
260
261 pub fn build(self) -> ListRootsRequest {
263 if let Some(meta) = self.meta {
264 ListRootsRequest::new().with_meta(meta)
265 } else {
266 ListRootsRequest::new()
267 }
268 }
269}
270
271impl Default for ListRootsRequestBuilder {
272 fn default() -> Self {
273 Self::new()
274 }
275}
276
277pub struct RootsNotificationBuilder {
279 meta: Option<HashMap<String, Value>>,
280}
281
282impl RootsNotificationBuilder {
283 pub fn new() -> Self {
284 Self { meta: None }
285 }
286
287 pub fn meta(mut self, meta: HashMap<String, Value>) -> Self {
289 self.meta = Some(meta);
290 self
291 }
292
293 pub fn meta_value(mut self, key: impl Into<String>, value: Value) -> Self {
295 if self.meta.is_none() {
296 self.meta = Some(HashMap::new());
297 }
298 self.meta.as_mut().unwrap().insert(key.into(), value);
299 self
300 }
301
302 pub fn build(self) -> RootsListChangedNotification {
304 if let Some(meta) = self.meta {
305 RootsListChangedNotification::new().with_meta(meta)
306 } else {
307 RootsListChangedNotification::new()
308 }
309 }
310}
311
312impl Default for RootsNotificationBuilder {
313 fn default() -> Self {
314 Self::new()
315 }
316}
317
318impl RootBuilder {
320 pub fn source_code_root(path: impl Into<PathBuf>) -> Self {
322 Self::from_path(path)
323 .name("Source Code")
324 .description("Source code directory with common programming files")
325 .allowed_extensions(vec![
326 "rs".to_string(),
327 "py".to_string(),
328 "js".to_string(),
329 "ts".to_string(),
330 "java".to_string(),
331 "cpp".to_string(),
332 "c".to_string(),
333 "h".to_string(),
334 "go".to_string(),
335 "rb".to_string(),
336 "php".to_string(),
337 "swift".to_string(),
338 "kt".to_string(),
339 "scala".to_string(),
340 "clj".to_string(),
341 "hs".to_string(),
342 ])
343 .excluded_patterns(vec![
344 "node_modules".to_string(),
345 "target".to_string(),
346 ".git".to_string(),
347 "build".to_string(),
348 "dist".to_string(),
349 ])
350 .tag("source-code")
351 }
352
353 pub fn docs_root(path: impl Into<PathBuf>) -> Self {
355 Self::from_path(path)
356 .name("Documentation")
357 .description("Documentation and README files")
358 .allowed_extensions(vec![
359 "md".to_string(),
360 "txt".to_string(),
361 "rst".to_string(),
362 "adoc".to_string(),
363 "org".to_string(),
364 "tex".to_string(),
365 "html".to_string(),
366 "pdf".to_string(),
367 ])
368 .tag("documentation")
369 }
370
371 pub fn config_root(path: impl Into<PathBuf>) -> Self {
373 Self::from_path(path)
374 .name("Configuration")
375 .description("Configuration and settings files")
376 .allowed_extensions(vec![
377 "json".to_string(),
378 "yaml".to_string(),
379 "yml".to_string(),
380 "toml".to_string(),
381 "ini".to_string(),
382 "cfg".to_string(),
383 "conf".to_string(),
384 "config".to_string(),
385 "env".to_string(),
386 ])
387 .tag("configuration")
388 }
389
390 pub fn workspace_root(path: impl Into<PathBuf>) -> Self {
392 Self::from_path(path)
393 .name("Workspace")
394 .description("Temporary workspace with read-write access")
395 .read_write()
396 .max_depth(10)
397 .excluded_patterns(vec![
398 ".DS_Store".to_string(),
399 "Thumbs.db".to_string(),
400 "*.tmp".to_string(),
401 ])
402 .tag("workspace")
403 }
404}
405
406#[cfg(test)]
407mod tests {
408 use super::*;
409 use serde_json::json;
410 use turul_mcp_protocol::roots::RootDefinition;
411
412 #[test]
413 fn test_root_builder_basic() {
414 let root = RootBuilder::new("file:///home/user/project")
415 .name("My Project")
416 .description("A sample project")
417 .read_write()
418 .max_depth(5)
419 .build()
420 .expect("Failed to build root");
421
422 assert_eq!(root.uri(), "file:///home/user/project");
423 assert_eq!(root.name(), Some("My Project"));
424 assert_eq!(root.description(), Some("A sample project"));
425 assert!(root.can_write("any/path"));
426 assert_eq!(root.max_depth(), Some(5));
427 }
428
429 #[test]
430 fn test_root_builder_from_path() {
431 let path = PathBuf::from("/home/user/project");
432 let root = RootBuilder::from_path(&path)
433 .name("Project Root")
434 .build()
435 .expect("Failed to build root");
436
437 assert!(root.uri().starts_with("file://"));
438 assert!(root.uri().contains("/home/user/project"));
439 assert_eq!(root.name(), Some("Project Root"));
440 }
441
442 #[test]
443 fn test_root_builder_filtering() {
444 let root = RootBuilder::new("file:///home/user/src")
445 .allowed_extensions(vec!["rs".to_string(), "toml".to_string()])
446 .excluded_patterns(vec!["target".to_string(), ".git".to_string()])
447 .tags(vec!["rust".to_string(), "source".to_string()])
448 .build()
449 .expect("Failed to build root");
450
451 assert_eq!(
452 root.allowed_extensions(),
453 Some(&["rs".to_string(), "toml".to_string()][..])
454 );
455 assert_eq!(
456 root.excluded_patterns(),
457 Some(&["target".to_string(), ".git".to_string()][..])
458 );
459 assert_eq!(
460 root.tags(),
461 Some(&["rust".to_string(), "source".to_string()][..])
462 );
463 }
464
465 #[test]
466 fn test_root_builder_meta() {
467 let mut meta = HashMap::new();
468 meta.insert("version".to_string(), json!("1.0"));
469 meta.insert("type".to_string(), json!("workspace"));
470
471 let root = RootBuilder::new("file:///workspace")
472 .meta(meta.clone())
473 .build()
474 .expect("Failed to build root");
475
476 assert_eq!(root.annotations(), Some(&meta));
477 }
478
479 #[test]
480 fn test_root_builder_fluent_meta() {
481 let root = RootBuilder::new("file:///project")
482 .meta_value("project_id", json!("proj-123"))
483 .meta_value("owner", json!("alice"))
484 .build()
485 .expect("Failed to build root");
486
487 let annotations = root.annotations().expect("Expected annotations");
488 assert_eq!(annotations.get("project_id"), Some(&json!("proj-123")));
489 assert_eq!(annotations.get("owner"), Some(&json!("alice")));
490 }
491
492 #[test]
493 fn test_root_builder_permissions() {
494 let readonly_root = RootBuilder::new("file:///readonly")
496 .build()
497 .expect("Failed to build root");
498 assert!(readonly_root.can_read("any/file"));
499 assert!(!readonly_root.can_write("any/file"));
500
501 let readwrite_root = RootBuilder::new("file:///readwrite")
503 .read_write()
504 .build()
505 .expect("Failed to build root");
506 assert!(readwrite_root.can_read("any/file"));
507 assert!(readwrite_root.can_write("any/file"));
508 }
509
510 #[test]
511 fn test_root_builder_convenience_extensions() {
512 let root = RootBuilder::new("file:///src")
513 .allow_extension("rs")
514 .allow_extension("toml")
515 .exclude_pattern("target")
516 .exclude_pattern(".git")
517 .tag("rust")
518 .tag("project")
519 .build()
520 .expect("Failed to build root");
521
522 assert_eq!(
523 root.allowed_extensions(),
524 Some(&["rs".to_string(), "toml".to_string()][..])
525 );
526 assert_eq!(
527 root.excluded_patterns(),
528 Some(&["target".to_string(), ".git".to_string()][..])
529 );
530 assert_eq!(
531 root.tags(),
532 Some(&["rust".to_string(), "project".to_string()][..])
533 );
534 }
535
536 #[test]
537 fn test_root_validation() {
538 let valid = RootBuilder::new("file:///valid/path").build();
540 assert!(valid.is_ok());
541
542 let invalid = RootBuilder::new("http://invalid/path").build();
544 assert!(invalid.is_err());
545 assert!(invalid.unwrap_err().contains("must start with 'file://'"));
546 }
547
548 #[test]
549 fn test_root_definition_trait_implementation() {
550 let root = RootBuilder::new("file:///test")
551 .name("Test Root")
552 .build()
553 .expect("Failed to build root");
554
555 let protocol_root = root.to_root();
557 assert_eq!(protocol_root.uri, "file:///test");
558 assert_eq!(protocol_root.name, Some("Test Root".to_string()));
559
560 assert!(root.validate().is_ok());
562 }
563
564 #[test]
565 fn test_preset_builders() {
566 let src_root = RootBuilder::source_code_root("/home/user/project")
568 .build()
569 .expect("Failed to build source root");
570 assert_eq!(src_root.name(), Some("Source Code"));
571 assert!(
572 src_root
573 .allowed_extensions()
574 .unwrap()
575 .contains(&"rs".to_string())
576 );
577 assert!(
578 src_root
579 .excluded_patterns()
580 .unwrap()
581 .contains(&"node_modules".to_string())
582 );
583 assert!(
584 src_root
585 .tags()
586 .unwrap()
587 .contains(&"source-code".to_string())
588 );
589
590 let docs_root = RootBuilder::docs_root("/home/user/docs")
592 .build()
593 .expect("Failed to build docs root");
594 assert_eq!(docs_root.name(), Some("Documentation"));
595 assert!(
596 docs_root
597 .allowed_extensions()
598 .unwrap()
599 .contains(&"md".to_string())
600 );
601
602 let config_root = RootBuilder::config_root("/etc/myapp")
604 .build()
605 .expect("Failed to build config root");
606 assert_eq!(config_root.name(), Some("Configuration"));
607 assert!(
608 config_root
609 .allowed_extensions()
610 .unwrap()
611 .contains(&"json".to_string())
612 );
613
614 let workspace_root = RootBuilder::workspace_root("/tmp/workspace")
616 .build()
617 .expect("Failed to build workspace root");
618 assert_eq!(workspace_root.name(), Some("Workspace"));
619 assert!(workspace_root.can_write("any/file")); }
621
622 #[test]
623 fn test_list_roots_request_builder() {
624 let request = ListRootsRequestBuilder::new()
625 .meta_value("client_id", json!("client-123"))
626 .build();
627
628 assert_eq!(request.method, "roots/list");
629 let params = request.params.expect("Expected params");
630 let meta = params.meta.expect("Expected meta");
631 assert_eq!(meta.get("client_id"), Some(&json!("client-123")));
632 }
633
634 #[test]
635 fn test_roots_notification_builder() {
636 let notification = RootsNotificationBuilder::new()
637 .meta_value("timestamp", json!("2025-01-01T00:00:00Z"))
638 .build();
639
640 assert_eq!(notification.method, "notifications/roots/listChanged");
641 let params = notification.params.expect("Expected params");
642 let meta = params.meta.expect("Expected meta");
643 assert_eq!(meta.get("timestamp"), Some(&json!("2025-01-01T00:00:00Z")));
644 }
645
646 #[test]
647 fn test_root_filtering_functionality() {
648 let root = RootBuilder::new("file:///src")
649 .allowed_extensions(vec!["rs".to_string(), "toml".to_string()])
650 .excluded_patterns(vec!["target".to_string(), ".git".to_string()])
651 .build()
652 .expect("Failed to build root");
653
654 assert!(root.should_include("main.rs"));
656 assert!(root.should_include("Cargo.toml"));
657 assert!(!root.should_include("main.py")); assert!(!root.should_include("target/debug/main")); assert!(!root.should_include(".git/config")); }
661}