Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -56,5 +56,5 @@ jobs:
- name: cargo clippy
run: cargo clippy --all-targets

# - name: cargo test
# run: cargo test --all-targets
- name: cargo test
run: cargo test --all-targets
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -36,3 +36,6 @@ toml = "0.9.8"
tracing-subscriber = { version = "0.3.22", features = ["env-filter"] }
tray-icon = "0.21.3"
url = { version = "2.5.8", default-features = false }

[dev-dependencies]
tempfile = "3.23.0"
60 changes: 60 additions & 0 deletions src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -238,3 +238,63 @@ impl DebouncePolicy for Page {
}
}
}

#[cfg(test)]
mod tests {
use super::*;
use crate::config::Config;

#[test]
fn page_display_labels_are_stable() {
assert_eq!(Page::Main.to_string(), "App search");
assert_eq!(Page::FileSearch.to_string(), "File search");
assert_eq!(Page::ClipboardHistory.to_string(), "Clipboard history");
assert_eq!(Page::EmojiSearch.to_string(), "Emoji search");
assert_eq!(Page::Settings.to_string(), "Settings");
}

#[test]
fn page_debounce_policy_matches_expected_pages() {
let config = Config {
debounce_delay: 123,
..Config::default()
};

assert_eq!(Page::Main.debounce_delay(&config), None);
assert_eq!(Page::ClipboardHistory.debounce_delay(&config), None);
assert_eq!(Page::Settings.debounce_delay(&config), None);
assert_eq!(
Page::FileSearch.debounce_delay(&config),
Some(Duration::from_millis(123))
);
assert_eq!(
Page::EmojiSearch.debounce_delay(&config),
Some(Duration::from_millis(123))
);
}

#[test]
fn mode_to_apps_adds_default_when_missing() {
let mut modes = HashMap::new();
modes.insert("work".to_string(), "echo work".to_string());

let apps = modes.to_apps();

assert!(apps.iter().any(|app| app.search_name == "work"));
assert!(apps.iter().any(|app| app.search_name == "default"));
}

#[test]
fn mode_to_apps_does_not_duplicate_default() {
let mut modes = HashMap::new();
modes.insert("default".to_string(), "echo default".to_string());

let apps = modes.to_apps();
let default_count = apps
.iter()
.filter(|app| app.search_name == "default")
.count();

assert_eq!(default_count, 1);
}
}
207 changes: 190 additions & 17 deletions src/app/tile.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ use objc2::rc::Retained;
use objc2_app_kit::NSRunningApplication;
use rayon::iter::{IntoParallelRefIterator, ParallelIterator};
use rayon::slice::ParallelSliceMut;
use tokio::io::AsyncBufReadExt;
use tokio::io::{AsyncBufReadExt, AsyncRead};
use tray_icon::TrayIcon;

use std::collections::HashMap;
Expand Down Expand Up @@ -139,6 +139,21 @@ impl AppIndex {
}
}

fn build_mdfind_args(query: &str, dirs: &[String], home_dir: &str) -> Option<Vec<String>> {
assert!(query.len() < 1024, "Query too long.");
if query.len() < 2 {
return None;
}

let mut args = vec!["-name".to_string(), query.to_string()];
for dir in dirs {
args.push("-onlyin".to_string());
args.push(dir.replace("~", home_dir));
}

Some(args)
}

