turul_mcp_builders/
root.rs

1//! Root Builder for Runtime Root Configuration
2//!
3//! This module provides a builder pattern for creating MCP root directory configurations
4//! at runtime. This enables dynamic root setup for file system access and directory management.
5
6use serde_json::Value;
7use std::collections::HashMap;
8use std::path::PathBuf;
9
10// Import protocol types
11use turul_mcp_protocol::roots::{ListRootsRequest, RootsListChangedNotification};
12
13// Import framework traits from local crate
14use crate::traits::{HasRootAnnotations, HasRootFiltering, HasRootMetadata, HasRootPermissions};
15
16/// Builder for creating root configurations at runtime
17pub struct RootBuilder {
18    uri: String,
19    name: Option<String>,
20    description: Option<String>,
21    meta: Option<HashMap<String, Value>>,
22    // Permission settings
23    read_only: bool,
24    max_depth: Option<usize>,
25    // Filtering settings
26    allowed_extensions: Option<Vec<String>>,
27    excluded_patterns: Option<Vec<String>>,
28    tags: Option<Vec<String>>,
29}
30
31impl RootBuilder {
32    /// Create a new root builder with the given URI
33    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, // Safe default
40            max_depth: None,
41            allowed_extensions: None,
42            excluded_patterns: None,
43            tags: None,
44        }
45    }
46
47    /// Create a root builder from a file path (automatically converts to file:// URI)
48    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    /// Set the human-readable name for this root
55    pub fn name(mut self, name: impl Into<String>) -> Self {
56        self.name = Some(name.into());
57        self
58    }
59
60    /// Set the description for this root
61    pub fn description(mut self, description: impl Into<String>) -> Self {
62        self.description = Some(description.into());
63        self
64    }
65
66    /// Set meta information
67    pub fn meta(mut self, meta: HashMap<String, Value>) -> Self {
68        self.meta = Some(meta);
69        self
70    }
71
72    /// Add a meta key-value pair
73    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    /// Set whether this root is read-only (default: true for safety)
82    pub fn read_only(mut self, read_only: bool) -> Self {
83        self.read_only = read_only;
84        self
85    }
86
87    /// Allow read and write access (convenience method)
88    pub fn read_write(mut self) -> Self {
89        self.read_only = false;
90        self
91    }
92
93    /// Set maximum directory traversal depth
94    pub fn max_depth(mut self, depth: usize) -> Self {
95        self.max_depth = Some(depth);
96        self
97    }
98
99    /// Set allowed file extensions (None means all extensions allowed)
100    pub fn allowed_extensions(mut self, extensions: Vec<String>) -> Self {
101        self.allowed_extensions = Some(extensions);
102        self
103    }
104
105    /// Add an allowed file extension
106    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    /// Set excluded file patterns (glob-style patterns)
118    pub fn excluded_patterns(mut self, patterns: Vec<String>) -> Self {
119        self.excluded_patterns = Some(patterns);
120        self
121    }
122
123    /// Add an excluded file pattern
124    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    /// Set tags for this root
136    pub fn tags(mut self, tags: Vec<String>) -> Self {
137        self.tags = Some(tags);
138        self
139    }
140
141    /// Add a tag
142    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    /// Build the dynamic root configuration
151    pub fn build(self) -> Result<DynamicRoot, String> {
152        // Validate URI
153        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/// Dynamic root configuration created by RootBuilder
172#[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
185// Implement all fine-grained traits for DynamicRoot
186impl 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 // Always allow reading within the root
203    }
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
234// RootDefinition is automatically implemented via blanket impl!
235
236/// Builder for ListRootsRequest
237pub struct ListRootsRequestBuilder {
238    meta: Option<HashMap<String, Value>>,
239}
240
241impl ListRootsRequestBuilder {
242    pub fn new() -> Self {
243        Self { meta: None }
244    }
245
246    /// Set meta information
247    pub fn meta(mut self, meta: HashMap<String, Value>) -> Self {
248        self.meta = Some(meta);
249        self
250    }
251
252    /// Add a meta key-value pair
253    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    /// Build the ListRootsRequest
262    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
277/// Builder for RootsListChangedNotification
278pub struct RootsNotificationBuilder {
279    meta: Option<HashMap<String, Value>>,
280}
281
282impl RootsNotificationBuilder {
283    pub fn new() -> Self {
284        Self { meta: None }
285    }
286
287    /// Set meta information
288    pub fn meta(mut self, meta: HashMap<String, Value>) -> Self {
289        self.meta = Some(meta);
290        self
291    }
292
293    /// Add a meta key-value pair
294    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    /// Build the RootsListChangedNotification
303    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
318/// Convenience methods for common root patterns
319impl RootBuilder {
320    /// Create a source code root with common file extensions
321    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    /// Create a documentation root
354    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    /// Create a configuration root
372    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    /// Create a temporary workspace root with write access
391    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 crate::traits::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        // Read-only root (default)
495        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        // Read-write root
502        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        // Valid file:// URI
539        let valid = RootBuilder::new("file:///valid/path").build();
540        assert!(valid.is_ok());
541
542        // Invalid URI (not file://)
543        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        // Test that it implements RootDefinition
556        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        // Test validation
561        assert!(root.validate().is_ok());
562    }
563
564    #[test]
565    fn test_preset_builders() {
566        // Source code root
567        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        // Docs root
591        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        // Config root
603        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        // Workspace root
615        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")); // Read-write enabled
620    }
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        // Test should_include functionality (via trait implementation)
655        assert!(root.should_include("main.rs"));
656        assert!(root.should_include("Cargo.toml"));
657        assert!(!root.should_include("main.py")); // Wrong extension
658        assert!(!root.should_include("target/debug/main")); // Excluded pattern
659        assert!(!root.should_include(".git/config")); // Excluded pattern
660    }
661}