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
55pub fn matches_gitignore_pattern(pattern: &str, path: &str) -> bool {
57 let pattern = pattern.trim_end_matches('/'); 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_matches_glob(pattern, path)
73 }
74 } else {
75 path == pattern || path.starts_with(&format!("{}/", pattern))
77 }
78}
79
80pub 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 if !text[text_pos..].starts_with(part) {
92 return false;
93 }
94 text_pos += part.len();
95 } else if i == parts.len() - 1 {
96 return text[text_pos..].ends_with(part);
98 } else {
99 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
110pub fn is_supported_file(file_path: &Path) -> bool {
112 match file_path.file_name().and_then(|name| name.to_str()) {
113 Some(name) => {
114 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 }
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 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 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 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 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 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 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 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
288pub fn generate_password(length: usize, no_symbols: bool) -> String {
290 let mut rng = rand::rng();
291
292 let lowercase = "abcdefghijklmnopqrstuvwxyz";
294 let uppercase = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
295 let digits = "0123456789";
296 let symbols = "!@#$%^&*()_+-=[]{}|;:,.<>?";
297
298 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 let mut password = String::new();
312
313 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 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 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 password_chars.into_iter().take(length).collect()
363}
364
365pub fn sanitize_text_output(text: &str) -> String {
367 text.chars()
368 .filter(|&c| {
369 if c == '\u{FFFD}' {
371 return false;
372 }
373 if matches!(c, '\n' | '\t' | '\r' | ' ') {
375 return true;
376 }
377 !c.is_control()
379 })
380 .collect()
381}
382
383pub 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 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 assert_ne!(password1, password2);
489 }
490}
491
492#[derive(Debug, Clone)]
494pub struct DirectoryEntry {
495 pub name: String,
496 pub path: String,
497 pub is_directory: bool,
498}
499
500#[async_trait]
502pub trait FileSystemProvider {
503 type Error: std::fmt::Display;
504
505 async fn list_directory(&self, path: &str) -> Result<Vec<DirectoryEntry>, Self::Error>;
507}
508
509pub 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
626pub 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}