diff --git a/Cargo.lock b/Cargo.lock index b007f3c..7e3fd1e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1058,7 +1058,6 @@ dependencies = [ "syntect", "tantivy", "tempfile", - "thiserror 2.0.18", "toml", "ureq", "zstd", diff --git a/Cargo.toml b/Cargo.toml index a78c358..bee3821 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "hop" version = "0.2.8" -edition = "2021" +edition = "2024" description = "Fast full-text search and resume for coding-agent sessions" license = "MIT" # Resume uses Unix exec(2) to replace the process; not portable to Windows. @@ -17,7 +17,6 @@ path = "src/lib.rs" [dependencies] anyhow = "1" -thiserror = "2" clap = { version = "4.6", features = ["derive"] } tantivy = { version = "0.26", default-features = false, features = ["mmap", "lz4-compression", "columnar-zstd-compression"] } ratatui = "0.30" @@ -45,3 +44,26 @@ lto = true codegen-units = 1 strip = true panic = "abort" + +# Lint policy, kept in-tree so contributors and IDEs see the same contract CI +# enforces (`cargo clippy --all-targets --all-features -- -D warnings`). Deliberately +# pragmatic: we take `clippy::all` plus a small set of high-signal restriction lints +# rather than the full `pedantic`/`nursery` groups. Any future opt-out belongs here +# with a one-line reason. +[lints.rust] +unsafe_op_in_unsafe_fn = "warn" +unused_unsafe = "warn" + +[lints.clippy] +# Enable the whole group at low priority so individual rules below can override it. +all = { level = "warn", priority = -1 } +# Avoid accidental heap clones of ref-counted pointers; forces explicit Rc/Arc::clone. +clone_on_ref_ptr = "warn" +# `s.push_str(&format!(..))` allocates an intermediate String; use `write!`/`push_str`. +format_push_string = "warn" +# Debugging and stub macros must never reach a release build. +dbg_macro = "warn" +todo = "warn" +unimplemented = "warn" +# Every `unsafe` block must carry a `// SAFETY:` justification. +undocumented_unsafe_blocks = "warn" diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 85185dd..a0e1e4c 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -68,7 +68,7 @@ rows and surface as sync status warnings. `engine.set_sort`), so the Tantivy layer is unaware of the mode. - `src/enrich/`: per-session display enrichment. Fast enrichers are local and synchronous; slow enrichers run through `EnrichmentService`. -- `src/columns.rs`: column definitions and responsive width solving. +- `src/tui/columns.rs`: column definitions and responsive width solving. - `src/resume.rs`: terminal-safe process handoff through `exec`, plus an optional run-and-wait prepare step (e.g. `codex unarchive ` for archived sessions) executed after terminal restore and before the resume `exec`. diff --git a/rustfmt.toml b/rustfmt.toml new file mode 100644 index 0000000..30a37d8 --- /dev/null +++ b/rustfmt.toml @@ -0,0 +1,6 @@ +# Formatting config, mirroring the discipline of larger Rust projects (e.g. oxc). +# Kept minimal: only stable options plus the 2024 style edition. +style_edition = "2024" +# Wider layouts read better on modern screens and match rustc's own config. +use_small_heuristics = "Max" +use_field_init_shorthand = true diff --git a/src/adapters/claude.rs b/src/adapters/claude.rs index 8dfeacb..8cb9a60 100644 --- a/src/adapters/claude.rs +++ b/src/adapters/claude.rs @@ -1,7 +1,7 @@ -use crate::adapters::{file_mtime_ms, git_remote_url, parse_ts_secs, Adapter, GitFieldCache}; +use crate::adapters::{Adapter, GitFieldCache, file_mtime_ms, git_remote_url, parse_ts_secs}; use crate::core::{ - derive_session_title, is_command_tag_line, AgentId, ScanEntry, Session, SessionId, - SessionSummary, + AgentId, ScanEntry, Session, SessionId, SessionSummary, derive_session_title, + is_command_tag_line, }; use anyhow::{Context, Result}; use serde::Deserialize; @@ -17,10 +17,7 @@ pub struct ClaudeAdapter { impl ClaudeAdapter { pub fn new(root: PathBuf) -> Self { - Self { - root, - repo_cache: GitFieldCache::new(git_remote_url), - } + Self { root, repo_cache: GitFieldCache::new(git_remote_url) } } } @@ -73,7 +70,7 @@ struct Extracted { impl ClaudeAdapter { fn extract(&self, path: &Path) -> Result { - use crate::core::{split_blocks, Message, Role}; + use crate::core::{Message, Role, split_blocks}; let raw = std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?; let mut directory = String::new(); @@ -96,22 +93,19 @@ impl ClaudeAdapter { if let Some(t) = nonempty_text(parsed.ai_title.as_deref()) { title = Some(t.to_string()); has_ai_title = true; - } else if !has_ai_title { - if let Some(t) = nonempty_text(parsed.summary.as_deref()) { - title = Some(t.to_string()); - } + } else if !has_ai_title && let Some(t) = nonempty_text(parsed.summary.as_deref()) { + title = Some(t.to_string()); } - if directory.is_empty() { - if let Some(cwd) = &parsed.cwd { - directory = cwd.clone(); - } + if directory.is_empty() + && let Some(cwd) = &parsed.cwd + { + directory = cwd.clone(); } - if branch.is_none() { - if let Some(b) = parsed.git_branch.as_deref() { - if !b.trim().is_empty() { - branch = Some(b.to_string()); - } - } + if branch.is_none() + && let Some(b) = parsed.git_branch.as_deref() + && !b.trim().is_empty() + { + branch = Some(b.to_string()); } let kind = parsed.kind.as_deref().unwrap_or(""); let is_user = kind == "user"; @@ -126,12 +120,12 @@ impl ClaudeAdapter { // synthetic sentinels like "" that Claude writes for // injected turns. Runs after the meta/tool-result skip so an injected // assistant line can't overwrite the model the user conversed with. - if is_assistant { - if let Some(m) = parsed.message.as_ref().and_then(|m| m.model.as_deref()) { - let m = m.trim(); - if !m.is_empty() && !m.starts_with('<') { - model = Some(m.to_string()); - } + if is_assistant + && let Some(m) = parsed.message.as_ref().and_then(|m| m.model.as_deref()) + { + let m = m.trim(); + if !m.is_empty() && !m.starts_with('<') { + model = Some(m.to_string()); } } let text = parsed @@ -147,19 +141,9 @@ impl ClaudeAdapter { first_ts = parsed.timestamp.as_deref().and_then(parse_ts_secs); } let blocks = split_blocks(&text); - messages.push(Message { - role: if is_user { Role::User } else { Role::Agent }, - blocks, - }); + messages.push(Message { role: if is_user { Role::User } else { Role::Agent }, blocks }); } - Ok(Extracted { - messages, - directory, - branch, - title, - first_ts, - model, - }) + Ok(Extracted { messages, directory, branch, title, first_ts, model }) } } @@ -272,11 +256,7 @@ fn extract_text(content: &Content, is_user: bool) -> Option { .filter(|b| b.kind == "text") .filter_map(|b| b.text.as_deref()) .collect(); - if joined.is_empty() { - None - } else { - Some(joined.join(" ")) - } + if joined.is_empty() { None } else { Some(joined.join(" ")) } } } } diff --git a/src/adapters/codex.rs b/src/adapters/codex.rs index ea44e9b..da07dbf 100644 --- a/src/adapters/codex.rs +++ b/src/adapters/codex.rs @@ -1,7 +1,7 @@ -use crate::adapters::{file_mtime_ms, git_remote_url, parse_ts_secs, Adapter, GitFieldCache}; +use crate::adapters::{Adapter, GitFieldCache, file_mtime_ms, git_remote_url, parse_ts_secs}; use crate::core::{ - derive_session_title, is_command_tag_line, AgentId, ScanEntry, Session, SessionId, - SessionSummary, + AgentId, ScanEntry, Session, SessionId, SessionSummary, derive_session_title, + is_command_tag_line, }; use anyhow::{Context, Result}; use serde::Deserialize; @@ -18,21 +18,15 @@ pub struct CodexAdapter { impl CodexAdapter { pub fn new(root: PathBuf) -> Self { - Self { - root, - repo_cache: GitFieldCache::new(git_remote_url), - } + Self { root, repo_cache: GitFieldCache::new(git_remote_url) } } fn session_roots(&self) -> Vec { - vec![ - self.root.join("sessions"), - self.root.join("archived_sessions"), - ] + vec![self.root.join("sessions"), self.root.join("archived_sessions")] } fn extract(&self, path: &Path) -> Result { - use crate::core::{split_blocks, Message, Role}; + use crate::core::{Message, Role, split_blocks}; let raw = read_rollout(path)?; let mut directory = String::new(); let mut branch = None; @@ -55,10 +49,10 @@ impl CodexAdapter { Ok(l) => l, Err(_) => continue, }; - if first_ts.is_none() { - if let Some(ts) = parsed.timestamp.as_deref() { - first_ts = parse_ts_secs(ts); - } + if first_ts.is_none() + && let Some(ts) = parsed.timestamp.as_deref() + { + first_ts = parse_ts_secs(ts); } let Some(p) = parsed.payload else { continue }; match parsed.kind.as_str() { @@ -76,10 +70,10 @@ impl CodexAdapter { // A non-interactive thread classification (subagent / // memory_consolidation) also means the session isn't // user-resumable; let it drive the filter signal. - if let Some(ts) = normalize_source(p.thread_source) { - if is_non_interactive_source(Some(&ts)) { - source = Some(ts); - } + if let Some(ts) = normalize_source(p.thread_source) + && is_non_interactive_source(Some(&ts)) + { + source = Some(ts); } } "turn_context" => { @@ -92,10 +86,10 @@ impl CodexAdapter { // First non-empty model wins (matches branch/dir): later // turns may be a trailing "codex-auto-review" turn rather // than the model the user actually ran. - if model.is_none() { - if let Some(m) = p.model.filter(|m| !m.trim().is_empty()) { - model = Some(m); - } + if model.is_none() + && let Some(m) = p.model.filter(|m| !m.trim().is_empty()) + { + model = Some(m); } } "event_msg" => { @@ -134,10 +128,7 @@ impl CodexAdapter { let Some(text) = clean_event_message(&text) else { continue; }; - response_messages.push(Message { - role, - blocks: split_blocks(&text), - }); + response_messages.push(Message { role, blocks: split_blocks(&text) }); } _ => {} } @@ -238,9 +229,7 @@ fn clean_event_message(text: &str) -> Option { continue; } - if let Some((_, end)) = DROP_XML_BLOCKS - .iter() - .find(|(start, _)| trimmed.starts_with(start)) + if let Some((_, end)) = DROP_XML_BLOCKS.iter().find(|(start, _)| trimmed.starts_with(start)) { if !trimmed.contains(end) { skip_xml_until = Some(*end); @@ -257,11 +246,7 @@ fn clean_event_message(text: &str) -> Option { .map(|idx| &cleaned[idx + USER_MESSAGE_BEGIN.len()..]) .unwrap_or(&cleaned) .trim(); - if trimmed.is_empty() { - None - } else { - Some(trimmed.to_string()) - } + if trimmed.is_empty() { None } else { Some(trimmed.to_string()) } } fn strip_codex_wrappers(line: &str) -> String { @@ -305,9 +290,7 @@ fn read_rollout(path: &Path) -> Result { } fn is_compressed_rollout(path: &Path) -> bool { - path.file_name() - .and_then(|name| name.to_str()) - .is_some_and(|name| name.ends_with(".jsonl.zst")) + path.file_name().and_then(|name| name.to_str()).is_some_and(|name| name.ends_with(".jsonl.zst")) } #[derive(Deserialize)] @@ -411,9 +394,7 @@ impl Adapter for CodexAdapter { let content = flatten_messages(&ex.messages); // Prefer the remote recorded in session_meta; fall back to resolving it // from the cwd so older rollouts without git metadata still get a repo. - let repo_url = ex - .repo_url - .or_else(|| self.repo_cache.resolve(&ex.directory)); + let repo_url = ex.repo_url.or_else(|| self.repo_cache.resolve(&ex.directory)); Ok(Session { meta: SessionSummary { id, @@ -428,11 +409,7 @@ impl Adapter for CodexAdapter { source_path: Some(path.to_path_buf()), archived: is_archived_path(path), worktree: None, - permission_mode: if ex.yolo { - Some("yolo".into()) - } else { - Some("default".into()) - }, + permission_mode: if ex.yolo { Some("yolo".into()) } else { Some("default".into()) }, model: ex.model, commit: ex.commit, source: ex.source, @@ -476,8 +453,7 @@ impl Adapter for CodexAdapter { /// Codex archives by moving the rollout file there; the JSONL itself carries no /// archive flag, so the directory is the only signal. fn is_archived_path(path: &Path) -> bool { - path.components() - .any(|c| c.as_os_str() == "archived_sessions") + path.components().any(|c| c.as_os_str() == "archived_sessions") } /// Extract the session id from a `rollout--` filename stem. diff --git a/src/adapters/cursor.rs b/src/adapters/cursor.rs index 06d8fc0..85c70d8 100644 --- a/src/adapters/cursor.rs +++ b/src/adapters/cursor.rs @@ -1,7 +1,7 @@ -use crate::adapters::{file_mtime_ms, git_remote_url, Adapter, GitFieldCache}; +use crate::adapters::{Adapter, GitFieldCache, file_mtime_ms, git_remote_url}; use crate::core::{ - derive_session_title, split_blocks, AgentId, Message, Role, ScanEntry, Session, SessionId, - SessionSummary, + AgentId, Message, Role, ScanEntry, Session, SessionId, SessionSummary, derive_session_title, + split_blocks, }; use anyhow::{Context, Result}; use serde::Deserialize; @@ -102,9 +102,8 @@ fn read_store_meta(chats_root: &Path, workspace: &str, uuid: &str) -> Option Vec { if yolo { - vec![ - "cursor-agent".into(), - "--force".into(), - "--resume".into(), - s.meta.id.clone(), - ] + vec!["cursor-agent".into(), "--force".into(), "--resume".into(), s.meta.id.clone()] } else { vec!["cursor-agent".into(), "--resume".into(), s.meta.id.clone()] } diff --git a/src/adapters/mod.rs b/src/adapters/mod.rs index 49b950e..d2584dc 100644 --- a/src/adapters/mod.rs +++ b/src/adapters/mod.rs @@ -53,9 +53,7 @@ pub(crate) fn parse_ts_secs(s: &str) -> Option { pub(crate) fn file_mtime_ms(entry: &std::fs::DirEntry) -> Result { let modified = entry.metadata()?.modified()?; - let dur = modified - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default(); + let dur = modified.duration_since(std::time::UNIX_EPOCH).unwrap_or_default(); Ok(i64::try_from(dur.as_millis()).unwrap_or(i64::MAX)) } @@ -65,21 +63,12 @@ fn git_field(dir: &str, args: &[&str]) -> Option { if dir.is_empty() { return None; } - let out = std::process::Command::new("git") - .arg("-C") - .arg(dir) - .args(args) - .output() - .ok()?; + let out = std::process::Command::new("git").arg("-C").arg(dir).args(args).output().ok()?; if !out.status.success() { return None; } let value = String::from_utf8_lossy(&out.stdout).trim().to_string(); - if value.is_empty() { - None - } else { - Some(value) - } + if value.is_empty() { None } else { Some(value) } } /// The `origin` remote URL. Same across all worktrees of a repo, which is what @@ -101,10 +90,9 @@ pub fn git_remote_url(dir: &str) -> Option { break; } if ancestor.exists() { - if let Some(url) = git_field( - &ancestor.to_string_lossy(), - &["remote", "get-url", "origin"], - ) { + if let Some(url) = + git_field(&ancestor.to_string_lossy(), &["remote", "get-url", "origin"]) + { return Some(url); } break; @@ -123,10 +111,7 @@ pub(crate) struct GitFieldCache { impl GitFieldCache { pub(crate) fn new(resolver: fn(&str) -> Option) -> Self { - Self { - cache: RefCell::new(HashMap::new()), - resolver, - } + Self { cache: RefCell::new(HashMap::new()), resolver } } pub(crate) fn resolve(&self, dir: &str) -> Option { diff --git a/src/cli.rs b/src/cli.rs index 3d8b211..a593c82 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -120,17 +120,10 @@ impl Cli { if self.all || self.repo.is_some() { return false; } - let has_repo_token = self - .query - .as_deref() - .unwrap_or("") - .split_whitespace() - .any(|t| { - let body = t.strip_prefix(['-', '!']).unwrap_or(t); - body.split_once(':') - .map(|(k, _)| k == "repo") - .unwrap_or(false) - }); + let has_repo_token = self.query.as_deref().unwrap_or("").split_whitespace().any(|t| { + let body = t.strip_prefix(['-', '!']).unwrap_or(t); + body.split_once(':').map(|(k, _)| k == "repo").unwrap_or(false) + }); !has_repo_token } @@ -139,16 +132,12 @@ impl Cli { /// the user typed DSL on the command line, so the simple toolbar does not /// silently drop it. pub fn query_has_dsl(&self) -> bool { - self.query - .as_deref() - .unwrap_or("") - .split_whitespace() - .any(|t| { - let body = t.strip_prefix(['-', '!']).unwrap_or(t); - body.split_once(':') - .map(|(k, _)| matches!(k, "agent" | "dir" | "repo" | "date")) - .unwrap_or(false) - }) + self.query.as_deref().unwrap_or("").split_whitespace().any(|t| { + let body = t.strip_prefix(['-', '!']).unwrap_or(t); + body.split_once(':') + .map(|(k, _)| matches!(k, "agent" | "dir" | "repo" | "date")) + .unwrap_or(false) + }) } /// Filter flags the simple toolbar cannot yet represent (`--agent`/`--dir`). @@ -185,18 +174,12 @@ mod tests { repo: Some("hop".into()), ..cli() }; - assert_eq!( - cli.initial_query(None), - "agent:claude dir:api repo:hop auth " - ); + assert_eq!(cli.initial_query(None), "agent:claude dir:api repo:hop auth "); } #[test] fn initial_query_prepends_auto_repo() { - let cli = Cli { - query: Some("auth".into()), - ..cli() - }; + let cli = Cli { query: Some("auth".into()), ..cli() }; assert_eq!(cli.initial_query(Some("me/hop")), "repo:me/hop auth "); } @@ -215,30 +198,14 @@ mod tests { #[test] fn wants_auto_repo_for_bare_and_free_text() { assert!(cli().wants_auto_repo()); - assert!(Cli { - query: Some("auth".into()), - ..cli() - } - .wants_auto_repo()); + assert!(Cli { query: Some("auth".into()), ..cli() }.wants_auto_repo()); } #[test] fn wants_auto_repo_suppressed_by_explicit_filters() { assert!(!Cli { all: true, ..cli() }.wants_auto_repo()); - assert!(!Cli { - repo: Some("other".into()), - ..cli() - } - .wants_auto_repo()); - assert!(!Cli { - query: Some("repo:foo bug".into()), - ..cli() - } - .wants_auto_repo()); - assert!(!Cli { - query: Some("-repo:vendor".into()), - ..cli() - } - .wants_auto_repo()); + assert!(!Cli { repo: Some("other".into()), ..cli() }.wants_auto_repo()); + assert!(!Cli { query: Some("repo:foo bug".into()), ..cli() }.wants_auto_repo()); + assert!(!Cli { query: Some("-repo:vendor".into()), ..cli() }.wants_auto_repo()); } } diff --git a/src/config.rs b/src/config.rs index 3bd3a34..f718015 100644 --- a/src/config.rs +++ b/src/config.rs @@ -23,11 +23,7 @@ fn default_width_pct() -> u16 { impl Default for PreviewConfig { fn default() -> Self { - PreviewConfig { - visible: true, - width_pct: 50, - metadata_header: true, - } + PreviewConfig { visible: true, width_pct: 50, metadata_header: true } } } @@ -58,11 +54,11 @@ impl LauncherConfig { fn rewrite_argv_inner(tmpl: &str, agent: AgentId, argv: &[String]) -> anyhow::Result> { let expanded = tmpl.replace("{agent}", agent.slug()); - if let Some(pos) = expanded.find('{') { - if let Some(end) = expanded[pos..].find('}') { - let unknown = &expanded[pos..pos + end + 1]; - anyhow::bail!("unknown launcher template variable: {unknown}"); - } + if let Some(pos) = expanded.find('{') + && let Some(end) = expanded[pos..].find('}') + { + let unknown = &expanded[pos..pos + end + 1]; + anyhow::bail!("unknown launcher template variable: {unknown}"); } let mut prefix = shlex::split(&expanded) .ok_or_else(|| anyhow::anyhow!("unterminated quote in launcher command"))?; @@ -164,10 +160,7 @@ mod tests { let cfg = Config::default(); // Claude default ends in .claude/projects, Codex default ends in .codex assert!(cfg.data_dir(AgentId::Claude).ends_with("projects")); - assert!(cfg - .data_dir(AgentId::Codex) - .to_string_lossy() - .contains(".codex")); + assert!(cfg.data_dir(AgentId::Codex).to_string_lossy().contains(".codex")); } #[test] @@ -177,15 +170,9 @@ mod tests { claude = "/custom/claude" "#; let cfg = Config::from_toml_str(toml).unwrap(); - assert_eq!( - cfg.data_dir(AgentId::Claude), - std::path::PathBuf::from("/custom/claude") - ); + assert_eq!(cfg.data_dir(AgentId::Claude), std::path::PathBuf::from("/custom/claude")); // unset agent falls back to default - assert!(cfg - .data_dir(AgentId::Codex) - .to_string_lossy() - .contains(".codex")); + assert!(cfg.data_dir(AgentId::Codex).to_string_lossy().contains(".codex")); } #[test] @@ -228,21 +215,13 @@ mod tests { quit = "ctrl+q" "#; let cfg = Config::from_toml_str(toml).unwrap(); - assert_eq!( - cfg.keybindings.get("toggle_preview").map(String::as_str), - Some("ctrl+t") - ); - assert_eq!( - cfg.keybindings.get("quit").map(String::as_str), - Some("ctrl+q") - ); + assert_eq!(cfg.keybindings.get("toggle_preview").map(String::as_str), Some("ctrl+t")); + assert_eq!(cfg.keybindings.get("quit").map(String::as_str), Some("ctrl+q")); } #[test] fn launcher_rewrites_argv() { - let cfg = LauncherConfig { - command: Some("kv --ai {agent}".into()), - }; + let cfg = LauncherConfig { command: Some("kv --ai {agent}".into()) }; let argv: Vec = vec!["claude".into(), "--resume".into(), "abc-123".into()]; let result = cfg.rewrite_argv(AgentId::Claude, &argv).unwrap().unwrap(); assert_eq!(result, vec!["kv", "--ai", "claude", "--resume", "abc-123"]); @@ -250,9 +229,7 @@ mod tests { #[test] fn launcher_preserves_yolo_flags() { - let cfg = LauncherConfig { - command: Some("kv --ai {agent}".into()), - }; + let cfg = LauncherConfig { command: Some("kv --ai {agent}".into()) }; let argv: Vec = vec![ "claude".into(), "--dangerously-skip-permissions".into(), @@ -262,14 +239,7 @@ mod tests { let result = cfg.rewrite_argv(AgentId::Claude, &argv).unwrap().unwrap(); assert_eq!( result, - vec![ - "kv", - "--ai", - "claude", - "--dangerously-skip-permissions", - "--resume", - "id" - ] + vec!["kv", "--ai", "claude", "--dangerously-skip-permissions", "--resume", "id"] ); } @@ -282,9 +252,7 @@ mod tests { #[test] fn launcher_unknown_variable_is_error() { - let cfg = LauncherConfig { - command: Some("kv {unknown}".into()), - }; + let cfg = LauncherConfig { command: Some("kv {unknown}".into()) }; let argv: Vec = vec!["claude".into()]; assert!(cfg.rewrite_argv(AgentId::Claude, &argv).unwrap().is_err()); } @@ -303,12 +271,7 @@ mod tests { fn ui_state_roundtrips() { let tmp = tempfile::tempdir().unwrap(); let p = tmp.path().join("ui_state.toml"); - UiState { - preview_visible: false, - preview_width_pct: 35, - } - .save(&p) - .unwrap(); + UiState { preview_visible: false, preview_width_pct: 35 }.save(&p).unwrap(); let loaded = UiState::load(&p).unwrap(); assert!(!loaded.preview_visible); assert_eq!(loaded.preview_width_pct, 35); diff --git a/src/core.rs b/src/core.rs index 7049f32..5fe970a 100644 --- a/src/core.rs +++ b/src/core.rs @@ -44,10 +44,7 @@ pub fn split_blocks(text: &str) -> Vec { // any other line — including indented backtick examples — is captured verbatim. let trimmed = t.trim(); if trimmed.len() >= 3 && trimmed.chars().all(|c| c == '`') { - out.push(Block::Code { - lang: lang.take(), - text: code.join("\n"), - }); + out.push(Block::Code { lang: lang.take(), text: code.join("\n") }); code.clear(); in_code = false; } else { @@ -58,11 +55,7 @@ pub fn split_blocks(text: &str) -> Vec { if let Some(rest) = t.trim_start().strip_prefix("```") { flush_prose(&mut prose, &mut out); let l = rest.trim(); - lang = if l.is_empty() { - None - } else { - Some(l.to_string()) - }; + lang = if l.is_empty() { None } else { Some(l.to_string()) }; in_code = true; continue; } @@ -70,10 +63,7 @@ pub fn split_blocks(text: &str) -> Vec { } if in_code { // unterminated fence: keep what we have as code - out.push(Block::Code { - lang: lang.take(), - text: code.join("\n"), - }); + out.push(Block::Code { lang: lang.take(), text: code.join("\n") }); } else { flush_prose(&mut prose, &mut out); } @@ -119,9 +109,7 @@ const COMMAND_TAG_PREFIXES: [&str; 10] = [ pub fn is_command_tag_line(text: &str) -> bool { let trimmed = text.trim_start(); - COMMAND_TAG_PREFIXES - .iter() - .any(|prefix| trimmed.starts_with(prefix)) + COMMAND_TAG_PREFIXES.iter().any(|prefix| trimmed.starts_with(prefix)) } #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] @@ -289,15 +277,12 @@ pub fn truncate_title(raw: &str, max: usize) -> String { /// the first user prose message. Width truncation is a rendering concern. pub fn derive_session_title(explicit: Option<&str>, messages: &[Message]) -> String { let raw = explicit.or_else(|| { - messages - .iter() - .find(|m| m.role == Role::User) - .and_then(|m| { - m.blocks.iter().find_map(|b| match b { - Block::Prose(s) => Some(s.as_str()), - _ => None, - }) + messages.iter().find(|m| m.role == Role::User).and_then(|m| { + m.blocks.iter().find_map(|b| match b { + Block::Prose(s) => Some(s.as_str()), + _ => None, }) + }) }); raw.map(normalize_title) @@ -343,18 +328,12 @@ mod tests { let messages = vec![Message { role: Role::User, blocks: vec![ - Block::Code { - lang: None, - text: "ignored".into(), - }, + Block::Code { lang: None, text: "ignored".into() }, Block::Prose("first\nuser\tprompt".into()), ], }]; - assert_eq!( - derive_session_title(Some("explicit title"), &messages), - "explicit title" - ); + assert_eq!(derive_session_title(Some("explicit title"), &messages), "explicit title"); assert_eq!(derive_session_title(None, &messages), "first user prompt"); assert_eq!(derive_session_title(None, &[]), "(untitled)"); } @@ -367,10 +346,7 @@ mod tests { blocks, vec![ Block::Prose("before".into()), - Block::Code { - lang: Some("rust".into()), - text: "fn x() {}".into() - }, + Block::Code { lang: Some("rust".into()), text: "fn x() {}".into() }, Block::Prose("after".into()), ] ); @@ -378,22 +354,13 @@ mod tests { #[test] fn split_blocks_plain_prose_is_single_block() { - assert_eq!( - split_blocks("just text"), - vec![Block::Prose("just text".into())] - ); + assert_eq!(split_blocks("just text"), vec![Block::Prose("just text".into())]); } #[test] fn split_blocks_unlabeled_fence_has_no_lang() { let blocks = split_blocks("```\nraw\n```"); - assert_eq!( - blocks, - vec![Block::Code { - lang: None, - text: "raw".into() - }] - ); + assert_eq!(blocks, vec![Block::Code { lang: None, text: "raw".into() }]); } #[test] @@ -405,28 +372,19 @@ mod tests { fn split_blocks_unterminated_fence_kept_as_code() { assert_eq!( split_blocks("```rust\nlet x=1;"), - vec![Block::Code { - lang: Some("rust".into()), - text: "let x=1;".into() - }] + vec![Block::Code { lang: Some("rust".into()), text: "let x=1;".into() }] ); } #[test] fn flatten_messages_joins_prose_and_code() { let msgs = vec![ - Message { - role: Role::User, - blocks: vec![Block::Prose("hi".into())], - }, + Message { role: Role::User, blocks: vec![Block::Prose("hi".into())] }, Message { role: Role::Agent, blocks: vec![ Block::Prose("fixed".into()), - Block::Code { - lang: Some("rust".into()), - text: "let x=1;".into(), - }, + Block::Code { lang: Some("rust".into()), text: "let x=1;".into() }, ], }, ]; @@ -442,15 +400,9 @@ mod tests { )); assert!(is_command_tag_line("ok")); assert!(is_command_tag_line("")); - assert!(is_command_tag_line( - "done" - )); - assert!(is_command_tag_line( - "..." - )); - assert!(!is_command_tag_line( - "keep this user context" - )); + assert!(is_command_tag_line("done")); + assert!(is_command_tag_line("...")); + assert!(!is_command_tag_line("keep this user context")); assert!(!is_command_tag_line("regular command discussion")); } } diff --git a/src/engine.rs b/src/engine.rs index bd2d092..a014e77 100644 --- a/src/engine.rs +++ b/src/engine.rs @@ -1,6 +1,6 @@ use crate::adapters::Adapter; -use crate::core::{document_key, ResumeCommand, Session, SessionSummary, Transcript}; -use crate::index::{diff_authoritative, SearchIndex}; +use crate::core::{ResumeCommand, Session, SessionSummary, Transcript, document_key}; +use crate::index::{SearchIndex, diff_authoritative}; use crate::query::{self, ParsedQuery, SortOrder}; use anyhow::Result; use std::collections::{HashMap, HashSet}; @@ -122,18 +122,13 @@ impl Engine { pub fn search(&mut self) -> Result<()> { let now = jiff::Timestamp::now().as_second(); - self.results = self - .index - .search(&self.parsed, self.sort, now, self.limit)?; + self.results = self.index.search(&self.parsed, self.sort, now, self.limit)?; self.last_keystroke = None; Ok(()) } pub fn adapter_for(&self, agent: crate::core::AgentId) -> Option<&dyn Adapter> { - self.adapters - .iter() - .find(|a| a.id() == agent) - .map(|b| b.as_ref()) + self.adapters.iter().find(|a| a.id() == agent).map(|b| b.as_ref()) } pub fn transcript_for(&self, session: &SessionSummary) -> Option { @@ -144,15 +139,11 @@ impl Engine { } pub fn supports_yolo(&self, session: &SessionSummary) -> bool { - self.adapter_for(session.agent) - .is_some_and(|a| a.supports_yolo()) + self.adapter_for(session.agent).is_some_and(|a| a.supports_yolo()) } pub fn indexed_session(&self, session: &SessionSummary) -> Option { - self.index - .load_session(&session.document_key()) - .ok() - .flatten() + self.index.load_session(&session.document_key()).ok().flatten() } pub fn indexed_content(&self, session: &SessionSummary) -> Option { @@ -173,16 +164,8 @@ impl Engine { Some(Err(_)) => return None, None => argv, }; - let prepare = full - .meta - .archived - .then(|| adapter.unarchive_command(&full)) - .flatten(); - Some(ResumeCommand { - directory: full.meta.directory, - argv, - prepare, - }) + let prepare = full.meta.archived.then(|| adapter.unarchive_command(&full)).flatten(); + Some(ResumeCommand { directory: full.meta.directory, argv, prepare }) } #[cfg(test)] @@ -214,10 +197,8 @@ impl Engine { })(); let _ = tx.send(Update::Refresh); let _ = tx.send(Update::Done { - report: result.unwrap_or_else(|_| SyncReport { - fatal_errors: 1, - ..SyncReport::default() - }), + report: result + .unwrap_or_else(|_| SyncReport { fatal_errors: 1, ..SyncReport::default() }), }); }); (rx, handle) @@ -249,7 +230,6 @@ fn sync_index_with_sidecar_dir( mut on_batch_commit: impl FnMut(&SearchIndex), ) -> Result { let known = index.known_sync_state()?; - let mut writer = index.writer()?; let mut report = SyncReport::default(); let mut all_scanned = HashMap::new(); let mut owner = HashMap::new(); @@ -299,7 +279,7 @@ fn sync_index_with_sidecar_dir( } report.deleted = deleted.len(); for key in &deleted { - index.delete(&mut writer, key); + index.delete(key)?; } let mut since_commit = 0usize; @@ -327,25 +307,20 @@ fn sync_index_with_sidecar_dir( report.non_interactive_sessions += 1; continue; } - index.upsert_with_sidecar_stamp( - &mut writer, - &s, - sidecar_stamps.get(key).map(String::as_str), - ); + index.upsert_with_sidecar_stamp(&s, sidecar_stamps.get(key).map(String::as_str))?; report.indexed += 1; since_commit += 1; } Err(_) => report.parse_errors += 1, } if since_commit >= 200 { - writer.commit()?; + index.commit()?; index.reload()?; on_batch_commit(index); - writer = index.writer()?; since_commit = 0; } } - writer.commit()?; + index.commit()?; index.reload()?; Ok(report) } @@ -383,10 +358,7 @@ mod tests { .map(|s| { ( s.meta.id.clone(), - ScanEntry { - path: PathBuf::from(&s.meta.id), - mtime: s.mtime, - }, + ScanEntry { path: PathBuf::from(&s.meta.id), mtime: s.mtime }, ) }) .collect()) @@ -484,10 +456,8 @@ mod tests { #[test] fn sync_then_search_finds_indexed_sessions() { let dir = tempfile::tempdir().unwrap(); - let adapters: Vec> = vec![adapter( - AgentId::Claude, - vec![sess("a", "auth bug"), sess("b", "deploy")], - )]; + let adapters: Vec> = + vec![adapter(AgentId::Claude, vec![sess("a", "auth bug"), sess("b", "deploy")])]; let mut engine = Engine::new(dir.path(), adapters).unwrap(); // synchronous full sync (the blocking core that the bg thread also calls) @@ -513,32 +483,18 @@ mod tests { engine.set_query(""); engine.search().unwrap(); - let row = |id: &str| { - engine - .results() - .iter() - .find(|s| s.id == id) - .cloned() - .unwrap() - }; + let row = |id: &str| engine.results().iter().find(|s| s.id == id).cloned().unwrap(); let no_launcher = crate::config::LauncherConfig::default(); - let active_cmd = engine - .resume_command_for(&row("active"), false, &no_launcher) - .unwrap(); + let active_cmd = engine.resume_command_for(&row("active"), false, &no_launcher).unwrap(); assert_eq!(active_cmd.prepare, None, "active sessions need no prep"); - let archived_cmd = engine - .resume_command_for(&row("gone"), false, &no_launcher) - .unwrap(); + let archived_cmd = engine.resume_command_for(&row("gone"), false, &no_launcher).unwrap(); assert_eq!( archived_cmd.prepare, Some(vec!["unarchive".to_string(), "gone".to_string()]), "archived sessions unarchive before resuming" ); - assert_eq!( - archived_cmd.argv, - vec!["echo".to_string(), "gone".to_string()] - ); + assert_eq!(archived_cmd.argv, vec!["echo".to_string(), "gone".to_string()]); } #[test] @@ -564,14 +520,8 @@ mod tests { fn sync_namespaces_overlapping_raw_ids_by_agent() { let dir = tempfile::tempdir().unwrap(); let adapters: Vec> = vec![ - adapter( - AgentId::Claude, - vec![sess_for(AgentId::Claude, "same", "auth claude")], - ), - adapter( - AgentId::Codex, - vec![sess_for(AgentId::Codex, "same", "auth codex")], - ), + adapter(AgentId::Claude, vec![sess_for(AgentId::Claude, "same", "auth claude")]), + adapter(AgentId::Codex, vec![sess_for(AgentId::Codex, "same", "auth codex")]), ]; let mut engine = Engine::new(dir.path(), adapters).unwrap(); @@ -580,14 +530,8 @@ mod tests { engine.search().unwrap(); assert_eq!(engine.results().len(), 2); - assert!(engine - .results() - .iter() - .any(|s| s.id == "same" && s.agent == AgentId::Claude)); - assert!(engine - .results() - .iter() - .any(|s| s.id == "same" && s.agent == AgentId::Codex)); + assert!(engine.results().iter().any(|s| s.id == "same" && s.agent == AgentId::Claude)); + assert!(engine.results().iter().any(|s| s.id == "same" && s.agent == AgentId::Codex)); } #[test] @@ -596,14 +540,8 @@ mod tests { let mut engine = Engine::new( dir.path(), vec![ - adapter( - AgentId::Claude, - vec![sess_for(AgentId::Claude, "a", "auth")], - ), - adapter( - AgentId::Codex, - vec![sess_for(AgentId::Codex, "b", "deploy")], - ), + adapter(AgentId::Claude, vec![sess_for(AgentId::Claude, "a", "auth")]), + adapter(AgentId::Codex, vec![sess_for(AgentId::Codex, "b", "deploy")]), ], ) .unwrap(); @@ -611,10 +549,7 @@ mod tests { engine.replace_adapters(vec![ unavailable_adapter(AgentId::Claude), - adapter( - AgentId::Codex, - vec![sess_for(AgentId::Codex, "b", "deploy")], - ), + adapter(AgentId::Codex, vec![sess_for(AgentId::Codex, "b", "deploy")]), ]); let report = engine.sync_once().unwrap(); assert_eq!(report.adapters_unavailable, 1); @@ -622,10 +557,7 @@ mod tests { engine.set_query(""); engine.search().unwrap(); assert_eq!(engine.results().len(), 2); - assert!(engine - .results() - .iter() - .any(|s| s.id == "a" && s.agent == AgentId::Claude)); + assert!(engine.results().iter().any(|s| s.id == "a" && s.agent == AgentId::Claude)); } #[test] @@ -633,10 +565,7 @@ mod tests { let dir = tempfile::tempdir().unwrap(); let mut engine = Engine::new( dir.path(), - vec![adapter( - AgentId::Claude, - vec![sess_for(AgentId::Claude, "a", "auth")], - )], + vec![adapter(AgentId::Claude, vec![sess_for(AgentId::Claude, "a", "auth")])], ) .unwrap(); engine.sync_once().unwrap(); @@ -657,10 +586,8 @@ mod tests { let mut empty = sess_for(AgentId::Claude, "empty", ""); empty.content.clear(); empty.meta.message_count = 0; - let adapters: Vec> = vec![adapter( - AgentId::Claude, - vec![empty, sess("real", "auth bug")], - )]; + let adapters: Vec> = + vec![adapter(AgentId::Claude, vec![empty, sess("real", "auth bug")])]; let mut engine = Engine::new(dir.path(), adapters).unwrap(); engine.sync_once().unwrap(); engine.set_query(""); @@ -716,7 +643,7 @@ mod tests { #[test] fn sidecar_only_change_reindexes_unchanged_session() { - use crate::hooks::sidecar::{sidecar_path_in, HookEvent, Sidecar, SidecarEvent}; + use crate::hooks::sidecar::{HookEvent, Sidecar, SidecarEvent, sidecar_path_in}; let dir = tempfile::tempdir().unwrap(); let sidecars = tempfile::tempdir().unwrap(); @@ -727,9 +654,7 @@ mod tests { let first = sync_index_with_sidecar_dir(&index, &adapters, sidecars.path(), |_| {}).unwrap(); assert_eq!(first.indexed, 1); - let initial = index - .search(&ParsedQuery::default(), SortOrder::Recent, 1_000, 10) - .unwrap(); + let initial = index.search(&ParsedQuery::default(), SortOrder::Recent, 1_000, 10).unwrap(); assert_eq!(initial[0].branch, None); let sidecar = Sidecar { @@ -746,16 +671,12 @@ mod tests { permission_mode: None, }], }; - sidecar - .write(&sidecar_path_in(sidecars.path(), AgentId::Claude, "s1")) - .unwrap(); + sidecar.write(&sidecar_path_in(sidecars.path(), AgentId::Claude, "s1")).unwrap(); let second = sync_index_with_sidecar_dir(&index, &adapters, sidecars.path(), |_| {}).unwrap(); assert_eq!(second.indexed, 1); - let updated = index - .search(&ParsedQuery::default(), SortOrder::Recent, 1_000, 10) - .unwrap(); + let updated = index.search(&ParsedQuery::default(), SortOrder::Recent, 1_000, 10).unwrap(); assert_eq!(updated[0].branch.as_deref(), Some("feature/hooks")); } @@ -806,9 +727,6 @@ mod tests { engine.set_query("after compression"); engine.search().unwrap(); assert_eq!(engine.results().len(), 1); - assert_eq!( - engine.results()[0].source_path.as_deref(), - Some(compressed.as_path()) - ); + assert_eq!(engine.results()[0].source_path.as_deref(), Some(compressed.as_path())); } } diff --git a/src/enrich/gh_pr.rs b/src/enrich/gh_pr.rs index 522959c..25e31e2 100644 --- a/src/enrich/gh_pr.rs +++ b/src/enrich/gh_pr.rs @@ -20,9 +20,7 @@ impl Enricher for GhPrEnricher { return None; } let num = gh_pr_number(branch, s.repo_url.as_deref(), &s.directory)?; - Some(EnrichValue { - text: format!("#{num}"), - }) + Some(EnrichValue { text: format!("#{num}") }) } fn cache_key(&self, s: &SessionSummary) -> String { let repo = s @@ -76,11 +74,7 @@ pub fn open_pr_in_browser(pr_text: &str, repo_url: Option<&str>, dir: &str) -> b } else if !dir.is_empty() { cmd.current_dir(dir); } - cmd.stdout(Stdio::null()) - .stderr(Stdio::null()) - .status() - .map(|s| s.success()) - .unwrap_or(false) + cmd.stdout(Stdio::null()).stderr(Stdio::null()).status().map(|s| s.success()).unwrap_or(false) } /// Parse a resolved PR label such as `"#4821"` back into its number. Returns @@ -133,11 +127,7 @@ mod tests { // A non-numeric label can never resolve to a PR, so the helper returns // false before it would ever shell out to `gh`. assert!(!open_pr_in_browser("—", None, "")); - assert!(!open_pr_in_browser( - "", - Some("git@github.com:me/web.git"), - "/w" - )); + assert!(!open_pr_in_browser("", Some("git@github.com:me/web.git"), "/w")); } #[test] @@ -149,23 +139,14 @@ mod tests { #[test] fn owner_repo_extraction() { - assert_eq!( - owner_repo_from_url("git@github.com:me/web.git").as_deref(), - Some("me/web") - ); - assert_eq!( - owner_repo_from_url("https://github.com/me/web").as_deref(), - Some("me/web") - ); + assert_eq!(owner_repo_from_url("git@github.com:me/web.git").as_deref(), Some("me/web")); + assert_eq!(owner_repo_from_url("https://github.com/me/web").as_deref(), Some("me/web")); assert_eq!(owner_repo_from_url("file:///tmp/x"), None); assert_eq!( owner_repo_from_url("https://github.com/owner/repo/tree/main").as_deref(), Some("owner/repo") ); - assert_eq!( - owner_repo_from_url("https://notgithub.com/owner/repo"), - None - ); + assert_eq!(owner_repo_from_url("https://notgithub.com/owner/repo"), None); } #[test] diff --git a/src/enrich/mod.rs b/src/enrich/mod.rs index c30190e..4c80ce9 100644 --- a/src/enrich/mod.rs +++ b/src/enrich/mod.rs @@ -62,20 +62,13 @@ impl Enricher for RepoEnricher { EnrichKind::Fast } fn resolve(&self, s: &SessionSummary) -> Option { - if let Some(url) = &s.repo_url { - if let Some(name) = repo_name_from_url(url) { - return Some(EnrichValue { text: name }); - } - } - let base = Path::new(&s.directory) - .file_name()? - .to_string_lossy() - .to_string(); - if base.is_empty() { - None - } else { - Some(EnrichValue { text: base }) + if let Some(url) = &s.repo_url + && let Some(name) = repo_name_from_url(url) + { + return Some(EnrichValue { text: name }); } + let base = Path::new(&s.directory).file_name()?.to_string_lossy().to_string(); + if base.is_empty() { None } else { Some(EnrichValue { text: base }) } } } @@ -83,11 +76,7 @@ impl Enricher for RepoEnricher { pub fn repo_name_from_url(url: &str) -> Option { let trimmed = url.trim().trim_end_matches(".git"); let last = trimmed.rsplit(['/', ':']).next()?; - if last.is_empty() { - None - } else { - Some(last.to_string()) - } + if last.is_empty() { None } else { Some(last.to_string()) } } /// `git@github.com:owner/repo.git` or `https://host/owner/repo(.git)` -> `owner/repo`. @@ -95,10 +84,7 @@ pub fn repo_name_from_url(url: &str) -> Option { /// repos that share a basename. Used to auto-scope `hop` to the current repo. pub fn repo_slug_from_url(url: &str) -> Option { let trimmed = url.trim().trim_end_matches(".git"); - let parts: Vec<&str> = trimmed - .split(['/', ':']) - .filter(|s| !s.is_empty()) - .collect(); + let parts: Vec<&str> = trimmed.split(['/', ':']).filter(|s| !s.is_empty()).collect(); match parts.as_slice() { [.., owner, name] => Some(format!("{owner}/{name}")), _ => None, @@ -126,24 +112,15 @@ mod tests { #[test] fn branch_from_data() { assert_eq!( - BranchEnricher - .resolve(&sess(Some("feat/x"), None, "/w")) - .unwrap() - .text, + BranchEnricher.resolve(&sess(Some("feat/x"), None, "/w")).unwrap().text, "feat/x" ); } #[test] fn repo_from_url_then_dir() { - assert_eq!( - repo_name_from_url("git@github.com:me/web.git").as_deref(), - Some("web") - ); - assert_eq!( - repo_name_from_url("https://github.com/me/web").as_deref(), - Some("web") - ); + assert_eq!(repo_name_from_url("git@github.com:me/web.git").as_deref(), Some("web")); + assert_eq!(repo_name_from_url("https://github.com/me/web").as_deref(), Some("web")); assert_eq!( RepoEnricher .resolve(&sess(None, Some("git@github.com:me/web.git"), "/a/b")) @@ -151,29 +128,14 @@ mod tests { .text, "web" ); - assert_eq!( - RepoEnricher - .resolve(&sess(None, None, "/a/myproj")) - .unwrap() - .text, - "myproj" - ); + assert_eq!(RepoEnricher.resolve(&sess(None, None, "/a/myproj")).unwrap().text, "myproj"); } #[test] fn slug_keeps_owner() { - assert_eq!( - repo_slug_from_url("git@github.com:me/web.git").as_deref(), - Some("me/web") - ); - assert_eq!( - repo_slug_from_url("https://github.com/me/web.git").as_deref(), - Some("me/web") - ); - assert_eq!( - repo_slug_from_url("https://github.com/me/web").as_deref(), - Some("me/web") - ); + assert_eq!(repo_slug_from_url("git@github.com:me/web.git").as_deref(), Some("me/web")); + assert_eq!(repo_slug_from_url("https://github.com/me/web.git").as_deref(), Some("me/web")); + assert_eq!(repo_slug_from_url("https://github.com/me/web").as_deref(), Some("me/web")); assert_eq!(repo_slug_from_url("").as_deref(), None); } } diff --git a/src/enrich/service.rs b/src/enrich/service.rs index 375aab0..38d75a3 100644 --- a/src/enrich/service.rs +++ b/src/enrich/service.rs @@ -31,10 +31,7 @@ struct CacheFile { } fn now_secs() -> u64 { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|d| d.as_secs()) - .unwrap_or(0) + SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0) } /// Pure cache-hit check used by the worker and by tests. @@ -43,11 +40,7 @@ fn cache_lookup(cache: &CacheFile, key: &str, ttl_secs: u64) -> Option ttl_secs { return None; // stale } - if text.is_empty() { - Some(None) - } else { - Some(Some(EnrichValue { text: text.clone() })) - } + if text.is_empty() { Some(None) } else { Some(Some(EnrichValue { text: text.clone() })) } } pub struct EnrichmentService { @@ -64,10 +57,7 @@ pub struct EnrichmentState { impl EnrichmentState { pub fn pr_pending(&self) -> usize { - self.requested - .iter() - .filter(|key| !self.resolved.contains_key(*key)) - .count() + self.requested.iter().filter(|key| !self.resolved.contains_key(*key)).count() } pub fn request_visible( @@ -82,10 +72,8 @@ impl EnrichmentState { let key = (session.document_key(), "pr"); if !self.requested.contains(&key) { self.requested.insert(key.clone()); - let _ = service.req_tx.send(EnrichRequest { - session: session.clone(), - enricher: "pr", - }); + let _ = + service.req_tx.send(EnrichRequest { session: session.clone(), enricher: "pr" }); } } self.drain(service); @@ -93,8 +81,7 @@ impl EnrichmentState { pub fn drain(&mut self, service: &EnrichmentService) { while let Ok(r) = service.res_rx.try_recv() { - self.resolved - .insert((r.session_key, r.enricher), r.value.map(|v| v.text)); + self.resolved.insert((r.session_key, r.enricher), r.value.map(|v| v.text)); } } } @@ -129,10 +116,7 @@ impl EnrichmentService { cache.entries.insert( key.clone(), ( - resolved - .as_ref() - .map(|v| v.text.clone()) - .unwrap_or_default(), + resolved.as_ref().map(|v| v.text.clone()).unwrap_or_default(), now_secs(), ), ); @@ -152,11 +136,7 @@ impl EnrichmentService { }); } }); - EnrichmentService { - req_tx, - res_rx, - _handle: handle, - } + EnrichmentService { req_tx, res_rx, _handle: handle } } } @@ -176,9 +156,7 @@ mod tests { EnrichKind::Slow } fn resolve(&self, s: &SessionSummary) -> Option { - Some(EnrichValue { - text: format!("v:{}", s.id), - }) + Some(EnrichValue { text: format!("v:{}", s.id) }) } fn cache_key(&self, s: &SessionSummary) -> String { s.id.clone() @@ -204,12 +182,7 @@ mod tests { let tmp = tempfile::tempdir().unwrap(); let cache = tmp.path().join("gh_pr.json"); let svc = EnrichmentService::spawn(vec![Box::new(FakeEnricher)], cache.clone()); - svc.req_tx - .send(EnrichRequest { - session: sess("a"), - enricher: "fake", - }) - .unwrap(); + svc.req_tx.send(EnrichRequest { session: sess("a"), enricher: "fake" }).unwrap(); let r = svc.res_rx.recv_timeout(Duration::from_secs(2)).unwrap(); assert_eq!(r.session_key, "claude:a"); assert_eq!(r.value.unwrap().text, "v:a"); diff --git a/src/hooks/capture.rs b/src/hooks/capture.rs index f76635c..6971a07 100644 --- a/src/hooks/capture.rs +++ b/src/hooks/capture.rs @@ -32,20 +32,14 @@ pub fn capture_to_dir( let se = SidecarEvent { event, timestamp: ts, - cwd: if ctx.cwd.is_empty() { - None - } else { - Some(ctx.cwd) - }, + cwd: if ctx.cwd.is_empty() { None } else { Some(ctx.cwd) }, branch: git.branch, repo_url: git.repo_url, worktree: git.worktree, permission_mode: None, }; - let path = sidecar_base - .join(agent.slug()) - .join(format!("{}.json", ctx.session_id)); + let path = sidecar_base.join(agent.slug()).join(format!("{}.json", ctx.session_id)); let mut sidecar = Sidecar::read(&path).unwrap_or_else(|| Sidecar::new(agent, ctx.session_id)); sidecar.append_event(se); @@ -97,13 +91,7 @@ mod tests { let dir = tempfile::tempdir().unwrap(); let sidecar_base = dir.path().to_path_buf(); let start_input = r#"{"session_id":"s1","cwd":".","hook_event_name":"SessionStart"}"#; - capture_to_dir( - AgentId::Claude, - HookEvent::Start, - start_input, - &sidecar_base, - ) - .unwrap(); + capture_to_dir(AgentId::Claude, HookEvent::Start, start_input, &sidecar_base).unwrap(); let stop_input = r#"{"session_id":"s1","cwd":".","hook_event_name":"Stop"}"#; capture_to_dir(AgentId::Claude, HookEvent::Stop, stop_input, &sidecar_base).unwrap(); let path = sidecar_base.join("claude").join("s1.json"); diff --git a/src/hooks/git_meta.rs b/src/hooks/git_meta.rs index 3a48733..18d623e 100644 --- a/src/hooks/git_meta.rs +++ b/src/hooks/git_meta.rs @@ -15,30 +15,17 @@ impl GitMeta { let branch = git_field(cwd, &["rev-parse", "--abbrev-ref", "HEAD"]).filter(|b| b != "HEAD"); let repo_url = git_field(cwd, &["remote", "get-url", "origin"]); let worktree = detect_worktree(cwd); - Self { - branch, - repo_url, - worktree, - } + Self { branch, repo_url, worktree } } } fn git_field(dir: &str, args: &[&str]) -> Option { - let out = Command::new("git") - .arg("-C") - .arg(dir) - .args(args) - .output() - .ok()?; + let out = Command::new("git").arg("-C").arg(dir).args(args).output().ok()?; if !out.status.success() { return None; } let value = String::from_utf8_lossy(&out.stdout).trim().to_string(); - if value.is_empty() { - None - } else { - Some(value) - } + if value.is_empty() { None } else { Some(value) } } fn detect_worktree(dir: &str) -> Option { @@ -46,11 +33,7 @@ fn detect_worktree(dir: &str) -> Option { let common_dir = git_field(dir, &["rev-parse", "--git-common-dir"])?; let git_dir = git_field(dir, &["rev-parse", "--git-dir"])?; // If git-dir != common-dir, we're in a linked worktree - if git_dir != common_dir { - Some(toplevel) - } else { - None - } + if git_dir != common_dir { Some(toplevel) } else { None } } /// Build a hermetic git repo in a fresh tempdir with a known branch and origin @@ -61,12 +44,7 @@ pub(crate) fn init_test_repo() -> tempfile::TempDir { let dir = tempfile::tempdir().unwrap(); let path = dir.path(); let run = |args: &[&str]| { - let status = Command::new("git") - .arg("-C") - .arg(path) - .args(args) - .status() - .unwrap(); + let status = Command::new("git").arg("-C").arg(path).args(args).status().unwrap(); assert!(status.success(), "git {args:?} failed"); }; run(&["init"]); @@ -74,12 +52,7 @@ pub(crate) fn init_test_repo() -> tempfile::TempDir { run(&["config", "user.name", "Test"]); run(&["checkout", "-b", "test-branch"]); run(&["commit", "--allow-empty", "-m", "init"]); - run(&[ - "remote", - "add", - "origin", - "https://example.com/test/repo.git", - ]); + run(&["remote", "add", "origin", "https://example.com/test/repo.git"]); dir } diff --git a/src/hooks/merge.rs b/src/hooks/merge.rs index 53daf49..463fdea 100644 --- a/src/hooks/merge.rs +++ b/src/hooks/merge.rs @@ -1,6 +1,6 @@ use crate::core::SessionSummary; use crate::hooks::git_meta::GitMeta; -use crate::hooks::sidecar::{sidecar_dir, sidecar_path_in, Sidecar}; +use crate::hooks::sidecar::{Sidecar, sidecar_dir, sidecar_path_in}; use std::path::Path; pub fn apply_sidecar(summary: &mut SessionSummary, sidecar: &Sidecar) { @@ -70,12 +70,7 @@ mod tests { } fn sidecar_with_events(events: Vec) -> Sidecar { - Sidecar { - version: 1, - session_id: "s1".into(), - agent: AgentId::Claude, - events, - } + Sidecar { version: 1, session_id: "s1".into(), agent: AgentId::Claude, events } } #[test] @@ -174,10 +169,7 @@ mod tests { enrich_from_git_if_needed(&mut summary); // Values unchanged — should not have shelled out assert_eq!(summary.branch.as_deref(), Some("existing-branch")); - assert_eq!( - summary.repo_url.as_deref(), - Some("https://example.com/repo") - ); + assert_eq!(summary.repo_url.as_deref(), Some("https://example.com/repo")); } #[test] @@ -263,9 +255,7 @@ mod tests { worktree: None, permission_mode: None, }]); - sidecar - .write(&sidecar_path_in(sidecars.path(), AgentId::Claude, "s1")) - .unwrap(); + sidecar.write(&sidecar_path_in(sidecars.path(), AgentId::Claude, "s1")).unwrap(); merge_sidecar_from_dir(&mut summary, sidecars.path()); diff --git a/src/hooks/providers.rs b/src/hooks/providers.rs index 4a0ac49..cc18956 100644 --- a/src/hooks/providers.rs +++ b/src/hooks/providers.rs @@ -28,11 +28,7 @@ pub fn home_dir() -> PathBuf { pub fn detect_providers() -> Vec { let home = home_dir(); - vec![ - detect_claude(&home), - detect_codex(&home), - detect_cursor(&home), - ] + vec![detect_claude(&home), detect_codex(&home), detect_cursor(&home)] } fn detect_claude(home: &Path) -> ProviderStatus { @@ -40,13 +36,7 @@ fn detect_claude(home: &Path) -> ProviderStatus { let config_path = plugin_dir.join("hooks").join("hooks.json"); let detected = home.join(".claude").exists(); let installed = detected && is_claude_plugin_installed(); - ProviderStatus { - agent: AgentId::Claude, - detected, - installed, - config_path, - best_effort: false, - } + ProviderStatus { agent: AgentId::Claude, detected, installed, config_path, best_effort: false } } fn detect_codex(home: &Path) -> ProviderStatus { @@ -54,26 +44,14 @@ fn detect_codex(home: &Path) -> ProviderStatus { let config_path = plugin_dir.join("hooks.json"); let detected = home.join(".codex").join("config.toml").exists(); let installed = detected && is_codex_plugin_installed(); - ProviderStatus { - agent: AgentId::Codex, - detected, - installed, - config_path, - best_effort: false, - } + ProviderStatus { agent: AgentId::Codex, detected, installed, config_path, best_effort: false } } fn detect_cursor(home: &Path) -> ProviderStatus { let config_path = home.join(".cursor").join("hooks.json"); let detected = home.join(".cursor").exists(); let installed = detected && is_cursor_installed(&config_path); - ProviderStatus { - agent: AgentId::Cursor, - detected, - installed, - config_path, - best_effort: true, - } + ProviderStatus { agent: AgentId::Cursor, detected, installed, config_path, best_effort: true } } fn is_cursor_installed(path: &Path) -> bool { @@ -101,9 +79,7 @@ fn claude_marketplace_root(home: &Path) -> PathBuf { } fn claude_plugin_dir(home: &Path) -> PathBuf { - claude_marketplace_root(home) - .join("plugins") - .join(CLAUDE_PLUGIN_NAME) + claude_marketplace_root(home).join("plugins").join(CLAUDE_PLUGIN_NAME) } fn claude_plugin_manifest() -> String { @@ -224,31 +200,22 @@ pub fn install_claude(home: &Path) -> Result { claude_run(&["plugin", "marketplace", "add", root_arg.as_ref()])?; } } - claude_run(&[ - "plugin", - "install", - CLAUDE_PLUGIN_SELECTOR, - "--scope", - "user", - ])?; - Ok(format!( - "Claude Code: installed {CLAUDE_PLUGIN_SELECTOR} from {}", - root.display() - )) + claude_run(&["plugin", "install", CLAUDE_PLUGIN_SELECTOR, "--scope", "user"])?; + Ok(format!("Claude Code: installed {CLAUDE_PLUGIN_SELECTOR} from {}", root.display())) } pub fn uninstall_claude(home: &Path) -> Result { let root = claude_marketplace_root(home); let installed = is_claude_plugin_installed(); let registered = registered_claude_marketplace_root()?; - if let Some(existing) = ®istered { - if existing != &root { - anyhow::bail!( - "Claude marketplace {CLAUDE_MARKETPLACE_NAME} points to {}, not hop's {}", - existing.display(), - root.display() - ); - } + if let Some(existing) = ®istered + && existing != &root + { + anyhow::bail!( + "Claude marketplace {CLAUDE_MARKETPLACE_NAME} points to {}, not hop's {}", + existing.display(), + root.display() + ); } if installed { claude_run(&["plugin", "uninstall", CLAUDE_PLUGIN_SELECTOR])?; @@ -283,9 +250,7 @@ fn codex_marketplace_root(home: &Path) -> PathBuf { } fn codex_plugin_dir(home: &Path) -> PathBuf { - codex_marketplace_root(home) - .join("plugins") - .join(CODEX_PLUGIN_NAME) + codex_marketplace_root(home).join("plugins").join(CODEX_PLUGIN_NAME) } fn codex_plugin_manifest() -> String { @@ -339,9 +304,7 @@ fn write_codex_plugin(home: &Path) -> Result { std::fs::write(manifest_dir.join("plugin.json"), codex_plugin_manifest())?; std::fs::write(plugin_dir.join("hooks.json"), codex_hooks_json())?; std::fs::write( - root.join(".agents") - .join("plugins") - .join("marketplace.json"), + root.join(".agents").join("plugins").join("marketplace.json"), codex_marketplace_json(), )?; Ok(root) @@ -367,15 +330,12 @@ fn is_codex_plugin_installed() -> bool { } fn codex_plugin_is_enabled(value: &serde_json::Value) -> bool { - value - .get("installed") - .and_then(|i| i.as_array()) - .is_some_and(|installed| { - installed.iter().any(|plugin| { - plugin.get("pluginId").and_then(|id| id.as_str()) == Some(CODEX_PLUGIN_SELECTOR) - && plugin.get("enabled").and_then(|e| e.as_bool()) == Some(true) - }) + value.get("installed").and_then(|i| i.as_array()).is_some_and(|installed| { + installed.iter().any(|plugin| { + plugin.get("pluginId").and_then(|id| id.as_str()) == Some(CODEX_PLUGIN_SELECTOR) + && plugin.get("enabled").and_then(|e| e.as_bool()) == Some(true) }) + }) } fn registered_codex_marketplace_root() -> Result> { @@ -384,23 +344,20 @@ fn registered_codex_marketplace_root() -> Result> { } fn codex_marketplace_root_from_json(value: &serde_json::Value) -> Option { - value - .get("marketplaces") - .and_then(|m| m.as_array()) - .and_then(|marketplaces| { - marketplaces.iter().find_map(|marketplace| { - (marketplace.get("name").and_then(|n| n.as_str()) == Some(CODEX_MARKETPLACE_NAME)) - .then(|| { - marketplace - .get("marketplaceSource") - .and_then(|source| source.get("source")) - .and_then(|source| source.as_str()) - .or_else(|| marketplace.get("root").and_then(|root| root.as_str())) - }) - .flatten() - .map(PathBuf::from) - }) + value.get("marketplaces").and_then(|m| m.as_array()).and_then(|marketplaces| { + marketplaces.iter().find_map(|marketplace| { + (marketplace.get("name").and_then(|n| n.as_str()) == Some(CODEX_MARKETPLACE_NAME)) + .then(|| { + marketplace + .get("marketplaceSource") + .and_then(|source| source.get("source")) + .and_then(|source| source.as_str()) + .or_else(|| marketplace.get("root").and_then(|root| root.as_str())) + }) + .flatten() + .map(PathBuf::from) }) + }) } pub fn install_codex(home: &Path) -> Result { @@ -417,36 +374,27 @@ pub fn install_codex(home: &Path) -> Result { } } codex_json(&["plugin", "add", CODEX_PLUGIN_SELECTOR, "--json"])?; - Ok(format!( - "Codex: installed {CODEX_PLUGIN_SELECTOR} from {}", - root.display() - )) + Ok(format!("Codex: installed {CODEX_PLUGIN_SELECTOR} from {}", root.display())) } pub fn uninstall_codex(home: &Path) -> Result { let root = codex_marketplace_root(home); let installed = is_codex_plugin_installed(); let registered = registered_codex_marketplace_root()?; - if let Some(existing) = ®istered { - if existing != &root { - anyhow::bail!( - "Codex marketplace {CODEX_MARKETPLACE_NAME} points to {}, not hop's {}", - existing.display(), - root.display() - ); - } + if let Some(existing) = ®istered + && existing != &root + { + anyhow::bail!( + "Codex marketplace {CODEX_MARKETPLACE_NAME} points to {}, not hop's {}", + existing.display(), + root.display() + ); } if installed { codex_json(&["plugin", "remove", CODEX_PLUGIN_SELECTOR, "--json"])?; } if registered.is_some() { - codex_json(&[ - "plugin", - "marketplace", - "remove", - CODEX_MARKETPLACE_NAME, - "--json", - ])?; + codex_json(&["plugin", "marketplace", "remove", CODEX_MARKETPLACE_NAME, "--json"])?; } if root.exists() { std::fs::remove_dir_all(&root)?; @@ -474,22 +422,15 @@ pub fn install_cursor(home: &Path) -> Result { .entry("hooks") .or_insert_with(|| serde_json::json!({})); let hooks_obj = hooks.as_object_mut().context("hooks not object")?; - let stop_arr = hooks_obj - .entry("stop") - .or_insert_with(|| serde_json::json!([])); + let stop_arr = hooks_obj.entry("stop").or_insert_with(|| serde_json::json!([])); let arr = stop_arr.as_array_mut().context("stop not array")?; arr.retain(|e| { - e.get("command") - .and_then(|c| c.as_str()) - .is_none_or(|c| !c.contains("hop meta capture")) + e.get("command").and_then(|c| c.as_str()).is_none_or(|c| !c.contains("hop meta capture")) }); arr.push(serde_json::json!({"command": "hop meta capture --agent cursor --event stop"})); let json = serde_json::to_string_pretty(&v)?; std::fs::write(&path, &json)?; - Ok(format!( - "Cursor: added stop hook to {} [best-effort]", - path.display() - )) + Ok(format!("Cursor: added stop hook to {} [best-effort]", path.display())) } pub fn uninstall_cursor(home: &Path) -> Result { @@ -499,14 +440,14 @@ pub fn uninstall_cursor(home: &Path) -> Result { } let existing = std::fs::read_to_string(&path)?; let mut v: serde_json::Value = serde_json::from_str(&existing)?; - if let Some(hooks) = v.get_mut("hooks").and_then(|h| h.as_object_mut()) { - if let Some(stop) = hooks.get_mut("stop").and_then(|s| s.as_array_mut()) { - stop.retain(|e| { - e.get("command") - .and_then(|c| c.as_str()) - .is_none_or(|c| !c.contains("hop meta capture")) - }); - } + if let Some(hooks) = v.get_mut("hooks").and_then(|h| h.as_object_mut()) + && let Some(stop) = hooks.get_mut("stop").and_then(|s| s.as_array_mut()) + { + stop.retain(|e| { + e.get("command") + .and_then(|c| c.as_str()) + .is_none_or(|c| !c.contains("hop meta capture")) + }); } let json = serde_json::to_string_pretty(&v)?; std::fs::write(&path, &json)?; @@ -539,10 +480,12 @@ mod tests { let v: serde_json::Value = serde_json::from_str(&json).unwrap(); assert!(!v["hooks"]["SessionStart"].is_null()); assert!(!v["hooks"]["SessionEnd"].is_null()); - assert!(v["hooks"]["SessionStart"][0]["hooks"][0]["command"] - .as_str() - .unwrap() - .contains("hop meta capture --agent claude")); + assert!( + v["hooks"]["SessionStart"][0]["hooks"][0]["command"] + .as_str() + .unwrap() + .contains("hop meta capture --agent claude") + ); } #[test] @@ -564,10 +507,7 @@ mod tests { .unwrap(); assert_eq!(marketplace["name"], CLAUDE_MARKETPLACE_NAME); assert_eq!(marketplace["plugins"][0]["name"], CLAUDE_PLUGIN_NAME); - assert_eq!( - marketplace["plugins"][0]["source"], - format!("./plugins/{CLAUDE_PLUGIN_NAME}") - ); + assert_eq!(marketplace["plugins"][0]["source"], format!("./plugins/{CLAUDE_PLUGIN_NAME}")); assert!(plugin_dir.join("hooks/hooks.json").is_file()); } diff --git a/src/hooks/sidecar.rs b/src/hooks/sidecar.rs index ddabe0e..e306cf7 100644 --- a/src/hooks/sidecar.rs +++ b/src/hooks/sidecar.rs @@ -41,12 +41,7 @@ fn de_agent<'de, D: serde::Deserializer<'de>>(d: D) -> std::result::Result Self { - Self { - version: 1, - session_id, - agent, - events: Vec::new(), - } + Self { version: 1, session_id, agent, events: Vec::new() } } pub fn append_event(&mut self, event: SidecarEvent) { @@ -98,10 +93,7 @@ pub fn sidecar_path(agent: AgentId, session_id: &str) -> PathBuf { pub fn sidecar_stamp_in(base: &Path, agent: AgentId, session_id: &str) -> Option { let metadata = std::fs::metadata(sidecar_path_in(base, agent, session_id)).ok()?; let modified = metadata.modified().ok()?; - let nanos = modified - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_nanos(); + let nanos = modified.duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_nanos(); Some(format!("{nanos}:{}", metadata.len())) } diff --git a/src/index.rs b/src/index.rs index b384c9b..1925bef 100644 --- a/src/index.rs +++ b/src/index.rs @@ -4,13 +4,14 @@ use anyhow::{Context, Result}; use std::collections::{HashMap, HashSet}; use std::ops::Bound; use std::path::{Path, PathBuf}; +use std::sync::Mutex; use tantivy::collector::{Count, TopDocs}; use tantivy::query::{ AllQuery, BooleanQuery, BoostQuery, FuzzyTermQuery, Occur, Query, QueryParser, RangeQuery, TermQuery, }; use tantivy::schema::{ - Field, IndexRecordOption, Schema, Value, FAST, INDEXED, STORED, STRING, TEXT, + FAST, Field, INDEXED, IndexRecordOption, STORED, STRING, Schema, TEXT, Value, }; use tantivy::{Index, IndexReader, IndexWriter, TantivyDocument, Term}; @@ -54,6 +55,23 @@ pub struct SearchIndex { index: Index, reader: IndexReader, f: Fields, + /// The single `IndexWriter` for this handle, created on first write and + /// reused for the handle's lifetime. Tantivy allows only one writer per + /// directory at a time, so we never reopen one mid-life; reopening would race + /// the previous writer's not-yet-released `.tantivy-writer.lock`. `None` until + /// the first write, so read-only handles never take the writer lock. + writer: Mutex>, +} + +impl Drop for SearchIndex { + fn drop(&mut self) { + // Wind the writer's merge threads down so the directory lock is released + // deterministically before any other handle opens a writer on this dir. + // Merely dropping an `IndexWriter` only *detaches* those threads. + if let Some(writer) = self.writer.get_mut().ok().and_then(Option::take) { + let _ = writer.wait_merging_threads(); + } + } } fn build_schema() -> (Schema, Fields) { @@ -88,10 +106,9 @@ impl SearchIndex { pub fn open_or_create(dir: &Path) -> Result { std::fs::create_dir_all(dir).with_context(|| format!("creating {}", dir.display()))?; let marker = dir.join(".schema_version"); - let version_ok = std::fs::read_to_string(&marker) - .ok() - .and_then(|s| s.trim().parse::().ok()) - == Some(SCHEMA_VERSION); + let version_ok = + std::fs::read_to_string(&marker).ok().and_then(|s| s.trim().parse::().ok()) + == Some(SCHEMA_VERSION); if !version_ok && dir.join("meta.json").exists() { // schema changed: drop the whole index dir and recreate it empty @@ -109,11 +126,26 @@ impl SearchIndex { std::fs::write(&marker, SCHEMA_VERSION.to_string())?; let reader = index.reader()?; - Ok(Self { index, reader, f }) + Ok(Self { index, reader, f, writer: Mutex::new(None) }) } - pub fn writer(&self) -> Result { - Ok(self.index.writer(WRITER_HEAP)?) + /// Run `f` against this handle's single long-lived writer, creating it on + /// first use. See the `writer` field docs for why we keep one writer rather + /// than reopening per operation. + fn with_writer(&self, f: impl FnOnce(&mut IndexWriter) -> Result) -> Result { + let mut guard = self.writer.lock().expect("index writer mutex poisoned"); + if guard.is_none() { + *guard = Some(self.index.writer(WRITER_HEAP)?); + } + f(guard.as_mut().expect("writer initialized above")) + } + + /// Commit the pending writes. No-op if nothing has been written yet. + pub fn commit(&self) -> Result<()> { + self.with_writer(|w| { + w.commit()?; + Ok(()) + }) } pub fn reload(&self) -> Result<()> { @@ -121,19 +153,17 @@ impl SearchIndex { Ok(()) } - pub fn upsert(&self, w: &mut IndexWriter, s: &Session) { - self.upsert_with_sidecar_stamp(w, s, None); + pub fn upsert(&self, s: &Session) -> Result<()> { + self.upsert_with_sidecar_stamp(s, None) } pub fn upsert_with_sidecar_stamp( &self, - w: &mut IndexWriter, s: &Session, sidecar_stamp: Option<&str>, - ) { + ) -> Result<()> { let m = &s.meta; let doc_key = m.document_key(); - w.delete_term(Term::from_field_text(self.f.doc_key, &doc_key)); let mut doc = TantivyDocument::default(); doc.add_text(self.f.doc_key, &doc_key); doc.add_text(self.f.id, &m.id); @@ -170,11 +200,19 @@ impl SearchIndex { if let Some(stamp) = sidecar_stamp { doc.add_text(self.f.sidecar_stamp, stamp); } - let _ = w.add_document(doc); + self.with_writer(|w| { + // Replace any existing row for this key, then add the fresh document. + w.delete_term(Term::from_field_text(self.f.doc_key, &doc_key)); + let _ = w.add_document(doc); + Ok(()) + }) } - pub fn delete(&self, w: &mut IndexWriter, doc_key: &str) { - w.delete_term(Term::from_field_text(self.f.doc_key, doc_key)); + pub fn delete(&self, doc_key: &str) -> Result<()> { + self.with_writer(|w| { + w.delete_term(Term::from_field_text(self.f.doc_key, doc_key)); + Ok(()) + }) } /// document_key -> mtime for every indexed session (drives incremental diff). @@ -208,11 +246,7 @@ impl SearchIndex { } } } - Ok(KnownSyncState { - mtimes, - sidecar_stamps: sidecars, - source_paths, - }) + Ok(KnownSyncState { mtimes, sidecar_stamps: sidecars, source_paths }) } /// Run a parsed query. `now` is unix seconds (for date filtering). `sort` @@ -233,9 +267,8 @@ impl SearchIndex { clauses.push((Occur::Must, Box::new(AllQuery))); } else { let qp = QueryParser::for_index(&self.index, vec![self.f.title, self.f.content]); - let exact = qp - .parse_query(&sanitize(&q.free_text)) - .unwrap_or_else(|_| Box::new(AllQuery)); + let exact = + qp.parse_query(&sanitize(&q.free_text)).unwrap_or_else(|_| Box::new(AllQuery)); let mut should: Vec<(Occur, Box)> = vec![(Occur::Should, Box::new(BoostQuery::new(exact, EXACT_BOOST)))]; for word in q.free_text.split_whitespace() { @@ -281,10 +314,10 @@ impl SearchIndex { if hi.is_some_and(|hi| hi < 0) { return Ok(Vec::new()); } - if let (Some(lo), Some(hi)) = (lo, hi) { - if lo > hi { - return Ok(Vec::new()); - } + if let (Some(lo), Some(hi)) = (lo, hi) + && lo > hi + { + return Ok(Vec::new()); } let lower = lo .map(|lo| Bound::Included(Term::from_field_u64(self.f.timestamp, lo.max(0) as u64))) @@ -329,9 +362,8 @@ impl SearchIndex { searcher .search( &query, - &TopDocs::with_limit(page_limit) - .and_offset(offset) - .tweak_score(move |segment_reader: &tantivy::SegmentReader| { + &TopDocs::with_limit(page_limit).and_offset(offset).tweak_score( + move |segment_reader: &tantivy::SegmentReader| { let timestamps = segment_reader .fast_fields() .u64("timestamp") @@ -343,7 +375,8 @@ impl SearchIndex { let combined = score + recency_boost(age); (score_bucket(combined), ts) } - }), + }, + ), )? .into_iter() .map(|(_, a)| a) @@ -386,29 +419,15 @@ impl SearchIndex { fn to_session(&self, doc: &TantivyDocument) -> Session { let meta = self.to_summary(doc); - let content = doc - .get_first(self.f.content) - .and_then(|v| v.as_str()) - .unwrap_or("") - .to_string(); - let mtime = doc - .get_first(self.f.mtime) - .and_then(|v| v.as_u64()) - .unwrap_or(0) as i64; - Session { - meta, - content, - mtime, - } + let content = + doc.get_first(self.f.content).and_then(|v| v.as_str()).unwrap_or("").to_string(); + let mtime = doc.get_first(self.f.mtime).and_then(|v| v.as_u64()).unwrap_or(0) as i64; + Session { meta, content, mtime } } fn to_summary(&self, doc: &TantivyDocument) -> SessionSummary { - let get_str = |f: Field| { - doc.get_first(f) - .and_then(|v| v.as_str()) - .unwrap_or("") - .to_string() - }; + let get_str = + |f: Field| doc.get_first(f).and_then(|v| v.as_str()).unwrap_or("").to_string(); // Stored text fields treat the empty string as absent. let get_opt_str = |f: Field| Some(get_str(f)).filter(|s| !s.is_empty()); let get_u64 = |f: Field| doc.get_first(f).and_then(|v| v.as_u64()).unwrap_or(0); @@ -446,15 +465,8 @@ fn dir_ok(directory: &str, q: &ParsedQuery) -> bool { /// (non-git directories correctly drop out of any `repo:` query). fn repo_ok(repo_url: Option<&str>, q: &ParsedQuery) -> bool { let r = repo_url.unwrap_or_default().to_lowercase(); - q.repos - .include - .iter() - .all(|i| r.contains(&i.to_lowercase())) - && !q - .repos - .exclude - .iter() - .any(|e| r.contains(&e.to_lowercase())) + q.repos.include.iter().all(|i| r.contains(&i.to_lowercase())) + && !q.repos.exclude.iter().any(|e| r.contains(&e.to_lowercase())) } fn score_bucket(score: tantivy::Score) -> i64 { @@ -471,12 +483,7 @@ fn recency_boost(age_secs: u64) -> f32 { /// Escape characters that would make tantivy's QueryParser error out. fn sanitize(s: &str) -> String { - s.replace( - [ - '+', '-', '!', '^', '~', '*', '?', ':', '(', ')', '[', ']', '{', '}', '"', - ], - " ", - ) + s.replace(['+', '-', '!', '^', '~', '*', '?', ':', '(', ')', '[', ']', '{', '}', '"'], " ") } /// Pure incremental diff. Returns (changed[(id, entry)], deleted[id]). diff --git a/src/main.rs b/src/main.rs index d996afe..de74fc9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -10,7 +10,7 @@ use hop::enrich::service::{EnrichmentService, EnrichmentState}; use hop::enrich::{BranchEnricher, Enricher, RepoEnricher}; use hop::resume; use hop::tui::toolbar::Scope; -use hop::tui::{preview, view::RenderModel, view::StatusLine, Action, App, SearchMode}; +use hop::tui::{Action, App, SearchMode, preview, view::RenderModel, view::StatusLine}; use ratatui::crossterm::event::{self, Event}; use std::time::Duration; @@ -87,11 +87,7 @@ fn main() -> Result<()> { eprintln!("Detected providers:"); for (i, p) in detected.iter().enumerate() { let effort = if p.best_effort { " [best-effort]" } else { "" }; - let status = if p.installed { - " (already installed)" - } else { - "" - }; + let status = if p.installed { " (already installed)" } else { "" }; eprintln!(" {}. {}{}{}", i + 1, p.agent.badge(), effort, status); } eprint!("Install for all? [Y/n] "); @@ -135,11 +131,7 @@ fn main() -> Result<()> { let providers = hop::hooks::providers::detect_providers(); for p in &providers { let detected = if p.detected { "detected" } else { "not found" }; - let installed = if p.installed { - "installed" - } else { - "not installed" - }; + let installed = if p.installed { "installed" } else { "not installed" }; let effort = if p.best_effort { " [best-effort]" } else { "" }; eprintln!( "{}: {} / {}{}", @@ -196,34 +188,18 @@ fn main() -> Result<()> { }; // Slug for the simple-mode "This repo" scope: explicit --repo wins, else auto. let scope_slug = cli.repo.clone().or_else(|| auto_repo.clone()); - let scope = if cli.all || scope_slug.is_none() { - Scope::All - } else { - Scope::ThisRepo - }; + let scope = if cli.all || scope_slug.is_none() { Scope::All } else { Scope::ThisRepo }; let initial = match mode { SearchMode::Raw => { let q = cli.initial_query(auto_repo.as_deref()); engine.set_query(q.clone()); - InitialSearch { - mode, - scope, - repo_slug: scope_slug, - input: q, - } + InitialSearch { mode, scope, repo_slug: scope_slug, input: q } } SearchMode::Simple => { let free = cli.query.clone().unwrap_or_default(); - let repo = matches!(scope, Scope::ThisRepo) - .then(|| scope_slug.as_deref()) - .flatten(); + let repo = matches!(scope, Scope::ThisRepo).then(|| scope_slug.as_deref()).flatten(); engine.set_query(hop::query::compose_simple(&free, repo)); - InitialSearch { - mode, - scope, - repo_slug: scope_slug, - input: free, - } + InitialSearch { mode, scope, repo_slug: scope_slug, input: free } } }; engine.search()?; // immediate results from whatever is already indexed @@ -241,10 +217,7 @@ fn main() -> Result<()> { render_enrichers.push(Box::new(GhPrEnricher)); } let service = if pr_enabled { - Some(EnrichmentService::spawn( - vec![Box::new(GhPrEnricher)], - enrich_cache_path(), - )) + Some(EnrichmentService::spawn(vec![Box::new(GhPrEnricher)], enrich_cache_path())) } else { None }; @@ -322,12 +295,7 @@ fn run_tui( let mut terminal = ratatui::init(); let mut app = App::new(); app.set_keymap(keymap); - app.init_search( - initial.mode, - initial.scope, - initial.repo_slug, - initial.input, - ); + app.init_search(initial.mode, initial.scope, initial.repo_slug, initial.input); app.set_preview(init_preview.0, init_preview.1); app.set_preview_header(config.preview.metadata_header); sync_results_into_app(engine, &mut app); @@ -374,11 +342,7 @@ fn run_tui( .and_then(|s| engine.resume_command_for(s, yolo, &config.launcher)) .map(|command| command.argv) }); - app.set_indexing(if state.sync_done { - None - } else { - Some(app.results().len()) - }); + app.set_indexing(if state.sync_done { None } else { Some(app.results().len()) }); terminal.draw(|f| { hop::tui::view::render( f, @@ -409,34 +373,33 @@ fn run_tui( state.process_sync(&updates, engine, &mut app)?; } - if event::poll(Duration::from_millis(50))? { - if let Event::Key(key) = event::read()? { - match app.handle_key(key) { - Action::Quit => return Ok(None), - Action::Search => { - engine.set_query(app.effective_query()); - engine.set_sort(app.sort()); - } - Action::Resume { index, yolo } => { - if let Some(s) = app.results().get(index).cloned() { - return Ok(Some((s, yolo))); - } + if event::poll(Duration::from_millis(50))? + && let Event::Key(key) = event::read()? + { + match app.handle_key(key) { + Action::Quit => return Ok(None), + Action::Search => { + engine.set_query(app.effective_query()); + engine.set_sort(app.sort()); + } + Action::Resume { index, yolo } => { + if let Some(s) = app.results().get(index).cloned() { + return Ok(Some((s, yolo))); } - Action::OpenPr { index } => { - if let Some(s) = app.results().get(index) { - if let Some(Some(pr)) = - state.enrichment.resolved.get(&(s.document_key(), "pr")) - { - hop::enrich::gh_pr::open_pr_in_browser( - pr, - s.repo_url.as_deref(), - &s.directory, - ); - } - } + } + Action::OpenPr { index } => { + if let Some(s) = app.results().get(index) + && let Some(Some(pr)) = + state.enrichment.resolved.get(&(s.document_key(), "pr")) + { + hop::enrich::gh_pr::open_pr_in_browser( + pr, + s.repo_url.as_deref(), + &s.directory, + ); } - _ => {} } + _ => {} } } diff --git a/src/query.rs b/src/query.rs index 15fd2c4..d6ba6ba 100644 --- a/src/query.rs +++ b/src/query.rs @@ -1,5 +1,5 @@ use crate::core::AgentId; -use jiff::{tz::TimeZone, Timestamp}; +use jiff::{Timestamp, tz::TimeZone}; #[derive(Debug, Default, Clone, PartialEq, Eq)] pub struct AgentFilter { @@ -67,17 +67,8 @@ impl DateFilter { _ => return None, }; let end_date = start_date.tomorrow().ok()?; - let start = start_date - .to_zoned(tz.clone()) - .ok()? - .timestamp() - .as_second(); - let end = end_date - .to_zoned(tz) - .ok()? - .timestamp() - .as_second() - .checked_sub(1)?; + let start = start_date.to_zoned(tz.clone()).ok()?.timestamp().as_second(); + let end = end_date.to_zoned(tz).ok()?.timestamp().as_second().checked_sub(1)?; Some((Some(start), Some(end))) } @@ -164,23 +155,13 @@ impl ParsedQuery { if !self.agents.include.is_empty() { filters.push(format!( "agent:{}", - self.agents - .include - .iter() - .map(|a| a.slug()) - .collect::>() - .join(",") + self.agents.include.iter().map(|a| a.slug()).collect::>().join(",") )); } if !self.agents.exclude.is_empty() { filters.push(format!( "-agent:{}", - self.agents - .exclude - .iter() - .map(|a| a.slug()) - .collect::>() - .join(",") + self.agents.exclude.iter().map(|a| a.slug()).collect::>().join(",") )); } for dir in &self.dirs.include { @@ -198,11 +179,7 @@ impl ParsedQuery { if let Some(date) = self.date { filters.push(date.summary()); } - if filters.is_empty() { - None - } else { - Some(filters.join(",")) - } + if filters.is_empty() { None } else { Some(filters.join(",")) } } } @@ -212,11 +189,11 @@ impl ParsedQuery { /// is reused unchanged rather than building a `ParsedQuery` by hand. pub fn compose_simple(free_text: &str, repo_scope: Option<&str>) -> String { let mut out = String::new(); - if let Some(slug) = repo_scope { - if !slug.is_empty() { - out.push_str("repo:"); - out.push_str(slug); - } + if let Some(slug) = repo_scope + && !slug.is_empty() + { + out.push_str("repo:"); + out.push_str(slug); } if !free_text.is_empty() { if !out.is_empty() { @@ -301,11 +278,7 @@ fn parse_date(val: &str) -> Option { None => (false, val.strip_prefix('<').unwrap_or(val)), }; let secs = parse_duration(rest)?; - Some(if older { - DateFilter::OlderThan(secs) - } else { - DateFilter::Within(secs) - }) + Some(if older { DateFilter::OlderThan(secs) } else { DateFilter::Within(secs) }) } fn parse_duration(s: &str) -> Option { @@ -355,10 +328,7 @@ fn complete_value(partial: &str, candidates: &[&str]) -> Option { if partial.is_empty() { return None; } - let matches: Vec<&&str> = candidates - .iter() - .filter(|c| c.starts_with(partial)) - .collect(); + let matches: Vec<&&str> = candidates.iter().filter(|c| c.starts_with(partial)).collect(); // Only complete when unambiguous and not already complete. match matches.as_slice() { [only] if **only != partial => Some((**only).to_string()), @@ -419,31 +389,16 @@ mod tests { assert_eq!(parse("date:month").date, Some(DateFilter::LastMonth)); assert_eq!(parse("date:<1h").date, Some(DateFilter::Within(3600))); assert_eq!(parse("date:<2d").date, Some(DateFilter::Within(2 * 86400))); - assert_eq!( - parse("date:>1w").date, - Some(DateFilter::OlderThan(7 * 86400)) - ); + assert_eq!(parse("date:>1w").date, Some(DateFilter::OlderThan(7 * 86400))); } #[test] fn date_range_windows() { let now = 1_000_000i64; - assert_eq!( - DateFilter::LastWeek.range(now), - (Some(now - 7 * 86400), Some(now)) - ); - assert_eq!( - DateFilter::LastMonth.range(now), - (Some(now - 30 * 86400), Some(now)) - ); - assert_eq!( - DateFilter::Within(3600).range(now), - (Some(now - 3600), Some(now)) - ); - assert_eq!( - DateFilter::OlderThan(3600).range(now), - (None, Some(now - 3600)) - ); + assert_eq!(DateFilter::LastWeek.range(now), (Some(now - 7 * 86400), Some(now))); + assert_eq!(DateFilter::LastMonth.range(now), (Some(now - 30 * 86400), Some(now))); + assert_eq!(DateFilter::Within(3600).range(now), (Some(now - 3600), Some(now))); + assert_eq!(DateFilter::OlderThan(3600).range(now), (None, Some(now - 3600))); } #[test] @@ -456,17 +411,11 @@ mod tests { .timestamp() .as_second(); - let expected_start = jiff::civil::date(2024, 3, 10) - .to_zoned(tz.clone()) - .unwrap() - .timestamp() - .as_second(); - let expected_end = jiff::civil::date(2024, 3, 11) - .to_zoned(tz.clone()) - .unwrap() - .timestamp() - .as_second() - - 1; + let expected_start = + jiff::civil::date(2024, 3, 10).to_zoned(tz.clone()).unwrap().timestamp().as_second(); + let expected_end = + jiff::civil::date(2024, 3, 11).to_zoned(tz.clone()).unwrap().timestamp().as_second() + - 1; assert_eq!( DateFilter::Today.calendar_day_range(now, tz, 0), @@ -485,17 +434,11 @@ mod tests { .timestamp() .as_second(); - let expected_start = jiff::civil::date(2024, 3, 9) - .to_zoned(tz.clone()) - .unwrap() - .timestamp() - .as_second(); - let expected_end = jiff::civil::date(2024, 3, 10) - .to_zoned(tz.clone()) - .unwrap() - .timestamp() - .as_second() - - 1; + let expected_start = + jiff::civil::date(2024, 3, 9).to_zoned(tz.clone()).unwrap().timestamp().as_second(); + let expected_end = + jiff::civil::date(2024, 3, 10).to_zoned(tz.clone()).unwrap().timestamp().as_second() + - 1; assert_eq!( DateFilter::Yesterday.calendar_day_range(now, tz, -1), @@ -506,10 +449,7 @@ mod tests { #[test] fn autocomplete_agent_value() { assert_eq!(autocomplete("agent:cl").as_deref(), Some("agent:claude")); - assert_eq!( - autocomplete("bug agent:co").as_deref(), - Some("bug agent:codex") - ); + assert_eq!(autocomplete("bug agent:co").as_deref(), Some("bug agent:codex")); // already complete -> no suggestion assert_eq!(autocomplete("agent:claude"), None); // free text -> no suggestion diff --git a/src/resume.rs b/src/resume.rs index 22bac91..0eb5651 100644 --- a/src/resume.rs +++ b/src/resume.rs @@ -1,4 +1,4 @@ -use anyhow::{bail, Context, Result}; +use anyhow::{Context, Result, bail}; use std::os::unix::process::CommandExt; use std::process::Command; diff --git a/src/tui/columns.rs b/src/tui/columns.rs index 93282e6..9216b2a 100644 --- a/src/tui/columns.rs +++ b/src/tui/columns.rs @@ -140,18 +140,11 @@ pub fn solve_layout_with_desired( ) -> Vec<(usize, u16)> { let mut kept: Vec = (0..columns.len()).collect(); - let floor = |i: usize| -> u16 { - columns[i] - .min_width - .max(display_width(columns[i].header) as u16) - }; + let floor = + |i: usize| -> u16 { columns[i].min_width.max(display_width(columns[i].header) as u16) }; let desired = |i: usize| -> u16 { - desired_widths - .get(i) - .copied() - .unwrap_or_else(|| floor(i)) - .max(floor(i)) + desired_widths.get(i).copied().unwrap_or_else(|| floor(i)).max(floor(i)) }; let needed = |kept: &[usize]| -> u16 { @@ -188,11 +181,11 @@ pub fn solve_layout_with_desired( extra -= grow_by; } - if extra > 0 { - if let Some((i, width)) = out.iter_mut().find(|(i, _)| columns[*i].flex) { - *width += extra; - debug_assert!(columns[*i].flex); - } + if extra > 0 + && let Some((i, width)) = out.iter_mut().find(|(i, _)| columns[*i].flex) + { + *width += extra; + debug_assert!(columns[*i].flex); } out @@ -388,15 +381,8 @@ mod tests { fn flex_column_absorbs_extra_width() { let cols = default_columns(); let layout = solve_layout(&cols, 200); - let title_w = layout - .iter() - .find(|&&(i, _)| cols[i].id == "title") - .unwrap() - .1; - assert!( - title_w > 12, - "title should grow past its min on a wide pane" - ); + let title_w = layout.iter().find(|&&(i, _)| cols[i].id == "title").unwrap().1; + assert!(title_w > 12, "title should grow past its min on a wide pane"); } #[test] @@ -404,13 +390,7 @@ mod tests { let cols = default_columns(); let desired = vec![6, 3, 18, 60, 4, 5, 4]; let layout = solve_layout_with_desired(&cols, 100, &desired); - let width = |id| { - layout - .iter() - .find(|&&(i, _)| cols[i].id == id) - .map(|&(_, w)| w) - .unwrap() - }; + let width = |id| layout.iter().find(|&&(i, _)| cols[i].id == id).map(|&(_, w)| w).unwrap(); assert_eq!(width("repo"), 4); // header is wider than the visible value assert_eq!(width("branch"), 18); @@ -442,10 +422,7 @@ mod tests { #[test] fn fit_end_truncates_start() { - assert_eq!( - fit_end("/Users/amitt/workspaces/personal/hop", 20), - "…spaces/personal/hop" - ); + assert_eq!(fit_end("/Users/amitt/workspaces/personal/hop", 20), "…spaces/personal/hop"); assert_eq!(fit_end("/short", 20), "/short"); assert_eq!(fit_end("abc", 3), "abc"); assert_eq!(fit_end("abcd", 3), "…cd"); @@ -455,11 +432,7 @@ mod tests { #[test] fn configured_columns_orders_and_disables() { let disabled = vec!["pr".to_string()]; - let order = vec![ - "time".to_string(), - "title".to_string(), - "missing".to_string(), - ]; + let order = vec!["time".to_string(), "title".to_string(), "missing".to_string()]; let cols = configured_columns(default_columns(), &disabled, &order); let ids: Vec<&str> = cols.iter().map(|c| c.id).collect(); assert_eq!(ids[0..2], ["time", "title"]); diff --git a/src/tui/help.rs b/src/tui/help.rs index a20bfe2..1056d8d 100644 --- a/src/tui/help.rs +++ b/src/tui/help.rs @@ -1,13 +1,13 @@ //! Centered help overlay listing the keymap. +use crate::tui::SearchMode; use crate::tui::keymap::Keymap; use crate::tui::theme::Theme; -use crate::tui::SearchMode; +use ratatui::Frame; use ratatui::layout::Alignment; use ratatui::style::{Modifier, Style}; use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, Clear, Padding, Paragraph}; -use ratatui::Frame; pub fn lines(keymap: &Keymap, mode: SearchMode, theme: &Theme) -> Vec> { let table = crate::tui::keymap::bindings(keymap, mode); @@ -76,12 +76,7 @@ const QUERY_KEYWORDS: &[(&str, &str)] = &[ ]; fn section(label: &'static str, theme: &Theme) -> Line<'static> { - Line::from(Span::styled( - label, - Style::default() - .fg(theme.accent) - .add_modifier(Modifier::BOLD), - )) + Line::from(Span::styled(label, Style::default().fg(theme.accent).add_modifier(Modifier::BOLD))) } /// Render the overlay centered over the frame. @@ -93,29 +88,17 @@ pub fn render(f: &mut Frame, keymap: &Keymap, mode: SearchMode, theme: &Theme) { let body = lines(keymap, mode, theme); let w = 58u16.min(area.width.saturating_sub(4)).max(8); - let h = (body.len() as u16 + 4) - .min(area.height.saturating_sub(2)) - .max(4); + let h = (body.len() as u16 + 4).min(area.height.saturating_sub(2)).max(4); let rect = crate::tui::modal::center(area, w, h); - f.buffer_mut().set_style( - area, - Style::default().fg(theme.overlay_fg).bg(theme.overlay_bg), - ); + f.buffer_mut().set_style(area, Style::default().fg(theme.overlay_fg).bg(theme.overlay_bg)); f.render_widget(Clear, rect); let block = Block::bordered() .border_style(Style::default().fg(theme.accent)) .title(" help ") - .title_style( - Style::default() - .fg(theme.accent) - .add_modifier(Modifier::BOLD), - ) + .title_style(Style::default().fg(theme.accent).add_modifier(Modifier::BOLD)) .padding(Padding::symmetric(2, 1)); f.render_widget( - Paragraph::new(body) - .block(block) - .alignment(Alignment::Left) - .style(Style::default()), + Paragraph::new(body).block(block).alignment(Alignment::Left).style(Style::default()), rect, ); } @@ -127,12 +110,7 @@ mod tests { fn rendered_text(mode: SearchMode) -> String { lines(&Keymap::defaults(), mode, &Theme::default()) .iter() - .map(|x| { - x.spans - .iter() - .map(|s| s.content.as_ref()) - .collect::() - }) + .map(|x| x.spans.iter().map(|s| s.content.as_ref()).collect::()) .collect::>() .join("\n") } @@ -141,18 +119,10 @@ mod tests { fn help_lists_every_binding_from_table() { let text = rendered_text(SearchMode::Simple); for b in crate::tui::keymap::bindings(&Keymap::defaults(), SearchMode::Simple) { - assert!( - text.contains(b.label), - "help overlay missing binding label {:?}", - b.label - ); + assert!(text.contains(b.label), "help overlay missing binding label {:?}", b.label); // The "type" pseudo-key has no literal key column in help. if b.keys != "type" { - assert!( - text.contains(&b.keys), - "help overlay missing binding keys {:?}", - b.keys - ); + assert!(text.contains(&b.keys), "help overlay missing binding keys {:?}", b.keys); } } // Group headings still render. @@ -170,10 +140,7 @@ mod tests { let text = rendered_text(SearchMode::Simple); assert!(text.contains("Query Keywords")); for (kw, _) in QUERY_KEYWORDS { - assert!( - text.contains(kw), - "help overlay missing query keyword {kw:?}" - ); + assert!(text.contains(kw), "help overlay missing query keyword {kw:?}"); } // Keyword labels must fit the shared key column so alignment holds. let key_w = crate::tui::keymap::bindings(&Keymap::defaults(), SearchMode::Simple) @@ -220,27 +187,14 @@ mod tests { #[test] fn overlay_renders_labels_into_buffer() { - use ratatui::backend::TestBackend; use ratatui::Terminal; + use ratatui::backend::TestBackend; let backend = TestBackend::new(64, 40); let mut term = Terminal::new(backend).unwrap(); - term.draw(|f| { - render( - f, - &Keymap::defaults(), - SearchMode::Simple, - &Theme::default(), - ) - }) - .unwrap(); - let text: String = term - .backend() - .buffer() - .content() - .iter() - .map(|c| c.symbol()) - .collect(); + term.draw(|f| render(f, &Keymap::defaults(), SearchMode::Simple, &Theme::default())) + .unwrap(); + let text: String = term.backend().buffer().content().iter().map(|c| c.symbol()).collect(); assert!(text.contains("toggle preview")); assert!(text.contains("resume")); assert!(text.contains("help")); diff --git a/src/tui/keymap.rs b/src/tui/keymap.rs index 7618fb7..091a3b0 100644 --- a/src/tui/keymap.rs +++ b/src/tui/keymap.rs @@ -43,11 +43,7 @@ struct ChordSpec { fn chord_specs() -> Vec { let ctrl = KeyModifiers::CONTROL; vec![ - ChordSpec { - name: "quit", - default: (ctrl, KeyCode::Char('c')), - command: Command::Quit, - }, + ChordSpec { name: "quit", default: (ctrl, KeyCode::Char('c')), command: Command::Quit }, ChordSpec { name: "toggle_preview", default: (ctrl, KeyCode::Char('p')), @@ -113,10 +109,7 @@ impl Default for Keymap { impl Keymap { /// The hardcoded default bindings. pub fn defaults() -> Keymap { - let chords = chord_specs() - .into_iter() - .map(|s| (s.default, s.command)) - .collect(); + let chords = chord_specs().into_iter().map(|s| (s.default, s.command)).collect(); Keymap { chords } } @@ -185,10 +178,7 @@ impl Keymap { /// The chord currently bound to `command`, for display in help/footer. fn chord_for(&self, command: Command) -> Option { - self.chords - .iter() - .find(|(_, c)| *c == command) - .map(|(chord, _)| *chord) + self.chords.iter().find(|(_, c)| *c == command).map(|(chord, _)| *chord) } } @@ -203,11 +193,7 @@ fn normalize_code(code: KeyCode) -> KeyCode { /// Parse a binding string like `"ctrl+t"` or `"ctrl+left"` into a chord. Only /// the Ctrl modifier is supported, and it is required (the chord-only invariant). fn parse_chord(s: &str) -> Result { - let parts: Vec<&str> = s - .split('+') - .map(str::trim) - .filter(|p| !p.is_empty()) - .collect(); + let parts: Vec<&str> = s.split('+').map(str::trim).filter(|p| !p.is_empty()).collect(); let Some((key_part, mod_parts)) = parts.split_last() else { return Err("empty binding".to_string()); }; @@ -215,11 +201,7 @@ fn parse_chord(s: &str) -> Result { for m in mod_parts { match m.to_ascii_lowercase().as_str() { "ctrl" | "control" => mods |= KeyModifiers::CONTROL, - other => { - return Err(format!( - "unsupported modifier `{other}` (only ctrl is allowed)" - )) - } + other => return Err(format!("unsupported modifier `{other}` (only ctrl is allowed)")), } } if !mods.contains(KeyModifiers::CONTROL) { @@ -310,12 +292,7 @@ pub struct Binding { pub fn bindings(keymap: &Keymap, mode: SearchMode) -> Vec { let chord = |cmd: Command| keymap.chord_for(cmd).map(format_chord).unwrap_or_default(); let pair = |a: Command, b: Command| format!("{}/{}", chord(a), chord(b)); - let row = |keys: String, group, label, primary| Binding { - keys, - group, - label, - primary, - }; + let row = |keys: String, group, label, primary| Binding { keys, group, label, primary }; vec![ // Navigation row("↑/↓".into(), "Navigation", "move selection", false), @@ -333,12 +310,7 @@ pub fn bindings(keymap: &Keymap, mode: SearchMode) -> Vec { false, ), // Preview - row( - chord(Command::TogglePreview), - "Preview", - "toggle preview", - false, - ), + row(chord(Command::TogglePreview), "Preview", "toggle preview", false), row( pair(Command::ResizePreview(-1), Command::ResizePreview(1)), "Preview", @@ -353,18 +325,8 @@ pub fn bindings(keymap: &Keymap, mode: SearchMode) -> Vec { // Actions row("type".into(), "Actions", "search", true), row("Enter".into(), "Actions", "resume", true), - row( - chord(Command::OpenPr), - "Actions", - "open PR in browser", - false, - ), - row( - chord(Command::ToggleSearchMode), - "Actions", - "toggle simple/raw search", - false, - ), + row(chord(Command::OpenPr), "Actions", "open PR in browser", false), + row(chord(Command::ToggleSearchMode), "Actions", "toggle simple/raw search", false), // Tab's action depends on the search mode: it focuses the guided toolbar // in simple mode, and autocompletes query keywords in raw mode. row( @@ -396,10 +358,7 @@ mod tests { #[test] fn ctrl_chords_map() { let km = Keymap::defaults(); - assert_eq!( - km.chord_action(&ctrl(KeyCode::Char('p'))), - Some(Command::TogglePreview) - ); + assert_eq!(km.chord_action(&ctrl(KeyCode::Char('p'))), Some(Command::TogglePreview)); assert!(matches!( km.chord_action(&ctrl(KeyCode::Char('u'))), Some(Command::ScrollPreview(n)) if n < 0 @@ -413,14 +372,8 @@ mod tests { #[test] fn ctrl_arrows_resize_preview() { let km = Keymap::defaults(); - assert_eq!( - km.chord_action(&ctrl(KeyCode::Left)), - Some(Command::ResizePreview(-1)) - ); - assert_eq!( - km.chord_action(&ctrl(KeyCode::Right)), - Some(Command::ResizePreview(1)) - ); + assert_eq!(km.chord_action(&ctrl(KeyCode::Left)), Some(Command::ResizePreview(-1))); + assert_eq!(km.chord_action(&ctrl(KeyCode::Right)), Some(Command::ResizePreview(1))); } #[test] @@ -428,10 +381,7 @@ mod tests { // Some terminals report Ctrl+letter with an uppercase code; the lookup // normalizes before matching the lowercase-stored chord. let km = Keymap::defaults(); - assert_eq!( - km.chord_action(&ctrl(KeyCode::Char('P'))), - Some(Command::TogglePreview) - ); + assert_eq!(km.chord_action(&ctrl(KeyCode::Char('P'))), Some(Command::TogglePreview)); } #[test] @@ -444,18 +394,9 @@ mod tests { #[test] fn parse_chord_accepts_letters_and_named_keys() { - assert_eq!( - parse_chord("ctrl+t"), - Ok((KeyModifiers::CONTROL, KeyCode::Char('t'))) - ); - assert_eq!( - parse_chord("Ctrl+Left"), - Ok((KeyModifiers::CONTROL, KeyCode::Left)) - ); - assert_eq!( - parse_chord("ctrl + j"), - Ok((KeyModifiers::CONTROL, KeyCode::Char('j'))) - ); + assert_eq!(parse_chord("ctrl+t"), Ok((KeyModifiers::CONTROL, KeyCode::Char('t')))); + assert_eq!(parse_chord("Ctrl+Left"), Ok((KeyModifiers::CONTROL, KeyCode::Left))); + assert_eq!(parse_chord("ctrl + j"), Ok((KeyModifiers::CONTROL, KeyCode::Char('j')))); } #[test] @@ -471,14 +412,8 @@ mod tests { let mut overrides = HashMap::new(); overrides.insert("toggle_preview".to_string(), "ctrl+t".to_string()); let (km, warnings) = Keymap::from_config(&overrides); - assert!( - warnings.is_empty(), - "valid override should not warn: {warnings:?}" - ); - assert_eq!( - km.chord_action(&ctrl(KeyCode::Char('t'))), - Some(Command::TogglePreview) - ); + assert!(warnings.is_empty(), "valid override should not warn: {warnings:?}"); + assert_eq!(km.chord_action(&ctrl(KeyCode::Char('t'))), Some(Command::TogglePreview)); // The default chord no longer triggers toggle. assert_eq!(km.chord_action(&ctrl(KeyCode::Char('p'))), None); } @@ -493,10 +428,7 @@ mod tests { assert!(warnings.iter().any(|w| w.contains("toggle_preview"))); assert!(warnings.iter().any(|w| w.contains("bogus_command"))); // Falls back to the default chord. - assert_eq!( - km.chord_action(&ctrl(KeyCode::Char('p'))), - Some(Command::TogglePreview) - ); + assert_eq!(km.chord_action(&ctrl(KeyCode::Char('p'))), Some(Command::TogglePreview)); } #[test] @@ -543,9 +475,6 @@ mod tests { assert!(!b.group.is_empty(), "binding group must be non-empty"); } // At least one binding is flagged primary (footer subset). - assert!( - table.iter().any(|b| b.primary), - "need at least one primary binding" - ); + assert!(table.iter().any(|b| b.primary), "need at least one primary binding"); } } diff --git a/src/tui/mod.rs b/src/tui/mod.rs index 5c3bd8e..355c9a1 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -259,17 +259,11 @@ impl App { } pub fn open_yolo_modal(&mut self) { - self.mode = Mode::YoloModal { - index: self.selected, - yolo: false, - }; + self.mode = Mode::YoloModal { index: self.selected, yolo: false }; } pub fn open_yolo_modal_with(&mut self, yolo: bool) { - self.mode = Mode::YoloModal { - index: self.selected, - yolo, - }; + self.mode = Mode::YoloModal { index: self.selected, yolo }; } pub fn preview_visible(&self) -> bool { @@ -309,10 +303,8 @@ impl App { self.preview_scroll_step = preview_height.saturating_sub(1).max(1); } pub fn set_preview_matches(&mut self, matches: Vec) { - self.preview_matches = matches - .into_iter() - .map(|line| line.min(u16::MAX as usize) as u16) - .collect(); + self.preview_matches = + matches.into_iter().map(|line| line.min(u16::MAX as usize) as u16).collect(); self.preview_match_index = 0; self.preview_scroll = self.preview_matches.first().copied().unwrap_or(0); } @@ -473,9 +465,7 @@ impl App { if self.results.is_empty() { Action::None } else { - Action::OpenPr { - index: self.selected, - } + Action::OpenPr { index: self.selected } } } keymap::Command::ToggleSearchMode => self.toggle_search_mode(), @@ -493,11 +483,7 @@ impl App { match self.toolbar_focus { Focus::Scope => self.scope = self.scope.toggled(), Focus::Sort => { - self.sort = if dir < 0 { - self.sort.prev() - } else { - self.sort.next() - }; + self.sort = if dir < 0 { self.sort.prev() } else { self.sort.next() }; } Focus::Query => return Action::None, } @@ -597,16 +583,10 @@ impl App { let yolo_capable = self.yolo_supported.get(idx).copied().unwrap_or(false); let archived = self.results.get(idx).is_some_and(|s| s.archived); if yolo_capable || archived { - self.mode = Mode::YoloModal { - index: idx, - yolo: false, - }; + self.mode = Mode::YoloModal { index: idx, yolo: false }; Action::None } else { - Action::Resume { - index: idx, - yolo: false, - } + Action::Resume { index: idx, yolo: false } } } } @@ -770,10 +750,7 @@ mod tests { app.set_results(vec![s]); app.set_yolo_supported(vec![false]); // agent does not support yolo assert_eq!(app.handle_key(key(KeyCode::Enter)), Action::None); - assert!( - app.modal_open(), - "archived sessions must be confirmed before resume" - ); + assert!(app.modal_open(), "archived sessions must be confirmed before resume"); match app.handle_key(key(KeyCode::Enter)) { Action::Resume { index, .. } => assert_eq!(index, 0), other => panic!("expected resume after confirm, got {other:?}"), diff --git a/src/tui/modal.rs b/src/tui/modal.rs index 4d306a5..c396112 100644 --- a/src/tui/modal.rs +++ b/src/tui/modal.rs @@ -1,11 +1,11 @@ use crate::core::SessionSummary; use crate::tui::columns; use crate::tui::theme::Theme; +use ratatui::Frame; use ratatui::layout::{Alignment, Constraint, Flex, Layout, Rect}; use ratatui::style::{Modifier, Style}; use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, Clear, Padding, Paragraph}; -use ratatui::Frame; /// A `w` x `h` rect centered within `area` on both axes (clamped to `area`). pub fn center(area: Rect, w: u16, h: u16) -> Rect { @@ -61,14 +61,11 @@ pub fn render_yolo_modal( let directory = session .map(|s| fit_for_modal(&s.directory, value_budget)) .unwrap_or_else(|| "—".to_string()); - let command = modal_command - .map(shell_join) - .unwrap_or_else(|| "resume command unavailable".to_string()); + let command = + modal_command.map(shell_join).unwrap_or_else(|| "resume command unavailable".to_string()); let command = fit_for_modal(&command, value_budget); - let label_style = Style::default() - .fg(theme.muted) - .add_modifier(Modifier::BOLD); + let label_style = Style::default().fg(theme.muted).add_modifier(Modifier::BOLD); let mut body = vec![ Line::from(vec![ @@ -93,9 +90,7 @@ pub fn render_yolo_modal( Span::styled(format!("{: String { argv.iter() .map(|arg| { - if arg - .chars() - .all(|c| c.is_ascii_alphanumeric() || "-_./:@".contains(c)) - { + if arg.chars().all(|c| c.is_ascii_alphanumeric() || "-_./:@".contains(c)) { arg.clone() } else { format!("'{}'", arg.replace('\'', "'\\''")) diff --git a/src/tui/preview.rs b/src/tui/preview.rs index 242cc46..585430d 100644 --- a/src/tui/preview.rs +++ b/src/tui/preview.rs @@ -99,10 +99,7 @@ pub fn render_prose(text: &str, theme: &Theme) -> Vec> { Event::Start(Tag::Item) => { in_item = true; item_line_start = false; - spans.push(Span::raw(format!( - "{}• ", - " ".repeat(list_depth.saturating_sub(1)) - ))); + spans.push(Span::raw(format!("{}• ", " ".repeat(list_depth.saturating_sub(1))))); } Event::End(TagEnd::Item) => { in_item = false; @@ -127,9 +124,8 @@ pub fn render_prose(text: &str, theme: &Theme) -> Vec> { if item_line_start { item_line_start = false; let trimmed = text.trim_start(); - if let Some(rest) = trimmed - .strip_prefix("- ") - .or_else(|| trimmed.strip_prefix("* ")) + if let Some(rest) = + trimmed.strip_prefix("- ").or_else(|| trimmed.strip_prefix("* ")) { spans.push(Span::raw("• ")); text = rest.to_string(); @@ -199,9 +195,7 @@ pub fn render_transcript_with_terms( Span::styled("● ", Style::default().fg(theme.agent_color(agent))), Span::styled( agent.badge(), - Style::default() - .fg(theme.agent_color(agent)) - .add_modifier(Modifier::BOLD), + Style::default().fg(theme.agent_color(agent)).add_modifier(Modifier::BOLD), ), ])); for b in &m.blocks { @@ -332,12 +326,8 @@ pub fn match_lines(lines: &[Line<'static>], terms: &[String]) -> Vec { .iter() .enumerate() .filter_map(|(i, l)| { - let text: String = l - .spans - .iter() - .map(|s| s.content.as_ref()) - .collect::() - .to_lowercase(); + let text: String = + l.spans.iter().map(|s| s.content.as_ref()).collect::().to_lowercase(); terms.iter().any(|t| text.contains(t.as_str())).then_some(i) }) .collect() @@ -419,18 +409,12 @@ mod tests { fn msgs() -> Vec { vec![ - Message { - role: Role::User, - blocks: vec![Block::Prose("fix the auth bug".into())], - }, + Message { role: Role::User, blocks: vec![Block::Prose("fix the auth bug".into())] }, Message { role: Role::Agent, blocks: vec![ Block::Prose("the refresh token dropped".into()), - Block::Code { - lang: Some("rust".into()), - text: "fn refresh() {}".into(), - }, + Block::Code { lang: Some("rust".into()), text: "fn refresh() {}".into() }, ], }, ] @@ -442,12 +426,7 @@ mod tests { let lines = render_transcript(&msgs(), "", crate::core::AgentId::Claude, &theme); let joined: String = lines .iter() - .map(|l| { - l.spans - .iter() - .map(|s| s.content.as_ref()) - .collect::() - }) + .map(|l| l.spans.iter().map(|s| s.content.as_ref()).collect::()) .collect::>() .join("\n"); assert!(joined.contains("› fix the auth bug")); @@ -466,12 +445,8 @@ mod tests { #[test] fn filter_tokens_are_not_match_terms() { let theme = crate::tui::theme::Theme::default(); - let lines = render_transcript( - &msgs(), - "agent:claude", - crate::core::AgentId::Claude, - &theme, - ); + let lines = + render_transcript(&msgs(), "agent:claude", crate::core::AgentId::Claude, &theme); assert_eq!(first_match_line(&lines, "agent:claude"), None); } @@ -481,9 +456,7 @@ mod tests { let lines = render_transcript(&msgs(), "auth", crate::core::AgentId::Claude, &theme); let any_reverse = lines.iter().flat_map(|l| &l.spans).any(|s| { s.content.contains("auth") - && s.style - .add_modifier - .contains(ratatui::style::Modifier::REVERSED) + && s.style.add_modifier.contains(ratatui::style::Modifier::REVERSED) }); assert!(any_reverse); } @@ -494,21 +467,14 @@ mod tests { let lines = render_indexed_fallback("refresh token failed", "token", &theme); let joined: String = lines .iter() - .map(|l| { - l.spans - .iter() - .map(|s| s.content.as_ref()) - .collect::() - }) + .map(|l| l.spans.iter().map(|s| s.content.as_ref()).collect::()) .collect::>() .join("\n"); assert!(joined.contains("source unavailable")); assert!(joined.contains("refresh token failed")); let any_reverse = lines.iter().flat_map(|l| &l.spans).any(|s| { s.content.contains("token") - && s.style - .add_modifier - .contains(ratatui::style::Modifier::REVERSED) + && s.style.add_modifier.contains(ratatui::style::Modifier::REVERSED) }); assert!(any_reverse); } @@ -539,12 +505,7 @@ mod tests { let lines = render_prose("- one\n- two", &theme); let joined: String = lines .iter() - .map(|l| { - l.spans - .iter() - .map(|s| s.content.as_ref()) - .collect::() - }) + .map(|l| l.spans.iter().map(|s| s.content.as_ref()).collect::()) .collect::>() .join("\n"); assert!(joined.contains("• one")); @@ -557,12 +518,7 @@ mod tests { let lines = render_prose("- one\n - two", &theme); let rendered: Vec = lines .iter() - .map(|l| { - l.spans - .iter() - .map(|s| s.content.as_ref()) - .collect::() - }) + .map(|l| l.spans.iter().map(|s| s.content.as_ref()).collect::()) .collect(); assert!(rendered.iter().any(|line| line == "• one")); assert!(rendered.iter().any(|line| line == " • two")); @@ -574,9 +530,7 @@ mod tests { let lines = render_prose("**strong**", &theme); let bold = lines.iter().flat_map(|l| &l.spans).any(|s| { s.content.contains("strong") - && s.style - .add_modifier - .contains(ratatui::style::Modifier::BOLD) + && s.style.add_modifier.contains(ratatui::style::Modifier::BOLD) }); assert!(bold); } @@ -640,9 +594,7 @@ mod tests { // did not panic; and the ASCII term is still reverse-highlighted let any_rev = lines.iter().flat_map(|l| &l.spans).any(|s| { s.content.contains("latte") - && s.style - .add_modifier - .contains(ratatui::style::Modifier::REVERSED) + && s.style.add_modifier.contains(ratatui::style::Modifier::REVERSED) }); assert!(any_rev); } diff --git a/src/tui/results_list.rs b/src/tui/results_list.rs index 782ede8..e9c062a 100644 --- a/src/tui/results_list.rs +++ b/src/tui/results_list.rs @@ -3,7 +3,7 @@ use crate::core::SessionSummary; use crate::enrich::{EnrichKind, Enricher}; -use crate::tui::columns::{display_width, fit, solve_layout_with_desired, Column}; +use crate::tui::columns::{Column, display_width, fit, solve_layout_with_desired}; use crate::tui::theme::Theme; use crate::tui::view::rel_time; use ratatui::style::{Modifier, Style}; @@ -22,24 +22,16 @@ fn cell( theme: &Theme, ) -> (String, Style) { match col.id { - "agent" => ( - s.agent.badge().to_string(), - Style::default().fg(theme.agent_color(s.agent)), - ), + "agent" => (s.agent.badge().to_string(), Style::default().fg(theme.agent_color(s.agent))), "title" => (s.title.clone(), Style::default()), "msgs" => ( - if s.message_count > 0 { - s.message_count.to_string() - } else { - "-".into() - }, + if s.message_count > 0 { s.message_count.to_string() } else { "-".into() }, Style::default().fg(theme.muted), ), "time" => (rel_time(s.timestamp, now), Style::default().fg(theme.muted)), - "model" => ( - s.model.clone().unwrap_or_else(|| "-".into()), - Style::default().fg(theme.muted), - ), + "model" => { + (s.model.clone().unwrap_or_else(|| "-".into()), Style::default().fg(theme.muted)) + } other => enrichment_cell(other, s, enrichers, resolved, frame, theme), } } @@ -93,10 +85,7 @@ fn desired_widths( now: i64, frame: u64, ) -> Vec { - let mut widths: Vec = columns - .iter() - .map(|col| display_width(col.header) as u16) - .collect(); + let mut widths: Vec = columns.iter().map(|col| display_width(col.header) as u16).collect(); // Widths depend only on cell text, not style, so the theme is irrelevant // here; build one default instead of one per cell. @@ -147,15 +136,8 @@ pub fn session_row( session.archived, )) } else { - let (text, style) = cell( - session, - col, - ctx.enrichers, - ctx.resolved, - ctx.now, - ctx.frame, - ctx.theme, - ); + let (text, style) = + cell(session, col, ctx.enrichers, ctx.resolved, ctx.now, ctx.frame, ctx.theme); Cell::from(Span::styled(fit(&text, width, col.align), style)) } }) @@ -163,11 +145,7 @@ pub fn session_row( let row = Row::new(cells).height(1); // Dim the whole row for archived sessions; the selection highlight still // layers on top via the Table's row_highlight_style. - if session.archived { - row.style(Style::default().add_modifier(Modifier::DIM)) - } else { - row - } + if session.archived { row.style(Style::default().add_modifier(Modifier::DIM)) } else { row } } /// Build the TITLE line, reverse-highlighting any query-term matches by @@ -180,17 +158,9 @@ fn title_line( theme: &Theme, archived: bool, ) -> Line<'static> { - let marker_width = if archived { - ARCHIVED_MARKER.len() as u16 - } else { - 0 - }; + let marker_width = if archived { ARCHIVED_MARKER.len() as u16 } else { 0 }; let title_width = width.saturating_sub(marker_width); - let base = Line::from(Span::raw(fit( - title, - title_width, - crate::tui::columns::Align::Left, - ))); + let base = Line::from(Span::raw(fit(title, title_width, crate::tui::columns::Align::Left))); let highlighted = if terms.is_empty() { base } else { @@ -199,10 +169,7 @@ fn title_line( if !archived { return highlighted; } - let mut spans = vec![Span::styled( - ARCHIVED_MARKER, - Style::default().fg(theme.muted), - )]; + let mut spans = vec![Span::styled(ARCHIVED_MARKER, Style::default().fg(theme.muted))]; spans.extend(highlighted.spans); Line::from(spans) } @@ -210,10 +177,8 @@ fn title_line( /// Build the muted header row for the kept columns. Styled at the Row level so /// every header cell shares the muted color. pub fn header_row(layout: &[(usize, u16)], columns: &[Column], theme: &Theme) -> Row<'static> { - let cells: Vec> = layout - .iter() - .map(|&(ci, _)| Cell::from(columns[ci].header)) - .collect(); + let cells: Vec> = + layout.iter().map(|&(ci, _)| Cell::from(columns[ci].header)).collect(); Row::new(cells).style(Style::default().fg(theme.muted)) } @@ -243,15 +208,8 @@ mod tests { let enr: Vec> = vec![Box::new(BranchEnricher), Box::new(RepoEnricher)]; let resolved = HashMap::new(); let row_data = sess(); - let layout = layout_for_rows( - &cols, - 120, - std::slice::from_ref(&row_data), - &enr, - &resolved, - 3600, - 0, - ); + let layout = + layout_for_rows(&cols, 120, std::slice::from_ref(&row_data), &enr, &resolved, 3600, 0); let ctx = RowCtx { enrichers: &enr, resolved: &resolved, @@ -290,13 +248,7 @@ mod tests { let enr: Vec> = vec![Box::new(BranchEnricher), Box::new(RepoEnricher)]; let resolved = HashMap::new(); let layout = layout_for_rows(&cols, 120, &[row], &enr, &resolved, 0, 0); - let width = |id| { - layout - .iter() - .find(|&&(i, _)| cols[i].id == id) - .map(|&(_, w)| w) - .unwrap() - }; + let width = |id| layout.iter().find(|&&(i, _)| cols[i].id == id).map(|&(_, w)| w).unwrap(); assert_eq!(width("repo"), "responsive-editor".len() as u16); assert_eq!(width("branch"), "workflow/ghostty-terminal".len() as u16); @@ -354,9 +306,6 @@ mod tests { assert_eq!(first.style.fg, Some(theme.muted)); // Non-archived titles carry no marker. let plain = super::title_line("fix auth", 40, &[], &theme, false); - assert_ne!( - plain.spans.first().map(|s| s.content.as_ref()), - Some(super::ARCHIVED_MARKER) - ); + assert_ne!(plain.spans.first().map(|s| s.content.as_ref()), Some(super::ARCHIVED_MARKER)); } } diff --git a/src/tui/toolbar.rs b/src/tui/toolbar.rs index e7fd1e3..d61bb39 100644 --- a/src/tui/toolbar.rs +++ b/src/tui/toolbar.rs @@ -82,22 +82,10 @@ pub fn line( ) -> Line<'static> { let mut spans: Vec> = vec![Span::raw(" ")]; if has_repo { - push_control( - &mut spans, - "Scope", - scope.label(), - focus == Focus::Scope, - theme, - ); + push_control(&mut spans, "Scope", scope.label(), focus == Focus::Scope, theme); spans.push(Span::raw(" ")); } - push_control( - &mut spans, - "Sort", - sort.label(), - focus == Focus::Sort, - theme, - ); + push_control(&mut spans, "Sort", sort.label(), focus == Focus::Sort, theme); Line::from(spans) } @@ -108,15 +96,9 @@ fn push_control( focused: bool, theme: &Theme, ) { - spans.push(Span::styled( - format!("{name}: "), - Style::default().fg(theme.muted), - )); + spans.push(Span::styled(format!("{name}: "), Style::default().fg(theme.muted))); let value_style = if focused { - Style::default() - .fg(theme.selection_fg) - .bg(theme.accent) - .add_modifier(Modifier::BOLD) + Style::default().fg(theme.selection_fg).bg(theme.accent).add_modifier(Modifier::BOLD) } else { Style::default().fg(theme.accent) }; @@ -154,20 +136,10 @@ mod tests { #[test] fn line_hides_scope_without_repo() { let theme = Theme::default(); - let with = render_text(line( - Scope::ThisRepo, - SortOrder::Recent, - Focus::Query, - true, - &theme, - )); - let without = render_text(line( - Scope::ThisRepo, - SortOrder::Recent, - Focus::Query, - false, - &theme, - )); + let with = + render_text(line(Scope::ThisRepo, SortOrder::Recent, Focus::Query, true, &theme)); + let without = + render_text(line(Scope::ThisRepo, SortOrder::Recent, Focus::Query, false, &theme)); assert!(with.contains("Scope")); assert!(with.contains("This repo")); assert!(!without.contains("Scope")); diff --git a/src/tui/view.rs b/src/tui/view.rs index d3b6ff3..089dad4 100644 --- a/src/tui/view.rs +++ b/src/tui/view.rs @@ -3,12 +3,12 @@ use crate::enrich::{BranchEnricher, Enricher}; use crate::tui::columns::Column; use crate::tui::modal; use crate::tui::theme::Theme; -use crate::tui::{help, results_list, App}; +use crate::tui::{App, help, results_list}; +use ratatui::Frame; use ratatui::layout::{Alignment, Constraint, Flex, Layout, Position, Rect}; use ratatui::style::{Modifier, Style, Stylize}; use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, Borders, Padding, Paragraph, Row, Table, TableState, Wrap}; -use ratatui::Frame; use std::collections::HashMap; use std::ops::Range; @@ -90,10 +90,7 @@ pub fn render(f: &mut Frame, app: &App, model: RenderModel<'_>) { // the caret is shown whenever no overlay is covering the input. let mut header = Line::from(vec![ Span::styled(" ❯ ", Style::default().fg(model.theme.accent)), - Span::styled( - app.query().to_string(), - Style::default().fg(model.theme.selection_fg), - ), + Span::styled(app.query().to_string(), Style::default().fg(model.theme.selection_fg)), Span::raw(format!(" {}/{}", pos, total)).fg(model.theme.muted), ]); if let Some(count) = app.indexing() { @@ -161,12 +158,8 @@ pub fn render(f: &mut Frame, app: &App, model: RenderModel<'_>) { .alignment(Alignment::Center); // Vertically center the single line within the list area. let y = list_area.y.saturating_add(list_area.height / 2); - let centered = Rect { - x: list_area.x, - y, - width: list_area.width, - height: 1.min(list_area.height), - }; + let centered = + Rect { x: list_area.x, y, width: list_area.width, height: 1.min(list_area.height) }; f.render_widget(para, centered); } else { let layout = results_list::layout_for_rows( @@ -257,11 +250,7 @@ pub fn render(f: &mut Frame, app: &App, model: RenderModel<'_>) { .flex(Flex::SpaceBetween) .areas(footer_area); f.render_widget( - Paragraph::new(footer_hints_line( - app.keymap(), - app.search_mode(), - &model.theme, - )), + Paragraph::new(footer_hints_line(app.keymap(), app.search_mode(), &model.theme)), hints_area, ); f.render_widget( @@ -305,15 +294,10 @@ fn footer_hints_line( if i == 0 { spans.push(Span::styled( hint.clone(), - Style::default() - .fg(theme.accent) - .add_modifier(Modifier::BOLD), + Style::default().fg(theme.accent).add_modifier(Modifier::BOLD), )); } else { - spans.push(Span::styled( - format!(" · {hint}"), - Style::default().fg(theme.muted), - )); + spans.push(Span::styled(format!(" · {hint}"), Style::default().fg(theme.muted))); } } Line::from(spans) @@ -325,18 +309,12 @@ fn footer_status_line(status: &StatusLine, theme: &Theme) -> Line<'static> { let mut spans = Vec::new(); let push_sep = |spans: &mut Vec>| { if !spans.is_empty() { - spans.push(Span::styled( - " · ".to_string(), - Style::default().fg(theme.muted), - )); + spans.push(Span::styled(" · ".to_string(), Style::default().fg(theme.muted))); } }; if let Some(sync) = status.sync.as_deref().filter(|s| !s.is_empty()) { push_sep(&mut spans); - spans.push(Span::styled( - sync.to_string(), - Style::default().fg(theme.muted), - )); + spans.push(Span::styled(sync.to_string(), Style::default().fg(theme.muted))); } if status.pr_pending > 0 { push_sep(&mut spans); @@ -347,17 +325,11 @@ fn footer_status_line(status: &StatusLine, theme: &Theme) -> Line<'static> { } if let Some(filters) = status.filters.as_deref().filter(|s| !s.is_empty()) { push_sep(&mut spans); - spans.push(Span::styled( - format!("filters {filters}"), - Style::default().fg(theme.muted), - )); + spans.push(Span::styled(format!("filters {filters}"), Style::default().fg(theme.muted))); } if let Some(warning) = status.warning.as_deref().filter(|s| !s.is_empty()) { push_sep(&mut spans); - spans.push(Span::styled( - warning.to_string(), - Style::default().fg(theme.warning), - )); + spans.push(Span::styled(warning.to_string(), Style::default().fg(theme.warning))); } Line::from(spans) } @@ -365,11 +337,8 @@ fn footer_status_line(status: &StatusLine, theme: &Theme) -> Line<'static> { /// Display width of the rendered status line, used to size the right footer /// region so the status is never clipped. fn footer_status_width(status: &StatusLine, theme: &Theme) -> u16 { - let text: String = footer_status_line(status, theme) - .spans - .iter() - .map(|s| s.content.as_ref()) - .collect(); + let text: String = + footer_status_line(status, theme).spans.iter().map(|s| s.content.as_ref()).collect(); crate::tui::columns::display_width(&text).min(u16::MAX as usize) as u16 } @@ -402,14 +371,8 @@ fn preview_header_lines( let badge = s.agent.badge(); let branch = BranchEnricher.resolve(s).map(|v| v.text); - let pr = resolved - .get(&(s.document_key(), "pr")) - .and_then(|v| v.as_deref()); - let msgs = if s.message_count > 0 { - Some(format!("{} msgs", s.message_count)) - } else { - None - }; + let pr = resolved.get(&(s.document_key(), "pr")).and_then(|v| v.as_deref()); + let msgs = if s.message_count > 0 { Some(format!("{} msgs", s.message_count)) } else { None }; let time = rel_time(s.timestamp, now); let dw = crate::tui::columns::display_width; @@ -439,9 +402,7 @@ fn preview_header_lines( }; let dir_text = crate::tui::columns::fit_end(&s.directory, dir_budget as u16); - let branch_text = branch - .as_ref() - .map(|b| modal::fit_for_modal(b, branch_budget)); + let branch_text = branch.as_ref().map(|b| modal::fit_for_modal(b, branch_budget)); let push_sep = |spans: &mut Vec>| { spans.push(Span::styled(SEP, sep_style)); @@ -450,9 +411,7 @@ fn preview_header_lines( meta.push(Span::styled( badge, - Style::default() - .fg(theme.agent_color(s.agent)) - .add_modifier(Modifier::BOLD), + Style::default().fg(theme.agent_color(s.agent)).add_modifier(Modifier::BOLD), )); if !dir_text.is_empty() { @@ -467,10 +426,7 @@ fn preview_header_lines( if let Some(pr) = pr { push_sep(&mut meta); - meta.push(Span::styled( - pr.to_string(), - Style::default().fg(theme.accent), - )); + meta.push(Span::styled(pr.to_string(), Style::default().fg(theme.accent))); } if let Some(msgs) = msgs { @@ -493,10 +449,7 @@ pub fn visible_result_range(total: usize, selected: usize, height: usize) -> Ran } let len = height.min(total); let max_start = total - len; - let start = selected - .saturating_add(1) - .saturating_sub(len) - .min(max_start); + let start = selected.saturating_add(1).saturating_sub(len).min(max_start); start..start + len } @@ -506,8 +459,8 @@ mod tests { use crate::core::{AgentId, SessionSummary}; use crate::tui::toolbar::Scope; use crate::tui::{App, SearchMode}; - use ratatui::backend::TestBackend; use ratatui::Terminal; + use ratatui::backend::TestBackend; /// Render `app` to an 100x12 test terminal and return the flattened text. fn render_to_text(app: &App) -> String { @@ -536,12 +489,7 @@ mod tests { ) }) .unwrap(); - term.backend() - .buffer() - .content() - .iter() - .map(|c| c.symbol()) - .collect() + term.backend().buffer().content().iter().map(|c| c.symbol()).collect() } #[test] @@ -678,13 +626,7 @@ mod tests { ) }) .unwrap(); - let text: String = term - .backend() - .buffer() - .content() - .iter() - .map(|c| c.symbol()) - .collect(); + let text: String = term.backend().buffer().content().iter().map(|c| c.symbol()).collect(); assert!(text.contains("fix auth")); assert!(!text.contains("Type to search")); assert!(!text.contains("No sessions match")); @@ -732,10 +674,7 @@ mod tests { .unwrap(); let buf = term.backend().buffer().clone(); let text: String = buf.content().iter().map(|c| c.symbol()).collect(); - assert!( - text.contains("arch fix auth"), - "archived marker prefixes title" - ); + assert!(text.contains("arch fix auth"), "archived marker prefixes title"); // The title cell on the archived row must carry the DIM modifier. let dimmed = buf .content() @@ -808,10 +747,8 @@ mod tests { }]); let enr: Vec> = vec![Box::new(RepoEnricher), Box::new(BranchEnricher)]; let resolved: HashMap<(String, &'static str), Option> = HashMap::new(); - let transcript = vec![Message { - role: Role::User, - blocks: vec![Block::Prose("fix auth".into())], - }]; + let transcript = + vec![Message { role: Role::User, blocks: vec![Block::Prose("fix auth".into())] }]; let lines = crate::tui::preview::render_transcript( &transcript, @@ -966,22 +903,14 @@ mod tests { let marker = SELECTION_MARKER.trim(); let has_marker = buf.content().iter().any(|c| c.symbol() == marker); assert!(has_marker, "selection marker should be rendered"); - let has_sel_bg = buf - .content() - .iter() - .any(|c| c.bg == crate::tui::theme::Theme::default().selection_bg); - assert!( - has_sel_bg, - "selected row should carry the selection background" - ); + let has_sel_bg = + buf.content().iter().any(|c| c.bg == crate::tui::theme::Theme::default().selection_bg); + assert!(has_sel_bg, "selected row should carry the selection background"); let has_sel_fg = buf.content().iter().any(|c| { c.fg == crate::tui::theme::Theme::default().selection_fg && c.bg == crate::tui::theme::Theme::default().selection_bg }); - assert!( - has_sel_fg, - "selected row text should use the selection fg over selection bg" - ); + assert!(has_sel_fg, "selected row text should use the selection fg over selection bg"); } #[test] @@ -1014,13 +943,7 @@ mod tests { ) }) .unwrap(); - let text: String = term - .backend() - .buffer() - .content() - .iter() - .map(|c| c.symbol()) - .collect(); + let text: String = term.backend().buffer().content().iter().map(|c| c.symbol()).collect(); assert!(text.contains("type to search")); // Esc hint derives from the bindings table label ("clear query / quit"); // assert on a stable substring rather than exact spacing. @@ -1073,12 +996,7 @@ mod tests { ) }) .unwrap(); - term.backend() - .buffer() - .content() - .iter() - .map(|c| c.symbol()) - .collect() + term.backend().buffer().content().iter().map(|c| c.symbol()).collect() } #[test] @@ -1192,22 +1110,10 @@ mod tests { ) }) .unwrap(); - let text: String = term - .backend() - .buffer() - .content() - .iter() - .map(|c| c.symbol()) - .collect(); - assert!( - text.contains("TITLE"), - "TITLE header must survive narrow width" - ); + let text: String = term.backend().buffer().content().iter().map(|c| c.symbol()).collect(); + assert!(text.contains("TITLE"), "TITLE header must survive narrow width"); assert!(text.contains("fix auth"), "title value must survive"); - assert!( - !text.contains("PR"), - "lowest-priority PR column should be dropped" - ); + assert!(!text.contains("PR"), "lowest-priority PR column should be dropped"); } #[test] @@ -1253,14 +1159,8 @@ mod tests { }) .unwrap(); let buf = term.backend().buffer(); - let any_reversed = buf - .content() - .iter() - .any(|c| c.modifier.contains(Modifier::REVERSED)); - assert!( - any_reversed, - "matched query term in title should render reversed" - ); + let any_reversed = buf.content().iter().any(|c| c.modifier.contains(Modifier::REVERSED)); + assert!(any_reversed, "matched query term in title should render reversed"); } #[test] @@ -1375,11 +1275,7 @@ mod tests { let buf = term.backend().buffer().clone(); let overlay_bg = crate::tui::theme::Theme::default().overlay_bg; - assert_eq!( - buf[(0, 0)].bg, - overlay_bg, - "backdrop must set bg, not fg-only" - ); + assert_eq!(buf[(0, 0)].bg, overlay_bg, "backdrop must set bg, not fg-only"); } #[test] @@ -1481,10 +1377,7 @@ mod tests { .unwrap(); let buf = term.backend().buffer().clone(); let text: String = buf.content().iter().map(|c| c.symbol()).collect(); - assert!( - text.contains("too small"), - "expected too-small notice, got: {text:?}" - ); + assert!(text.contains("too small"), "expected too-small notice, got: {text:?}"); } #[test] @@ -1656,10 +1549,7 @@ mod tests { .unwrap(); let buf = term.backend().buffer().clone(); let text: String = buf.content().iter().map(|c| c.symbol()).collect(); - assert!( - text.contains("WARNTOKEN"), - "warning must survive narrow footer, got: {text:?}" - ); + assert!(text.contains("WARNTOKEN"), "warning must survive narrow footer, got: {text:?}"); } #[test] @@ -1731,11 +1621,7 @@ mod tests { let enr: Vec> = vec![]; let resolved: HashMap<(String, &'static str), Option> = HashMap::new(); let cols = crate::tui::columns::default_columns(); - let command = vec![ - "claude".to_string(), - "--resume".to_string(), - "a".to_string(), - ]; + let command = vec!["claude".to_string(), "--resume".to_string(), "a".to_string()]; let backend = TestBackend::new(180, 16); let mut term = Terminal::new(backend).unwrap(); @@ -1759,14 +1645,8 @@ mod tests { .unwrap(); let buf = term.backend().buffer().clone(); let text: String = buf.content().iter().map(|c| c.symbol()).collect(); - assert!( - text.contains("does not exist"), - "missing-dir warning should appear: {text:?}" - ); - assert!( - text.contains("Missing"), - "Missing label should appear: {text:?}" - ); + assert!(text.contains("does not exist"), "missing-dir warning should appear: {text:?}"); + assert!(text.contains("Missing"), "Missing label should appear: {text:?}"); } #[test] @@ -1789,11 +1669,7 @@ mod tests { let enr: Vec> = vec![]; let resolved: HashMap<(String, &'static str), Option> = HashMap::new(); let cols = crate::tui::columns::default_columns(); - let command = vec![ - "claude".to_string(), - "--resume".to_string(), - "a".to_string(), - ]; + let command = vec!["claude".to_string(), "--resume".to_string(), "a".to_string()]; let backend = TestBackend::new(180, 16); let mut term = Terminal::new(backend).unwrap(); diff --git a/src/update.rs b/src/update.rs index 2c8a933..7394750 100644 --- a/src/update.rs +++ b/src/update.rs @@ -25,10 +25,7 @@ struct UpdateCache { } fn now_secs() -> u64 { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|d| d.as_secs()) - .unwrap_or(0) + SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0) } fn detect_install_method_from_path(path: &Path) -> InstallMethod { @@ -70,11 +67,7 @@ fn build_http_agent() -> ureq::Agent { .timeout_connect(Some(std::time::Duration::from_secs(5))) .timeout_recv_body(Some(std::time::Duration::from_secs(5))) .user_agent(concat!("hop/", env!("CARGO_PKG_VERSION"))) - .tls_config( - TlsConfig::builder() - .provider(TlsProvider::NativeTls) - .build(), - ) + .tls_config(TlsConfig::builder().provider(TlsProvider::NativeTls).build()) .build(); ureq::Agent::new_with_config(config) } @@ -107,35 +100,18 @@ pub fn check_for_update(cache_path: &Path) -> Option { let v = fetch_latest_version()?; write_cache( cache_path, - &UpdateCache { - latest_version: v.clone(), - checked_at: now_secs(), - }, + &UpdateCache { latest_version: v.clone(), checked_at: now_secs() }, ); v } } else { let v = fetch_latest_version()?; - write_cache( - cache_path, - &UpdateCache { - latest_version: v.clone(), - checked_at: now_secs(), - }, - ); + write_cache(cache_path, &UpdateCache { latest_version: v.clone(), checked_at: now_secs() }); v }; let latest = semver::Version::parse(&latest_str).ok()?; - if latest > current { - Some(UpdateAvailable { - current, - latest, - install_method, - }) - } else { - None - } + if latest > current { Some(UpdateAvailable { current, latest, install_method }) } else { None } } pub fn upgrade_message(info: &UpdateAvailable) -> String { @@ -158,46 +134,31 @@ mod tests { #[test] fn detect_homebrew_cellar() { let path = PathBuf::from("/opt/homebrew/Cellar/hop/0.2.3/bin/hop"); - assert_eq!( - detect_install_method_from_path(&path), - InstallMethod::Homebrew - ); + assert_eq!(detect_install_method_from_path(&path), InstallMethod::Homebrew); } #[test] fn detect_homebrew_prefix() { let path = PathBuf::from("/opt/homebrew/bin/hop"); - assert_eq!( - detect_install_method_from_path(&path), - InstallMethod::Homebrew - ); + assert_eq!(detect_install_method_from_path(&path), InstallMethod::Homebrew); } #[test] fn detect_homebrew_linux_prefix() { let path = PathBuf::from("/home/linuxbrew/.linuxbrew/Cellar/hop/0.2.3/bin/hop"); - assert_eq!( - detect_install_method_from_path(&path), - InstallMethod::Homebrew - ); + assert_eq!(detect_install_method_from_path(&path), InstallMethod::Homebrew); } #[test] fn detect_cargo_install() { let path = PathBuf::from("/Users/me/.cargo/bin/hop"); - assert_eq!( - detect_install_method_from_path(&path), - InstallMethod::CargoInstall - ); + assert_eq!(detect_install_method_from_path(&path), InstallMethod::CargoInstall); } #[test] fn detect_unknown() { let path = PathBuf::from("/usr/local/bin/hop"); - assert_eq!( - detect_install_method_from_path(&path), - InstallMethod::Unknown - ); + assert_eq!(detect_install_method_from_path(&path), InstallMethod::Unknown); } #[test] @@ -242,10 +203,7 @@ mod tests { assert!(read_cache(&path).is_none()); - let cache = UpdateCache { - latest_version: "0.3.0".to_string(), - checked_at: now_secs(), - }; + let cache = UpdateCache { latest_version: "0.3.0".to_string(), checked_at: now_secs() }; write_cache(&path, &cache); let loaded = read_cache(&path).unwrap(); @@ -271,10 +229,7 @@ mod tests { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("update_check.json"); - let cache = UpdateCache { - latest_version: "0.0.1".to_string(), - checked_at: now_secs(), - }; + let cache = UpdateCache { latest_version: "0.0.1".to_string(), checked_at: now_secs() }; write_cache(&path, &cache); assert!(check_for_update(&path).is_none()); @@ -285,10 +240,7 @@ mod tests { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("update_check.json"); - let cache = UpdateCache { - latest_version: "99.0.0".to_string(), - checked_at: now_secs(), - }; + let cache = UpdateCache { latest_version: "99.0.0".to_string(), checked_at: now_secs() }; write_cache(&path, &cache); let info = check_for_update(&path).unwrap(); diff --git a/tests/claude_adapter.rs b/tests/claude_adapter.rs index 66ab354..048b06f 100644 --- a/tests/claude_adapter.rs +++ b/tests/claude_adapter.rs @@ -1,12 +1,10 @@ -use hop::adapters::claude::ClaudeAdapter; use hop::adapters::Adapter; +use hop::adapters::claude::ClaudeAdapter; use hop::core::AgentId; use std::path::PathBuf; fn fixture(name: &str) -> PathBuf { - PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .join("tests/fixtures/claude") - .join(name) + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/claude").join(name) } #[test] @@ -21,9 +19,7 @@ fn parses_id_cwd_and_excludes_noise() { // content keeps only real user + assistant text assert!(s.content.contains("fix the auth refresh token bug")); - assert!(s - .content - .contains("The refresh token was dropped on retry.")); + assert!(s.content.contains("The refresh token was dropped on retry.")); // excluded: local-command output, slash-command, tool_result, tool_use, isMeta assert!(!s.content.contains("noise")); assert!(!s.content.contains("/clear")); @@ -91,8 +87,8 @@ fn claude_meta_assistant_line_does_not_override_model() { #[test] fn claude_prefers_ai_title_over_first_prompt() { - use hop::adapters::claude::ClaudeAdapter; use hop::adapters::Adapter; + use hop::adapters::claude::ClaudeAdapter; use std::fs; let tmp = tempfile::tempdir().unwrap(); @@ -118,8 +114,8 @@ fn claude_prefers_ai_title_over_first_prompt() { #[test] fn claude_uses_top_level_summary_title() { - use hop::adapters::claude::ClaudeAdapter; use hop::adapters::Adapter; + use hop::adapters::claude::ClaudeAdapter; use std::fs; let tmp = tempfile::tempdir().unwrap(); @@ -142,8 +138,8 @@ fn claude_uses_top_level_summary_title() { #[test] fn claude_preserves_long_normalized_title() { - use hop::adapters::claude::ClaudeAdapter; use hop::adapters::Adapter; + use hop::adapters::claude::ClaudeAdapter; use std::fs; let tmp = tempfile::tempdir().unwrap(); @@ -168,8 +164,8 @@ fn claude_preserves_long_normalized_title() { #[test] fn claude_captures_branch_and_filters_internals() { - use hop::adapters::claude::ClaudeAdapter; use hop::adapters::Adapter; + use hop::adapters::claude::ClaudeAdapter; use std::fs; let tmp = tempfile::tempdir().unwrap(); @@ -194,8 +190,8 @@ fn claude_captures_branch_and_filters_internals() { #[test] fn claude_transcript_has_roles_and_code() { - use hop::adapters::claude::ClaudeAdapter; use hop::adapters::Adapter; + use hop::adapters::claude::ClaudeAdapter; use hop::core::{Block, Role}; use std::fs; diff --git a/tests/codex_adapter.rs b/tests/codex_adapter.rs index 276c30c..f27f739 100644 --- a/tests/codex_adapter.rs +++ b/tests/codex_adapter.rs @@ -1,20 +1,16 @@ -use hop::adapters::codex::CodexAdapter; use hop::adapters::Adapter; +use hop::adapters::codex::CodexAdapter; use hop::core::AgentId; use std::path::PathBuf; fn fixture(name: &str) -> PathBuf { - PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .join("tests/fixtures/codex") - .join(name) + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/codex").join(name) } #[test] fn parses_meta_clean_text_and_detects_yolo() { let adapter = CodexAdapter::new(PathBuf::from("/unused")); - let s = adapter - .parse(&fixture("rollout-2026-06-04T10-00-00-codexsample.jsonl")) - .unwrap(); + let s = adapter.parse(&fixture("rollout-2026-06-04T10-00-00-codexsample.jsonl")).unwrap(); assert_eq!(s.meta.agent, AgentId::Codex); assert_eq!(s.meta.id, "codexsample"); // from session_meta.payload.id @@ -138,9 +134,7 @@ fn flags_archived_sessions_by_directory() { #[test] fn codex_unarchive_command_wraps_session_id() { let adapter = CodexAdapter::new(PathBuf::from("/unused")); - let s = adapter - .parse(&fixture("rollout-2026-06-04T10-00-00-codexsample.jsonl")) - .unwrap(); + let s = adapter.parse(&fixture("rollout-2026-06-04T10-00-00-codexsample.jsonl")).unwrap(); assert_eq!( adapter.unarchive_command(&s), Some(vec!["codex".into(), "unarchive".into(), s.meta.id.clone()]) @@ -171,23 +165,20 @@ fn scan_keys_by_full_uuid() { #[test] fn codex_captures_branch_and_repo_url() { - use hop::adapters::codex::CodexAdapter; use hop::adapters::Adapter; + use hop::adapters::codex::CodexAdapter; let path = std::path::Path::new("tests/fixtures/codex/rollout-2026-06-04T10-00-00-codexsample.jsonl"); let a = CodexAdapter::new(std::path::PathBuf::from("/unused")); let s = a.parse(path).unwrap(); assert_eq!(s.meta.branch.as_deref(), Some("main")); - assert_eq!( - s.meta.repo_url.as_deref(), - Some("git@github.com:me/web.git") - ); + assert_eq!(s.meta.repo_url.as_deref(), Some("git@github.com:me/web.git")); } #[test] fn codex_transcript_roles_and_filters_internals() { - use hop::adapters::codex::CodexAdapter; use hop::adapters::Adapter; + use hop::adapters::codex::CodexAdapter; use hop::core::Role; let path = std::path::Path::new("tests/fixtures/codex/rollout-2026-06-04T10-00-00-codexsample.jsonl"); @@ -202,9 +193,7 @@ fn codex_transcript_roles_and_filters_internals() { #[test] fn codex_preserves_long_normalized_title() { let tmp = tempfile::tempdir().unwrap(); - let file = tmp - .path() - .join("rollout-2026-06-04T10-00-00-longtitle.jsonl"); + let file = tmp.path().join("rollout-2026-06-04T10-00-00-longtitle.jsonl"); let long_title = "please review the terminal result table and make the repository and branch columns fit their visible content before the title column receives leftover width"; let meta = serde_json::json!({ "type": "session_meta", @@ -232,9 +221,7 @@ fn codex_filters_event_message_tags_and_external_tool_blocks() { use hop::core::{Block, Role}; let tmp = tempfile::tempdir().unwrap(); - let file = tmp - .path() - .join("rollout-2026-06-04T10-00-00-tagsample.jsonl"); + let file = tmp.path().join("rollout-2026-06-04T10-00-00-tagsample.jsonl"); std::fs::write( &file, concat!( @@ -276,9 +263,7 @@ fn paginated_history_uses_response_items_without_duplicates() { use hop::core::Role; let tmp = tempfile::tempdir().unwrap(); - let file = tmp - .path() - .join("rollout-2026-07-11T10-00-00-paginated.jsonl"); + let file = tmp.path().join("rollout-2026-07-11T10-00-00-paginated.jsonl"); let lines = [ serde_json::json!({ "type": "session_meta", @@ -333,11 +318,7 @@ fn paginated_history_uses_response_items_without_duplicates() { ]; std::fs::write( &file, - lines - .iter() - .map(serde_json::Value::to_string) - .collect::>() - .join("\n"), + lines.iter().map(serde_json::Value::to_string).collect::>().join("\n"), ) .unwrap(); @@ -361,9 +342,8 @@ fn paginated_history_uses_response_items_without_duplicates() { #[test] fn history_mode_defaults_and_empty_preferred_source_falls_back() { fn write_session(path: &std::path::Path, history_mode: Option<&str>, records: &str) { - let mode = history_mode - .map(|value| format!(r#", "history_mode": "{value}""#)) - .unwrap_or_default(); + let mode = + history_mode.map(|value| format!(r#", "history_mode": "{value}""#)).unwrap_or_default(); std::fs::write( path, format!( @@ -387,18 +367,15 @@ fn history_mode_defaults_and_empty_preferred_source_falls_back() { ), ); - let paginated_fallback = tmp - .path() - .join("rollout-2026-07-11T10-00-00-paginated-fallback.jsonl"); + let paginated_fallback = + tmp.path().join("rollout-2026-07-11T10-00-00-paginated-fallback.jsonl"); write_session( &paginated_fallback, Some("paginated"), r#"{"type":"event_msg","payload":{"type":"user_message","message":"event fallback"}}"#, ); - let legacy_fallback = tmp - .path() - .join("rollout-2026-07-11T10-00-00-legacy-fallback.jsonl"); + let legacy_fallback = tmp.path().join("rollout-2026-07-11T10-00-00-legacy-fallback.jsonl"); write_session( &legacy_fallback, Some("future-mode"), @@ -407,14 +384,8 @@ fn history_mode_defaults_and_empty_preferred_source_falls_back() { let adapter = CodexAdapter::new(PathBuf::from("/unused")); assert_eq!(adapter.parse(&legacy).unwrap().meta.title, "legacy wins"); - assert_eq!( - adapter.parse(&paginated_fallback).unwrap().meta.title, - "event fallback" - ); - assert_eq!( - adapter.parse(&legacy_fallback).unwrap().meta.title, - "response fallback" - ); + assert_eq!(adapter.parse(&paginated_fallback).unwrap().meta.title, "event fallback"); + assert_eq!(adapter.parse(&legacy_fallback).unwrap().meta.title, "response fallback"); } #[test] @@ -434,7 +405,8 @@ fn codex_filters_all_injected_context_blocks_and_request_prefix() { ]; let mut message = String::new(); for tag in tags { - message.push_str(&format!("<{tag}>hidden {tag}\n")); + use std::fmt::Write as _; + writeln!(message, "<{tag}>hidden {tag}").unwrap(); } message.push_str("## My request for Codex:\nkeep this request"); @@ -485,11 +457,7 @@ fn codex_title_skips_review_mode_boilerplate() { ]; std::fs::write( &file, - records - .iter() - .map(serde_json::Value::to_string) - .collect::>() - .join("\n"), + records.iter().map(serde_json::Value::to_string).collect::>().join("\n"), ) .unwrap(); @@ -537,21 +505,13 @@ fn scans_and_parses_compressed_rollouts_and_prefers_plain_siblings() { assert_eq!(session.meta.id, "compressed"); assert_eq!(session.meta.title, "from compressed"); assert!(session.meta.archived); - assert_eq!( - adapter - .transcript(&scanned["compressed"].path) - .unwrap() - .len(), - 1 - ); + assert_eq!(adapter.transcript(&scanned["compressed"].path).unwrap().len(), 1); } #[test] fn corrupt_compressed_rollout_is_a_parse_error() { let tmp = tempfile::tempdir().unwrap(); - let file = tmp - .path() - .join("rollout-2026-07-11T10-00-00-corrupt.jsonl.zst"); + let file = tmp.path().join("rollout-2026-07-11T10-00-00-corrupt.jsonl.zst"); std::fs::write(&file, b"not a zstd stream").unwrap(); let adapter = CodexAdapter::new(PathBuf::from("/unused")); diff --git a/tests/cursor_adapter.rs b/tests/cursor_adapter.rs index d4c04c6..106caf0 100644 --- a/tests/cursor_adapter.rs +++ b/tests/cursor_adapter.rs @@ -1,14 +1,12 @@ -use hop::adapters::cursor::CursorAdapter; use hop::adapters::Adapter; +use hop::adapters::cursor::CursorAdapter; use hop::core::AgentId; use std::path::PathBuf; const UUID: &str = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"; fn fixture(name: &str) -> PathBuf { - PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .join("tests/fixtures/cursor") - .join(name) + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/cursor").join(name) } /// Build the standard tree under `root`: @@ -21,11 +19,7 @@ fn setup_tree(root: &std::path::Path) -> PathBuf { let canonical = conv_dir.join(format!("{UUID}.jsonl")); std::fs::copy(fixture("sample.jsonl"), &canonical).unwrap(); - std::fs::copy( - fixture("hook-sidecar.jsonl"), - conv_dir.join("hook-sidecar.jsonl"), - ) - .unwrap(); + std::fs::copy(fixture("hook-sidecar.jsonl"), conv_dir.join("hook-sidecar.jsonl")).unwrap(); canonical } @@ -129,14 +123,9 @@ fn enriches_from_store_db() { std::fs::create_dir_all(&db_dir).unwrap(); let conn = rusqlite::Connection::open(db_dir.join("store.db")).unwrap(); - conn.execute("CREATE TABLE meta (key TEXT PRIMARY KEY, value TEXT)", []) - .unwrap(); + conn.execute("CREATE TABLE meta (key TEXT PRIMARY KEY, value TEXT)", []).unwrap(); let json = r#"{"name":"My Chat","createdAt":1700000000000,"isRunEverything":true}"#; - conn.execute( - "INSERT INTO meta VALUES ('0', ?1)", - [&hex::encode(json.as_bytes())], - ) - .unwrap(); + conn.execute("INSERT INTO meta VALUES ('0', ?1)", [&hex::encode(json.as_bytes())]).unwrap(); drop(conn); let adapter = CursorAdapter::new(projects_dir); @@ -160,11 +149,8 @@ fn reads_cwd_from_worker_log() { // Write worker.log with a path that contains a space let project_dir = tmp.path().join(slug); - std::fs::write( - project_dir.join("worker.log"), - "info workspacePath=/tmp/my workspace\n", - ) - .unwrap(); + std::fs::write(project_dir.join("worker.log"), "info workspacePath=/tmp/my workspace\n") + .unwrap(); let adapter = CursorAdapter::new(tmp.path().to_path_buf()); let s = adapter.parse(&canonical).unwrap(); diff --git a/tests/git_remote_url.rs b/tests/git_remote_url.rs index 95e539e..15a9977 100644 --- a/tests/git_remote_url.rs +++ b/tests/git_remote_url.rs @@ -3,11 +3,7 @@ use std::process::Command; use tempfile::TempDir; fn init_repo_with_remote(dir: &std::path::Path, remote: &str) { - Command::new("git") - .args(["init"]) - .current_dir(dir) - .output() - .unwrap(); + Command::new("git").args(["init"]).current_dir(dir).output().unwrap(); Command::new("git") .args(["remote", "add", "origin", remote]) .current_dir(dir) @@ -20,10 +16,7 @@ fn resolves_remote_in_existing_repo() { let tmp = TempDir::new().unwrap(); let url = "https://github.com/test/repo.git"; init_repo_with_remote(tmp.path(), url); - assert_eq!( - git_remote_url(&tmp.path().to_string_lossy()), - Some(url.to_string()), - ); + assert_eq!(git_remote_url(&tmp.path().to_string_lossy()), Some(url.to_string()),); } #[test] @@ -37,10 +30,7 @@ fn walks_up_from_missing_subdirectory() { let missing = tmp.path().join(".worktrees").join("wt-1"); assert!(!missing.exists()); - assert_eq!( - git_remote_url(&missing.to_string_lossy()), - Some(url.to_string()), - ); + assert_eq!(git_remote_url(&missing.to_string_lossy()), Some(url.to_string()),); } #[test] diff --git a/tests/index_sync.rs b/tests/index_sync.rs index f697dca..915eff8 100644 --- a/tests/index_sync.rs +++ b/tests/index_sync.rs @@ -1,5 +1,5 @@ use hop::core::{AgentId, ScanEntry, Session, SessionSummary}; -use hop::index::{diff, diff_authoritative, SearchIndex}; +use hop::index::{SearchIndex, diff, diff_authoritative}; use hop::query; use std::collections::HashMap; use std::path::PathBuf; @@ -39,22 +39,13 @@ fn build_search_and_reconstruct() { let dir = tempfile::tempdir().unwrap(); let idx = SearchIndex::open_or_create(dir.path()).unwrap(); - let mut w = idx.writer().unwrap(); - idx.upsert( - &mut w, - &sess("a", "auth refresh", "token bug", AgentId::Claude, 100, 1), - ); - idx.upsert( - &mut w, - &sess("b", "unrelated", "nothing here", AgentId::Codex, 90, 1), - ); - w.commit().unwrap(); + idx.upsert(&sess("a", "auth refresh", "token bug", AgentId::Claude, 100, 1)).unwrap(); + idx.upsert(&sess("b", "unrelated", "nothing here", AgentId::Codex, 90, 1)).unwrap(); + idx.commit().unwrap(); idx.reload().unwrap(); let q = query::parse("auth"); - let results = idx - .search(&q, query::SortOrder::Relevance, 1000, 50) - .unwrap(); + let results = idx.search(&q, query::SortOrder::Relevance, 1000, 50).unwrap(); assert_eq!(results.len(), 1); assert_eq!(results[0].id, "a"); assert_eq!(results[0].title, "auth refresh"); @@ -65,22 +56,13 @@ fn build_search_and_reconstruct() { fn exact_ranks_above_fuzzy() { let dir = tempfile::tempdir().unwrap(); let idx = SearchIndex::open_or_create(dir.path()).unwrap(); - let mut w = idx.writer().unwrap(); - idx.upsert( - &mut w, - &sess("exact", "refactor", "refactor", AgentId::Claude, 100, 1), - ); - idx.upsert( - &mut w, - &sess("fuzzy", "refacter", "refacter", AgentId::Claude, 200, 1), - ); - w.commit().unwrap(); + idx.upsert(&sess("exact", "refactor", "refactor", AgentId::Claude, 100, 1)).unwrap(); + idx.upsert(&sess("fuzzy", "refacter", "refacter", AgentId::Claude, 200, 1)).unwrap(); + idx.commit().unwrap(); idx.reload().unwrap(); let q = query::parse("refactor"); - let results = idx - .search(&q, query::SortOrder::Relevance, 1000, 50) - .unwrap(); + let results = idx.search(&q, query::SortOrder::Relevance, 1000, 50).unwrap(); assert_eq!(results[0].id, "exact"); // exact boosted above edit-distance-1 } @@ -88,36 +70,13 @@ fn exact_ranks_above_fuzzy() { fn text_search_breaks_equal_scores_by_recency() { let dir = tempfile::tempdir().unwrap(); let idx = SearchIndex::open_or_create(dir.path()).unwrap(); - let mut w = idx.writer().unwrap(); - idx.upsert( - &mut w, - &sess( - "old", - "shared topic", - "shared topic", - AgentId::Claude, - 100, - 1, - ), - ); - idx.upsert( - &mut w, - &sess( - "new", - "shared topic", - "shared topic", - AgentId::Claude, - 200, - 1, - ), - ); - w.commit().unwrap(); + idx.upsert(&sess("old", "shared topic", "shared topic", AgentId::Claude, 100, 1)).unwrap(); + idx.upsert(&sess("new", "shared topic", "shared topic", AgentId::Claude, 200, 1)).unwrap(); + idx.commit().unwrap(); idx.reload().unwrap(); let q = query::parse("shared"); - let results = idx - .search(&q, query::SortOrder::Relevance, 1000, 50) - .unwrap(); + let results = idx.search(&q, query::SortOrder::Relevance, 1000, 50).unwrap(); assert_eq!(results.len(), 2); assert_eq!(results[0].id, "new"); assert_eq!(results[1].id, "old"); @@ -127,22 +86,13 @@ fn text_search_breaks_equal_scores_by_recency() { fn agent_filter_applies() { let dir = tempfile::tempdir().unwrap(); let idx = SearchIndex::open_or_create(dir.path()).unwrap(); - let mut w = idx.writer().unwrap(); - idx.upsert( - &mut w, - &sess("c", "deploy", "ship it", AgentId::Claude, 100, 1), - ); - idx.upsert( - &mut w, - &sess("x", "deploy", "ship it", AgentId::Codex, 100, 1), - ); - w.commit().unwrap(); + idx.upsert(&sess("c", "deploy", "ship it", AgentId::Claude, 100, 1)).unwrap(); + idx.upsert(&sess("x", "deploy", "ship it", AgentId::Codex, 100, 1)).unwrap(); + idx.commit().unwrap(); idx.reload().unwrap(); let q = query::parse("deploy agent:codex"); - let results = idx - .search(&q, query::SortOrder::Relevance, 1000, 50) - .unwrap(); + let results = idx.search(&q, query::SortOrder::Relevance, 1000, 50).unwrap(); assert_eq!(results.len(), 1); assert_eq!(results[0].agent, AgentId::Codex); } @@ -155,35 +105,14 @@ fn incremental_diff_detects_changes_and_deletions() { known.insert("deleted".into(), 100); let mut scanned: HashMap = HashMap::new(); - scanned.insert( - "keep".into(), - ScanEntry { - path: PathBuf::from("k"), - mtime: 100, - }, - ); - scanned.insert( - "changed".into(), - ScanEntry { - path: PathBuf::from("c"), - mtime: 500, - }, - ); - scanned.insert( - "new".into(), - ScanEntry { - path: PathBuf::from("n"), - mtime: 10, - }, - ); + scanned.insert("keep".into(), ScanEntry { path: PathBuf::from("k"), mtime: 100 }); + scanned.insert("changed".into(), ScanEntry { path: PathBuf::from("c"), mtime: 500 }); + scanned.insert("new".into(), ScanEntry { path: PathBuf::from("n"), mtime: 10 }); let (changed, deleted) = diff(&known, &scanned); let mut changed_ids: Vec<&String> = changed.iter().map(|(id, _)| id).collect(); changed_ids.sort(); - assert_eq!( - changed_ids, - vec![&"changed".to_string(), &"new".to_string()] - ); + assert_eq!(changed_ids, vec![&"changed".to_string(), &"new".to_string()]); assert_eq!(deleted, vec!["deleted".to_string()]); } @@ -205,10 +134,9 @@ fn authoritative_diff_deletes_only_successfully_scanned_agents() { fn empty_query_returns_all_sorted_by_recency() { let dir = tempfile::tempdir().unwrap(); let idx = SearchIndex::open_or_create(dir.path()).unwrap(); - let mut w = idx.writer().unwrap(); - idx.upsert(&mut w, &sess("old", "a", "x", AgentId::Claude, 100, 1)); - idx.upsert(&mut w, &sess("new", "b", "y", AgentId::Claude, 200, 1)); - w.commit().unwrap(); + idx.upsert(&sess("old", "a", "x", AgentId::Claude, 100, 1)).unwrap(); + idx.upsert(&sess("new", "b", "y", AgentId::Claude, 200, 1)).unwrap(); + idx.commit().unwrap(); idx.reload().unwrap(); let q = query::parse(""); @@ -221,10 +149,9 @@ fn empty_query_returns_all_sorted_by_recency() { fn sort_oldest_reverses_recent_order() { let dir = tempfile::tempdir().unwrap(); let idx = SearchIndex::open_or_create(dir.path()).unwrap(); - let mut w = idx.writer().unwrap(); - idx.upsert(&mut w, &sess("old", "a", "x", AgentId::Claude, 100, 1)); - idx.upsert(&mut w, &sess("new", "b", "y", AgentId::Claude, 200, 1)); - w.commit().unwrap(); + idx.upsert(&sess("old", "a", "x", AgentId::Claude, 100, 1)).unwrap(); + idx.upsert(&sess("new", "b", "y", AgentId::Claude, 200, 1)).unwrap(); + idx.commit().unwrap(); idx.reload().unwrap(); let q = query::parse(""); @@ -239,10 +166,9 @@ fn sort_oldest_reverses_recent_order() { fn known_mtimes_maps_document_key_to_mtime() { let dir = tempfile::tempdir().unwrap(); let idx = SearchIndex::open_or_create(dir.path()).unwrap(); - let mut w = idx.writer().unwrap(); - idx.upsert(&mut w, &sess("a", "t", "c", AgentId::Claude, 100, 42)); - idx.upsert(&mut w, &sess("b", "t", "c", AgentId::Codex, 100, 7)); - w.commit().unwrap(); + idx.upsert(&sess("a", "t", "c", AgentId::Claude, 100, 42)).unwrap(); + idx.upsert(&sess("b", "t", "c", AgentId::Codex, 100, 7)).unwrap(); + idx.commit().unwrap(); idx.reload().unwrap(); let map = idx.known_mtimes().unwrap(); @@ -255,48 +181,23 @@ fn known_mtimes_maps_document_key_to_mtime() { fn raw_session_id_can_overlap_between_agents() { let dir = tempfile::tempdir().unwrap(); let idx = SearchIndex::open_or_create(dir.path()).unwrap(); - let mut w = idx.writer().unwrap(); - idx.upsert( - &mut w, - &sess("same", "claude row", "shared", AgentId::Claude, 100, 11), - ); - idx.upsert( - &mut w, - &sess("same", "codex row", "shared", AgentId::Codex, 90, 22), - ); - w.commit().unwrap(); - drop(w); + idx.upsert(&sess("same", "claude row", "shared", AgentId::Claude, 100, 11)).unwrap(); + idx.upsert(&sess("same", "codex row", "shared", AgentId::Codex, 90, 22)).unwrap(); + idx.commit().unwrap(); idx.reload().unwrap(); - let results = idx - .search( - &query::parse("shared"), - query::SortOrder::Relevance, - 1_000, - 10, - ) - .unwrap(); + let results = + idx.search(&query::parse("shared"), query::SortOrder::Relevance, 1_000, 10).unwrap(); assert_eq!(results.len(), 2); - assert!(results - .iter() - .any(|s| s.id == "same" && s.agent == AgentId::Claude)); - assert!(results - .iter() - .any(|s| s.id == "same" && s.agent == AgentId::Codex)); - - let mut w = idx.writer().unwrap(); - idx.delete(&mut w, "claude:same"); - w.commit().unwrap(); + assert!(results.iter().any(|s| s.id == "same" && s.agent == AgentId::Claude)); + assert!(results.iter().any(|s| s.id == "same" && s.agent == AgentId::Codex)); + + idx.delete("claude:same").unwrap(); + idx.commit().unwrap(); idx.reload().unwrap(); - let results = idx - .search( - &query::parse("shared"), - query::SortOrder::Relevance, - 1_000, - 10, - ) - .unwrap(); + let results = + idx.search(&query::parse("shared"), query::SortOrder::Relevance, 1_000, 10).unwrap(); assert_eq!(results.len(), 1); assert_eq!(results[0].agent, AgentId::Codex); } @@ -305,44 +206,33 @@ fn raw_session_id_can_overlap_between_agents() { fn dir_filter_pages_past_many_filtered_out_hits() { let dir = tempfile::tempdir().unwrap(); let idx = SearchIndex::open_or_create(dir.path()).unwrap(); - let mut w = idx.writer().unwrap(); for i in 0..5_100 { - idx.upsert( - &mut w, - &sess_in_dir( - &format!("other-{i}"), - "recent", - "recent", - AgentId::Claude, - "/work/other", - 10_000 + i as i64, - i as i64, - ), - ); - } - idx.upsert( - &mut w, - &sess_in_dir( - "target", - "target", - "target", + idx.upsert(&sess_in_dir( + &format!("other-{i}"), + "recent", + "recent", AgentId::Claude, - "/work/target", - 1, - 9_999, - ), - ); - w.commit().unwrap(); + "/work/other", + 10_000 + i as i64, + i as i64, + )) + .unwrap(); + } + idx.upsert(&sess_in_dir( + "target", + "target", + "target", + AgentId::Claude, + "/work/target", + 1, + 9_999, + )) + .unwrap(); + idx.commit().unwrap(); idx.reload().unwrap(); - let results = idx - .search( - &query::parse("dir:target"), - query::SortOrder::Recent, - 20_000, - 1, - ) - .unwrap(); + let results = + idx.search(&query::parse("dir:target"), query::SortOrder::Recent, 20_000, 1).unwrap(); assert_eq!(results.len(), 1); assert_eq!(results[0].id, "target"); } @@ -354,7 +244,6 @@ fn branch_roundtrips_through_index() { use hop::query::ParsedQuery; let dir = tempfile::tempdir().unwrap(); let idx = SearchIndex::open_or_create(dir.path()).unwrap(); - let mut w = idx.writer().unwrap(); let s = Session { meta: SessionSummary { id: "a".into(), @@ -371,53 +260,30 @@ fn branch_roundtrips_through_index() { content: "hello".into(), mtime: 1, }; - idx.upsert(&mut w, &s); - w.commit().unwrap(); + idx.upsert(&s).unwrap(); + idx.commit().unwrap(); idx.reload().unwrap(); - let out = idx - .search(&ParsedQuery::default(), query::SortOrder::Recent, 100, 10) - .unwrap(); + let out = idx.search(&ParsedQuery::default(), query::SortOrder::Recent, 100, 10).unwrap(); assert_eq!(out.len(), 1); assert_eq!(out[0].branch.as_deref(), Some("feat/x")); - assert_eq!( - out[0].repo_url.as_deref(), - Some("git@github.com:me/web.git") - ); - assert_eq!( - out[0].source_path.as_deref(), - Some(std::path::Path::new("/sessions/a.jsonl")) - ); + assert_eq!(out[0].repo_url.as_deref(), Some("git@github.com:me/web.git")); + assert_eq!(out[0].source_path.as_deref(), Some(std::path::Path::new("/sessions/a.jsonl"))); } #[test] fn recency_boosts_recent_over_old_with_similar_text() { let dir = tempfile::tempdir().unwrap(); let idx = SearchIndex::open_or_create(dir.path()).unwrap(); - let mut w = idx.writer().unwrap(); let now = 1_700_000_000i64; let three_months_ago = now - 90 * 86_400; - idx.upsert( - &mut w, - &sess( - "old", - "deploy api", - "deploy api", - AgentId::Claude, - three_months_ago, - 1, - ), - ); - idx.upsert( - &mut w, - &sess("new", "deploy api", "deploy api", AgentId::Claude, now, 1), - ); - w.commit().unwrap(); + idx.upsert(&sess("old", "deploy api", "deploy api", AgentId::Claude, three_months_ago, 1)) + .unwrap(); + idx.upsert(&sess("new", "deploy api", "deploy api", AgentId::Claude, now, 1)).unwrap(); + idx.commit().unwrap(); idx.reload().unwrap(); let q = query::parse("deploy"); - let results = idx - .search(&q, query::SortOrder::Relevance, now, 50) - .unwrap(); + let results = idx.search(&q, query::SortOrder::Relevance, now, 50).unwrap(); assert_eq!(results.len(), 2); assert_eq!(results[0].id, "new"); assert_eq!(results[1].id, "old");