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
131pub fn generate_password(length: usize, no_symbols: bool) -> String {
133 let mut rng = rand::rng();
134
135 let lowercase = "abcdefghijklmnopqrstuvwxyz";
137 let uppercase = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
138 let digits = "0123456789";
139 let symbols = "!@#$%^&*()_+-=[]{}|;:,.<>?";
140
141 let mut charset = String::new();
143 charset.push_str(lowercase);
144 charset.push_str(uppercase);
145 charset.push_str(digits);
146
147 if !no_symbols {
148 charset.push_str(symbols);
149 }
150
151 let charset_chars: Vec<char> = charset.chars().collect();
152
153 let mut password = String::new();
155
156 password.push(
158 lowercase
159 .chars()
160 .nth(rng.random_range(0..lowercase.len()))
161 .unwrap(),
162 );
163 password.push(
164 uppercase
165 .chars()
166 .nth(rng.random_range(0..uppercase.len()))
167 .unwrap(),
168 );
169 password.push(
170 digits
171 .chars()
172 .nth(rng.random_range(0..digits.len()))
173 .unwrap(),
174 );
175
176 if !no_symbols {
177 password.push(
178 symbols
179 .chars()
180 .nth(rng.random_range(0..symbols.len()))
181 .unwrap(),
182 );
183 }
184
185 let remaining_length = if length > password.len() {
187 length - password.len()
188 } else {
189 0
190 };
191
192 for _ in 0..remaining_length {
193 let random_char = charset_chars[rng.random_range(0..charset_chars.len())];
194 password.push(random_char);
195 }
196
197 let mut password_chars: Vec<char> = password.chars().collect();
199 for i in 0..password_chars.len() {
200 let j = rng.random_range(0..password_chars.len());
201 password_chars.swap(i, j);
202 }
203
204 password_chars.into_iter().take(length).collect()
206}
207
208pub fn normalize_optional_string(value: Option<String>) -> Option<String> {
212 value.and_then(|value| {
213 let trimmed = value.trim();
214 if trimmed.is_empty() {
215 None
216 } else {
217 Some(trimmed.to_string())
218 }
219 })
220}
221
222pub fn sanitize_text_output(text: &str) -> String {
224 text.chars()
225 .filter(|&c| {
226 if c == '\u{FFFD}' {
228 return false;
229 }
230 if matches!(c, '\n' | '\t' | '\r' | ' ') {
232 return true;
233 }
234 !c.is_control()
236 })
237 .collect()
238}
239
240pub fn truncate_chars_with_ellipsis(text: &str, max_chars: usize) -> String {
244 if text.chars().count() <= max_chars {
245 return text.to_string();
246 }
247
248 let mut truncated: String = text.chars().take(max_chars).collect();
249 truncated.push_str("...");
250 truncated
251}
252
253pub fn handle_large_output(
257 output: &str,
258 file_prefix: &str,
259 max_lines: usize,
260 show_head: bool,
261) -> Result<String, String> {
262 let output_lines = output.lines().collect::<Vec<_>>();
263 if output_lines.len() >= max_lines {
264 let mut __rng__ = rand::rng();
265 let output_file = format!(
266 "{}.{:06x}.txt",
267 file_prefix,
268 __rng__.random_range(0..=0xFFFFFF)
269 );
270 let output_file_path = match LocalStore::write_session_data(&output_file, output) {
271 Ok(path) => path,
272 Err(e) => {
273 return Err(format!("Failed to write session data: {}", e));
274 }
275 };
276
277 let excerpt = if show_head {
278 let head_lines: Vec<&str> = output_lines.iter().take(max_lines).copied().collect();
279 head_lines.join("\n")
280 } else {
281 let mut tail_lines: Vec<&str> =
282 output_lines.iter().rev().take(max_lines).copied().collect();
283 tail_lines.reverse();
284 tail_lines.join("\n")
285 };
286
287 let position = if show_head { "first" } else { "last" };
288 Ok(format!(
289 "Showing the {} {} / {} output lines. Full output saved to {}\n{}\n{}",
290 position,
291 max_lines,
292 output_lines.len(),
293 output_file_path,
294 if show_head { "" } else { "...\n" },
295 excerpt
296 ))
297 } else {
298 Ok(output.to_string())
299 }
300}
301
302#[cfg(test)]
303mod password_tests {
304 use super::*;
305
306 #[test]
307 fn test_generate_password_length() {
308 let password = generate_password(10, false);
309 assert_eq!(password.len(), 10);
310
311 let password = generate_password(20, true);
312 assert_eq!(password.len(), 20);
313 }
314
315 #[test]
316 fn test_generate_password_no_symbols() {
317 let password = generate_password(50, true);
318 let symbols = "!@#$%^&*()_+-=[]{}|;:,.<>?";
319
320 for symbol in symbols.chars() {
321 assert!(
322 !password.contains(symbol),
323 "Password should not contain symbol: {}",
324 symbol
325 );
326 }
327 }
328
329 #[test]
330 fn test_generate_password_with_symbols() {
331 let password = generate_password(50, false);
332 let symbols = "!@#$%^&*()_+-=[]{}|;:,.<>?";
333
334 let has_symbol = password.chars().any(|c| symbols.contains(c));
336 assert!(has_symbol, "Password should contain at least one symbol");
337 }
338
339 #[test]
340 fn test_generate_password_contains_required_chars() {
341 let password = generate_password(50, false);
342
343 let has_lowercase = password.chars().any(|c| c.is_ascii_lowercase());
344 let has_uppercase = password.chars().any(|c| c.is_ascii_uppercase());
345 let has_digit = password.chars().any(|c| c.is_ascii_digit());
346
347 assert!(has_lowercase, "Password should contain lowercase letters");
348 assert!(has_uppercase, "Password should contain uppercase letters");
349 assert!(has_digit, "Password should contain digits");
350 }
351
352 #[test]
353 fn test_generate_password_uniqueness() {
354 let password1 = generate_password(20, false);
355 let password2 = generate_password(20, false);
356
357 assert_ne!(password1, password2);
359 }
360}
361
362#[cfg(test)]
363mod truncate_tests {
364 use super::*;
365
366 #[test]
367 fn normalize_optional_string_trims_and_drops_empty() {
368 assert_eq!(
369 normalize_optional_string(Some(" hello ".to_string())),
370 Some("hello".to_string())
371 );
372 assert_eq!(normalize_optional_string(Some(" ".to_string())), None);
373 assert_eq!(normalize_optional_string(None), None);
374 }
375
376 #[test]
377 fn truncate_chars_with_ellipsis_exact_boundary_keeps_value() {
378 let value = "a".repeat(20);
379 let truncated = truncate_chars_with_ellipsis(&value, 20);
380 assert_eq!(truncated, value);
381 }
382
383 #[test]
384 fn truncate_chars_with_ellipsis_appends_suffix_when_truncated() {
385 let value = "é".repeat(10);
386 let truncated = truncate_chars_with_ellipsis(&value, 5);
387 assert_eq!(truncated, "ééééé...");
388 }
389}
390
391#[derive(Debug, Clone)]
393pub struct DirectoryEntry {
394 pub name: String,
395 pub path: String,
396 pub is_directory: bool,
397}
398
399#[async_trait]
401pub trait FileSystemProvider {
402 type Error: std::fmt::Display;
403
404 async fn list_directory(&self, path: &str) -> Result<Vec<DirectoryEntry>, Self::Error>;
406}
407
408pub async fn generate_directory_tree<P: FileSystemProvider>(
410 provider: &P,
411 path: &str,
412 prefix: &str,
413 max_depth: usize,
414 current_depth: usize,
415) -> Result<String, P::Error> {
416 let mut result = String::new();
417
418 if current_depth >= max_depth || current_depth >= 10 {
419 return Ok(result);
420 }
421
422 let entries = provider.list_directory(path).await?;
423 let mut file_entries = Vec::new();
424 let mut dir_entries = Vec::new();
425 for entry in entries.iter() {
426 if entry.is_directory {
427 if entry.name == "."
428 || entry.name == ".."
429 || entry.name == ".git"
430 || entry.name == "node_modules"
431 {
432 continue;
433 }
434 dir_entries.push(entry.clone());
435 } else {
436 file_entries.push(entry.clone());
437 }
438 }
439
440 dir_entries.sort_by(|a, b| a.name.cmp(&b.name));
441 file_entries.sort_by(|a, b| a.name.cmp(&b.name));
442
443 const MAX_ITEMS: usize = 5;
444 let total_items = dir_entries.len() + file_entries.len();
445 let should_limit = current_depth > 0 && total_items > MAX_ITEMS;
446
447 if should_limit {
448 if dir_entries.len() > MAX_ITEMS {
449 dir_entries.truncate(MAX_ITEMS);
450 file_entries.clear();
451 } else {
452 let remaining_items = MAX_ITEMS - dir_entries.len();
453 file_entries.truncate(remaining_items);
454 }
455 }
456
457 let mut dir_headers = Vec::new();
458 let mut dir_futures = Vec::new();
459 for (i, entry) in dir_entries.iter().enumerate() {
460 let is_last_dir = i == dir_entries.len() - 1;
461 let is_last_overall = is_last_dir && file_entries.is_empty() && !should_limit;
462 let current_prefix = if is_last_overall {
463 "└── "
464 } else {
465 "├── "
466 };
467 let next_prefix = format!(
468 "{}{}",
469 prefix,
470 if is_last_overall { " " } else { "│ " }
471 );
472
473 let header = format!("{}{}{}/\n", prefix, current_prefix, entry.name);
474 dir_headers.push(header);
475
476 let entry_path = entry.path.clone();
477 let next_prefix_clone = next_prefix.clone();
478 let future = async move {
479 generate_directory_tree(
480 provider,
481 &entry_path,
482 &next_prefix_clone,
483 max_depth,
484 current_depth + 1,
485 )
486 .await
487 };
488 dir_futures.push(future);
489 }
490 if !dir_futures.is_empty() {
491 let subtree_results = futures::future::join_all(dir_futures).await;
492
493 for (i, header) in dir_headers.iter().enumerate() {
494 result.push_str(header);
495 if let Some(Ok(subtree)) = subtree_results.get(i) {
496 result.push_str(subtree);
497 }
498 }
499 }
500
501 for (i, entry) in file_entries.iter().enumerate() {
502 let is_last_file = i == file_entries.len() - 1;
503 let is_last_overall = is_last_file && !should_limit;
504 let current_prefix = if is_last_overall {
505 "└── "
506 } else {
507 "├── "
508 };
509 result.push_str(&format!("{}{}{}\n", prefix, current_prefix, entry.name));
510 }
511
512 if should_limit {
513 let remaining_count = total_items - MAX_ITEMS;
514 result.push_str(&format!(
515 "{}└── ... {} more item{}\n",
516 prefix,
517 remaining_count,
518 if remaining_count == 1 { "" } else { "s" }
519 ));
520 }
521
522 Ok(result)
523}
524
525pub fn strip_tool_name(name: &str) -> &str {
530 let mut result = name;
531
532 if let Some((_, suffix)) = result.split_once("__") {
534 result = suffix;
535 }
536
537 if let Some(stripped) = result.strip_suffix("()") {
539 result = stripped;
540 }
541
542 backward_compatibility_mapping(result)
543}
544
545pub fn backward_compatibility_mapping(name: &str) -> &str {
548 match name {
549 "read_rulebook" | "read_rulebooks" => "load_skill",
550 _ => name,
551 }
552}
553
554pub struct LocalFileSystemProvider;
556
557#[async_trait]
558impl FileSystemProvider for LocalFileSystemProvider {
559 type Error = std::io::Error;
560
561 async fn list_directory(&self, path: &str) -> Result<Vec<DirectoryEntry>, Self::Error> {
562 let entries = fs::read_dir(path)?;
563 let mut result = Vec::new();
564
565 for entry in entries {
566 let entry = entry?;
567 let file_name = entry.file_name().to_string_lossy().to_string();
568 let file_path = entry.path().to_string_lossy().to_string();
569 let is_directory = entry.file_type()?.is_dir();
570
571 result.push(DirectoryEntry {
572 name: file_name,
573 path: file_path,
574 is_directory,
575 });
576 }
577
578 Ok(result)
579 }
580}
581
582#[cfg(test)]
583mod tests {
584 use super::*;
585 use std::fs;
586 use std::io::Write;
587 use tempfile::TempDir;
588
589 #[test]
590 fn test_matches_gitignore_pattern_exact() {
591 assert!(matches_gitignore_pattern("node_modules", "node_modules"));
592 assert!(matches_gitignore_pattern(
593 "node_modules",
594 "node_modules/package.json"
595 ));
596 assert!(!matches_gitignore_pattern(
597 "node_modules",
598 "src/node_modules"
599 ));
600 }
601
602 #[test]
603 fn test_matches_gitignore_pattern_wildcard_prefix() {
604 assert!(matches_gitignore_pattern("*.log", "debug.log"));
605 assert!(matches_gitignore_pattern("*.log", "error.log"));
606 assert!(!matches_gitignore_pattern("*.log", "log.txt"));
607 }
608
609 #[test]
610 fn test_matches_gitignore_pattern_wildcard_suffix() {
611 assert!(matches_gitignore_pattern("temp*", "temp"));
612 assert!(matches_gitignore_pattern("temp*", "temp.txt"));
613 assert!(matches_gitignore_pattern("temp*", "temporary"));
614 assert!(!matches_gitignore_pattern("temp*", "mytemp"));
615 }
616
617 #[test]
618 fn test_matches_gitignore_pattern_wildcard_middle() {
619 assert!(matches_gitignore_pattern("*temp*", "temp"));
620 assert!(matches_gitignore_pattern("*temp*", "mytemp"));
621 assert!(matches_gitignore_pattern("*temp*", "temporary"));
622 assert!(matches_gitignore_pattern("*temp*", "mytemporary"));
623 assert!(!matches_gitignore_pattern("*temp*", "example"));
624 }
625
626 #[test]
627 fn test_pattern_matches_glob() {
628 assert!(pattern_matches_glob("test*.txt", "test.txt"));
629 assert!(pattern_matches_glob("test*.txt", "test123.txt"));
630 assert!(pattern_matches_glob("*test*.txt", "mytest.txt"));
631 assert!(pattern_matches_glob("*test*.txt", "mytestfile.txt"));
632 assert!(!pattern_matches_glob("test*.txt", "test.log"));
633 assert!(!pattern_matches_glob("*test*.txt", "example.txt"));
634 }
635
636 #[test]
637 fn test_read_gitignore_patterns() -> Result<(), Box<dyn std::error::Error>> {
638 let temp_dir = TempDir::new()?;
639 let temp_path = temp_dir.path();
640
641 let gitignore_content = r#"
643# This is a comment
644node_modules
645*.log
646dist/
647.env
648
649# Another comment
650temp*
651"#;
652
653 let gitignore_path = temp_path.join(".gitignore");
654 let mut file = fs::File::create(&gitignore_path)?;
655 file.write_all(gitignore_content.as_bytes())?;
656
657 let patterns = read_gitignore_patterns(temp_path.to_str().unwrap());
658
659 assert!(patterns.contains(&".git".to_string()));
661 assert!(patterns.contains(&"node_modules".to_string()));
662 assert!(patterns.contains(&"*.log".to_string()));
663 assert!(patterns.contains(&"dist/".to_string()));
664 assert!(patterns.contains(&".env".to_string()));
665 assert!(patterns.contains(&"temp*".to_string()));
666
667 assert!(!patterns.iter().any(|p| p.starts_with('#')));
669 assert!(!patterns.contains(&"".to_string()));
670
671 Ok(())
672 }
673
674 #[test]
675 fn test_read_gitignore_patterns_no_file() {
676 let temp_dir = TempDir::new().unwrap();
677 let temp_path = temp_dir.path();
678
679 let patterns = read_gitignore_patterns(temp_path.to_str().unwrap());
680
681 assert_eq!(patterns, vec![".git".to_string()]);
683 }
684
685 #[test]
686 fn test_strip_tool_name() {
687 assert_eq!(strip_tool_name("stakpak__run_command"), "run_command");
688 assert_eq!(strip_tool_name("run_command"), "run_command");
689 assert_eq!(strip_tool_name("str_replace()"), "str_replace");
690 assert_eq!(strip_tool_name("stakpak__read_rulebook"), "load_skill");
691 assert_eq!(strip_tool_name("read_rulebook()"), "load_skill");
692 assert_eq!(strip_tool_name("read_rulebooks"), "load_skill");
693 assert_eq!(strip_tool_name("just_name"), "just_name");
695 assert_eq!(strip_tool_name("prefix__name()"), "name");
696 assert_eq!(strip_tool_name("nested__prefix__tool"), "prefix__tool");
697 assert_eq!(strip_tool_name("empty_suffix()"), "empty_suffix");
698 }
699
700 #[test]
701 fn test_backward_compatibility_mapping() {
702 assert_eq!(
703 backward_compatibility_mapping("read_rulebook"),
704 "load_skill"
705 );
706 assert_eq!(
707 backward_compatibility_mapping("read_rulebooks"),
708 "load_skill"
709 );
710 assert_eq!(backward_compatibility_mapping("run_command"), "run_command");
711 }
712
713 #[test]
714 fn test_gitignore_integration() -> Result<(), Box<dyn std::error::Error>> {
715 let temp_dir = TempDir::new()?;
716 let temp_path = temp_dir.path();
717
718 let gitignore_content = "node_modules\n*.log\ndist/\n";
720 let gitignore_path = temp_path.join(".gitignore");
721 let mut file = fs::File::create(&gitignore_path)?;
722 file.write_all(gitignore_content.as_bytes())?;
723
724 let patterns = read_gitignore_patterns(temp_path.to_str().unwrap());
725
726 assert!(
728 patterns
729 .iter()
730 .any(|p| matches_gitignore_pattern(p, "node_modules"))
731 );
732 assert!(
733 patterns
734 .iter()
735 .any(|p| matches_gitignore_pattern(p, "node_modules/package.json"))
736 );
737 assert!(
738 patterns
739 .iter()
740 .any(|p| matches_gitignore_pattern(p, "debug.log"))
741 );
742 assert!(
743 patterns
744 .iter()
745 .any(|p| matches_gitignore_pattern(p, "dist/bundle.js"))
746 );
747 assert!(
748 patterns
749 .iter()
750 .any(|p| matches_gitignore_pattern(p, ".git"))
751 );
752
753 assert!(
755 !patterns
756 .iter()
757 .any(|p| matches_gitignore_pattern(p, "src/main.js"))
758 );
759 assert!(
760 !patterns
761 .iter()
762 .any(|p| matches_gitignore_pattern(p, "README.md"))
763 );
764
765 Ok(())
766 }
767}