mcpls_core/bridge/
state.rs

1//! Document state management.
2//!
3//! Tracks open documents and their versions for LSP synchronization.
4
5use std::collections::HashMap;
6use std::path::{Path, PathBuf};
7
8use lsp_types::{DidOpenTextDocumentParams, TextDocumentItem, Uri};
9
10use crate::error::{Error, Result};
11use crate::lsp::LspClient;
12
13/// State of a single document.
14#[derive(Debug, Clone, PartialEq, Eq)]
15pub struct DocumentState {
16    /// Document URI.
17    pub uri: Uri,
18    /// Language identifier.
19    pub language_id: String,
20    /// Document version (monotonically increasing).
21    pub version: i32,
22    /// Document content.
23    pub content: String,
24}
25
26/// Resource limits for document tracking.
27#[derive(Debug, Clone, Copy)]
28pub struct ResourceLimits {
29    /// Maximum number of open documents (0 = unlimited).
30    pub max_documents: usize,
31    /// Maximum file size in bytes (0 = unlimited).
32    pub max_file_size: u64,
33}
34
35impl Default for ResourceLimits {
36    fn default() -> Self {
37        Self {
38            max_documents: 100,
39            max_file_size: 10 * 1024 * 1024, // 10MB
40        }
41    }
42}
43
44/// Tracks document state across the workspace.
45#[derive(Debug)]
46pub struct DocumentTracker {
47    /// Open documents by file path.
48    documents: HashMap<PathBuf, DocumentState>,
49    /// Resource limits for tracking.
50    limits: ResourceLimits,
51    /// Custom file extension to language ID mappings.
52    extension_map: HashMap<String, String>,
53}
54
55impl DocumentTracker {
56    /// Create a new document tracker with custom limits and extension mappings.
57    #[must_use]
58    pub fn new(limits: ResourceLimits, extension_map: HashMap<String, String>) -> Self {
59        Self {
60            documents: HashMap::new(),
61            limits,
62            extension_map,
63        }
64    }
65
66    /// Check if a document is currently open.
67    #[must_use]
68    pub fn is_open(&self, path: &Path) -> bool {
69        self.documents.contains_key(path)
70    }
71
72    /// Get the state of an open document.
73    #[must_use]
74    pub fn get(&self, path: &Path) -> Option<&DocumentState> {
75        self.documents.get(path)
76    }
77
78    /// Get the number of open documents.
79    #[must_use]
80    pub fn len(&self) -> usize {
81        self.documents.len()
82    }
83
84    /// Check if there are no open documents.
85    #[must_use]
86    pub fn is_empty(&self) -> bool {
87        self.documents.is_empty()
88    }
89
90    /// Open a document and track its state.
91    ///
92    /// Returns the document URI for use in LSP requests.
93    ///
94    /// # Errors
95    ///
96    /// Returns an error if:
97    /// - Document limit is exceeded
98    /// - File size limit is exceeded
99    pub fn open(&mut self, path: PathBuf, content: String) -> Result<Uri> {
100        // Check document limit
101        if self.limits.max_documents > 0 && self.documents.len() >= self.limits.max_documents {
102            return Err(Error::DocumentLimitExceeded {
103                current: self.documents.len(),
104                max: self.limits.max_documents,
105            });
106        }
107
108        // Check file size limit
109        let size = content.len() as u64;
110        if self.limits.max_file_size > 0 && size > self.limits.max_file_size {
111            return Err(Error::FileSizeLimitExceeded {
112                size,
113                max: self.limits.max_file_size,
114            });
115        }
116
117        let uri = path_to_uri(&path);
118        let language_id = detect_language(&path, &self.extension_map);
119
120        let state = DocumentState {
121            uri: uri.clone(),
122            language_id,
123            version: 1,
124            content,
125        };
126
127        self.documents.insert(path, state);
128        Ok(uri)
129    }
130
131    /// Update a document's content and increment its version.
132    ///
133    /// Returns `None` if the document is not open.
134    pub fn update(&mut self, path: &Path, content: String) -> Option<i32> {
135        if let Some(state) = self.documents.get_mut(path) {
136            state.version += 1;
137            state.content = content;
138            Some(state.version)
139        } else {
140            None
141        }
142    }
143
144    /// Close a document and remove it from tracking.
145    ///
146    /// Returns the document state if it was open.
147    pub fn close(&mut self, path: &Path) -> Option<DocumentState> {
148        self.documents.remove(path)
149    }
150
151    /// Close all documents.
152    pub fn close_all(&mut self) -> Vec<DocumentState> {
153        self.documents.drain().map(|(_, state)| state).collect()
154    }
155
156    /// Ensure a document is open, opening it lazily if necessary.
157    ///
158    /// If the document is already open, returns its URI immediately.
159    /// Otherwise, reads the file from disk, opens it in the tracker,
160    /// and sends a `didOpen` notification to the LSP server.
161    ///
162    /// # Errors
163    ///
164    /// Returns an error if:
165    /// - The file cannot be read from disk
166    /// - The `didOpen` notification fails to send
167    /// - Resource limits are exceeded
168    pub async fn ensure_open(&mut self, path: &Path, lsp_client: &LspClient) -> Result<Uri> {
169        if let Some(state) = self.documents.get(path) {
170            return Ok(state.uri.clone());
171        }
172
173        let content = tokio::fs::read_to_string(path)
174            .await
175            .map_err(|e| Error::FileIo {
176                path: path.to_path_buf(),
177                source: e,
178            })?;
179
180        let uri = self.open(path.to_path_buf(), content.clone())?;
181        let state = self
182            .documents
183            .get(path)
184            .ok_or_else(|| Error::DocumentNotFound(path.to_path_buf()))?;
185
186        let params = DidOpenTextDocumentParams {
187            text_document: TextDocumentItem {
188                uri: uri.clone(),
189                language_id: state.language_id.clone(),
190                version: state.version,
191                text: content,
192            },
193        };
194
195        lsp_client.notify("textDocument/didOpen", params).await?;
196
197        Ok(uri)
198    }
199}
200
201/// Convert a file path to a URI.
202#[must_use]
203pub fn path_to_uri(path: &Path) -> Uri {
204    // Convert path to file:// URI string and parse
205    let uri_string = if cfg!(windows) {
206        format!("file:///{}", path.display().to_string().replace('\\', "/"))
207    } else {
208        format!("file://{}", path.display())
209    };
210    // Path-to-URI conversion should always succeed for valid paths
211    #[allow(clippy::expect_used)]
212    uri_string.parse().expect("failed to create URI from path")
213}
214
215/// Detect the language ID from a file path.
216///
217/// Consults the extension map to determine the language ID for a file.
218/// If the extension is not found in the map, returns "plaintext".
219#[must_use]
220pub fn detect_language(path: &Path, extension_map: &HashMap<String, String>) -> String {
221    let extension = path.extension().and_then(|e| e.to_str()).unwrap_or("");
222
223    extension_map
224        .get(extension)
225        .cloned()
226        .unwrap_or_else(|| "plaintext".to_string())
227}
228
229#[cfg(test)]
230#[allow(clippy::unwrap_used)]
231mod tests {
232    use super::*;
233
234    #[test]
235    fn test_detect_language() {
236        let mut map = HashMap::new();
237        map.insert("rs".to_string(), "rust".to_string());
238        map.insert("py".to_string(), "python".to_string());
239        map.insert("ts".to_string(), "typescript".to_string());
240
241        assert_eq!(detect_language(Path::new("main.rs"), &map), "rust");
242        assert_eq!(detect_language(Path::new("script.py"), &map), "python");
243        assert_eq!(detect_language(Path::new("app.ts"), &map), "typescript");
244        assert_eq!(detect_language(Path::new("unknown.xyz"), &map), "plaintext");
245    }
246
247    #[test]
248    fn test_document_tracker() {
249        let mut map = HashMap::new();
250        map.insert("rs".to_string(), "rust".to_string());
251
252        let mut tracker = DocumentTracker::new(ResourceLimits::default(), map);
253        let path = PathBuf::from("/test/file.rs");
254
255        assert!(!tracker.is_open(&path));
256
257        tracker
258            .open(path.clone(), "fn main() {}".to_string())
259            .unwrap();
260        assert!(tracker.is_open(&path));
261        assert_eq!(tracker.len(), 1);
262
263        let state = tracker.get(&path).unwrap();
264        assert_eq!(state.version, 1);
265        assert_eq!(state.language_id, "rust");
266
267        let new_version = tracker.update(&path, "fn main() { println!() }".to_string());
268        assert_eq!(new_version, Some(2));
269
270        tracker.close(&path);
271        assert!(!tracker.is_open(&path));
272        assert!(tracker.is_empty());
273    }
274
275    #[test]
276    fn test_document_limit() {
277        let limits = ResourceLimits {
278            max_documents: 2,
279            max_file_size: 100,
280        };
281        let mut map = HashMap::new();
282        map.insert("rs".to_string(), "rust".to_string());
283
284        let mut tracker = DocumentTracker::new(limits, map);
285
286        // First two documents should succeed
287        tracker
288            .open(PathBuf::from("/test/file1.rs"), "fn test1() {}".to_string())
289            .unwrap();
290        tracker
291            .open(PathBuf::from("/test/file2.rs"), "fn test2() {}".to_string())
292            .unwrap();
293
294        // Third should fail
295        let result = tracker.open(PathBuf::from("/test/file3.rs"), "fn test3() {}".to_string());
296        assert!(matches!(result, Err(Error::DocumentLimitExceeded { .. })));
297    }
298
299    #[test]
300    fn test_file_size_limit() {
301        let limits = ResourceLimits {
302            max_documents: 10,
303            max_file_size: 10,
304        };
305        let mut map = HashMap::new();
306        map.insert("rs".to_string(), "rust".to_string());
307
308        let mut tracker = DocumentTracker::new(limits, map);
309
310        // Small file should succeed
311        tracker
312            .open(PathBuf::from("/test/small.rs"), "fn f(){}".to_string())
313            .unwrap();
314
315        // Large file should fail
316        let large_content = "x".repeat(100);
317        let result = tracker.open(PathBuf::from("/test/large.rs"), large_content);
318        assert!(matches!(result, Err(Error::FileSizeLimitExceeded { .. })));
319    }
320
321    #[test]
322    fn test_resource_limits_default() {
323        let limits = ResourceLimits::default();
324        assert_eq!(limits.max_documents, 100);
325        assert_eq!(limits.max_file_size, 10 * 1024 * 1024);
326    }
327
328    #[test]
329    fn test_resource_limits_custom() {
330        let limits = ResourceLimits {
331            max_documents: 50,
332            max_file_size: 5 * 1024 * 1024,
333        };
334        assert_eq!(limits.max_documents, 50);
335        assert_eq!(limits.max_file_size, 5 * 1024 * 1024);
336    }
337
338    #[test]
339    fn test_resource_limits_zero_unlimited() {
340        let limits = ResourceLimits {
341            max_documents: 0,
342            max_file_size: 0,
343        };
344        let mut map = HashMap::new();
345        map.insert("rs".to_string(), "rust".to_string());
346
347        let mut tracker = DocumentTracker::new(limits, map);
348
349        // Should allow many documents when limit is 0
350        for i in 0..200 {
351            tracker
352                .open(
353                    PathBuf::from(format!("/test/file{i}.rs")),
354                    "content".to_string(),
355                )
356                .unwrap();
357        }
358        assert_eq!(tracker.len(), 200);
359
360        // Should allow large files when limit is 0
361        let huge_content = "x".repeat(100_000_000);
362        tracker
363            .open(PathBuf::from("/test/huge.rs"), huge_content)
364            .unwrap();
365    }
366
367    #[test]
368    fn test_document_state_clone() {
369        let state = DocumentState {
370            uri: "file:///test.rs".parse().unwrap(),
371            language_id: "rust".to_string(),
372            version: 5,
373            content: "fn main() {}".to_string(),
374        };
375
376        #[allow(clippy::redundant_clone)]
377        let cloned = state.clone();
378        assert_eq!(cloned.uri, state.uri);
379        assert_eq!(cloned.language_id, state.language_id);
380        assert_eq!(cloned.version, 5);
381        assert_eq!(cloned.content, state.content);
382    }
383
384    #[test]
385    fn test_update_nonexistent_document() {
386        let map = HashMap::new();
387        let mut tracker = DocumentTracker::new(ResourceLimits::default(), map);
388        let path = PathBuf::from("/test/nonexistent.rs");
389
390        let version = tracker.update(&path, "new content".to_string());
391        assert_eq!(
392            version, None,
393            "Updating non-existent document should return None"
394        );
395    }
396
397    #[test]
398    fn test_close_nonexistent_document() {
399        let map = HashMap::new();
400        let mut tracker = DocumentTracker::new(ResourceLimits::default(), map);
401        let path = PathBuf::from("/test/nonexistent.rs");
402
403        let state = tracker.close(&path);
404        assert_eq!(
405            state, None,
406            "Closing non-existent document should return None"
407        );
408    }
409
410    #[test]
411    fn test_close_all_documents() {
412        let mut map = HashMap::new();
413        map.insert("rs".to_string(), "rust".to_string());
414
415        let mut tracker = DocumentTracker::new(ResourceLimits::default(), map);
416
417        tracker
418            .open(PathBuf::from("/test/file1.rs"), "content1".to_string())
419            .unwrap();
420        tracker
421            .open(PathBuf::from("/test/file2.rs"), "content2".to_string())
422            .unwrap();
423        tracker
424            .open(PathBuf::from("/test/file3.rs"), "content3".to_string())
425            .unwrap();
426
427        assert_eq!(tracker.len(), 3);
428
429        let closed = tracker.close_all();
430        assert_eq!(closed.len(), 3);
431        assert!(tracker.is_empty());
432    }
433
434    #[test]
435    fn test_get_nonexistent_document() {
436        let map = HashMap::new();
437        let tracker = DocumentTracker::new(ResourceLimits::default(), map);
438        let path = PathBuf::from("/test/nonexistent.rs");
439
440        let state = tracker.get(&path);
441        assert!(
442            state.is_none(),
443            "Getting non-existent document should return None"
444        );
445    }
446
447    #[test]
448    fn test_document_version_increments() {
449        let mut map = HashMap::new();
450        map.insert("rs".to_string(), "rust".to_string());
451
452        let mut tracker = DocumentTracker::new(ResourceLimits::default(), map);
453        let path = PathBuf::from("/test/versioned.rs");
454
455        tracker.open(path.clone(), "v1".to_string()).unwrap();
456        assert_eq!(tracker.get(&path).unwrap().version, 1);
457
458        tracker.update(&path, "v2".to_string());
459        assert_eq!(tracker.get(&path).unwrap().version, 2);
460
461        tracker.update(&path, "v3".to_string());
462        assert_eq!(tracker.get(&path).unwrap().version, 3);
463
464        tracker.update(&path, "v4".to_string());
465        assert_eq!(tracker.get(&path).unwrap().version, 4);
466    }
467
468    #[test]
469    #[allow(clippy::too_many_lines)]
470    fn test_detect_language_all_extensions() {
471        let mut map = HashMap::new();
472        map.insert("rs".to_string(), "rust".to_string());
473        map.insert("py".to_string(), "python".to_string());
474        map.insert("pyw".to_string(), "python".to_string());
475        map.insert("pyi".to_string(), "python".to_string());
476        map.insert("js".to_string(), "javascript".to_string());
477        map.insert("mjs".to_string(), "javascript".to_string());
478        map.insert("cjs".to_string(), "javascript".to_string());
479        map.insert("ts".to_string(), "typescript".to_string());
480        map.insert("mts".to_string(), "typescript".to_string());
481        map.insert("cts".to_string(), "typescript".to_string());
482        map.insert("tsx".to_string(), "typescriptreact".to_string());
483        map.insert("jsx".to_string(), "javascriptreact".to_string());
484        map.insert("go".to_string(), "go".to_string());
485        map.insert("c".to_string(), "c".to_string());
486        map.insert("h".to_string(), "c".to_string());
487        map.insert("cpp".to_string(), "cpp".to_string());
488        map.insert("cc".to_string(), "cpp".to_string());
489        map.insert("cxx".to_string(), "cpp".to_string());
490        map.insert("hpp".to_string(), "cpp".to_string());
491        map.insert("hh".to_string(), "cpp".to_string());
492        map.insert("hxx".to_string(), "cpp".to_string());
493        map.insert("java".to_string(), "java".to_string());
494        map.insert("rb".to_string(), "ruby".to_string());
495        map.insert("php".to_string(), "php".to_string());
496        map.insert("swift".to_string(), "swift".to_string());
497        map.insert("kt".to_string(), "kotlin".to_string());
498        map.insert("kts".to_string(), "kotlin".to_string());
499        map.insert("scala".to_string(), "scala".to_string());
500        map.insert("sc".to_string(), "scala".to_string());
501        map.insert("zig".to_string(), "zig".to_string());
502        map.insert("lua".to_string(), "lua".to_string());
503        map.insert("sh".to_string(), "shellscript".to_string());
504        map.insert("bash".to_string(), "shellscript".to_string());
505        map.insert("zsh".to_string(), "shellscript".to_string());
506        map.insert("json".to_string(), "json".to_string());
507        map.insert("toml".to_string(), "toml".to_string());
508        map.insert("yaml".to_string(), "yaml".to_string());
509        map.insert("yml".to_string(), "yaml".to_string());
510        map.insert("xml".to_string(), "xml".to_string());
511        map.insert("html".to_string(), "html".to_string());
512        map.insert("htm".to_string(), "html".to_string());
513        map.insert("css".to_string(), "css".to_string());
514        map.insert("scss".to_string(), "scss".to_string());
515        map.insert("less".to_string(), "less".to_string());
516        map.insert("md".to_string(), "markdown".to_string());
517        map.insert("markdown".to_string(), "markdown".to_string());
518
519        assert_eq!(detect_language(Path::new("main.rs"), &map), "rust");
520        assert_eq!(detect_language(Path::new("script.py"), &map), "python");
521        assert_eq!(detect_language(Path::new("script.pyw"), &map), "python");
522        assert_eq!(detect_language(Path::new("script.pyi"), &map), "python");
523        assert_eq!(detect_language(Path::new("app.js"), &map), "javascript");
524        assert_eq!(detect_language(Path::new("app.mjs"), &map), "javascript");
525        assert_eq!(detect_language(Path::new("app.cjs"), &map), "javascript");
526        assert_eq!(detect_language(Path::new("app.ts"), &map), "typescript");
527        assert_eq!(detect_language(Path::new("app.mts"), &map), "typescript");
528        assert_eq!(detect_language(Path::new("app.cts"), &map), "typescript");
529        assert_eq!(
530            detect_language(Path::new("component.tsx"), &map),
531            "typescriptreact"
532        );
533        assert_eq!(
534            detect_language(Path::new("component.jsx"), &map),
535            "javascriptreact"
536        );
537        assert_eq!(detect_language(Path::new("main.go"), &map), "go");
538        assert_eq!(detect_language(Path::new("main.c"), &map), "c");
539        assert_eq!(detect_language(Path::new("header.h"), &map), "c");
540        assert_eq!(detect_language(Path::new("main.cpp"), &map), "cpp");
541        assert_eq!(detect_language(Path::new("main.cc"), &map), "cpp");
542        assert_eq!(detect_language(Path::new("main.cxx"), &map), "cpp");
543        assert_eq!(detect_language(Path::new("header.hpp"), &map), "cpp");
544        assert_eq!(detect_language(Path::new("header.hh"), &map), "cpp");
545        assert_eq!(detect_language(Path::new("header.hxx"), &map), "cpp");
546        assert_eq!(detect_language(Path::new("Main.java"), &map), "java");
547        assert_eq!(detect_language(Path::new("script.rb"), &map), "ruby");
548        assert_eq!(detect_language(Path::new("index.php"), &map), "php");
549        assert_eq!(detect_language(Path::new("App.swift"), &map), "swift");
550        assert_eq!(detect_language(Path::new("Main.kt"), &map), "kotlin");
551        assert_eq!(detect_language(Path::new("script.kts"), &map), "kotlin");
552        assert_eq!(detect_language(Path::new("Main.scala"), &map), "scala");
553        assert_eq!(detect_language(Path::new("script.sc"), &map), "scala");
554        assert_eq!(detect_language(Path::new("main.zig"), &map), "zig");
555        assert_eq!(detect_language(Path::new("script.lua"), &map), "lua");
556        assert_eq!(detect_language(Path::new("script.sh"), &map), "shellscript");
557        assert_eq!(
558            detect_language(Path::new("script.bash"), &map),
559            "shellscript"
560        );
561        assert_eq!(
562            detect_language(Path::new("script.zsh"), &map),
563            "shellscript"
564        );
565        assert_eq!(detect_language(Path::new("data.json"), &map), "json");
566        assert_eq!(detect_language(Path::new("config.toml"), &map), "toml");
567        assert_eq!(detect_language(Path::new("config.yaml"), &map), "yaml");
568        assert_eq!(detect_language(Path::new("config.yml"), &map), "yaml");
569        assert_eq!(detect_language(Path::new("data.xml"), &map), "xml");
570        assert_eq!(detect_language(Path::new("index.html"), &map), "html");
571        assert_eq!(detect_language(Path::new("index.htm"), &map), "html");
572        assert_eq!(detect_language(Path::new("styles.css"), &map), "css");
573        assert_eq!(detect_language(Path::new("styles.scss"), &map), "scss");
574        assert_eq!(detect_language(Path::new("styles.less"), &map), "less");
575        assert_eq!(detect_language(Path::new("README.md"), &map), "markdown");
576        assert_eq!(
577            detect_language(Path::new("README.markdown"), &map),
578            "markdown"
579        );
580        assert_eq!(detect_language(Path::new("unknown.xyz"), &map), "plaintext");
581        assert_eq!(
582            detect_language(Path::new("no_extension"), &map),
583            "plaintext"
584        );
585    }
586
587    #[test]
588    fn test_path_to_uri_unix() {
589        #[cfg(not(windows))]
590        {
591            let path = Path::new("/home/user/project/main.rs");
592            let uri = path_to_uri(path);
593            assert!(
594                uri.as_str()
595                    .starts_with("file:///home/user/project/main.rs")
596            );
597        }
598    }
599
600    #[test]
601    fn test_path_to_uri_with_special_chars() {
602        let path = Path::new("/home/user/project-test/main.rs");
603        let uri = path_to_uri(path);
604        assert!(uri.as_str().starts_with("file://"));
605        assert!(uri.as_str().contains("project-test"));
606    }
607
608    #[test]
609    fn test_document_tracker_concurrent_operations() {
610        let mut map = HashMap::new();
611        map.insert("rs".to_string(), "rust".to_string());
612
613        let mut tracker = DocumentTracker::new(ResourceLimits::default(), map);
614        let path1 = PathBuf::from("/test/file1.rs");
615        let path2 = PathBuf::from("/test/file2.rs");
616
617        tracker.open(path1.clone(), "content1".to_string()).unwrap();
618        tracker.open(path2.clone(), "content2".to_string()).unwrap();
619
620        assert_eq!(tracker.len(), 2);
621        assert!(tracker.is_open(&path1));
622        assert!(tracker.is_open(&path2));
623
624        tracker.update(&path1, "new content1".to_string());
625        assert_eq!(tracker.get(&path1).unwrap().content, "new content1");
626        assert_eq!(tracker.get(&path2).unwrap().content, "content2");
627
628        tracker.close(&path1);
629        assert_eq!(tracker.len(), 1);
630        assert!(!tracker.is_open(&path1));
631        assert!(tracker.is_open(&path2));
632    }
633
634    #[test]
635    fn test_empty_content() {
636        let mut map = HashMap::new();
637        map.insert("rs".to_string(), "rust".to_string());
638
639        let mut tracker = DocumentTracker::new(ResourceLimits::default(), map);
640        let path = PathBuf::from("/test/empty.rs");
641
642        tracker.open(path.clone(), String::new()).unwrap();
643        assert!(tracker.is_open(&path));
644        assert_eq!(tracker.get(&path).unwrap().content, "");
645    }
646
647    #[test]
648    fn test_unicode_content() {
649        let mut map = HashMap::new();
650        map.insert("rs".to_string(), "rust".to_string());
651
652        let mut tracker = DocumentTracker::new(ResourceLimits::default(), map);
653        let path = PathBuf::from("/test/unicode.rs");
654        let content = "fn テスト() { println!(\"こんにちは\"); }";
655
656        tracker.open(path.clone(), content.to_string()).unwrap();
657        assert_eq!(tracker.get(&path).unwrap().content, content);
658    }
659
660    #[test]
661    fn test_document_limit_exact_boundary() {
662        let limits = ResourceLimits {
663            max_documents: 5,
664            max_file_size: 1000,
665        };
666        let mut map = HashMap::new();
667        map.insert("rs".to_string(), "rust".to_string());
668
669        let mut tracker = DocumentTracker::new(limits, map);
670
671        for i in 0..5 {
672            tracker
673                .open(
674                    PathBuf::from(format!("/test/file{i}.rs")),
675                    "content".to_string(),
676                )
677                .unwrap();
678        }
679
680        assert_eq!(tracker.len(), 5);
681
682        let result = tracker.open(PathBuf::from("/test/file6.rs"), "content".to_string());
683        assert!(matches!(result, Err(Error::DocumentLimitExceeded { .. })));
684    }
685
686    #[test]
687    fn test_file_size_exact_boundary() {
688        let limits = ResourceLimits {
689            max_documents: 10,
690            max_file_size: 100,
691        };
692        let mut map = HashMap::new();
693        map.insert("rs".to_string(), "rust".to_string());
694
695        let mut tracker = DocumentTracker::new(limits, map);
696
697        let exact_size_content = "x".repeat(100);
698        tracker
699            .open(PathBuf::from("/test/exact.rs"), exact_size_content)
700            .unwrap();
701
702        let over_size_content = "x".repeat(101);
703        let result = tracker.open(PathBuf::from("/test/over.rs"), over_size_content);
704        assert!(matches!(result, Err(Error::FileSizeLimitExceeded { .. })));
705    }
706
707    #[test]
708    fn test_detect_language_with_custom_extension() {
709        let mut map = HashMap::new();
710        map.insert("nu".to_string(), "nushell".to_string());
711
712        assert_eq!(detect_language(Path::new("script.nu"), &map), "nushell");
713
714        let empty_map = HashMap::new();
715        assert_eq!(
716            detect_language(Path::new("script.nu"), &empty_map),
717            "plaintext"
718        );
719    }
720
721    #[test]
722    fn test_detect_language_custom_overrides_default() {
723        let mut custom_map = HashMap::new();
724        custom_map.insert("rs".to_string(), "custom-rust".to_string());
725
726        assert_eq!(
727            detect_language(Path::new("main.rs"), &custom_map),
728            "custom-rust"
729        );
730
731        let mut default_map = HashMap::new();
732        default_map.insert("rs".to_string(), "rust".to_string());
733
734        assert_eq!(detect_language(Path::new("main.rs"), &default_map), "rust");
735    }
736
737    #[test]
738    fn test_detect_language_fallback_to_plaintext() {
739        let mut map = HashMap::new();
740        map.insert("nu".to_string(), "nushell".to_string());
741
742        // .rs not in custom map, should return plaintext
743        assert_eq!(detect_language(Path::new("main.rs"), &map), "plaintext");
744    }
745
746    #[test]
747    fn test_detect_language_empty_map() {
748        let map = HashMap::new();
749        assert_eq!(detect_language(Path::new("main.rs"), &map), "plaintext");
750    }
751
752    #[test]
753    fn test_document_tracker_with_extensions() {
754        let mut map = HashMap::new();
755        map.insert("nu".to_string(), "nushell".to_string());
756
757        let mut tracker = DocumentTracker::new(ResourceLimits::default(), map);
758
759        let path = PathBuf::from("/test/script.nu");
760        tracker
761            .open(path.clone(), "# nushell script".to_string())
762            .unwrap();
763
764        let state = tracker.get(&path).unwrap();
765        assert_eq!(state.language_id, "nushell");
766    }
767
768    #[test]
769    fn test_document_tracker_uses_provided_map() {
770        let mut map = HashMap::new();
771        map.insert("rs".to_string(), "rust".to_string());
772
773        let mut tracker = DocumentTracker::new(ResourceLimits::default(), map);
774        let path = PathBuf::from("/test/main.rs");
775        tracker
776            .open(path.clone(), "fn main() {}".to_string())
777            .unwrap();
778
779        let state = tracker.get(&path).unwrap();
780        assert_eq!(state.language_id, "rust");
781    }
782
783    #[test]
784    fn test_multiple_extensions_same_language() {
785        let mut map = HashMap::new();
786        map.insert("cpp".to_string(), "c++".to_string());
787        map.insert("cc".to_string(), "c++".to_string());
788        map.insert("cxx".to_string(), "c++".to_string());
789
790        assert_eq!(detect_language(Path::new("main.cpp"), &map), "c++");
791        assert_eq!(detect_language(Path::new("main.cc"), &map), "c++");
792        assert_eq!(detect_language(Path::new("main.cxx"), &map), "c++");
793    }
794
795    #[test]
796    fn test_case_sensitive_extensions() {
797        let mut map = HashMap::new();
798        map.insert("NU".to_string(), "nushell".to_string());
799
800        // Lowercase .nu should not match uppercase "NU" in map
801        assert_eq!(detect_language(Path::new("script.nu"), &map), "plaintext");
802    }
803}