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