stakpak_shared/
utils.rs

1use async_trait::async_trait;
2use rand::Rng;
3use std::fs;
4use std::path::{Path, PathBuf};
5use walkdir::DirEntry;
6
7/// Read .gitignore patterns from the specified base directory
8pub fn read_gitignore_patterns(base_dir: &str) -> Vec<String> {
9    let mut patterns = vec![".git".to_string()]; // Always ignore .git directory
10
11    let gitignore_path = PathBuf::from(base_dir).join(".gitignore");
12    if let Ok(content) = std::fs::read_to_string(&gitignore_path) {
13        for line in content.lines() {
14            let line = line.trim();
15            // Skip empty lines and comments
16            if !line.is_empty() && !line.starts_with('#') {
17                patterns.push(line.to_string());
18            }
19        }
20    }
21
22    patterns
23}
24
25/// Check if a directory entry should be included based on gitignore patterns and file type support
26pub fn should_include_entry(entry: &DirEntry, base_dir: &str, ignore_patterns: &[String]) -> bool {
27    let path = entry.path();
28    let is_file = entry.file_type().is_file();
29
30    // Get relative path from base directory
31    let base_path = PathBuf::from(base_dir);
32    let relative_path = match path.strip_prefix(&base_path) {
33        Ok(rel_path) => rel_path,
34        Err(_) => path,
35    };
36
37    let path_str = relative_path.to_string_lossy();
38
39    // Check if path matches any ignore pattern
40    for pattern in ignore_patterns {
41        if matches_gitignore_pattern(pattern, &path_str) {
42            return false;
43        }
44    }
45
46    // For files, also check if they are supported file types
47    if is_file {
48        is_supported_file(entry.path())
49    } else {
50        true // Allow directories to be traversed
51    }
52}
53
54/// Check if a path matches a gitignore pattern
55pub fn matches_gitignore_pattern(pattern: &str, path: &str) -> bool {
56    // Basic gitignore pattern matching
57    let pattern = pattern.trim_end_matches('/'); // Remove trailing slash
58
59    if pattern.contains('*') {
60        if pattern == "*" {
61            true
62        } else if pattern.starts_with('*') && pattern.ends_with('*') {
63            let middle = &pattern[1..pattern.len() - 1];
64            path.contains(middle)
65        } else if let Some(suffix) = pattern.strip_prefix('*') {
66            path.ends_with(suffix)
67        } else if let Some(prefix) = pattern.strip_suffix('*') {
68            path.starts_with(prefix)
69        } else {
70            // Pattern contains * but not at start/end, do basic glob matching
71            pattern_matches_glob(pattern, path)
72        }
73    } else {
74        // Exact match or directory match
75        path == pattern || path.starts_with(&format!("{}/", pattern))
76    }
77}
78
79/// Simple glob pattern matching for basic cases
80pub fn pattern_matches_glob(pattern: &str, text: &str) -> bool {
81    let parts: Vec<&str> = pattern.split('*').collect();
82    if parts.len() == 1 {
83        return text == pattern;
84    }
85
86    let mut text_pos = 0;
87    for (i, part) in parts.iter().enumerate() {
88        if i == 0 {
89            // First part must match at the beginning
90            if !text[text_pos..].starts_with(part) {
91                return false;
92            }
93            text_pos += part.len();
94        } else if i == parts.len() - 1 {
95            // Last part must match at the end
96            return text[text_pos..].ends_with(part);
97        } else {
98            // Middle parts must be found in order
99            if let Some(pos) = text[text_pos..].find(part) {
100                text_pos += pos + part.len();
101            } else {
102                return false;
103            }
104        }
105    }
106    true
107}
108
109/// Check if a directory entry represents a supported file type
110pub fn is_supported_file(file_path: &Path) -> bool {
111    match file_path.file_name().and_then(|name| name.to_str()) {
112        Some(name) => {
113            // Only allow supported files
114            if file_path.is_file() {
115                name.ends_with(".tf")
116                    || name.ends_with(".tfvars")
117                    || name.ends_with(".yaml")
118                    || name.ends_with(".yml")
119                    || name.to_lowercase().contains("dockerfile")
120            } else {
121                true // Allow directories to be traversed
122            }
123        }
124        None => false,
125    }
126}
127
128#[cfg(test)]
129mod tests {
130    use super::*;
131    use std::fs;
132    use std::io::Write;
133    use tempfile::TempDir;
134
135    #[test]
136    fn test_matches_gitignore_pattern_exact() {
137        assert!(matches_gitignore_pattern("node_modules", "node_modules"));
138        assert!(matches_gitignore_pattern(
139            "node_modules",
140            "node_modules/package.json"
141        ));
142        assert!(!matches_gitignore_pattern(
143            "node_modules",
144            "src/node_modules"
145        ));
146    }
147
148    #[test]
149    fn test_matches_gitignore_pattern_wildcard_prefix() {
150        assert!(matches_gitignore_pattern("*.log", "debug.log"));
151        assert!(matches_gitignore_pattern("*.log", "error.log"));
152        assert!(!matches_gitignore_pattern("*.log", "log.txt"));
153    }
154
155    #[test]
156    fn test_matches_gitignore_pattern_wildcard_suffix() {
157        assert!(matches_gitignore_pattern("temp*", "temp"));
158        assert!(matches_gitignore_pattern("temp*", "temp.txt"));
159        assert!(matches_gitignore_pattern("temp*", "temporary"));
160        assert!(!matches_gitignore_pattern("temp*", "mytemp"));
161    }
162
163    #[test]
164    fn test_matches_gitignore_pattern_wildcard_middle() {
165        assert!(matches_gitignore_pattern("*temp*", "temp"));
166        assert!(matches_gitignore_pattern("*temp*", "mytemp"));
167        assert!(matches_gitignore_pattern("*temp*", "temporary"));
168        assert!(matches_gitignore_pattern("*temp*", "mytemporary"));
169        assert!(!matches_gitignore_pattern("*temp*", "example"));
170    }
171
172    #[test]
173    fn test_pattern_matches_glob() {
174        assert!(pattern_matches_glob("test*.txt", "test.txt"));
175        assert!(pattern_matches_glob("test*.txt", "test123.txt"));
176        assert!(pattern_matches_glob("*test*.txt", "mytest.txt"));
177        assert!(pattern_matches_glob("*test*.txt", "mytestfile.txt"));
178        assert!(!pattern_matches_glob("test*.txt", "test.log"));
179        assert!(!pattern_matches_glob("*test*.txt", "example.txt"));
180    }
181
182    #[test]
183    fn test_read_gitignore_patterns() -> Result<(), Box<dyn std::error::Error>> {
184        let temp_dir = TempDir::new()?;
185        let temp_path = temp_dir.path();
186
187        // Create a .gitignore file
188        let gitignore_content = r#"
189# This is a comment
190node_modules
191*.log
192dist/
193.env
194
195# Another comment
196temp*
197"#;
198
199        let gitignore_path = temp_path.join(".gitignore");
200        let mut file = fs::File::create(&gitignore_path)?;
201        file.write_all(gitignore_content.as_bytes())?;
202
203        let patterns = read_gitignore_patterns(temp_path.to_str().unwrap());
204
205        // Should include .git by default
206        assert!(patterns.contains(&".git".to_string()));
207        assert!(patterns.contains(&"node_modules".to_string()));
208        assert!(patterns.contains(&"*.log".to_string()));
209        assert!(patterns.contains(&"dist/".to_string()));
210        assert!(patterns.contains(&".env".to_string()));
211        assert!(patterns.contains(&"temp*".to_string()));
212
213        // Should not include comments or empty lines
214        assert!(!patterns.iter().any(|p| p.starts_with('#')));
215        assert!(!patterns.contains(&"".to_string()));
216
217        Ok(())
218    }
219
220    #[test]
221    fn test_read_gitignore_patterns_no_file() {
222        let temp_dir = TempDir::new().unwrap();
223        let temp_path = temp_dir.path();
224
225        let patterns = read_gitignore_patterns(temp_path.to_str().unwrap());
226
227        // Should only contain .git when no .gitignore exists
228        assert_eq!(patterns, vec![".git".to_string()]);
229    }
230
231    #[test]
232    fn test_gitignore_integration() -> Result<(), Box<dyn std::error::Error>> {
233        let temp_dir = TempDir::new()?;
234        let temp_path = temp_dir.path();
235
236        // Create a .gitignore file
237        let gitignore_content = "node_modules\n*.log\ndist/\n";
238        let gitignore_path = temp_path.join(".gitignore");
239        let mut file = fs::File::create(&gitignore_path)?;
240        file.write_all(gitignore_content.as_bytes())?;
241
242        let patterns = read_gitignore_patterns(temp_path.to_str().unwrap());
243
244        // Test various paths
245        assert!(
246            patterns
247                .iter()
248                .any(|p| matches_gitignore_pattern(p, "node_modules"))
249        );
250        assert!(
251            patterns
252                .iter()
253                .any(|p| matches_gitignore_pattern(p, "node_modules/package.json"))
254        );
255        assert!(
256            patterns
257                .iter()
258                .any(|p| matches_gitignore_pattern(p, "debug.log"))
259        );
260        assert!(
261            patterns
262                .iter()
263                .any(|p| matches_gitignore_pattern(p, "dist/bundle.js"))
264        );
265        assert!(
266            patterns
267                .iter()
268                .any(|p| matches_gitignore_pattern(p, ".git"))
269        );
270
271        // These should not match
272        assert!(
273            !patterns
274                .iter()
275                .any(|p| matches_gitignore_pattern(p, "src/main.js"))
276        );
277        assert!(
278            !patterns
279                .iter()
280                .any(|p| matches_gitignore_pattern(p, "README.md"))
281        );
282
283        Ok(())
284    }
285}
286
287/// Generate a secure password with alphanumeric characters and optional symbols
288pub fn generate_password(length: usize, no_symbols: bool) -> String {
289    let mut rng = rand::rng();
290
291    // Define character sets
292    let lowercase = "abcdefghijklmnopqrstuvwxyz";
293    let uppercase = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
294    let digits = "0123456789";
295    let symbols = "!@#$%^&*()_+-=[]{}|;:,.<>?";
296
297    // Build the character set based on options
298    let mut charset = String::new();
299    charset.push_str(lowercase);
300    charset.push_str(uppercase);
301    charset.push_str(digits);
302
303    if !no_symbols {
304        charset.push_str(symbols);
305    }
306
307    let charset_chars: Vec<char> = charset.chars().collect();
308
309    // Generate password ensuring at least one character from each required category
310    let mut password = String::new();
311
312    // Ensure at least one character from each category
313    password.push(
314        lowercase
315            .chars()
316            .nth(rng.random_range(0..lowercase.len()))
317            .unwrap(),
318    );
319    password.push(
320        uppercase
321            .chars()
322            .nth(rng.random_range(0..uppercase.len()))
323            .unwrap(),
324    );
325    password.push(
326        digits
327            .chars()
328            .nth(rng.random_range(0..digits.len()))
329            .unwrap(),
330    );
331
332    if !no_symbols {
333        password.push(
334            symbols
335                .chars()
336                .nth(rng.random_range(0..symbols.len()))
337                .unwrap(),
338        );
339    }
340
341    // Fill the rest with random characters from the full charset
342    let remaining_length = if length > password.len() {
343        length - password.len()
344    } else {
345        0
346    };
347
348    for _ in 0..remaining_length {
349        let random_char = charset_chars[rng.random_range(0..charset_chars.len())];
350        password.push(random_char);
351    }
352
353    // Shuffle the password to randomize the order
354    let mut password_chars: Vec<char> = password.chars().collect();
355    for i in 0..password_chars.len() {
356        let j = rng.random_range(0..password_chars.len());
357        password_chars.swap(i, j);
358    }
359
360    // Take only the requested length
361    password_chars.into_iter().take(length).collect()
362}
363
364#[cfg(test)]
365mod password_tests {
366    use super::*;
367
368    #[test]
369    fn test_generate_password_length() {
370        let password = generate_password(10, false);
371        assert_eq!(password.len(), 10);
372
373        let password = generate_password(20, true);
374        assert_eq!(password.len(), 20);
375    }
376
377    #[test]
378    fn test_generate_password_no_symbols() {
379        let password = generate_password(50, true);
380        let symbols = "!@#$%^&*()_+-=[]{}|;:,.<>?";
381
382        for symbol in symbols.chars() {
383            assert!(
384                !password.contains(symbol),
385                "Password should not contain symbol: {}",
386                symbol
387            );
388        }
389    }
390
391    #[test]
392    fn test_generate_password_with_symbols() {
393        let password = generate_password(50, false);
394        let symbols = "!@#$%^&*()_+-=[]{}|;:,.<>?";
395
396        // At least one symbol should be present (due to our algorithm)
397        let has_symbol = password.chars().any(|c| symbols.contains(c));
398        assert!(has_symbol, "Password should contain at least one symbol");
399    }
400
401    #[test]
402    fn test_generate_password_contains_required_chars() {
403        let password = generate_password(50, false);
404
405        let has_lowercase = password.chars().any(|c| c.is_ascii_lowercase());
406        let has_uppercase = password.chars().any(|c| c.is_ascii_uppercase());
407        let has_digit = password.chars().any(|c| c.is_ascii_digit());
408
409        assert!(has_lowercase, "Password should contain lowercase letters");
410        assert!(has_uppercase, "Password should contain uppercase letters");
411        assert!(has_digit, "Password should contain digits");
412    }
413
414    #[test]
415    fn test_generate_password_uniqueness() {
416        let password1 = generate_password(20, false);
417        let password2 = generate_password(20, false);
418
419        // Very unlikely to generate the same password twice
420        assert_ne!(password1, password2);
421    }
422}
423
424/// Directory entry information for tree generation
425#[derive(Debug, Clone)]
426pub struct DirectoryEntry {
427    pub name: String,
428    pub path: String,
429    pub is_directory: bool,
430}
431
432/// Trait for abstracting file system operations for tree generation
433#[async_trait]
434pub trait FileSystemProvider {
435    type Error: std::fmt::Display;
436
437    /// List directory contents
438    async fn list_directory(&self, path: &str) -> Result<Vec<DirectoryEntry>, Self::Error>;
439}
440
441/// Generate a tree view of a directory structure using a generic file system provider
442pub async fn generate_directory_tree<P: FileSystemProvider>(
443    provider: &P,
444    path: &str,
445    prefix: &str,
446    max_depth: usize,
447    current_depth: usize,
448) -> Result<String, P::Error> {
449    let mut result = String::new();
450
451    if current_depth >= max_depth || current_depth >= 10 {
452        return Ok(result);
453    }
454
455    let entries = provider.list_directory(path).await?;
456    let mut file_entries = Vec::new();
457    let mut dir_entries = Vec::new();
458    for entry in entries.iter() {
459        if entry.is_directory {
460            if entry.name == "."
461                || entry.name == ".."
462                || entry.name == ".git"
463                || entry.name == "node_modules"
464            {
465                continue;
466            }
467            dir_entries.push(entry.clone());
468        } else {
469            file_entries.push(entry.clone());
470        }
471    }
472
473    dir_entries.sort_by(|a, b| a.name.cmp(&b.name));
474    file_entries.sort_by(|a, b| a.name.cmp(&b.name));
475
476    const MAX_ITEMS: usize = 5;
477    let total_items = dir_entries.len() + file_entries.len();
478    let should_limit = current_depth > 0 && total_items > MAX_ITEMS;
479
480    if should_limit {
481        if dir_entries.len() > MAX_ITEMS {
482            dir_entries.truncate(MAX_ITEMS);
483            file_entries.clear();
484        } else {
485            let remaining_items = MAX_ITEMS - dir_entries.len();
486            file_entries.truncate(remaining_items);
487        }
488    }
489
490    let mut dir_headers = Vec::new();
491    let mut dir_futures = Vec::new();
492    for (i, entry) in dir_entries.iter().enumerate() {
493        let is_last_dir = i == dir_entries.len() - 1;
494        let is_last_overall = is_last_dir && file_entries.is_empty() && !should_limit;
495        let current_prefix = if is_last_overall {
496            "└── "
497        } else {
498            "├── "
499        };
500        let next_prefix = format!(
501            "{}{}",
502            prefix,
503            if is_last_overall { "    " } else { "│   " }
504        );
505
506        let header = format!("{}{}{}/\n", prefix, current_prefix, entry.name);
507        dir_headers.push(header);
508
509        let entry_path = entry.path.clone();
510        let next_prefix_clone = next_prefix.clone();
511        let future = async move {
512            generate_directory_tree(
513                provider,
514                &entry_path,
515                &next_prefix_clone,
516                max_depth,
517                current_depth + 1,
518            )
519            .await
520        };
521        dir_futures.push(future);
522    }
523    if !dir_futures.is_empty() {
524        let subtree_results = futures::future::join_all(dir_futures).await;
525
526        for (i, header) in dir_headers.iter().enumerate() {
527            result.push_str(header);
528            if let Some(Ok(subtree)) = subtree_results.get(i) {
529                result.push_str(subtree);
530            }
531        }
532    }
533
534    for (i, entry) in file_entries.iter().enumerate() {
535        let is_last_file = i == file_entries.len() - 1;
536        let is_last_overall = is_last_file && !should_limit;
537        let current_prefix = if is_last_overall {
538            "└── "
539        } else {
540            "├── "
541        };
542        result.push_str(&format!("{}{}{}\n", prefix, current_prefix, entry.name));
543    }
544
545    if should_limit {
546        let remaining_count = total_items - MAX_ITEMS;
547        result.push_str(&format!(
548            "{}└── ... {} more item{}\n",
549            prefix,
550            remaining_count,
551            if remaining_count == 1 { "" } else { "s" }
552        ));
553    }
554
555    Ok(result)
556}
557
558/// Local file system provider implementation
559pub struct LocalFileSystemProvider;
560
561#[async_trait]
562impl FileSystemProvider for LocalFileSystemProvider {
563    type Error = std::io::Error;
564
565    async fn list_directory(&self, path: &str) -> Result<Vec<DirectoryEntry>, Self::Error> {
566        let entries = fs::read_dir(path)?;
567        let mut result = Vec::new();
568
569        for entry in entries {
570            let entry = entry?;
571            let file_name = entry.file_name().to_string_lossy().to_string();
572            let file_path = entry.path().to_string_lossy().to_string();
573            let is_directory = entry.file_type()?.is_dir();
574
575            result.push(DirectoryEntry {
576                name: file_name,
577                path: file_path,
578                is_directory,
579            });
580        }
581
582        Ok(result)
583    }
584}