/// This is the base window, and its a "Tile"
/// Its fields are:
/// - Theme ([`iced::Theme`])
Expand Down Expand Up @@ -372,8 +387,8 @@ fn handle_clipboard_history() -> impl futures::Stream<Item = Message> {
///
/// Returns when stdout reaches EOF, the receiver signals a new query, or
/// max results are reached. Caller is responsible for process lifetime.
async fn read_mdfind_results(
stdout: tokio::process::ChildStdout,
async fn read_mdfind_results<R: AsyncRead + Unpin>(
stdout: R,
home_dir: &str,
receiver: &mut tokio::sync::watch::Receiver<(String, Vec<String>)>,
output: &mut iced::futures::channel::mpsc::Sender<Message>,
Expand Down Expand Up @@ -504,23 +519,11 @@ fn handle_file_search() -> impl futures::Stream<Item = Message> {
child = None;

let (query, dirs) = receiver.borrow_and_update().clone();
assert!(query.len() < 1024, "Query too long.");

if query.len() < 2 {
let Some(args) = build_mdfind_args(&query, &dirs, &home_dir) else {
output.send(Message::FileSearchClear).await.ok();
continue;
}

// The query is passed as a -name argument to mdfind. mdfind interprets
// this as a substring match on filenames — not as a glob or shell expression.
// Passed via args (not shell), so no shell injection risk.
// When dirs is empty, omit -onlyin so mdfind searches system-wide.
let mut args: Vec<String> = vec!["-name".to_string(), query.clone()];
for dir in &dirs {
let expanded = dir.replace("~", &home_dir);
args.push("-onlyin".to_string());
args.push(expanded);
}
};

let mut command = tokio::process::Command::new("mdfind");
command.args(&args);
Expand Down Expand Up @@ -571,6 +574,176 @@ fn handle_file_search() -> impl futures::Stream<Item = Message> {
})
}

#[cfg(test)]
#[allow(clippy::items_after_test_module)]
mod tests {
use super::*;
use crate::app::apps::{App, AppCommand};
use crate::commands::Function;
use iced::futures::StreamExt;
use tokio::io::{AsyncWriteExt, duplex};

fn test_app(name: &str, ranking: i32) -> App {
App {
ranking,
open_command: AppCommand::Function(Function::OpenApp(format!(
"/Applications/{name}.app"
))),
desc: "Application".to_string(),
icons: None,
display_name: name.to_string(),
search_name: name.to_lowercase(),
}
}

#[test]
fn app_index_search_prefix_matches_prefix_and_word_boundaries() {
let index = AppIndex::from_apps(vec![
test_app("Safari", 0),
App {
search_name: "visual studio code".to_string(),
display_name: "Visual Studio Code".to_string(),
..test_app("Visual Studio Code", 0)
},
App {
search_name: "signal-desktop".to_string(),
display_name: "Signal Desktop".to_string(),
..test_app("Signal Desktop", 0)
},
]);

let prefix_results: Vec<_> = index
.search_prefix("sa")
.map(|app| app.display_name.clone())
.collect();
let spaced_results: Vec<_> = index
.search_prefix("studio")
.map(|app| app.display_name.clone())
.collect();
let hyphen_results: Vec<_> = index
.search_prefix("desktop")
.map(|app| app.display_name.clone())
.collect();

assert_eq!(prefix_results, vec!["Safari".to_string()]);
assert_eq!(spaced_results, vec!["Visual Studio Code".to_string()]);
assert_eq!(hyphen_results, vec!["Signal Desktop".to_string()]);
}

#[test]
fn app_index_ranking_helpers_work() {
let mut index = AppIndex::from_apps(vec![
test_app("Safari", 1),
test_app("Notes", -1),
test_app("Arc", 3),
test_app("Alfred", 3),
]);

index.update_ranking("safari");
index.set_ranking("notes", -1);

assert_eq!(index.get_rankings().get("safari"), Some(&2));
assert_eq!(index.get_rankings().get("notes"), None);

let top_ranked = index.top_ranked(2);
assert_eq!(top_ranked.len(), 2);
assert_eq!(top_ranked[0].display_name, "Alfred");
assert_eq!(top_ranked[1].display_name, "Arc");

let favourites = index.get_favourites();
assert_eq!(favourites.len(), 1);
assert_eq!(favourites[0].display_name, "Notes");
}

#[test]
fn build_mdfind_args_expands_home_and_omits_onlyin_for_empty_dirs() {
assert_eq!(
build_mdfind_args("ab", &[], "/Users/test").unwrap(),
vec!["-name".to_string(), "ab".to_string()]
);

assert_eq!(
build_mdfind_args(
"report",
&[String::from("~/Documents"), String::from("/tmp")],
"/Users/test"
)
.unwrap(),
vec![
"-name".to_string(),
"report".to_string(),
"-onlyin".to_string(),
"/Users/test/Documents".to_string(),
"-onlyin".to_string(),
"/tmp".to_string(),
]
);
}

#[test]
fn build_mdfind_args_rejects_short_queries() {
assert!(build_mdfind_args("a", &[], "/Users/test").is_none());
}

#[tokio::test]
async fn read_mdfind_results_batches_and_limits_results() {
let total_lines = crate::app::FILE_SEARCH_BATCH_SIZE + 1;
let input = (0..=total_lines)
.map(|idx| format!("/Users/test/Documents/file-{idx}.txt\n"))
.collect::<String>();

let (mut writer, reader) = duplex(16 * 1024);
writer.write_all(input.as_bytes()).await.unwrap();
drop(writer);

let (_notify_sender, mut receiver) =
tokio::sync::watch::channel((String::new(), Vec::<String>::new()));
receiver.borrow_and_update();
let (mut sender, mut out_receiver) = iced::futures::channel::mpsc::channel(16);

let canceled = read_mdfind_results(reader, "/Users/test", &mut receiver, &mut sender).await;
assert!(!canceled);

drop(sender);

let mut batches = Vec::new();
while let Some(message) = out_receiver.next().await {
if let Message::FileSearchResult(apps) = message {
batches.push(apps);
}
}

assert_eq!(batches.len(), 2);
assert_eq!(batches[0].len() as u32, crate::app::FILE_SEARCH_BATCH_SIZE);
assert_eq!(batches[1].len(), 2);
assert_eq!(batches[0][0].desc, "~/Documents/file-0.txt");
}

#[tokio::test]
async fn read_mdfind_results_cancels_when_query_changes() {
let (notify_sender, mut receiver) =
tokio::sync::watch::channel((String::new(), Vec::<String>::new()));
receiver.borrow_and_update();

let (mut out_sender, _out_receiver) = iced::futures::channel::mpsc::channel(4);
let (writer, reader) = duplex(1024);

let task = tokio::spawn(async move {
read_mdfind_results(reader, "/Users/test", &mut receiver, &mut out_sender).await
});

// Keep the writer alive so the read loop waits on the watch channel.
let _writer = writer;

tokio::time::sleep(std::time::Duration::from_millis(10)).await;
notify_sender
.send((String::from("next"), Vec::<String>::new()))
.unwrap();

assert!(task.await.unwrap());
}
}

/// Handles the rx / receiver for sending and receiving messages
fn handle_recipient() -> impl futures::Stream<Item = Message> {
stream::channel(100, async |mut output| {
Expand Down
Loading
Loading