3 Commits

Author SHA1 Message Date
teernisse
59f65b127a fix(search): pass FTS5 boolean operators through unquoted
FTS5 boolean operators (AND, OR, NOT, NEAR) are case-sensitive uppercase
keywords that must appear unquoted in the query string. Previously, the
user-friendly query builder would double-quote every token, causing
queries like "switch AND health" to search for the literal word "AND"
instead of using it as a boolean conjunction.

Adds a FTS5_OPERATORS constant and checks each token against it before
quoting, allowing natural boolean search syntax to work as expected.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 14:56:29 -05:00
teernisse
f36e900570 feat(cli): add pipeline progress spinners to timeline and search
Adds numbered stage spinners ([1/3], [2/3], [3/3]) to the timeline
pipeline stages (seed, expand, collect) so users see activity during
longer queries. TimelineParams gains a robot_mode field to suppress
spinners in JSON output mode.

Adds a [1/1] spinner to the search command for consistency, using the
shared stage_spinner from cli/progress.

Also refactors wrap_snippet() to delegate to wrap_text() with a 4-line
cap, eliminating the duplicated word-wrapping logic.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 14:56:19 -05:00
teernisse
e2efc61beb refactor(cli): extract stage_spinner to shared progress module
Moves stage_spinner() from a private function in sync.rs to a pub function
in cli/progress.rs so it can be reused by the timeline and search commands.
The function creates a numbered spinner (e.g. [1/3]) for pipeline stages,
returning a hidden no-op bar in robot mode to keep caller code path-uniform.

sync.rs now imports from crate::cli::progress::stage_spinner instead of
defining its own copy. Adds unit tests for robot mode (hidden bar), human
mode (prefix/message properties), and prefix formatting.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 14:56:10 -05:00
5 changed files with 77 additions and 38 deletions

View File

