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#[cfg(test)]
132mod tests {
133    use super::*;
134    use std::fs;
135    use std::io::Write;
136    use tempfile::TempDir;
137
138    #[test]
139    fn test_matches_gitignore_pattern_exact() {
140        assert!(matches_gitignore_pattern("node_modules", "node_modules"));
141        assert!(matches_gitignore_pattern(
142            "node_modules",
143            "node_modules/package.json"
144        ));
145        assert!(!matches_gitignore_pattern(
146            "node_modules",
147            "src/node_modules"
148        ));
149    }
150
151    #[test]
152    fn test_matches_gitignore_pattern_wildcard_prefix() {
153        assert!(matches_gitignore_pattern("*.log", "debug.log"));
154        assert!(matches_gitignore_pattern("*.log", "error.log"));
155        assert!(!matches_gitignore_pattern("*.log", "log.txt"));
156    }
157
158    #[test]
159    fn test_matches_gitignore_pattern_wildcard_suffix() {
160        assert!(matches_gitignore_pattern("temp*", "temp"));
161        assert!(matches_gitignore_pattern("temp*", "temp.txt"));
162        assert!(matches_gitignore_pattern("temp*", "temporary"));
163        assert!(!matches_gitignore_pattern("temp*", "mytemp"));
164    }
165
166    #[test]
167    fn test_matches_gitignore_pattern_wildcard_middle() {
168        assert!(matches_gitignore_pattern("*temp*", "temp"));
169        assert!(matches_gitignore_pattern("*temp*", "mytemp"));
170        assert!(matches_gitignore_pattern("*temp*", "temporary"));
171        assert!(matches_gitignore_pattern("*temp*", "mytemporary"));
172        assert!(!matches_gitignore_pattern("*temp*", "example"));
173    }
174
175    #[test]
176    fn test_pattern_matches_glob() {
177        assert!(pattern_matches_glob("test*.txt", "test.txt"));
178        assert!(pattern_matches_glob("test*.txt", "test123.txt"));
179        assert!(pattern_matches_glob("*test*.txt", "mytest.txt"));
180        assert!(pattern_matches_glob("*test*.txt", "mytestfile.txt"));
181        assert!(!pattern_matches_glob("test*.txt", "test.log"));
182        assert!(!pattern_matches_glob("*test*.txt", "example.txt"));
183    }
184
185    #[test]
186    fn test_read_gitignore_patterns() -> Result<(), Box<dyn std::error::Error>> {
187        let temp_dir = TempDir::new()?;
188        let temp_path = temp_dir.path();
189
190        // Create a .gitignore file
191        let gitignore_content = r#"
192# This is a comment
193node_modules
194*.log
195dist/
196.env
197
198# Another comment
199temp*
200"#;
201
202        let gitignore_path = temp_path.join(".gitignore");
203        let mut file = fs::File::create(&gitignore_path)?;
204        file.write_all(gitignore_content.as_bytes())?;
205
206        let patterns = read_gitignore_patterns(temp_path.to_str().unwrap());
207
208        // Should include .git by default
209        assert!(patterns.contains(&".git".to_string()));
210        assert!(patterns.contains(&"node_modules".to_string()));
211        assert!(patterns.contains(&"*.log".to_string()));
212        assert!(patterns.contains(&"dist/".to_string()));
213        assert!(patterns.contains(&".env".to_string()));
214        assert!(patterns.contains(&"temp*".to_string()));
215
216        // Should not include comments or empty lines
217        assert!(!patterns.iter().any(|p| p.starts_with('#')));
218        assert!(!patterns.contains(&"".to_string()));
219
220        Ok(())
221    }
222
223    #[test]
224    fn test_read_gitignore_patterns_no_file() {
225        let temp_dir = TempDir::new().unwrap();
226        let temp_path = temp_dir.path();
227
228        let patterns = read_gitignore_patterns(temp_path.to_str().unwrap());
229
230        // Should only contain .git when no .gitignore exists
231        assert_eq!(patterns, vec![".git".to_string()]);
232    }
233
234    #[test]
235    fn test_gitignore_integration() -> Result<(), Box<dyn std::error::Error>> {
236        let temp_dir = TempDir::new()?;
237        let temp_path = temp_dir.path();
238
239        // Create a .gitignore file
240        let gitignore_content = "node_modules\n*.log\ndist/\n";
241        let gitignore_path = temp_path.join(".gitignore");
242        let mut file = fs::File::create(&gitignore_path)?;
243        file.write_all(gitignore_content.as_bytes())?;
244
245        let patterns = read_gitignore_patterns(temp_path.to_str().unwrap());
246
247        // Test various paths
248        assert!(
249            patterns
250                .iter()
251                .any(|p| matches_gitignore_pattern(p, "node_modules"))
252        );
253        assert!(
254            patterns
255                .iter()
256                .any(|p| matches_gitignore_pattern(p, "node_modules/package.json"))
257        );
258        assert!(
259            patterns
260                .iter()
261                .any(|p| matches_gitignore_pattern(p, "debug.log"))
262        );
263        assert!(
264            patterns
265                .iter()
266                .any(|p| matches_gitignore_pattern(p, "dist/bundle.js"))
267        );
268        assert!(
269            patterns
270                .iter()
271                .any(|p| matches_gitignore_pattern(p, ".git"))
272        );
273
274        // These should not match
275        assert!(
276            !patterns
277                .iter()
278                .any(|p| matches_gitignore_pattern(p, "src/main.js"))
279        );
280        assert!(
281            !patterns
282                .iter()
283                .any(|p| matches_gitignore_pattern(p, "README.md"))
284        );
285
286        Ok(())
287    }
288}
289
290/// Generate a secure password with alphanumeric characters and optional symbols
291pub fn generate_password(length: usize, no_symbols: bool) -> String {
292    let mut rng = rand::rng();
293
294    // Define character sets
295    let lowercase = "abcdefghijklmnopqrstuvwxyz";
296    let uppercase = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
297    let digits = "0123456789";
298    let symbols = "!@#$%^&*()_+-=[]{}|;:,.<>?";
299
300    // Build the character set based on options
301    let mut charset = String::new();
302    charset.push_str(lowercase);
303    charset.push_str(uppercase);
304    charset.push_str(digits);
305
306    if !no_symbols {
307        charset.push_str(symbols);
308    }
309
310    let charset_chars: Vec<char> = charset.chars().collect();
311
312    // Generate password ensuring at least one character from each required category
313    let mut password = String::new();
314
315    // Ensure at least one character from each category
316    password.push(
317        lowercase
318            .chars()
319            .nth(rng.random_range(0..lowercase.len()))
320            .unwrap(),
321    );
322    password.push(
323        uppercase
324            .chars()
325            .nth(rng.random_range(0..uppercase.len()))
326            .unwrap(),
327    );
328    password.push(
329        digits
330            .chars()
331            .nth(rng.random_range(0..digits.len()))
332            .unwrap(),
333    );
334
335    if !no_symbols {
336        password.push(
337            symbols
338                .chars()
339                .nth(rng.random_range(0..symbols.len()))
340                .unwrap(),
341        );
342    }
343
344    // Fill the rest with random characters from the full charset
345    let remaining_length = if length > password.len() {
346        length - password.len()
347    } else {
348        0
349    };
350
351    for _ in 0..remaining_length {
352        let random_char = charset_chars[rng.random_range(0..charset_chars.len())];
353        password.push(random_char);
354    }
355
356    // Shuffle the password to randomize the order
357    let mut password_chars: Vec<char> = password.chars().collect();
358    for i in 0..password_chars.len() {
359        let j = rng.random_range(0..password_chars.len());
360        password_chars.swap(i, j);
361    }
362
363    // Take only the requested length
364    password_chars.into_iter().take(length).collect()
365}
366
367/// Sanitize text output by removing control characters while preserving essential whitespace
368pub fn sanitize_text_output(text: &str) -> String {
369    text.chars()
370        .filter(|&c| {
371            // Drop replacement char
372            if c == '\u{FFFD}' {
373                return false;
374            }
375            // Allow essential whitespace even though they're "control"
376            if matches!(c, '\n' | '\t' | '\r' | ' ') {
377                return true;
378            }
379            // Keep everything else that's not a control character
380            !c.is_control()
381        })
382        .collect()
383}
384
385/// Handle large output: if the output has >= `max_lines`, save the full content to session
386/// storage and return a string showing only the first or last `max_lines` lines with a pointer
387/// to the saved file. Returns `Ok(final_string)` or `Err(error_string)` on failure.
388pub fn handle_large_output(
389    output: &str,
390    file_prefix: &str,
391    max_lines: usize,
392    show_head: bool,
393) -> Result<String, String> {
394    let output_lines = output.lines().collect::<Vec<_>>();
395    if output_lines.len() >= max_lines {
396        let mut __rng__ = rand::rng();
397        let output_file = format!(
398            "{}.{:06x}.txt",
399            file_prefix,
400            __rng__.random_range(0..=0xFFFFFF)
401        );
402        let output_file_path = match LocalStore::write_session_data(&output_file, output) {
403            Ok(path) => path,
404            Err(e) => {
405                return Err(format!("Failed to write session data: {}", e));
406            }
407        };
408
409        let excerpt = if show_head {
410            let head_lines: Vec<&str> = output_lines.iter().take(max_lines).copied().collect();
411            head_lines.join("\n")
412        } else {
413            let mut tail_lines: Vec<&str> =
414                output_lines.iter().rev().take(max_lines).copied().collect();
415            tail_lines.reverse();
416            tail_lines.join("\n")
417        };
418
419        let position = if show_head { "first" } else { "last" };
420        Ok(format!(
421            "Showing the {} {} / {} output lines. Full output saved to {}\n{}\n{}",
422            position,
423            max_lines,
424            output_lines.len(),
425            output_file_path,
426            if show_head { "" } else { "...\n" },
427            excerpt
428        ))
429    } else {
430        Ok(output.to_string())
431    }
432}
433
434#[cfg(test)]
435mod password_tests {
436    use super::*;
437
438    #[test]
439    fn test_generate_password_length() {
440        let password = generate_password(10, false);
441        assert_eq!(password.len(), 10);
442
443        let password = generate_password(20, true);
444        assert_eq!(password.len(), 20);
445    }
446
447    #[test]
448    fn test_generate_password_no_symbols() {
449        let password = generate_password(50, true);
450        let symbols = "!@#$%^&*()_+-=[]{}|;:,.<>?";
451
452        for symbol in symbols.chars() {
453            assert!(
454                !password.contains(symbol),
455                "Password should not contain symbol: {}",
456                symbol
457            );
458        }
459    }
460
461    #[test]
462    fn test_generate_password_with_symbols() {
463        let password = generate_password(50, false);
464        let symbols = "!@#$%^&*()_+-=[]{}|;:,.<>?";
465
466        // At least one symbol should be present (due to our algorithm)
467        let has_symbol = password.chars().any(|c| symbols.contains(c));
468        assert!(has_symbol, "Password should contain at least one symbol");
469    }
470
471    #[test]
472    fn test_generate_password_contains_required_chars() {
473        let password = generate_password(50, false);
474
475        let has_lowercase = password.chars().any(|c| c.is_ascii_lowercase());
476        let has_uppercase = password.chars().any(|c| c.is_ascii_uppercase());
477        let has_digit = password.chars().any(|c| c.is_ascii_digit());
478
479        assert!(has_lowercase, "Password should contain lowercase letters");
480        assert!(has_uppercase, "Password should contain uppercase letters");
481        assert!(has_digit, "Password should contain digits");
482    }
483
484    #[test]
485    fn test_generate_password_uniqueness() {
486        let password1 = generate_password(20, false);
487        let password2 = generate_password(20, false);
488
489        // Very unlikely to generate the same password twice
490        assert_ne!(password1, password2);
491    }
492}
493
494/// Directory entry information for tree generation
495#[derive(Debug, Clone)]
496pub struct DirectoryEntry {
497    pub name: String,
498    pub path: String,
499    pub is_directory: bool,
500}
501
502/// Trait for abstracting file system operations for tree generation
503#[async_trait]
504pub trait FileSystemProvider {
505    type Error: std::fmt::Display;
506
507    /// List directory contents
508    async fn list_directory(&self, path: &str) -> Result<Vec<DirectoryEntry>, Self::Error>;
509}
510
511/// Generate a tree view of a directory structure using a generic file system provider
512pub async fn generate_directory_tree<P: FileSystemProvider>(
513    provider: &P,
514    path: &str,
515    prefix: &str,
516    max_depth: usize,
517    current_depth: usize,
518) -> Result<String, P::Error> {
519    let mut result = String::new();
520
521    if current_depth >= max_depth || current_depth >= 10 {
522        return Ok(result);
523    }
524
525    let entries = provider.list_directory(path).await?;
526    let mut file_entries = Vec::new();
527    let mut dir_entries = Vec::new();
528    for entry in entries.iter() {
529        if entry.is_directory {
530            if entry.name == "."
531                || entry.name == ".."
532                || entry.name == ".git"
533                || entry.name == "node_modules"
534            {
535                continue;
536            }
537            dir_entries.push(entry.clone());
538        } else {
539            file_entries.push(entry.clone());
540        }
541    }
542
543    dir_entries.sort_by(|a, b| a.name.cmp(&b.name));
544    file_entries.sort_by(|a, b| a.name.cmp(&b.name));
545
546    const MAX_ITEMS: usize = 5;
547    let total_items = dir_entries.len() + file_entries.len();
548    let should_limit = current_depth > 0 && total_items > MAX_ITEMS;
549
550    if should_limit {
551        if dir_entries.len() > MAX_ITEMS {
552            dir_entries.truncate(MAX_ITEMS);
553            file_entries.clear();
554        } else {
555            let remaining_items = MAX_ITEMS - dir_entries.len();
556            file_entries.truncate(remaining_items);
557        }
558    }
559
560    let mut dir_headers = Vec::new();
561    let mut dir_futures = Vec::new();
562    for (i, entry) in dir_entries.iter().enumerate() {
563        let is_last_dir = i == dir_entries.len() - 1;
564        let is_last_overall = is_last_dir && file_entries.is_empty() && !should_limit;
565        let current_prefix = if is_last_overall {
566            "└── "
567        } else {
568            "├── "
569        };
570        let next_prefix = format!(
571            "{}{}",
572            prefix,
573            if is_last_overall { "    " } else { "│   " }
574        );
575
576        let header = format!("{}{}{}/\n", prefix, current_prefix, entry.name);
577        dir_headers.push(header);
578
579        let entry_path = entry.path.clone();
580        let next_prefix_clone = next_prefix.clone();
581        let future = async move {
582            generate_directory_tree(
583                provider,
584                &entry_path,
585                &next_prefix_clone,
586                max_depth,
587                current_depth + 1,
588            )
589            .await
590        };
591        dir_futures.push(future);
592    }
593    if !dir_futures.is_empty() {
594        let subtree_results = futures::future::join_all(dir_futures).await;
595
596        for (i, header) in dir_headers.iter().enumerate() {
597            result.push_str(header);
598            if let Some(Ok(subtree)) = subtree_results.get(i) {
599                result.push_str(subtree);
600            }
601        }
602    }
603
604    for (i, entry) in file_entries.iter().enumerate() {
605        let is_last_file = i == file_entries.len() - 1;
606        let is_last_overall = is_last_file && !should_limit;
607        let current_prefix = if is_last_overall {
608            "└── "
609        } else {
610            "├── "
611        };
612        result.push_str(&format!("{}{}{}\n", prefix, current_prefix, entry.name));
613    }
614
615    if should_limit {
616        let remaining_count = total_items - MAX_ITEMS;
617        result.push_str(&format!(
618            "{}└── ... {} more item{}\n",
619            prefix,
620            remaining_count,
621            if remaining_count == 1 { "" } else { "s" }
622        ));
623    }
624
625    Ok(result)
626}
627
628/// Local file system provider implementation
629pub struct LocalFileSystemProvider;
630
631#[async_trait]
632impl FileSystemProvider for LocalFileSystemProvider {
633    type Error = std::io::Error;
634
635    async fn list_directory(&self, path: &str) -> Result<Vec<DirectoryEntry>, Self::Error> {
636        let entries = fs::read_dir(path)?;
637        let mut result = Vec::new();
638
639        for entry in entries {
640            let entry = entry?;
641            let file_name = entry.file_name().to_string_lossy().to_string();
642            let file_path = entry.path().to_string_lossy().to_string();
643            let is_directory = entry.file_type()?.is_dir();
644
645            result.push(DirectoryEntry {
646                name: file_name,
647                path: file_path,
648                is_directory,
649            });
650        }
651
652        Ok(result)
653    }
654}