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 walkdir::DirEntry;
7
8/// Read .gitignore patterns from the specified base directory
9pub fn read_gitignore_patterns(base_dir: &str) -> Vec<String> {
10    let mut patterns = vec![".git".to_string()]; // Always ignore .git directory
11
12    let gitignore_path = PathBuf::from(base_dir).join(".gitignore");
13    if let Ok(content) = std::fs::read_to_string(&gitignore_path) {
14        for line in content.lines() {
15            let line = line.trim();
16            // Skip empty lines and comments
17            if !line.is_empty() && !line.starts_with('#') {
18                patterns.push(line.to_string());
19            }
20        }
21    }
22
23    patterns
24}
25
26/// Check if a directory entry should be included based on gitignore patterns and file type support
27pub fn should_include_entry(entry: &DirEntry, base_dir: &str, ignore_patterns: &[String]) -> bool {
28    let path = entry.path();
29    let is_file = entry.file_type().is_file();
30
31    // Get relative path from base directory
32    let base_path = PathBuf::from(base_dir);
33    let relative_path = match path.strip_prefix(&base_path) {
34        Ok(rel_path) => rel_path,
35        Err(_) => path,
36    };
37
38    let path_str = relative_path.to_string_lossy();
39
40    // Check if path matches any ignore pattern
41    for pattern in ignore_patterns {
42        if matches_gitignore_pattern(pattern, &path_str) {
43            return false;
44        }
45    }
46
47    // For files, also check if they are supported file types
48    if is_file {
49        is_supported_file(entry.path())
50    } else {
51        true // Allow directories to be traversed
52    }
53}
54
55/// Check if a path matches a gitignore pattern
56#[allow(clippy::string_slice)] // pattern[1..len-1] guarded by starts_with('*')/ends_with('*'), '*' is ASCII
57pub fn matches_gitignore_pattern(pattern: &str, path: &str) -> bool {
58    // Basic gitignore pattern matching
59    let pattern = pattern.trim_end_matches('/'); // Remove trailing slash
60
61    if pattern.contains('*') {
62        if pattern == "*" {
63            true
64        } else if pattern.starts_with('*') && pattern.ends_with('*') {
65            let middle = &pattern[1..pattern.len() - 1];
66            path.contains(middle)
67        } else if let Some(suffix) = pattern.strip_prefix('*') {
68            path.ends_with(suffix)
69        } else if let Some(prefix) = pattern.strip_suffix('*') {
70            path.starts_with(prefix)
71        } else {
72            // Pattern contains * but not at start/end, do basic glob matching
73            pattern_matches_glob(pattern, path)
74        }
75    } else {
76        // Exact match or directory match
77        path == pattern || path.starts_with(&format!("{}/", pattern))
78    }
79}
80
81/// Simple glob pattern matching for basic cases
82#[allow(clippy::string_slice)] // text_pos accumulated from starts_with/find on same string, always valid boundaries
83pub fn pattern_matches_glob(pattern: &str, text: &str) -> bool {
84    let parts: Vec<&str> = pattern.split('*').collect();
85    if parts.len() == 1 {
86        return text == pattern;
87    }
88
89    let mut text_pos = 0;
90    for (i, part) in parts.iter().enumerate() {
91        if i == 0 {
92            // First part must match at the beginning
93            if !text[text_pos..].starts_with(part) {
94                return false;
95            }
96            text_pos += part.len();
97        } else if i == parts.len() - 1 {
98            // Last part must match at the end
99            return text[text_pos..].ends_with(part);
100        } else {
101            // Middle parts must be found in order
102            if let Some(pos) = text[text_pos..].find(part) {
103                text_pos += pos + part.len();
104            } else {
105                return false;
106            }
107        }
108    }
109    true
110}
111
112/// Check if a directory entry represents a supported file type
113pub fn is_supported_file(file_path: &Path) -> bool {
114    match file_path.file_name().and_then(|name| name.to_str()) {
115        Some(name) => {
116            // Only allow supported files
117            if file_path.is_file() {
118                name.ends_with(".tf")
119                    || name.ends_with(".tfvars")
120                    || name.ends_with(".yaml")
121                    || name.ends_with(".yml")
122                    || name.to_lowercase().contains("dockerfile")
123            } else {
124                true // Allow directories to be traversed
125            }
126        }
127        None => false,
128    }
129}
130
131/// Generate a secure password with alphanumeric characters and optional symbols
132pub fn generate_password(length: usize, no_symbols: bool) -> String {
133    let mut rng = rand::rng();
134
135    // Define character sets
136    let lowercase = "abcdefghijklmnopqrstuvwxyz";
137    let uppercase = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
138    let digits = "0123456789";
139    let symbols = "!@#$%^&*()_+-=[]{}|;:,.<>?";
140
141    // Build the character set based on options
142    let mut charset = String::new();
143    charset.push_str(lowercase);
144    charset.push_str(uppercase);
145    charset.push_str(digits);
146
147    if !no_symbols {
148        charset.push_str(symbols);
149    }
150
151    let charset_chars: Vec<char> = charset.chars().collect();
152
153    // Generate password ensuring at least one character from each required category
154    let mut password = String::new();
155
156    // Ensure at least one character from each category
157    password.push(
158        lowercase
159            .chars()
160            .nth(rng.random_range(0..lowercase.len()))
161            .unwrap(),
162    );
163    password.push(
164        uppercase
165            .chars()
166            .nth(rng.random_range(0..uppercase.len()))
167            .unwrap(),
168    );
169    password.push(
170        digits
171            .chars()
172            .nth(rng.random_range(0..digits.len()))
173            .unwrap(),
174    );
175
176    if !no_symbols {
177        password.push(
178            symbols
179                .chars()
180                .nth(rng.random_range(0..symbols.len()))
181                .unwrap(),
182        );
183    }
184
185    // Fill the rest with random characters from the full charset
186    let remaining_length = if length > password.len() {
187        length - password.len()
188    } else {
189        0
190    };
191
192    for _ in 0..remaining_length {
193        let random_char = charset_chars[rng.random_range(0..charset_chars.len())];
194        password.push(random_char);
195    }
196
197    // Shuffle the password to randomize the order
198    let mut password_chars: Vec<char> = password.chars().collect();
199    for i in 0..password_chars.len() {
200        let j = rng.random_range(0..password_chars.len());
201        password_chars.swap(i, j);
202    }
203
204    // Take only the requested length
205    password_chars.into_iter().take(length).collect()
206}
207
208/// Normalize an optional string by trimming leading/trailing whitespace.
209///
210/// Returns `None` when the input is `None` or the trimmed value is empty.
211pub fn normalize_optional_string(value: Option<String>) -> Option<String> {
212    value.and_then(|value| {
213        let trimmed = value.trim();
214        if trimmed.is_empty() {
215            None
216        } else {
217            Some(trimmed.to_string())
218        }
219    })
220}
221
222/// Sanitize text output by removing control characters while preserving essential whitespace
223pub fn sanitize_text_output(text: &str) -> String {
224    text.chars()
225        .filter(|&c| {
226            // Drop replacement char
227            if c == '\u{FFFD}' {
228                return false;
229            }
230            // Allow essential whitespace even though they're "control"
231            if matches!(c, '\n' | '\t' | '\r' | ' ') {
232                return true;
233            }
234            // Keep everything else that's not a control character
235            !c.is_control()
236        })
237        .collect()
238}
239
240/// Truncate a string by character count and append `...` when truncated.
241///
242/// Uses char iteration (not byte slicing) so it is UTF-8 safe.
243pub fn truncate_chars_with_ellipsis(text: &str, max_chars: usize) -> String {
244    if text.chars().count() <= max_chars {
245        return text.to_string();
246    }
247
248    let mut truncated: String = text.chars().take(max_chars).collect();
249    truncated.push_str("...");
250    truncated
251}
252
253/// Handle large output: if the output has >= `max_lines`, save the full content to session
254/// storage and return a string showing only the first or last `max_lines` lines with a pointer
255/// to the saved file. Returns `Ok(final_string)` or `Err(error_string)` on failure.
256pub fn handle_large_output(
257    output: &str,
258    file_prefix: &str,
259    max_lines: usize,
260    show_head: bool,
261) -> Result<String, String> {
262    let output_lines = output.lines().collect::<Vec<_>>();
263    if output_lines.len() >= max_lines {
264        let mut __rng__ = rand::rng();
265        let output_file = format!(
266            "{}.{:06x}.txt",
267            file_prefix,
268            __rng__.random_range(0..=0xFFFFFF)
269        );
270        let output_file_path = match LocalStore::write_session_data(&output_file, output) {
271            Ok(path) => path,
272            Err(e) => {
273                return Err(format!("Failed to write session data: {}", e));
274            }
275        };
276
277        let excerpt = if show_head {
278            let head_lines: Vec<&str> = output_lines.iter().take(max_lines).copied().collect();
279            head_lines.join("\n")
280        } else {
281            let mut tail_lines: Vec<&str> =
282                output_lines.iter().rev().take(max_lines).copied().collect();
283            tail_lines.reverse();
284            tail_lines.join("\n")
285        };
286
287        let position = if show_head { "first" } else { "last" };
288        Ok(format!(
289            "Showing the {} {} / {} output lines. Full output saved to {}\n{}\n{}",
290            position,
291            max_lines,
292            output_lines.len(),
293            output_file_path,
294            if show_head { "" } else { "...\n" },
295            excerpt
296        ))
297    } else {
298        Ok(output.to_string())
299    }
300}
301
302#[cfg(test)]
303mod password_tests {
304    use super::*;
305
306    #[test]
307    fn test_generate_password_length() {
308        let password = generate_password(10, false);
309        assert_eq!(password.len(), 10);
310
311        let password = generate_password(20, true);
312        assert_eq!(password.len(), 20);
313    }
314
315    #[test]
316    fn test_generate_password_no_symbols() {
317        let password = generate_password(50, true);
318        let symbols = "!@#$%^&*()_+-=[]{}|;:,.<>?";
319
320        for symbol in symbols.chars() {
321            assert!(
322                !password.contains(symbol),
323                "Password should not contain symbol: {}",
324                symbol
325            );
326        }
327    }
328
329    #[test]
330    fn test_generate_password_with_symbols() {
331        let password = generate_password(50, false);
332        let symbols = "!@#$%^&*()_+-=[]{}|;:,.<>?";
333
334        // At least one symbol should be present (due to our algorithm)
335        let has_symbol = password.chars().any(|c| symbols.contains(c));
336        assert!(has_symbol, "Password should contain at least one symbol");
337    }
338
339    #[test]
340    fn test_generate_password_contains_required_chars() {
341        let password = generate_password(50, false);
342
343        let has_lowercase = password.chars().any(|c| c.is_ascii_lowercase());
344        let has_uppercase = password.chars().any(|c| c.is_ascii_uppercase());
345        let has_digit = password.chars().any(|c| c.is_ascii_digit());
346
347        assert!(has_lowercase, "Password should contain lowercase letters");
348        assert!(has_uppercase, "Password should contain uppercase letters");
349        assert!(has_digit, "Password should contain digits");
350    }
351
352    #[test]
353    fn test_generate_password_uniqueness() {
354        let password1 = generate_password(20, false);
355        let password2 = generate_password(20, false);
356
357        // Very unlikely to generate the same password twice
358        assert_ne!(password1, password2);
359    }
360}
361
362#[cfg(test)]
363mod truncate_tests {
364    use super::*;
365
366    #[test]
367    fn normalize_optional_string_trims_and_drops_empty() {
368        assert_eq!(
369            normalize_optional_string(Some("  hello  ".to_string())),
370            Some("hello".to_string())
371        );
372        assert_eq!(normalize_optional_string(Some("   ".to_string())), None);
373        assert_eq!(normalize_optional_string(None), None);
374    }
375
376    #[test]
377    fn truncate_chars_with_ellipsis_exact_boundary_keeps_value() {
378        let value = "a".repeat(20);
379        let truncated = truncate_chars_with_ellipsis(&value, 20);
380        assert_eq!(truncated, value);
381    }
382
383    #[test]
384    fn truncate_chars_with_ellipsis_appends_suffix_when_truncated() {
385        let value = "é".repeat(10);
386        let truncated = truncate_chars_with_ellipsis(&value, 5);
387        assert_eq!(truncated, "ééééé...");
388    }
389}
390
391/// Directory entry information for tree generation
392#[derive(Debug, Clone)]
393pub struct DirectoryEntry {
394    pub name: String,
395    pub path: String,
396    pub is_directory: bool,
397}
398
399/// Trait for abstracting file system operations for tree generation
400#[async_trait]
401pub trait FileSystemProvider {
402    type Error: std::fmt::Display;
403
404    /// List directory contents
405    async fn list_directory(&self, path: &str) -> Result<Vec<DirectoryEntry>, Self::Error>;
406}
407
408/// Generate a tree view of a directory structure using a generic file system provider
409pub async fn generate_directory_tree<P: FileSystemProvider>(
410    provider: &P,
411    path: &str,
412    prefix: &str,
413    max_depth: usize,
414    current_depth: usize,
415) -> Result<String, P::Error> {
416    let mut result = String::new();
417
418    if current_depth >= max_depth || current_depth >= 10 {
419        return Ok(result);
420    }
421
422    let entries = provider.list_directory(path).await?;
423    let mut file_entries = Vec::new();
424    let mut dir_entries = Vec::new();
425    for entry in entries.iter() {
426        if entry.is_directory {
427            if entry.name == "."
428                || entry.name == ".."
429                || entry.name == ".git"
430                || entry.name == "node_modules"
431            {
432                continue;
433            }
434            dir_entries.push(entry.clone());
435        } else {
436            file_entries.push(entry.clone());
437        }
438    }
439
440    dir_entries.sort_by(|a, b| a.name.cmp(&b.name));
441    file_entries.sort_by(|a, b| a.name.cmp(&b.name));
442
443    const MAX_ITEMS: usize = 5;
444    let total_items = dir_entries.len() + file_entries.len();
445    let should_limit = current_depth > 0 && total_items > MAX_ITEMS;
446
447    if should_limit {
448        if dir_entries.len() > MAX_ITEMS {
449            dir_entries.truncate(MAX_ITEMS);
450            file_entries.clear();
451        } else {
452            let remaining_items = MAX_ITEMS - dir_entries.len();
453            file_entries.truncate(remaining_items);
454        }
455    }
456
457    let mut dir_headers = Vec::new();
458    let mut dir_futures = Vec::new();
459    for (i, entry) in dir_entries.iter().enumerate() {
460        let is_last_dir = i == dir_entries.len() - 1;
461        let is_last_overall = is_last_dir && file_entries.is_empty() && !should_limit;
462        let current_prefix = if is_last_overall {
463            "└── "
464        } else {
465            "├── "
466        };
467        let next_prefix = format!(
468            "{}{}",
469            prefix,
470            if is_last_overall { "    " } else { "│   " }
471        );
472
473        let header = format!("{}{}{}/\n", prefix, current_prefix, entry.name);
474        dir_headers.push(header);
475
476        let entry_path = entry.path.clone();
477        let next_prefix_clone = next_prefix.clone();
478        let future = async move {
479            generate_directory_tree(
480                provider,
481                &entry_path,
482                &next_prefix_clone,
483                max_depth,
484                current_depth + 1,
485            )
486            .await
487        };
488        dir_futures.push(future);
489    }
490    if !dir_futures.is_empty() {
491        let subtree_results = futures::future::join_all(dir_futures).await;
492
493        for (i, header) in dir_headers.iter().enumerate() {
494            result.push_str(header);
495            if let Some(Ok(subtree)) = subtree_results.get(i) {
496                result.push_str(subtree);
497            }
498        }
499    }
500
501    for (i, entry) in file_entries.iter().enumerate() {
502        let is_last_file = i == file_entries.len() - 1;
503        let is_last_overall = is_last_file && !should_limit;
504        let current_prefix = if is_last_overall {
505            "└── "
506        } else {
507            "├── "
508        };
509        result.push_str(&format!("{}{}{}\n", prefix, current_prefix, entry.name));
510    }
511
512    if should_limit {
513        let remaining_count = total_items - MAX_ITEMS;
514        result.push_str(&format!(
515            "{}└── ... {} more item{}\n",
516            prefix,
517            remaining_count,
518            if remaining_count == 1 { "" } else { "s" }
519        ));
520    }
521
522    Ok(result)
523}
524
525/// Strip the MCP server prefix and any trailing "()" from a tool name.
526/// Example: "stakpak__run_command" -> "run_command"
527/// Example: "run_command" -> "run_command"
528/// Example: "str_replace()" -> "str_replace"
529pub fn strip_tool_name(name: &str) -> &str {
530    let mut result = name;
531
532    // Strip the MCP server prefix (e.g., "stakpak__")
533    if let Some((_, suffix)) = result.split_once("__") {
534        result = suffix;
535    }
536
537    // Strip trailing "()" if present
538    if let Some(stripped) = result.strip_suffix("()") {
539        result = stripped;
540    }
541
542    backward_compatibility_mapping(result)
543}
544
545/// Map legacy tool names to their current counterparts.
546/// Currently handles mapping "read_rulebook" to "load_skill".
547pub fn backward_compatibility_mapping(name: &str) -> &str {
548    match name {
549        "read_rulebook" | "read_rulebooks" => "load_skill",
550        _ => name,
551    }
552}
553
554/// Local file system provider implementation
555pub struct LocalFileSystemProvider;
556
557#[async_trait]
558impl FileSystemProvider for LocalFileSystemProvider {
559    type Error = std::io::Error;
560
561    async fn list_directory(&self, path: &str) -> Result<Vec<DirectoryEntry>, Self::Error> {
562        let entries = fs::read_dir(path)?;
563        let mut result = Vec::new();
564
565        for entry in entries {
566            let entry = entry?;
567            let file_name = entry.file_name().to_string_lossy().to_string();
568            let file_path = entry.path().to_string_lossy().to_string();
569            let is_directory = entry.file_type()?.is_dir();
570
571            result.push(DirectoryEntry {
572                name: file_name,
573                path: file_path,
574                is_directory,
575            });
576        }
577
578        Ok(result)
579    }
580}
581
582#[cfg(test)]
583mod tests {
584    use super::*;
585    use std::fs;
586    use std::io::Write;
587    use tempfile::TempDir;
588
589    #[test]
590    fn test_matches_gitignore_pattern_exact() {
591        assert!(matches_gitignore_pattern("node_modules", "node_modules"));
592        assert!(matches_gitignore_pattern(
593            "node_modules",
594            "node_modules/package.json"
595        ));
596        assert!(!matches_gitignore_pattern(
597            "node_modules",
598            "src/node_modules"
599        ));
600    }
601
602    #[test]
603    fn test_matches_gitignore_pattern_wildcard_prefix() {
604        assert!(matches_gitignore_pattern("*.log", "debug.log"));
605        assert!(matches_gitignore_pattern("*.log", "error.log"));
606        assert!(!matches_gitignore_pattern("*.log", "log.txt"));
607    }
608
609    #[test]
610    fn test_matches_gitignore_pattern_wildcard_suffix() {
611        assert!(matches_gitignore_pattern("temp*", "temp"));
612        assert!(matches_gitignore_pattern("temp*", "temp.txt"));
613        assert!(matches_gitignore_pattern("temp*", "temporary"));
614        assert!(!matches_gitignore_pattern("temp*", "mytemp"));
615    }
616
617    #[test]
618    fn test_matches_gitignore_pattern_wildcard_middle() {
619        assert!(matches_gitignore_pattern("*temp*", "temp"));
620        assert!(matches_gitignore_pattern("*temp*", "mytemp"));
621        assert!(matches_gitignore_pattern("*temp*", "temporary"));
622        assert!(matches_gitignore_pattern("*temp*", "mytemporary"));
623        assert!(!matches_gitignore_pattern("*temp*", "example"));
624    }
625
626    #[test]
627    fn test_pattern_matches_glob() {
628        assert!(pattern_matches_glob("test*.txt", "test.txt"));
629        assert!(pattern_matches_glob("test*.txt", "test123.txt"));
630        assert!(pattern_matches_glob("*test*.txt", "mytest.txt"));
631        assert!(pattern_matches_glob("*test*.txt", "mytestfile.txt"));
632        assert!(!pattern_matches_glob("test*.txt", "test.log"));
633        assert!(!pattern_matches_glob("*test*.txt", "example.txt"));
634    }
635
636    #[test]
637    fn test_read_gitignore_patterns() -> Result<(), Box<dyn std::error::Error>> {
638        let temp_dir = TempDir::new()?;
639        let temp_path = temp_dir.path();
640
641        // Create a .gitignore file
642        let gitignore_content = r#"
643# This is a comment
644node_modules
645*.log
646dist/
647.env
648
649# Another comment
650temp*
651"#;
652
653        let gitignore_path = temp_path.join(".gitignore");
654        let mut file = fs::File::create(&gitignore_path)?;
655        file.write_all(gitignore_content.as_bytes())?;
656
657        let patterns = read_gitignore_patterns(temp_path.to_str().unwrap());
658
659        // Should include .git by default
660        assert!(patterns.contains(&".git".to_string()));
661        assert!(patterns.contains(&"node_modules".to_string()));
662        assert!(patterns.contains(&"*.log".to_string()));
663        assert!(patterns.contains(&"dist/".to_string()));
664        assert!(patterns.contains(&".env".to_string()));
665        assert!(patterns.contains(&"temp*".to_string()));
666
667        // Should not include comments or empty lines
668        assert!(!patterns.iter().any(|p| p.starts_with('#')));
669        assert!(!patterns.contains(&"".to_string()));
670
671        Ok(())
672    }
673
674    #[test]
675    fn test_read_gitignore_patterns_no_file() {
676        let temp_dir = TempDir::new().unwrap();
677        let temp_path = temp_dir.path();
678
679        let patterns = read_gitignore_patterns(temp_path.to_str().unwrap());
680
681        // Should only contain .git when no .gitignore exists
682        assert_eq!(patterns, vec![".git".to_string()]);
683    }
684
685    #[test]
686    fn test_strip_tool_name() {
687        assert_eq!(strip_tool_name("stakpak__run_command"), "run_command");
688        assert_eq!(strip_tool_name("run_command"), "run_command");
689        assert_eq!(strip_tool_name("str_replace()"), "str_replace");
690        assert_eq!(strip_tool_name("stakpak__read_rulebook"), "load_skill");
691        assert_eq!(strip_tool_name("read_rulebook()"), "load_skill");
692        assert_eq!(strip_tool_name("read_rulebooks"), "load_skill");
693        // Additional edge cases
694        assert_eq!(strip_tool_name("just_name"), "just_name");
695        assert_eq!(strip_tool_name("prefix__name()"), "name");
696        assert_eq!(strip_tool_name("nested__prefix__tool"), "prefix__tool");
697        assert_eq!(strip_tool_name("empty_suffix()"), "empty_suffix");
698    }
699
700    #[test]
701    fn test_backward_compatibility_mapping() {
702        assert_eq!(
703            backward_compatibility_mapping("read_rulebook"),
704            "load_skill"
705        );
706        assert_eq!(
707            backward_compatibility_mapping("read_rulebooks"),
708            "load_skill"
709        );
710        assert_eq!(backward_compatibility_mapping("run_command"), "run_command");
711    }
712
713    #[test]
714    fn test_gitignore_integration() -> Result<(), Box<dyn std::error::Error>> {
715        let temp_dir = TempDir::new()?;
716        let temp_path = temp_dir.path();
717
718        // Create a .gitignore file
719        let gitignore_content = "node_modules\n*.log\ndist/\n";
720        let gitignore_path = temp_path.join(".gitignore");
721        let mut file = fs::File::create(&gitignore_path)?;
722        file.write_all(gitignore_content.as_bytes())?;
723
724        let patterns = read_gitignore_patterns(temp_path.to_str().unwrap());
725
726        // Test various paths
727        assert!(
728            patterns
729                .iter()
730                .any(|p| matches_gitignore_pattern(p, "node_modules"))
731        );
732        assert!(
733            patterns
734                .iter()
735                .any(|p| matches_gitignore_pattern(p, "node_modules/package.json"))
736        );
737        assert!(
738            patterns
739                .iter()
740                .any(|p| matches_gitignore_pattern(p, "debug.log"))
741        );
742        assert!(
743            patterns
744                .iter()
745                .any(|p| matches_gitignore_pattern(p, "dist/bundle.js"))
746        );
747        assert!(
748            patterns
749                .iter()
750                .any(|p| matches_gitignore_pattern(p, ".git"))
751        );
752
753        // These should not match
754        assert!(
755            !patterns
756                .iter()
757                .any(|p| matches_gitignore_pattern(p, "src/main.js"))
758        );
759        assert!(
760            !patterns
761                .iter()
762                .any(|p| matches_gitignore_pattern(p, "README.md"))
763        );
764
765        Ok(())
766    }
767}