binocular-cli 0.2.0

Not exactly a telescope, but it's useful sometimes. TUI to search/navigate through files and workspaces.
Documentation
use crate::app::{App, AppEvent, Search};
use crate::infra::channel::{self, MapSender, Sender};
use crate::search::matcher::{spawn_exact_matcher, spawn_matcher, MatcherCommand};
use crate::search::sources::{
    spawn_git_searcher, spawn_searcher_with_config, spawn_stdin_searcher,
};
use crate::search::types::{SearchConfig, SearchItem};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;

enum SearchInputSource {
    Filesystem,
    GitSearch,
    Stdin(Arc<[String]>),
}

impl SearchInputSource {
    fn spawn_items(&self) -> Option<Vec<String>> {
        match self {
            Self::Filesystem => None,
            Self::GitSearch => None,
            Self::Stdin(items) => Some(items.iter().cloned().collect()),
        }
    }
}

struct ActiveSearchRun {
    epoch: u64,
    stop: Arc<AtomicBool>,
    tx_cmd: channel::DefaultSender<MatcherCommand>,
    _searcher_handle: std::thread::JoinHandle<()>,
    _matcher_handle: std::thread::JoinHandle<()>,
}

impl ActiveSearchRun {
    fn spawn(
        search_config: SearchConfig,
        stdin_items: Option<Vec<String>>,
        tx_main: &channel::DefaultSender<AppEvent>,
        epoch: u64,
    ) -> Self {
        let settings = search_config.settings;
        let stop = Arc::new(AtomicBool::new(false));
        let (tx_items, rx_items) = channel::unbounded_default::<Vec<SearchItem>>();
        let (tx_cmd, rx_cmd) = channel::unbounded_default::<MatcherCommand>();
        let tx_state = MapSender::new(tx_main.clone(), move |state| {
            AppEvent::Matcher(state, epoch)
        });

        let searcher_handle = if let Some(scope) = search_config.git_search_scope.clone() {
            spawn_git_searcher(scope, stop.clone(), tx_items)
        } else if let Some(items) = stdin_items {
            spawn_stdin_searcher(items, stop.clone(), tx_items)
        } else {
            spawn_searcher_with_config(search_config, stop.clone(), tx_items)
        };

        let matcher_handle = if settings.matcher.is_exact() {
            spawn_exact_matcher(
                rx_items,
                rx_cmd,
                stop.clone(),
                tx_state,
                settings.mode.is_file_name_only(),
                settings.mode.is_content(),
                String::new(),
            )
        } else {
            spawn_matcher(
                rx_items,
                rx_cmd,
                stop.clone(),
                tx_state,
                settings.mode.is_file_name_only(),
                settings.mode.is_content(),
            )
        };

        Self {
            epoch,
            stop,
            tx_cmd,
            _searcher_handle: searcher_handle,
            _matcher_handle: matcher_handle,
        }
    }

    fn epoch(&self) -> u64 {
        self.epoch
    }

    fn command_sender(&self) -> &channel::DefaultSender<MatcherCommand> {
        &self.tx_cmd
    }

    fn shutdown(self) {
        self.stop.store(true, Ordering::Relaxed);
    }
}

pub struct SearchController {
    tx_main: channel::DefaultSender<AppEvent>,
    source: SearchInputSource,
    active: Option<ActiveSearchRun>,
    next_epoch: u64,
}

impl SearchController {
    pub fn from_search_config(
        search_config: SearchConfig,
        stdin_items: Option<Vec<String>>,
        tx_main: &channel::DefaultSender<AppEvent>,
        search_enabled: bool,
    ) -> Self {
        let source = if search_config.git_search_scope.is_some() {
            SearchInputSource::GitSearch
        } else {
            stdin_items.map_or(SearchInputSource::Filesystem, |items| {
                SearchInputSource::Stdin(Arc::<[String]>::from(items))
            })
        };
        let mut manager = Self {
            tx_main: tx_main.clone(),
            source,
            active: None,
            next_epoch: 0,
        };
        if search_enabled {
            manager.restart(search_config);
        }
        manager
    }

    pub fn command_sender(&self) -> Option<&channel::DefaultSender<MatcherCommand>> {
        self.active.as_ref().map(ActiveSearchRun::command_sender)
    }

    pub fn send_query(&self, query: &str) {
        if query.is_empty() {
            return;
        }
        if let Some(tx_cmd) = self.command_sender() {
            let _ = tx_cmd.send(MatcherCommand::Query(query.to_owned()));
        }
    }

