Skip to content

Commit aec2efe

Browse files
committed
Feat - add search for searching a file, filetype or keyword
1 parent d832a7d commit aec2efe

2 files changed

Lines changed: 154 additions & 1 deletion

File tree

README.md

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ sudo cp target/release/struct /usr/local/bin/
4545
```bash
4646
struct 3 # Show structure up to depth 3
4747
struct 0 # Show everything (infinite depth)
48-
struct --size 2 # Show file sizes
48+
struct -z 2 # Show file sizes
4949
struct -g 2 # Git-tracked files only
5050
struct -s 100 3 # Skip folders larger than 100MB
5151
struct -i "*.log" 2 # Add custom ignore patterns
@@ -65,6 +65,19 @@ struct clear # Reset config
6565

6666
Config is stored in `~/.config/struct/ignores.txt`
6767

68+
### Search
69+
70+
Find files by pattern across your project:
71+
72+
```bash
73+
struct search "*.env" # Find all .env files
74+
struct search "config*" # Find files starting with "config"
75+
struct search "test*.py" # Find Python test files
76+
struct search "Cargo.toml" ~ # Search from home directory
77+
```
78+
79+
Shows file paths with sizes, skips ignored directories for speed.
80+
6881
## Auto-Ignored Directories
6982

7083
Common bloat folders are hidden by default:

src/main.rs

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,134 @@ fn clear_config_patterns() {
9292
}
9393
}
9494

95+
fn search_files(pattern: &str, start_path: &Path) {
96+
// Convert glob pattern to regex
97+
let regex_pattern = pattern.replace("*", ".*").replace("?", ".");
98+
let re = match Regex::new(&format!("^{}$", regex_pattern)) {
99+
Ok(r) => r,
100+
Err(e) => {
101+
eprintln!("invalid pattern: {}", e);
102+
return;
103+
}
104+
};
105+
106+
let mut found_count = 0;
107+
let mut matching_paths: HashSet<PathBuf> = HashSet::new();
108+
109+
// Search through all files
110+
for entry in WalkDir::new(start_path)
111+
.into_iter()
112+
.filter_entry(|e| {
113+
// Skip common ignore directories to make search faster
114+
if let Some(name) = e.file_name().to_str() {
115+
!should_ignore_dir(name)
116+
} else {
117+
true
118+
}
119+
})
120+
.filter_map(|e| e.ok())
121+
{
122+
if entry.file_type().is_file() {
123+
if let Some(filename) = entry.file_name().to_str() {
124+
if re.is_match(filename) {
125+
// Add the file path
126+
let file_path = entry.path().to_path_buf();
127+
matching_paths.insert(file_path.clone());
128+
129+
// Add all parent directories
130+
let mut current = file_path.parent();
131+
while let Some(parent) = current {
132+
if parent == start_path {
133+
break;
134+
}
135+
matching_paths.insert(parent.to_path_buf());
136+
current = parent.parent();
137+
}
138+
139+
found_count += 1;
140+
}
141+
}
142+
}
143+
}
144+
145+
if found_count == 0 {
146+
println!("{}", format!("no files matching '{}' found", pattern).yellow());
147+
return;
148+
}
149+
150+
println!("{} {}", format!("found {} file(s) matching", found_count).green(), pattern.cyan());
151+
println!();
152+
153+
// Display as tree
154+
display_search_tree(start_path, &matching_paths, 0, "", true);
155+
}
156+
157+
fn display_search_tree(
158+
path: &Path,
159+
matching_paths: &HashSet<PathBuf>,
160+
current_depth: usize,
161+
prefix: &str,
162+
_is_last: bool,
163+
) {
164+
let mut entries: Vec<_> = match fs::read_dir(path) {
165+
Ok(entries) => entries
166+
.filter_map(|e| e.ok())
167+
.filter(|e| {
168+
let entry_path = e.path();
169+
// Only show entries that are in our matching set or are parents of matches
170+
matching_paths.contains(&entry_path) ||
171+
matching_paths.iter().any(|p| p.starts_with(&entry_path))
172+
})
173+
.collect(),
174+
Err(_) => return,
175+
};
176+
177+
// Sort: directories first, then alphabetically
178+
entries.sort_by_key(|e| {
179+
let path = e.path();
180+
let is_dir = path.is_dir();
181+
let name = e.file_name().to_string_lossy().to_lowercase();
182+
(!is_dir, name)
183+
});
184+
185+
let total = entries.len();
186+
187+
for (idx, entry) in entries.iter().enumerate() {
188+
let is_last_entry = idx == total - 1;
189+
let entry_path = entry.path();
190+
let name = entry.file_name().to_string_lossy().to_string();
191+
let is_dir = entry_path.is_dir();
192+
193+
let connector = if is_last_entry { "└── " } else { "├── " };
194+
195+
if is_dir {
196+
let dir_name = format!("{}/", name).blue().bold();
197+
println!("{}{}{}", prefix, connector, dir_name);
198+
199+
let new_prefix = if is_last_entry {
200+
format!("{} ", prefix)
201+
} else {
202+
format!("{}│ ", prefix)
203+
};
204+
display_search_tree(&entry_path, matching_paths, current_depth + 1, &new_prefix, is_last_entry);
205+
} else {
206+
// This is a matching file
207+
let file_name = if is_executable(&entry_path) {
208+
name.green().bold()
209+
} else {
210+
name.cyan().bold()
211+
};
212+
213+
if let Ok(metadata) = fs::metadata(&entry_path) {
214+
let size_str = format!(" ({})", format_size(metadata.len())).bright_black();
215+
println!("{}{}{}{}", prefix, connector, file_name, size_str);
216+
} else {
217+
println!("{}{}{}", prefix, connector, file_name);
218+
}
219+
}
220+
}
221+
}
222+
95223
#[derive(Parser, Debug)]
96224
#[command(name = "struct")]
97225
#[command(about = "A smarter tree command with intelligent defaults", long_about = None)]
@@ -140,6 +268,14 @@ enum Commands {
140268
List,
141269
/// Clear all custom ignore patterns
142270
Clear,
271+
/// Search for files matching a pattern
272+
Search {
273+
/// Pattern to search for (e.g., "*.env", "config", "test*")
274+
pattern: String,
275+
/// Starting directory
276+
#[arg(default_value = ".")]
277+
path: PathBuf,
278+
},
143279
}
144280

145281
struct StructConfig {
@@ -172,6 +308,10 @@ fn main() {
172308
clear_config_patterns();
173309
return;
174310
}
311+
Commands::Search { pattern, path } => {
312+
search_files(&pattern, &path);
313+
return;
314+
}
175315
}
176316
}
177317

0 commit comments

Comments
 (0)