Skip to main content

stakpak_shared/
utils.rs

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
9/// Read .gitignore patterns from the specified base directory
10pub fn read_gitignore_patterns(base_dir: &str) -> Vec<String> {
11    let mut patterns = vec![".git".to_string()]; // Always ignore .git directory
12
13    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            // Skip empty lines and comments
18            if !line.is_empty() && !line.starts_with('#') {
19                patterns.push(line.to_string());
20            }
21        }
22    }
23
24    patterns
25}
26
27/// Check if a directory entry should be included based on gitignore patterns and file type support
28pub 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    // Get relative path from base directory
33    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    // Check if path matches any ignore pattern
42    for pattern in ignore_patterns {
43        if matches_gitignore_pattern(pattern, &path_str) {
44            return false;
45        }
46    }
47
48    // For files, also check if they are supported file types
49    if is_file {
50        is_supported_file(entry.path())
51    } else {
52        true // Allow directories to be traversed
53    }
54}
55
56/// Check if a path matches a gitignore pattern
57#[allow(clippy::string_slice)] // pattern[1..len-1] guarded by starts_with('*')/ends_with('*'), '*' is ASCII
58pub fn matches_gitignore_pattern(pattern: &str, path: &str) -> bool {
59    // Basic gitignore pattern matching
60    let pattern = pattern.trim_end_matches('/'); // Remove trailing slash
61
62    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 contains * but not at start/end, do basic glob matching
74            pattern_matches_glob(pattern, path)
75        }
76    } else {
77        // Exact match or directory match
78        path == pattern || path.starts_with(&format!("{}/", pattern))
79    }
80}
81
82/// Simple glob pattern matching for basic cases
83#[allow(clippy::string_slice)] // text_pos accumulated from starts_with/find on same string, always valid boundaries
84pub 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            // First part must match at the beginning
94            if !text[text_pos..].starts_with(part) {
95                return false;
96            }
97            text_pos += part.len();
98        } else if i == parts.len() - 1 {
99            // Last part must match at the end
100            return text[text_pos..].ends_with(part);
101        } else {
102            // Middle parts must be found in order
103            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
113/// Check if a directory entry represents a supported file type
114pub fn is_supported_file(file_path: &Path) -> bool {
115    match file_path.file_name().and_then(|name| name.to_str()) {
116        Some(name) => {
117            // Only allow supported files
118            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 // Allow directories to be traversed
126            }
127        }
128        None => false,
129    }
130}
131
132/// Generate a secure password with alphanumeric characters and optional symbols
133pub fn generate_password(length: usize, no_symbols: bool) -> String {
134    let mut rng = rand::rng();
135
136    // Define character sets
137    let lowercase = "abcdefghijklmnopqrstuvwxyz";
138    let uppercase = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
139    let digits = "0123456789";
140    let symbols = "!@#$%^&*()_+-=[]{}|;:,.<>?";
141
142    // Build the character set based on options
143    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    // Generate password ensuring at least one character from each required category
155    let mut password = String::new();
156
157    // Ensure at least one character from each category
158    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    // Fill the rest with random characters from the full charset
187    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    // Shuffle the password to randomize the order
199    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    // Take only the requested length
206    password_chars.into_iter().take(length).collect()
207}
208
209/// Normalize an optional string by trimming leading/trailing whitespace.
210///
211/// Returns `None` when the input is `None` or the trimmed value is empty.
212pub 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
223/// Sanitize text output by removing control characters while preserving essential whitespace
224pub fn sanitize_text_output(text: &str) -> String {
225    text.chars()
226        .filter(|&c| {
227            // Drop replacement char
228            if c == '\u{FFFD}' {
229                return false;
230            }
231            // Allow essential whitespace even though they're "control"
232            if matches!(c, '\n' | '\t' | '\r' | ' ') {
233                return true;
234            }
235            // Keep everything else that's not a control character
236            !c.is_control()
237        })
238        .collect()
239}
240
241/// Truncate a string by character count and append `...` when truncated.
242///
243/// Uses char iteration (not byte slicing) so it is UTF-8 safe.
244pub 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// start/end are adjusted to valid UTF-8 character boundaries before slicing.
322#[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
380/// Handle large output: if the output has >= `max_lines`, save the full content to session
381/// storage and return a string showing only the first or last `max_lines` lines with a pointer
382/// to the saved file. Returns `Ok(final_string)` or `Err(error_string)` on failure.
383pub 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        // At least one symbol should be present (due to our algorithm)
433        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        // Very unlikely to generate the same password twice
456        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/// Directory entry information for tree generation
601#[derive(Debug, Clone)]
602pub struct DirectoryEntry {
603    pub name: String,
604    pub path: String,
605    pub is_directory: bool,
606}
607
608/// Trait for abstracting file system operations for tree generation
609#[async_trait]
610pub trait FileSystemProvider {
611    type Error: std::fmt::Display;
612
613    /// List directory contents
614    async fn list_directory(&self, path: &str) -> Result<Vec<DirectoryEntry>, Self::Error>;
615}
616
617/// Generate a tree view of a directory structure using a generic file system provider
618pub 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
734/// Strip the MCP server prefix and any trailing "()" from a tool name.
735/// Example: "stakpak__run_command" -> "run_command"
736/// Example: "run_command" -> "run_command"
737/// Example: "str_replace()" -> "str_replace"
738pub fn strip_tool_name(name: &str) -> &str {
739    let mut result = name;
740
741    // Strip the MCP server prefix (e.g., "stakpak__")
742    if let Some((_, suffix)) = result.split_once("__") {
743        result = suffix;
744    }
745
746    // Strip trailing "()" if present
747    if let Some(stripped) = result.strip_suffix("()") {
748        result = stripped;
749    }
750
751    backward_compatibility_mapping(result)
752}
753
754/// Map legacy tool names to their current counterparts.
755/// Currently handles mapping "read_rulebook" to "load_skill".
756pub fn backward_compatibility_mapping(name: &str) -> &str {
757    match name {
758        "read_rulebook" | "read_rulebooks" => "load_skill",
759        _ => name,
760    }
761}
762
763/// Local file system provider implementation
764pub 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        // Create a .gitignore file
851        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        // Should include .git by default
869        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        // Should not include comments or empty lines
877        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        // Should only contain .git when no .gitignore exists
891        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        // Additional edge cases
903        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        // Create a .gitignore file
928        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        // Test various paths
936        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        // These should not match
963        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}