@@ -7,6 +7,7 @@ use tracing::Instrument;
use tracing::{info, warn}; use tracing::{info, warn};
use crate::Config; use crate::Config;
use crate::cli::progress::stage_spinner;
use crate::core::error::Result; use crate::core::error::Result;
use crate::core::metrics::{MetricsLayer, StageTiming}; use crate::core::metrics::{MetricsLayer, StageTiming};
use crate::core::shutdown::ShutdownSignal; use crate::core::shutdown::ShutdownSignal;
@@ -42,22 +43,6 @@ pub struct SyncResult {
pub status_enrichment_errors: usize, pub status_enrichment_errors: usize,
} }
fn stage_spinner(stage: u8, total: u8, msg: &str, robot_mode: bool) -> ProgressBar {
if robot_mode {
return ProgressBar::hidden();
}
let pb = crate::cli::progress::multi().add(ProgressBar::new_spinner());
pb.set_style(
ProgressStyle::default_spinner()
.template("{spinner:.blue} {prefix} {msg}")
.expect("valid template"),
);
pb.enable_steady_tick(std::time::Duration::from_millis(80));
pb.set_prefix(format!("[{stage}/{total}]"));
pb.set_message(msg.to_string());
pb
}
pub async fn run_sync( pub async fn run_sync(
config: &Config, config: &Config,
options: SyncOptions, options: SyncOptions,

View File

@@ -2,6 +2,7 @@ use console::{Alignment, pad_str, style};
use serde::Serialize; use serde::Serialize;
use crate::Config; use crate::Config;
use crate::cli::progress::stage_spinner;
use crate::core::db::create_connection; use crate::core::db::create_connection;
use crate::core::error::{LoreError, Result}; use crate::core::error::{LoreError, Result};
use crate::core::paths::get_db_path; use crate::core::paths::get_db_path;
@@ -26,6 +27,7 @@ pub struct TimelineParams {
pub max_seeds: usize, pub max_seeds: usize,
pub max_entities: usize, pub max_entities: usize,
pub max_evidence: usize, pub max_evidence: usize,
pub robot_mode: bool,
} }
/// Run the full timeline pipeline: SEED -> EXPAND -> COLLECT. /// Run the full timeline pipeline: SEED -> EXPAND -> COLLECT.
@@ -60,6 +62,7 @@ pub async fn run_timeline(config: &Config, params: &TimelineParams) -> Result<Ti
}); });
// Stage 1+2: SEED + HYDRATE (hybrid search with FTS fallback) // Stage 1+2: SEED + HYDRATE (hybrid search with FTS fallback)
let spinner = stage_spinner(1, 3, "Seeding timeline...", params.robot_mode);
let seed_result = seed_timeline( let seed_result = seed_timeline(
&conn, &conn,
Some(&client), Some(&client),
@@ -70,8 +73,10 @@ pub async fn run_timeline(config: &Config, params: &TimelineParams) -> Result<Ti
params.max_evidence, params.max_evidence,
) )
.await?; .await?;
spinner.finish_and_clear();
// Stage 3: EXPAND // Stage 3: EXPAND
let spinner = stage_spinner(2, 3, "Expanding cross-references...", params.robot_mode);
let expand_result = expand_timeline( let expand_result = expand_timeline(
&conn, &conn,
&seed_result.seed_entities, &seed_result.seed_entities,
@@ -79,8 +84,10 @@ pub async fn run_timeline(config: &Config, params: &TimelineParams) -> Result<Ti
params.expand_mentions, params.expand_mentions,
params.max_entities, params.max_entities,
)?; )?;
spinner.finish_and_clear();
// Stage 4: COLLECT // Stage 4: COLLECT
let spinner = stage_spinner(3, 3, "Collecting events...", params.robot_mode);
let (events, total_before_limit) = collect_events( let (events, total_before_limit) = collect_events(
&conn, &conn,
&seed_result.seed_entities, &seed_result.seed_entities,
@@ -90,6 +97,7 @@ pub async fn run_timeline(config: &Config, params: &TimelineParams) -> Result<Ti
since_ms, since_ms,
params.limit, params.limit,
)?; )?;
spinner.finish_and_clear();
Ok(TimelineResult { Ok(TimelineResult {
query: params.query.clone(), query: params.query.clone(),
@@ -276,25 +284,7 @@ fn wrap_text(text: &str, width: usize) -> Vec<String> {
} }
fn wrap_snippet(text: &str, width: usize) -> Vec<String> { fn wrap_snippet(text: &str, width: usize) -> Vec<String> {
let mut lines = Vec::new(); let mut lines = wrap_text(text, width);
let mut current = String::new();
for word in text.split_whitespace() {
if current.is_empty() {
current = word.to_string();
} else if current.len() + 1 + word.len() <= width {
current.push(' ');
current.push_str(word);
} else {
lines.push(current);
current = word.to_string();
}
}
if !current.is_empty() {
lines.push(current);
}
// Cap at 4 lines
lines.truncate(4); lines.truncate(4);
lines lines
} }

View File

@@ -1,4 +1,4 @@
use indicatif::MultiProgress; use indicatif::{MultiProgress, ProgressBar, ProgressStyle};
use std::io::Write; use std::io::Write;
use std::sync::LazyLock; use std::sync::LazyLock;
use tracing_subscriber::fmt::MakeWriter; use tracing_subscriber::fmt::MakeWriter;
@@ -9,6 +9,26 @@ pub fn multi() -> &'static MultiProgress {
&MULTI &MULTI
} }
/// Create a spinner for a numbered pipeline stage.
///
/// Returns a hidden (no-op) bar in robot mode so callers can use
/// the same code path regardless of output mode.
pub fn stage_spinner(stage: u8, total: u8, msg: &str, robot_mode: bool) -> ProgressBar {
if robot_mode {
return ProgressBar::hidden();
}
let pb = multi().add(ProgressBar::new_spinner());
pb.set_style(
ProgressStyle::default_spinner()
.template("{spinner:.blue} {prefix} {msg}")
.expect("valid template"),
);
pb.enable_steady_tick(std::time::Duration::from_millis(80));
pb.set_prefix(format!("[{stage}/{total}]"));
pb.set_message(msg.to_string());
pb
}
#[derive(Clone)] #[derive(Clone)]
pub struct SuspendingWriter; pub struct SuspendingWriter;
@@ -50,7 +70,6 @@ impl<'a> MakeWriter<'a> for SuspendingWriter {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use indicatif::ProgressBar;
#[test] #[test]
fn multi_returns_same_instance() { fn multi_returns_same_instance() {
@@ -88,4 +107,35 @@ mod tests {
let w = MakeWriter::make_writer(&writer); let w = MakeWriter::make_writer(&writer);
drop(w); drop(w);
} }
#[test]
fn stage_spinner_robot_mode_returns_hidden() {
let pb = stage_spinner(1, 3, "Testing...", true);
assert!(pb.is_hidden());
}
#[test]
fn stage_spinner_human_mode_sets_properties() {
// In non-TTY test environments, MultiProgress may report bars as
// hidden. Verify the human-mode code path by checking that prefix
// and message are configured (robot-mode returns a bare hidden bar).
let pb = stage_spinner(1, 3, "Testing...", false);
assert_eq!(pb.prefix(), "[1/3]");
assert_eq!(pb.message(), "Testing...");
pb.finish_and_clear();
}
#[test]
fn stage_spinner_sets_prefix_format() {
let pb = stage_spinner(2, 5, "Working...", false);
assert_eq!(pb.prefix(), "[2/5]");
pb.finish_and_clear();
}
#[test]
fn stage_spinner_sets_message() {
let pb = stage_spinner(1, 3, "Seeding timeline...", false);
assert_eq!(pb.message(), "Seeding timeline...");
pb.finish_and_clear();
}
} }

View File

@@ -1784,6 +1784,7 @@ async fn handle_timeline(
max_seeds: args.max_seeds, max_seeds: args.max_seeds,
max_entities: args.max_entities, max_entities: args.max_entities,
max_evidence: args.max_evidence, max_evidence: args.max_evidence,
robot_mode,
}; };
let result = run_timeline(&config, &params).await?; let result = run_timeline(&config, &params).await?;
@@ -1828,6 +1829,12 @@ async fn handle_search(
limit: args.limit, limit: args.limit,
}; };
let spinner = lore::cli::progress::stage_spinner(
1,
1,
&format!("Searching ({})...", args.mode),
robot_mode,
);
let start = std::time::Instant::now(); let start = std::time::Instant::now();
let response = run_search( let response = run_search(
&config, &config,
@@ -1839,6 +1846,7 @@ async fn handle_search(
) )
.await?; .await?;
let elapsed_ms = start.elapsed().as_millis() as u64; let elapsed_ms = start.elapsed().as_millis() as u64;
spinner.finish_and_clear();
if robot_mode { if robot_mode {
print_search_results_json(&response, elapsed_ms, args.fields.as_deref()); print_search_results_json(&response, elapsed_ms, args.fields.as_deref());

View File

@@ -52,12 +52,18 @@ pub fn to_fts_query(raw: &str, mode: FtsQueryMode) -> String {
return String::new(); return String::new();
} }
// FTS5 boolean operators are case-sensitive uppercase keywords.
// Pass them through unquoted so users can write "switch AND health".
const FTS5_OPERATORS: &[&str] = &["AND", "OR", "NOT", "NEAR"];
let mut result = String::with_capacity(trimmed.len() + 20); let mut result = String::with_capacity(trimmed.len() + 20);
for (i, token) in trimmed.split_whitespace().enumerate() { for (i, token) in trimmed.split_whitespace().enumerate() {
if i > 0 { if i > 0 {
result.push(' '); result.push(' ');
} }
if let Some(stem) = token.strip_suffix('*') if FTS5_OPERATORS.contains(&token) {
result.push_str(token);
} else if let Some(stem) = token.strip_suffix('*')
&& !stem.is_empty() && !stem.is_empty()
&& stem.chars().all(|c| c.is_alphanumeric() || c == '_') && stem.chars().all(|c| c.is_alphanumeric() || c == '_')
{ {