1use crate::local_store::LocalStore;
2use async_trait::async_trait;
3use rand::Rng;
4use std::fs;
5use std::path::{Path, PathBuf};
6use uuid::Uuid;
7use walkdir::DirEntry;
8
9pub fn read_gitignore_patterns(base_dir: &str) -> Vec<String> {
11 let mut patterns = vec![".git".to_string()]; let gitignore_path = PathBuf::from(base_dir).join(".gitignore");
14 if let Ok(content) = std::fs::read_to_string(&gitignore_path) {
15 for line in content.lines() {
16 let line = line.trim();
17 if !line.is_empty() && !line.starts_with('#') {
19 patterns.push(line.to_string());
20 }
21 }
22 }
23
24 patterns
25}
26
27pub fn should_include_entry(entry: &DirEntry, base_dir: &str, ignore_patterns: &[String]) -> bool {
29 let path = entry.path();
30 let is_file = entry.file_type().is_file();
31
32 let base_path = PathBuf::from(base_dir);
34 let relative_path = match path.strip_prefix(&base_path) {
35 Ok(rel_path) => rel_path,
36 Err(_) => path,
37 };
38
39 let path_str = relative_path.to_string_lossy();
40
41 for pattern in ignore_patterns {
43 if matches_gitignore_pattern(pattern, &path_str) {
44 return false;
45 }
46 }
47
48 if is_file {
50 is_supported_file(entry.path())
51 } else {
52 true }
54}
55
56#[allow(clippy::string_slice)] pub fn matches_gitignore_pattern(pattern: &str, path: &str) -> bool {
59 let pattern = pattern.trim_end_matches('/'); if pattern.contains('*') {
63 if pattern == "*" {
64 true
65 } else if pattern.starts_with('*') && pattern.ends_with('*') {
66 let middle = &pattern[1..pattern.len() - 1];
67 path.contains(middle)
68 } else if let Some(suffix) = pattern.strip_prefix('*') {
69 path.ends_with(suffix)
70 } else if let Some(prefix) = pattern.strip_suffix('*') {
71 path.starts_with(prefix)
72 } else {
73 pattern_matches_glob(pattern, path)
75 }
76 } else {
77 path == pattern || path.starts_with(&format!("{}/", pattern))
79 }
80}
81
82#[allow(clippy::string_slice)] pub fn pattern_matches_glob(pattern: &str, text: &str) -> bool {
85 let parts: Vec<&str> = pattern.split('*').collect();
86 if parts.len() == 1 {
87 return text == pattern;
88 }
89
90 let mut text_pos = 0;
91 for (i, part) in parts.iter().enumerate() {
92 if i == 0 {
93 if !text[text_pos..].starts_with(part) {
95 return false;
96 }
97 text_pos += part.len();
98 } else if i == parts.len() - 1 {
99 return text[text_pos..].ends_with(part);
101 } else {
102 if let Some(pos) = text[text_pos..].find(part) {
104 text_pos += pos + part.len();
105 } else {
106 return false;
107 }
108 }
109 }
110 true
111}
112
113pub fn is_supported_file(file_path: &Path) -> bool {
115 match file_path.file_name().and_then(|name| name.to_str()) {
116 Some(name) => {
117 if file_path.is_file() {
119 name.ends_with(".tf")
120 || name.ends_with(".tfvars")
121 || name.ends_with(".yaml")
122 || name.ends_with(".yml")
123 || name.to_lowercase().contains("dockerfile")
124 } else {
125 true }
127 }
128 None => false,
129 }
130}
131
132pub fn generate_password(length: usize, no_symbols: bool) -> String {
134 let mut rng = rand::rng();
135
136 let lowercase = "abcdefghijklmnopqrstuvwxyz";
138 let uppercase = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
139 let digits = "0123456789";
140 let symbols = "!@#$%^&*()_+-=[]{}|;:,.<>?";
141
142 let mut charset = String::new();
144 charset.push_str(lowercase);
145 charset.push_str(uppercase);
146 charset.push_str(digits);
147
148 if !no_symbols {
149 charset.push_str(symbols);
150 }
151
152 let charset_chars: Vec<char> = charset.chars().collect();
153
154 let mut password = String::new();
156
157 password.push(
159 lowercase
160 .chars()
161 .nth(rng.random_range(0..lowercase.len()))
162 .unwrap(),
163 );
164 password.push(
165 uppercase
166 .chars()
167 .nth(rng.random_range(0..uppercase.len()))
168 .unwrap(),
169 );
170 password.push(
171 digits
172 .chars()
173 .nth(rng.random_range(0..digits.len()))
174 .unwrap(),
175 );
176
177 if !no_symbols {
178 password.push(
179 symbols
180 .chars()
181 .nth(rng.random_range(0..symbols.len()))
182 .unwrap(),
183 );
184 }
185
186 let remaining_length = if length > password.len() {
188 length - password.len()
189 } else {
190 0
191 };
192
193 for _ in 0..remaining_length {
194 let random_char = charset_chars[rng.random_range(0..charset_chars.len())];
195 password.push(random_char);
196 }
197
198 let mut password_chars: Vec<char> = password.chars().collect();
200 for i in 0..password_chars.len() {
201 let j = rng.random_range(0..password_chars.len());
202 password_chars.swap(i, j);
203 }
204
205 password_chars.into_iter().take(length).collect()
207}
208
209pub fn normalize_optional_string(value: Option<String>) -> Option<String> {
213 value.and_then(|value| {
214 let trimmed = value.trim();
215 if trimmed.is_empty() {
216 None
217 } else {
218 Some(trimmed.to_string())
219 }
220 })
221}
222
223pub fn sanitize_text_output(text: &str) -> String {
225 text.chars()
226 .filter(|&c| {
227 if c == '\u{FFFD}' {
229 return false;
230 }
231 if matches!(c, '\n' | '\t' | '\r' | ' ') {
233 return true;
234 }
235 !c.is_control()
237 })
238 .collect()
239}
240
241pub fn truncate_chars_with_ellipsis(text: &str, max_chars: usize) -> String {
245 if text.chars().count() <= max_chars {
246 return text.to_string();
247 }
248
249 let mut truncated: String = text.chars().take(max_chars).collect();
250 truncated.push_str("...");
251 truncated
252}
253
254pub struct LargeOutputLimits<'a> {
255 pub file_prefix: &'a str,
256 pub max_lines: usize,
257 pub max_bytes: usize,
258 pub show_head: bool,
259}
260
261fn sanitize_artifact_file_prefix(file_prefix: &str) -> String {
262 let sanitized = file_prefix
263 .chars()
264 .map(|c| {
265 if c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.') {
266 c
267 } else {
268 '-'
269 }
270 })
271 .collect::<String>()
272 .trim_matches(|c| matches!(c, '-' | '_' | '.'))
273 .to_string();
274
275 if sanitized.is_empty() {
276 "output".to_string()
277 } else {
278 sanitized
279 }
280}
281
282fn write_output_artifact(file_prefix: &str, output: &str) -> Result<String, String> {
283 let output_file = format!(
284 "{}.{}.txt",
285 sanitize_artifact_file_prefix(file_prefix),
286 Uuid::new_v4().simple()
287 );
288
289 LocalStore::write_session_data(&output_file, output)
290 .map_err(|e| format!("Failed to write session data: {}", e))
291}
292
293fn line_preview(
294 output_lines: &[&str],
295 output_file_path: &str,
296 max_lines: usize,
297 show_head: bool,
298) -> String {
299 let excerpt = if show_head {
300 let head_lines: Vec<&str> = output_lines.iter().take(max_lines).copied().collect();
301 head_lines.join("\n")
302 } else {
303 let mut tail_lines: Vec<&str> =
304 output_lines.iter().rev().take(max_lines).copied().collect();
305 tail_lines.reverse();
306 tail_lines.join("\n")
307 };
308
309 let position = if show_head { "first" } else { "last" };
310 format!(
311 "Showing the {} {} / {} output lines. Full output saved to {}\n{}\n{}",
312 position,
313 max_lines,
314 output_lines.len(),
315 output_file_path,
316 if show_head { "" } else { "...\n" },
317 excerpt
318 )
319}
320
321#[allow(clippy::string_slice)]
323fn byte_excerpt(output: &str, max_bytes: usize, show_head: bool) -> (&str, usize) {
324 if show_head {
325 let mut end = max_bytes.min(output.len());
326 while end > 0 && !output.is_char_boundary(end) {
327 end -= 1;
328 }
329 (&output[..end], end)
330 } else {
331 let mut start = output.len().saturating_sub(max_bytes);
332 while start < output.len() && !output.is_char_boundary(start) {
333 start += 1;
334 }
335 (&output[start..], output.len() - start)
336 }
337}
338
339fn byte_preview(output: &str, output_file_path: &str, max_bytes: usize, show_head: bool) -> String {
340 let (excerpt, excerpt_bytes) = byte_excerpt(output, max_bytes, show_head);
341 let position = if show_head { "first" } else { "last" };
342
343 format!(
344 "Showing the {} {} / {} output bytes. Full output saved to {}\n{}\n{}",
345 position,
346 excerpt_bytes,
347 output.len(),
348 output_file_path,
349 if show_head { "" } else { "...\n" },
350 excerpt
351 )
352}
353
354pub fn handle_large_output_with_limits(
355 output: &str,
356 limits: LargeOutputLimits<'_>,
357) -> Result<String, String> {
358 let output_lines = output.lines().collect::<Vec<_>>();
359 if output_lines.len() >= limits.max_lines {
360 let output_file_path = write_output_artifact(limits.file_prefix, output)?;
361 Ok(line_preview(
362 &output_lines,
363 &output_file_path,
364 limits.max_lines,
365 limits.show_head,
366 ))
367 } else if output.len() > limits.max_bytes {
368 let output_file_path = write_output_artifact(limits.file_prefix, output)?;
369 Ok(byte_preview(
370 output,
371 &output_file_path,
372 limits.max_bytes,
373 limits.show_head,
374 ))
375 } else {
376 Ok(output.to_string())
377 }
378}
379
380pub fn handle_large_output(
384 output: &str,
385 file_prefix: &str,
386 max_lines: usize,
387 show_head: bool,
388) -> Result<String, String> {
389 handle_large_output_with_limits(
390 output,
391 LargeOutputLimits {
392 file_prefix,
393 max_lines,
394 max_bytes: usize::MAX,
395 show_head,
396 },
397 )
398}
399
400#[cfg(test)]
401mod password_tests {
402 use super::*;
403
404 #[test]
405 fn test_generate_password_length() {
406 let password = generate_password(10, false);
407 assert_eq!(password.len(), 10);
408
409 let password = generate_password(20, true);
410 assert_eq!(password.len(), 20);
411 }
412
413 #[test]
414 fn test_generate_password_no_symbols() {
415 let password = generate_password(50, true);
416 let symbols = "!@#$%^&*()_+-=[]{}|;:,.<>?";
417
418 for symbol in symbols.chars() {
419 assert!(
420 !password.contains(symbol),
421 "Password should not contain symbol: {}",
422 symbol
423 );
424 }
425 }
426
427 #[test]
428 fn test_generate_password_with_symbols() {
429 let password = generate_password(50, false);
430 let symbols = "!@#$%^&*()_+-=[]{}|;:,.<>?";
431
432 let has_symbol = password.chars().any(|c| symbols.contains(c));
434 assert!(has_symbol, "Password should contain at least one symbol");
435 }
436
437 #[test]
438 fn test_generate_password_contains_required_chars() {
439 let password = generate_password(50, false);
440
441 let has_lowercase = password.chars().any(|c| c.is_ascii_lowercase());
442 let has_uppercase = password.chars().any(|c| c.is_ascii_uppercase());
443 let has_digit = password.chars().any(|c| c.is_ascii_digit());
444
445 assert!(has_lowercase, "Password should contain lowercase letters");
446 assert!(has_uppercase, "Password should contain uppercase letters");
447 assert!(has_digit, "Password should contain digits");
448 }
449
450 #[test]
451 fn test_generate_password_uniqueness() {
452 let password1 = generate_password(20, false);
453 let password2 = generate_password(20, false);
454
455 assert_ne!(password1, password2);
457 }
458}
459
460#[cfg(test)]
461mod truncate_tests {
462 use super::*;
463 use std::path::Path;
464
465 #[test]
466 fn normalize_optional_string_trims_and_drops_empty() {
467 assert_eq!(
468 normalize_optional_string(Some(" hello ".to_string())),
469 Some("hello".to_string())
470 );
471 assert_eq!(normalize_optional_string(Some(" ".to_string())), None);
472 assert_eq!(normalize_optional_string(None), None);
473 }
474
475 #[test]
476 fn truncate_chars_with_ellipsis_exact_boundary_keeps_value() {
477 let value = "a".repeat(20);
478 let truncated = truncate_chars_with_ellipsis(&value, 20);
479 assert_eq!(truncated, value);
480 }
481
482 #[test]
483 fn truncate_chars_with_ellipsis_appends_suffix_when_truncated() {
484 let value = "é".repeat(10);
485 let truncated = truncate_chars_with_ellipsis(&value, 5);
486 assert_eq!(truncated, "ééééé...");
487 }
488
489 fn artifact_path_from_preview(preview: &str) -> &str {
490 preview
491 .lines()
492 .next()
493 .and_then(|line| line.split_once("Full output saved to "))
494 .map(|(_, path)| path)
495 .expect("preview should contain saved artifact path")
496 }
497
498 #[test]
499 fn handle_large_output_line_trigger_artifacts_full_output() {
500 let output = (1..=4)
501 .map(|line| format!("line {line}"))
502 .collect::<Vec<_>>()
503 .join("\n");
504
505 let preview = handle_large_output(&output, "line-test", 3, true)
506 .expect("large output should be handled");
507
508 assert!(preview.starts_with("Showing the first 3 / 4 output lines."));
509 assert!(preview.contains("\n\nline 1\nline 2\nline 3"));
510 assert!(!preview.contains("line 4"));
511
512 let artifact_path = artifact_path_from_preview(&preview);
513 let artifact = std::fs::read_to_string(artifact_path).expect("artifact should be readable");
514 assert_eq!(artifact, output);
515 std::fs::remove_file(artifact_path).expect("artifact should be removable");
516 }
517
518 #[test]
519 fn handle_large_output_with_limits_byte_trigger_artifacts_full_output() {
520 let output = "a".repeat(64);
521
522 let preview = handle_large_output_with_limits(
523 &output,
524 LargeOutputLimits {
525 file_prefix: "mcp tool/output",
526 max_lines: 300,
527 max_bytes: 32,
528 show_head: true,
529 },
530 )
531 .expect("large output should be handled");
532
533 assert!(preview.starts_with("Showing the first 32 / 64 output bytes."));
534 assert!(preview.contains("Full output saved to "));
535 assert!(preview.contains(&"a".repeat(32)));
536 assert!(!preview.contains(&"a".repeat(64)));
537
538 let artifact_path = artifact_path_from_preview(&preview);
539 let artifact = std::fs::read_to_string(artifact_path).expect("artifact should be readable");
540 assert_eq!(artifact, output);
541
542 let file_name = Path::new(artifact_path)
543 .file_name()
544 .and_then(|name| name.to_str())
545 .expect("artifact should have a valid UTF-8 file name");
546 assert!(file_name.starts_with("mcp-tool-output."));
547 assert!(file_name.ends_with(".txt"));
548 assert_eq!(
549 file_name.len(),
550 "mcp-tool-output.".len() + 32 + ".txt".len()
551 );
552
553 std::fs::remove_file(artifact_path).expect("artifact should be removable");
554 }
555
556 #[test]
557 fn handle_large_output_with_limits_byte_preview_is_utf8_safe() {
558 let output = format!("{}end", "é".repeat(20));
559
560 let preview = handle_large_output_with_limits(
561 &output,
562 LargeOutputLimits {
563 file_prefix: "utf8-test",
564 max_lines: 300,
565 max_bytes: 9,
566 show_head: true,
567 },
568 )
569 .expect("large output should be handled");
570
571 assert!(preview.starts_with("Showing the first 8 / 43 output bytes."));
572 assert!(preview.contains("\n\néééé"));
573 assert!(!preview.contains("end"));
574
575 let artifact_path = artifact_path_from_preview(&preview);
576 let artifact = std::fs::read_to_string(artifact_path).expect("artifact should be readable");
577 assert_eq!(artifact, output);
578 std::fs::remove_file(artifact_path).expect("artifact should be removable");
579 }
580
581 #[test]
582 fn handle_large_output_with_limits_small_output_passes_through() {
583 let output = "small\noutput";
584
585 let preview = handle_large_output_with_limits(
586 output,
587 LargeOutputLimits {
588 file_prefix: "small-test",
589 max_lines: 300,
590 max_bytes: 1024,
591 show_head: true,
592 },
593 )
594 .expect("small output should pass through");
595
596 assert_eq!(preview, output);
597 }
598}
599
600#[derive(Debug, Clone)]
602pub struct DirectoryEntry {
603 pub name: String,
604 pub path: String,
605 pub is_directory: bool,
606}
607
608#[async_trait]
610pub trait FileSystemProvider {
611 type Error: std::fmt::Display;
612
613 async fn list_directory(&self, path: &str) -> Result<Vec<DirectoryEntry>, Self::Error>;
615}
616
617pub async fn generate_directory_tree<P: FileSystemProvider>(
619 provider: &P,
620 path: &str,
621 prefix: &str,
622 max_depth: usize,
623 current_depth: usize,
624) -> Result<String, P::Error> {
625 let mut result = String::new();
626
627 if current_depth >= max_depth || current_depth >= 10 {
628 return Ok(result);
629 }
630
631 let entries = provider.list_directory(path).await?;
632 let mut file_entries = Vec::new();
633 let mut dir_entries = Vec::new();
634 for entry in entries.iter() {
635 if entry.is_directory {
636 if entry.name == "."
637 || entry.name == ".."
638 || entry.name == ".git"
639 || entry.name == "node_modules"
640 {
641 continue;
642 }
643 dir_entries.push(entry.clone());
644 } else {
645 file_entries.push(entry.clone());
646 }
647 }
648
649 dir_entries.sort_by(|a, b| a.name.cmp(&b.name));
650 file_entries.sort_by(|a, b| a.name.cmp(&b.name));
651
652 const MAX_ITEMS: usize = 5;
653 let total_items = dir_entries.len() + file_entries.len();
654 let should_limit = current_depth > 0 && total_items > MAX_ITEMS;
655
656 if should_limit {
657 if dir_entries.len() > MAX_ITEMS {
658 dir_entries.truncate(MAX_ITEMS);
659 file_entries.clear();
660 } else {
661 let remaining_items = MAX_ITEMS - dir_entries.len();
662 file_entries.truncate(remaining_items);
663 }
664 }
665
666 let mut dir_headers = Vec::new();
667 let mut dir_futures = Vec::new();
668 for (i, entry) in dir_entries.iter().enumerate() {
669 let is_last_dir = i == dir_entries.len() - 1;
670 let is_last_overall = is_last_dir && file_entries.is_empty() && !should_limit;
671 let current_prefix = if is_last_overall {
672 "└── "
673 } else {
674 "├── "
675 };
676 let next_prefix = format!(
677 "{}{}",
678 prefix,
679 if is_last_overall { " " } else { "│ " }
680 );
681
682 let header = format!("{}{}{}/\n", prefix, current_prefix, entry.name);
683 dir_headers.push(header);
684
685 let entry_path = entry.path.clone();
686 let next_prefix_clone = next_prefix.clone();
687 let future = async move {
688 generate_directory_tree(
689 provider,
690 &entry_path,
691 &next_prefix_clone,
692 max_depth,
693 current_depth + 1,
694 )
695 .await
696 };
697 dir_futures.push(future);
698 }
699 if !dir_futures.is_empty() {
700 let subtree_results = futures::future::join_all(dir_futures).await;
701
702 for (i, header) in dir_headers.iter().enumerate() {
703 result.push_str(header);
704 if let Some(Ok(subtree)) = subtree_results.get(i) {
705 result.push_str(subtree);
706 }
707 }
708 }
709
710 for (i, entry) in file_entries.iter().enumerate() {
711 let is_last_file = i == file_entries.len() - 1;
712 let is_last_overall = is_last_file && !should_limit;
713 let current_prefix = if is_last_overall {
714 "└── "
715 } else {
716 "├── "
717 };
718 result.push_str(&format!("{}{}{}\n", prefix, current_prefix, entry.name));
719 }
720
721 if should_limit {
722 let remaining_count = total_items - MAX_ITEMS;
723 result.push_str(&format!(
724 "{}└── ... {} more item{}\n",
725 prefix,
726 remaining_count,
727 if remaining_count == 1 { "" } else { "s" }
728 ));
729 }
730
731 Ok(result)
732}
733
734pub fn strip_tool_name(name: &str) -> &str {
739 let mut result = name;
740
741 if let Some((_, suffix)) = result.split_once("__") {
743 result = suffix;
744 }
745
746 if let Some(stripped) = result.strip_suffix("()") {
748 result = stripped;
749 }
750
751 backward_compatibility_mapping(result)
752}
753
754pub fn backward_compatibility_mapping(name: &str) -> &str {
757 match name {
758 "read_rulebook" | "read_rulebooks" => "load_skill",
759 _ => name,
760 }
761}
762
763pub struct LocalFileSystemProvider;
765
766#[async_trait]
767impl FileSystemProvider for LocalFileSystemProvider {
768 type Error = std::io::Error;
769
770 async fn list_directory(&self, path: &str) -> Result<Vec<DirectoryEntry>, Self::Error> {
771 let entries = fs::read_dir(path)?;
772 let mut result = Vec::new();
773
774 for entry in entries {
775 let entry = entry?;
776 let file_name = entry.file_name().to_string_lossy().to_string();
777 let file_path = entry.path().to_string_lossy().to_string();
778 let is_directory = entry.file_type()?.is_dir();
779
780 result.push(DirectoryEntry {
781 name: file_name,
782 path: file_path,
783 is_directory,
784 });
785 }
786
787 Ok(result)
788 }
789}
790
791#[cfg(test)]
792mod tests {
793 use super::*;
794 use std::fs;
795 use std::io::Write;
796 use tempfile::TempDir;
797
798 #[test]
799 fn test_matches_gitignore_pattern_exact() {
800 assert!(matches_gitignore_pattern("node_modules", "node_modules"));
801 assert!(matches_gitignore_pattern(
802 "node_modules",
803 "node_modules/package.json"
804 ));
805 assert!(!matches_gitignore_pattern(
806 "node_modules",
807 "src/node_modules"
808 ));
809 }
810
811 #[test]
812 fn test_matches_gitignore_pattern_wildcard_prefix() {
813 assert!(matches_gitignore_pattern("*.log", "debug.log"));
814 assert!(matches_gitignore_pattern("*.log", "error.log"));
815 assert!(!matches_gitignore_pattern("*.log", "log.txt"));
816 }
817
818 #[test]
819 fn test_matches_gitignore_pattern_wildcard_suffix() {
820 assert!(matches_gitignore_pattern("temp*", "temp"));
821 assert!(matches_gitignore_pattern("temp*", "temp.txt"));
822 assert!(matches_gitignore_pattern("temp*", "temporary"));
823 assert!(!matches_gitignore_pattern("temp*", "mytemp"));
824 }
825
826 #[test]
827 fn test_matches_gitignore_pattern_wildcard_middle() {
828 assert!(matches_gitignore_pattern("*temp*", "temp"));
829 assert!(matches_gitignore_pattern("*temp*", "mytemp"));
830 assert!(matches_gitignore_pattern("*temp*", "temporary"));
831 assert!(matches_gitignore_pattern("*temp*", "mytemporary"));
832 assert!(!matches_gitignore_pattern("*temp*", "example"));
833 }
834
835 #[test]
836 fn test_pattern_matches_glob() {
837 assert!(pattern_matches_glob("test*.txt", "test.txt"));
838 assert!(pattern_matches_glob("test*.txt", "test123.txt"));
839 assert!(pattern_matches_glob("*test*.txt", "mytest.txt"));
840 assert!(pattern_matches_glob("*test*.txt", "mytestfile.txt"));
841 assert!(!pattern_matches_glob("test*.txt", "test.log"));
842 assert!(!pattern_matches_glob("*test*.txt", "example.txt"));
843 }
844
845 #[test]
846 fn test_read_gitignore_patterns() -> Result<(), Box<dyn std::error::Error>> {
847 let temp_dir = TempDir::new()?;
848 let temp_path = temp_dir.path();
849
850 let gitignore_content = r#"
852# This is a comment
853node_modules
854*.log
855dist/
856.env
857
858# Another comment
859temp*
860"#;
861
862 let gitignore_path = temp_path.join(".gitignore");
863 let mut file = fs::File::create(&gitignore_path)?;
864 file.write_all(gitignore_content.as_bytes())?;
865
866 let patterns = read_gitignore_patterns(temp_path.to_str().unwrap());
867
868 assert!(patterns.contains(&".git".to_string()));
870 assert!(patterns.contains(&"node_modules".to_string()));
871 assert!(patterns.contains(&"*.log".to_string()));
872 assert!(patterns.contains(&"dist/".to_string()));
873 assert!(patterns.contains(&".env".to_string()));
874 assert!(patterns.contains(&"temp*".to_string()));
875
876 assert!(!patterns.iter().any(|p| p.starts_with('#')));
878 assert!(!patterns.contains(&"".to_string()));
879
880 Ok(())
881 }
882
883 #[test]
884 fn test_read_gitignore_patterns_no_file() {
885 let temp_dir = TempDir::new().unwrap();
886 let temp_path = temp_dir.path();
887
888 let patterns = read_gitignore_patterns(temp_path.to_str().unwrap());
889
890 assert_eq!(patterns, vec![".git".to_string()]);
892 }
893
894 #[test]
895 fn test_strip_tool_name() {
896 assert_eq!(strip_tool_name("stakpak__run_command"), "run_command");
897 assert_eq!(strip_tool_name("run_command"), "run_command");
898 assert_eq!(strip_tool_name("str_replace()"), "str_replace");
899 assert_eq!(strip_tool_name("stakpak__read_rulebook"), "load_skill");
900 assert_eq!(strip_tool_name("read_rulebook()"), "load_skill");
901 assert_eq!(strip_tool_name("read_rulebooks"), "load_skill");
902 assert_eq!(strip_tool_name("just_name"), "just_name");
904 assert_eq!(strip_tool_name("prefix__name()"), "name");
905 assert_eq!(strip_tool_name("nested__prefix__tool"), "prefix__tool");
906 assert_eq!(strip_tool_name("empty_suffix()"), "empty_suffix");
907 }
908
909 #[test]
910 fn test_backward_compatibility_mapping() {
911 assert_eq!(
912 backward_compatibility_mapping("read_rulebook"),
913 "load_skill"
914 );
915 assert_eq!(
916 backward_compatibility_mapping("read_rulebooks"),
917 "load_skill"
918 );
919 assert_eq!(backward_compatibility_mapping("run_command"), "run_command");
920 }
921
922 #[test]
923 fn test_gitignore_integration() -> Result<(), Box<dyn std::error::Error>> {
924 let temp_dir = TempDir::new()?;
925 let temp_path = temp_dir.path();
926
927 let gitignore_content = "node_modules\n*.log\ndist/\n";
929 let gitignore_path = temp_path.join(".gitignore");
930 let mut file = fs::File::create(&gitignore_path)?;
931 file.write_all(gitignore_content.as_bytes())?;
932
933 let patterns = read_gitignore_patterns(temp_path.to_str().unwrap());
934
935 assert!(
937 patterns
938 .iter()
939 .any(|p| matches_gitignore_pattern(p, "node_modules"))
940 );
941 assert!(
942 patterns
943 .iter()
944 .any(|p| matches_gitignore_pattern(p, "node_modules/package.json"))
945 );
946 assert!(
947 patterns
948 .iter()
949 .any(|p| matches_gitignore_pattern(p, "debug.log"))
950 );
951 assert!(
952 patterns
953 .iter()
954 .any(|p| matches_gitignore_pattern(p, "dist/bundle.js"))
955 );
956 assert!(
957 patterns
958 .iter()
959 .any(|p| matches_gitignore_pattern(p, ".git"))
960 );
961
962 assert!(
964 !patterns
965 .iter()
966 .any(|p| matches_gitignore_pattern(p, "src/main.js"))
967 );
968 assert!(
969 !patterns
970 .iter()
971 .any(|p| matches_gitignore_pattern(p, "README.md"))
972 );
973
974 Ok(())
975 }
976}