Files
owlry/src/providers/files.rs
vikingowl 0eccdc5883 refactor: centralize path handling with XDG Base Directory compliance
- Add src/paths.rs module for all XDG path lookups
- Move scripts from ~/.config to ~/.local/share (XDG data)
- Use $XDG_CONFIG_HOME for browser bookmark paths
- Add dev-logging feature flag for verbose debug output
- Add dev-install profile for testable release builds
- Remove CLAUDE.md from version control

BREAKING: Scripts directory moved from
~/.config/owlry/scripts/ to ~/.local/share/owlry/scripts/

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-29 16:46:14 +01:00

226 lines
6.7 KiB
Rust

use crate::paths;
use crate::providers::{LaunchItem, ProviderType};
use log::{debug, warn};
use std::process::Command;
/// File search provider - uses fd or locate for fast file finding
pub struct FileSearchProvider {
search_tool: SearchTool,
max_results: usize,
}
#[derive(Debug, Clone, Copy)]
enum SearchTool {
Fd,
Locate,
None,
}
impl FileSearchProvider {
pub fn new() -> Self {
let search_tool = Self::detect_search_tool();
debug!("File search using: {:?}", search_tool);
Self {
search_tool,
max_results: 20,
}
}
fn detect_search_tool() -> SearchTool {
// Prefer fd (faster, respects .gitignore)
if Self::command_exists("fd") {
return SearchTool::Fd;
}
// Fall back to locate (requires updatedb)
if Self::command_exists("locate") {
return SearchTool::Locate;
}
SearchTool::None
}
fn command_exists(cmd: &str) -> bool {
Command::new("which")
.arg(cmd)
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
/// Check if query is a file search query
/// Triggers on: `/ query`, `file query`, `find query`
pub fn is_file_query(query: &str) -> bool {
let trimmed = query.trim();
trimmed.starts_with("/ ")
|| trimmed.starts_with("/")
|| trimmed.to_lowercase().starts_with("file ")
|| trimmed.to_lowercase().starts_with("find ")
}
/// Extract the search term from the query
fn extract_search_term(query: &str) -> Option<&str> {
let trimmed = query.trim();
if let Some(rest) = trimmed.strip_prefix("/ ") {
Some(rest.trim())
} else if let Some(rest) = trimmed.strip_prefix("/") {
Some(rest.trim())
} else if trimmed.to_lowercase().starts_with("file ") {
Some(trimmed[5..].trim())
} else if trimmed.to_lowercase().starts_with("find ") {
Some(trimmed[5..].trim())
} else {
None
}
}
/// Evaluate a file search query
pub fn evaluate(&self, query: &str) -> Vec<LaunchItem> {
let search_term = match Self::extract_search_term(query) {
Some(t) if !t.is_empty() => t,
_ => return Vec::new(),
};
self.search_files(search_term)
}
/// Evaluate a raw search term (for :file filter mode)
pub fn evaluate_raw(&self, search_term: &str) -> Vec<LaunchItem> {
let trimmed = search_term.trim();
if trimmed.is_empty() {
return Vec::new();
}
self.search_files(trimmed)
}
fn search_files(&self, pattern: &str) -> Vec<LaunchItem> {
match self.search_tool {
SearchTool::Fd => self.search_with_fd(pattern),
SearchTool::Locate => self.search_with_locate(pattern),
SearchTool::None => {
debug!("No file search tool available");
Vec::new()
}
}
}
fn search_with_fd(&self, pattern: &str) -> Vec<LaunchItem> {
// fd searches from home directory by default
let home = paths::home().unwrap_or_default();
let output = match Command::new("fd")
.args([
"--max-results",
&self.max_results.to_string(),
"--type",
"f", // Files only
"--type",
"d", // And directories
pattern,
])
.current_dir(&home)
.output()
{
Ok(o) => o,
Err(e) => {
warn!("Failed to run fd: {}", e);
return Vec::new();
}
};
self.parse_file_results(&String::from_utf8_lossy(&output.stdout), &home)
}
fn search_with_locate(&self, pattern: &str) -> Vec<LaunchItem> {
let home = paths::home().unwrap_or_default();
let output = match Command::new("locate")
.args([
"--limit",
&self.max_results.to_string(),
"--ignore-case",
pattern,
])
.output()
{
Ok(o) => o,
Err(e) => {
warn!("Failed to run locate: {}", e);
return Vec::new();
}
};
self.parse_file_results(&String::from_utf8_lossy(&output.stdout), &home)
}
fn parse_file_results(&self, output: &str, home: &std::path::Path) -> Vec<LaunchItem> {
output
.lines()
.filter(|line| !line.is_empty())
.map(|path| {
let path = path.trim();
let full_path = if path.starts_with('/') {
path.to_string()
} else {
home.join(path).to_string_lossy().to_string()
};
// Get filename for display
let filename = std::path::Path::new(&full_path)
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| full_path.clone());
// Determine icon based on whether it's a directory
let is_dir = std::path::Path::new(&full_path).is_dir();
let icon = if is_dir {
"folder"
} else {
"text-x-generic"
};
// Command to open with xdg-open
let command = format!("xdg-open '{}'", full_path.replace('\'', "'\\''"));
LaunchItem {
id: format!("file:{}", full_path),
name: filename,
description: Some(full_path.clone()),
icon: Some(icon.to_string()),
provider: ProviderType::Files,
command,
terminal: false,
}
})
.collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_is_file_query() {
assert!(FileSearchProvider::is_file_query("/ config"));
assert!(FileSearchProvider::is_file_query("/config"));
assert!(FileSearchProvider::is_file_query("file config"));
assert!(FileSearchProvider::is_file_query("find config"));
assert!(!FileSearchProvider::is_file_query("config"));
assert!(!FileSearchProvider::is_file_query("? search"));
}
#[test]
fn test_extract_search_term() {
assert_eq!(
FileSearchProvider::extract_search_term("/ config.toml"),
Some("config.toml")
);
assert_eq!(
FileSearchProvider::extract_search_term("file bashrc"),
Some("bashrc")
);
}
}