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
8pub fn read_gitignore_patterns(base_dir: &str) -> Vec<String> {
10 let mut patterns = vec![".git".to_string()]; 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 if !line.is_empty() && !line.starts_with('#') {
18 patterns.push(line.to_string());
19 }
20 }
21 }
22
23 patterns
24}
25
26pub 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 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 for pattern in ignore_patterns {
42 if matches_gitignore_pattern(pattern, &path_str) {
43 return false;
44 }
45 }
46
47 if is_file {
49 is_supported_file(entry.path())
50 } else {
51 true }
53}
54
55#[allow(clippy::string_slice)] pub fn matches_gitignore_pattern(pattern: &str, path: &str) -> bool {
58 let pattern = pattern.trim_end_matches('/'); 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_matches_glob(pattern, path)
74 }
75 } else {
76 path == pattern || path.starts_with(&format!("{}/", pattern))
78 }
79}
80
81#[allow(clippy::string_slice)] pub 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 if !text[text_pos..].starts_with(part) {
94 return false;
95 }
96 text_pos += part.len();
97 } else if i == parts.len() - 1 {
98 return text[text_pos..].ends_with(part);
100 } else {
101 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
112pub fn is_supported_file(file_path: &Path) -> bool {
114 match file_path.file_name().and_then(|name| name.to_str()) {
115 Some(name) => {
116 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 }
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 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 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 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 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 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 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 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
290pub fn generate_password(length: usize, no_symbols: bool) -> String {
292 let mut rng = rand::rng();
293
294 let lowercase = "abcdefghijklmnopqrstuvwxyz";
296 let uppercase = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
297 let digits = "0123456789";
298 let symbols = "!@#$%^&*()_+-=[]{}|;:,.<>?";
299
300 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 let mut password = String::new();
314
315 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 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 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 password_chars.into_iter().take(length).collect()
365}
366
367pub fn sanitize_text_output(text: &str) -> String {
369 text.chars()
370 .filter(|&c| {
371 if c == '\u{FFFD}' {
373 return false;
374 }
375 if matches!(c, '\n' | '\t' | '\r' | ' ') {
377 return true;
378 }
379 !c.is_control()
381 })
382 .collect()
383}
384
385pub 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 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 assert_ne!(password1, password2);
491 }
492}
493
494#[derive(Debug, Clone)]
496pub struct DirectoryEntry {
497 pub name: String,
498 pub path: String,
499 pub is_directory: bool,
500}
501
502#[async_trait]
504pub trait FileSystemProvider {
505 type Error: std::fmt::Display;
506
507 async fn list_directory(&self, path: &str) -> Result<Vec<DirectoryEntry>, Self::Error>;
509}
510
511pub 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
628pub 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}