    pub fn accepts_epoch(&self, epoch: u64) -> bool {
        self.active
            .as_ref()
            .map(ActiveSearchRun::epoch)
            .is_some_and(|active_epoch| active_epoch == epoch)
    }

    pub fn reconcile(&mut self, app: &mut App, item_limit: &mut u32) {
        if !app.ui.restart_search {
            return;
        }

        app.ui.restart_search = false;
        self.restart(app.search_config());
        *item_limit = 100;
        app.search_session.search = Search::default();
        app.preview_session.preview.source = None;
        app.preview_session.preview.content = None;
        self.send_query(&app.search_session.query.text);
    }

    pub fn shutdown(&mut self) {
        if let Some(session) = self.active.take() {
            session.shutdown();
        }
    }

    fn restart(&mut self, search_config: SearchConfig) {
        if let Some(session) = self.active.take() {
            session.shutdown();
        }

        let epoch = self.next_epoch;
        self.next_epoch += 1;
        self.active = Some(ActiveSearchRun::spawn(
            search_config,
            self.source.spawn_items(),
            &self.tx_main,
            epoch,
        ));
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::config::LoadedAppConfig;
    use crate::runtime::config::RunConfig;
    use crate::search::types::{MatcherMode, SearchMode, SearchSettings};

    fn run_config() -> RunConfig {
        RunConfig {
            headless: false,
            output_format: crate::cli::args::OutputFormat::Plain,
            stdin: true,
            log: false,
            diff: None,
            preview_command: None,
            preview_delimiter: ":".to_string(),
            split: None,
            log_files: Vec::new(),
        }
    }

    fn search_config() -> SearchConfig {
        SearchConfig {
            query: Some("alpha".to_string()),
            locations: vec![],
            search_pdf: false,
            no_hidden: false,
            no_git_ignore: false,
            no_ignore: false,
            no_default_ignore_dirs: false,
            git_search_scope: None,
            settings: SearchSettings {
                mode: SearchMode::Path,
                matcher: MatcherMode::Fuzzy,
            },
        }
    }

    fn app() -> App {
        App::from_configs(run_config(), search_config(), LoadedAppConfig::default())
    }

    #[test]
    fn repeated_search_mode_toggles_restart_sessions() {
        let (tx_main, _rx_main) = channel::unbounded_default::<AppEvent>();
        let mut manager = SearchController::from_search_config(
            search_config(),
            Some(vec!["alpha".to_string(), "beta".to_string()]),
            &tx_main,
            true,
        );
        let mut app = app();
        let mut item_limit = 250;

        assert!(manager.accepts_epoch(0));

        app.apply_action(crate::app::AppAction::SetSearchMode(SearchMode::Files));
        manager.reconcile(&mut app, &mut item_limit);
        assert_eq!(app.search_session.settings.mode, SearchMode::Files);
        assert_eq!(item_limit, 100);
        assert!(manager.accepts_epoch(1));

        app.apply_action(crate::app::AppAction::SetSearchMode(SearchMode::Grep));
        manager.reconcile(&mut app, &mut item_limit);
        assert_eq!(app.search_session.settings.mode, SearchMode::Grep);
        assert!(manager.accepts_epoch(2));

        manager.shutdown();
    }

    #[test]
    fn exact_toggle_restarts_with_new_matcher_mode() {
        let (tx_main, _rx_main) = channel::unbounded_default::<AppEvent>();
        let mut manager = SearchController::from_search_config(
            search_config(),
            Some(vec!["alpha".to_string()]),
            &tx_main,
            true,
        );
        let mut app = app();
        let mut item_limit = 100;

        assert_eq!(app.search_session.settings.matcher, MatcherMode::Fuzzy);
        app.apply_action(crate::app::AppAction::ToggleExactMatcher);
        manager.reconcile(&mut app, &mut item_limit);
        assert_eq!(app.search_session.settings.matcher, MatcherMode::Exact);
        assert!(manager.accepts_epoch(1));

        app.apply_action(crate::app::AppAction::ToggleExactMatcher);
        manager.reconcile(&mut app, &mut item_limit);
        assert_eq!(app.search_session.settings.matcher, MatcherMode::Fuzzy);
        assert!(manager.accepts_epoch(2));

        manager.shutdown();
    }
}