mirror of
https://github.com/khodges42/exoshell.git
synced 2026-06-14 18:08:37 +00:00
attachable repository search context
attachable repository search context
This commit is contained in:
parent
ce9937f1dd
commit
3155ca76cd
2
Cargo.lock
generated
2
Cargo.lock
generated
|
|
@ -100,7 +100,7 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "exoshell"
|
name = "exoshell"
|
||||||
version = "0.9.0"
|
version = "0.10.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
[package]
|
[package]
|
||||||
name = "exoshell"
|
name = "exoshell"
|
||||||
version = "0.9.0"
|
version = "0.10.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
license = "GPL-3.0-or-later"
|
license = "GPL-3.0-or-later"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,7 @@ The current implementation supports the first shell-adjacent model chat mileston
|
||||||
|
|
||||||
The codebase also contains the Phase 1.5 context engine foundation: context entries, provenance metadata, priority and size estimates, a session context store, provider registry, default manual/file/command-output/stdin/directory-summary providers, context REPL commands, deterministic pruning, budget checks, transcript events, startup context flags, piped stdin import, and prompt-context rendering.
|
The codebase also contains the Phase 1.5 context engine foundation: context entries, provenance metadata, priority and size estimates, a session context store, provider registry, default manual/file/command-output/stdin/directory-summary providers, context REPL commands, deterministic pruning, budget checks, transcript events, startup context flags, piped stdin import, and prompt-context rendering.
|
||||||
|
|
||||||
The active milestone is Phase 3. The current implementation adds stance selection, explicit prompt assembly, visible prompt/context budget estimates, command suggestion IDs, simple risky-command warnings, command copy/explain/discard actions, a plain terminal session panel, Phase 2 help text, configurable model routing, Git-native project detection, lightweight project scans, attachable Git status context, attachable Git diff context, and attachable recent commit context.
|
The active milestone is Phase 3. The current implementation adds stance selection, explicit prompt assembly, visible prompt/context budget estimates, command suggestion IDs, simple risky-command warnings, command copy/explain/discard actions, a plain terminal session panel, Phase 2 help text, configurable model routing, Git-native project detection, lightweight project scans, attachable Git status context, attachable Git diff context, attachable recent commit context, and attachable repository search context.
|
||||||
|
|
||||||
The broader roadmap is tracked in [docs/PHASES.md](docs/PHASES.md) and [docs/tasks](docs/tasks).
|
The broader roadmap is tracked in [docs/PHASES.md](docs/PHASES.md) and [docs/tasks](docs/tasks).
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -232,6 +232,13 @@ exo> /add-commits --count 10
|
||||||
exo> /add-commits --author=alice src/app.rs
|
exo> /add-commits --author=alice src/app.rs
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Attach repository search results:
|
||||||
|
|
||||||
|
```text
|
||||||
|
exo> /search ContextProvider
|
||||||
|
exo> /search-path Cargo.toml
|
||||||
|
```
|
||||||
|
|
||||||
Ask a question:
|
Ask a question:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
|
|
|
||||||
|
|
@ -108,3 +108,4 @@ Historical codenames should be tracked in docs/versioning.md below
|
||||||
* 0.7.0 diff-lantern
|
* 0.7.0 diff-lantern
|
||||||
* 0.8.0 commit-oracle
|
* 0.8.0 commit-oracle
|
||||||
* 0.9.0 summary-relay
|
* 0.9.0 summary-relay
|
||||||
|
* 0.10.0 search-relay
|
||||||
|
|
|
||||||
75
src/app.rs
75
src/app.rs
|
|
@ -254,6 +254,14 @@ impl App {
|
||||||
return self.add_git_commit_context(args);
|
return self.add_git_commit_context(args);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let Some(query) = trimmed.strip_prefix("/search-path ") {
|
||||||
|
return self.add_repository_search_context("path", query.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(query) = trimmed.strip_prefix("/search ") {
|
||||||
|
return self.add_repository_search_context("text", query.trim());
|
||||||
|
}
|
||||||
|
|
||||||
Err(ContextError::InvalidInput(format!("unknown context command: {trimmed}")).into())
|
Err(ContextError::InvalidInput(format!("unknown context command: {trimmed}")).into())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -349,6 +357,29 @@ impl App {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn add_repository_search_context(
|
||||||
|
&mut self,
|
||||||
|
mode: &str,
|
||||||
|
query: &str,
|
||||||
|
) -> Result<String, AppError> {
|
||||||
|
if query.trim().is_empty() {
|
||||||
|
return Err(ContextError::InvalidInput("search query cannot be empty".into()).into());
|
||||||
|
}
|
||||||
|
let mut provider_options = HashMap::new();
|
||||||
|
provider_options.insert("mode".into(), mode.to_string());
|
||||||
|
provider_options.insert("query".into(), query.trim().to_string());
|
||||||
|
|
||||||
|
let path = self.project_context_path()?;
|
||||||
|
self.add_context(
|
||||||
|
"repo_search",
|
||||||
|
ContextProviderRequest {
|
||||||
|
path: Some(path),
|
||||||
|
provider_options,
|
||||||
|
..ContextProviderRequest::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn project_scan(&mut self, preview: bool) -> Result<String, AppError> {
|
pub fn project_scan(&mut self, preview: bool) -> Result<String, AppError> {
|
||||||
let cwd = std::env::current_dir().map_err(|error| ProjectError::Read {
|
let cwd = std::env::current_dir().map_err(|error| ProjectError::Read {
|
||||||
path: PathBuf::from("."),
|
path: PathBuf::from("."),
|
||||||
|
|
@ -696,6 +727,8 @@ fn help_overview() -> &'static str {
|
||||||
/add-git-status attach current Git branch and status
|
/add-git-status attach current Git branch and status
|
||||||
/add-diff [--staged] [path] attach unstaged or staged Git diff context
|
/add-diff [--staged] [path] attach unstaged or staged Git diff context
|
||||||
/add-commits [options] [path] attach recent Git commit history
|
/add-commits [options] [path] attach recent Git commit history
|
||||||
|
/search <query> attach repository text search results
|
||||||
|
/search-path <query> attach repository path search results
|
||||||
/add-output paste command output as explicit context
|
/add-output paste command output as explicit context
|
||||||
/stance [name] show or set operator, audit, teach, or quiet
|
/stance [name] show or set operator, audit, teach, or quiet
|
||||||
/copy <cmd-id> print a suggested command; does not execute it
|
/copy <cmd-id> print a suggested command; does not execute it
|
||||||
|
|
@ -720,6 +753,9 @@ fn help_topic(topic: &str) -> &'static str {
|
||||||
"git" => {
|
"git" => {
|
||||||
"Use /add-git-status to attach current branch, staged files, modified files, and untracked files. Use /add-diff, /add-diff --staged, or /add-diff --staged <path> to attach read-only diff context. Use /add-commits, /add-commits --count 10, /add-commits --author=alice, or /add-commits src/app.rs to attach recent history."
|
"Use /add-git-status to attach current branch, staged files, modified files, and untracked files. Use /add-diff, /add-diff --staged, or /add-diff --staged <path> to attach read-only diff context. Use /add-commits, /add-commits --count 10, /add-commits --author=alice, or /add-commits src/app.rs to attach recent history."
|
||||||
}
|
}
|
||||||
|
"search" => {
|
||||||
|
"Use /search <query> to attach repository text matches with file, line, and column locations. Use /search-path <query> to attach matching repository paths. Text search uses ripgrep when available and falls back to a bounded repository walk."
|
||||||
|
}
|
||||||
"stance" => {
|
"stance" => {
|
||||||
"Stances change the compact prompt fragment used for the next request: operator is concise and action-oriented, audit focuses on risks, teach explains more, and quiet minimizes prose while keeping safety warnings."
|
"Stances change the compact prompt fragment used for the next request: operator is concise and action-oriented, audit focuses on risks, teach explains more, and quiet minimizes prose while keeping safety warnings."
|
||||||
}
|
}
|
||||||
|
|
@ -730,7 +766,7 @@ fn help_topic(topic: &str) -> &'static str {
|
||||||
"The current line REPL does not install advanced terminal keybindings. Use /keys to see the predictable slash-command fallbacks for copy, explain, discard, context, and stance actions."
|
"The current line REPL does not install advanced terminal keybindings. Use /keys to see the predictable slash-command fallbacks for copy, explain, discard, context, and stance actions."
|
||||||
}
|
}
|
||||||
_ => {
|
_ => {
|
||||||
"Unknown help topic. Try /help context, /help project, /help git, /help stance, /help commands, or /help keys."
|
"Unknown help topic. Try /help context, /help project, /help git, /help search, /help stance, /help commands, or /help keys."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -888,6 +924,7 @@ mod tests {
|
||||||
use crate::config::{
|
use crate::config::{
|
||||||
CommandConfig, InteractionConfig, ProviderConfig, ShellConfig, TranscriptConfig,
|
CommandConfig, InteractionConfig, ProviderConfig, ShellConfig, TranscriptConfig,
|
||||||
};
|
};
|
||||||
|
use std::fs;
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -956,7 +993,8 @@ mod tests {
|
||||||
"directory_summary".to_string(),
|
"directory_summary".to_string(),
|
||||||
"git_status".to_string(),
|
"git_status".to_string(),
|
||||||
"git_diff".to_string(),
|
"git_diff".to_string(),
|
||||||
"git_commits".to_string()
|
"git_commits".to_string(),
|
||||||
|
"repo_search".to_string()
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
assert_eq!(app.context_store.total_size().characters, 0);
|
assert_eq!(app.context_store.total_size().characters, 0);
|
||||||
|
|
@ -1012,6 +1050,29 @@ mod tests {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn search_path_command_adds_repository_search_context() {
|
||||||
|
let tempdir = tempfile::tempdir().expect("tempdir");
|
||||||
|
let src = tempdir.path().join("src");
|
||||||
|
fs::create_dir(&src).expect("create src");
|
||||||
|
fs::write(src.join("app.rs"), "fn main() {}\n").expect("write app");
|
||||||
|
|
||||||
|
let mut config = test_config();
|
||||||
|
config.project.root = Some(tempdir.path().to_path_buf());
|
||||||
|
let mut app = App::new(config, Box::new(NoopProvider));
|
||||||
|
|
||||||
|
let message = app
|
||||||
|
.handle_command("/search-path APP")
|
||||||
|
.expect("search path command");
|
||||||
|
|
||||||
|
assert_eq!(message, "added ctx-001 (repository path search: APP)");
|
||||||
|
assert!(
|
||||||
|
app.handle_command("/context show ctx-001")
|
||||||
|
.expect("show")
|
||||||
|
.contains("src/app.rs")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn stance_command_shows_and_changes_current_stance() {
|
fn stance_command_shows_and_changes_current_stance() {
|
||||||
let mut app = App::new(test_config(), Box::new(NoopProvider));
|
let mut app = App::new(test_config(), Box::new(NoopProvider));
|
||||||
|
|
@ -1139,6 +1200,16 @@ mod tests {
|
||||||
.expect("help git")
|
.expect("help git")
|
||||||
.contains("/add-diff --staged")
|
.contains("/add-diff --staged")
|
||||||
);
|
);
|
||||||
|
assert!(
|
||||||
|
app.handle_command("/help")
|
||||||
|
.expect("help")
|
||||||
|
.contains("/search <query>")
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
app.handle_command("/help search")
|
||||||
|
.expect("help search")
|
||||||
|
.contains("ripgrep")
|
||||||
|
);
|
||||||
assert!(
|
assert!(
|
||||||
app.handle_command("/help commands")
|
app.handle_command("/help commands")
|
||||||
.expect("help")
|
.expect("help")
|
||||||
|
|
|
||||||
364
src/context.rs
364
src/context.rs
|
|
@ -319,6 +319,7 @@ pub fn register_default_context_providers(
|
||||||
registry.register(Box::new(GitStatusContextProvider))?;
|
registry.register(Box::new(GitStatusContextProvider))?;
|
||||||
registry.register(Box::new(GitDiffContextProvider::default()))?;
|
registry.register(Box::new(GitDiffContextProvider::default()))?;
|
||||||
registry.register(Box::new(GitCommitContextProvider))?;
|
registry.register(Box::new(GitCommitContextProvider))?;
|
||||||
|
registry.register(Box::new(RepositorySearchContextProvider::default()))?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -658,6 +659,113 @@ impl ContextProvider for GitCommitContextProvider {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct RepositorySearchContextProvider {
|
||||||
|
pub max_results: usize,
|
||||||
|
pub max_file_bytes: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for RepositorySearchContextProvider {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
max_results: 100,
|
||||||
|
max_file_bytes: 256 * 1024,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ContextProvider for RepositorySearchContextProvider {
|
||||||
|
fn metadata(&self) -> ContextProviderMetadata {
|
||||||
|
ContextProviderMetadata {
|
||||||
|
name: "repo_search".into(),
|
||||||
|
kind: ContextKind::SearchResult,
|
||||||
|
description: "searches repository text or paths as explicit context".into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn collect(&self, request: ContextProviderRequest) -> Result<ContextEntry, ContextError> {
|
||||||
|
let path = request
|
||||||
|
.path
|
||||||
|
.or(request.cwd)
|
||||||
|
.unwrap_or_else(|| PathBuf::from("."));
|
||||||
|
let query = request
|
||||||
|
.provider_options
|
||||||
|
.get("query")
|
||||||
|
.ok_or_else(|| ContextError::InvalidInput("search query is required".into()))?
|
||||||
|
.trim()
|
||||||
|
.to_string();
|
||||||
|
if query.is_empty() {
|
||||||
|
return Err(ContextError::InvalidInput(
|
||||||
|
"search query cannot be empty".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let mode = request
|
||||||
|
.provider_options
|
||||||
|
.get("mode")
|
||||||
|
.map(String::as_str)
|
||||||
|
.unwrap_or("text");
|
||||||
|
let search = match mode {
|
||||||
|
"text" => search_text(&path, &query, self.max_results, self.max_file_bytes)?,
|
||||||
|
"path" => search_paths(&path, &query, self.max_results)?,
|
||||||
|
other => {
|
||||||
|
return Err(ContextError::InvalidInput(format!(
|
||||||
|
"unsupported search mode '{other}', expected text or path"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut provenance = ContextProvenance::new(ContextOrigin::Search);
|
||||||
|
provenance.source_path = Some(path);
|
||||||
|
provenance
|
||||||
|
.provider_details
|
||||||
|
.insert("mode".into(), mode.to_string());
|
||||||
|
provenance
|
||||||
|
.provider_details
|
||||||
|
.insert("query".into(), query.clone());
|
||||||
|
provenance
|
||||||
|
.provider_details
|
||||||
|
.insert("engine".into(), search.engine.to_string());
|
||||||
|
provenance
|
||||||
|
.provider_details
|
||||||
|
.insert("result_count".into(), search.results.len().to_string());
|
||||||
|
provenance
|
||||||
|
.provider_details
|
||||||
|
.insert("max_results".into(), self.max_results.to_string());
|
||||||
|
provenance
|
||||||
|
.provider_details
|
||||||
|
.insert("truncated".into(), search.truncated.to_string());
|
||||||
|
|
||||||
|
let content = if search.results.is_empty() {
|
||||||
|
format!(
|
||||||
|
"mode: {mode}\nquery: {query}\nengine: {}\ntruncated: {}\nresults: none",
|
||||||
|
search.engine, search.truncated
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
format!(
|
||||||
|
"mode: {mode}\nquery: {query}\nengine: {}\ntruncated: {}\nresults:\n{}",
|
||||||
|
search.engine,
|
||||||
|
search.truncated,
|
||||||
|
search
|
||||||
|
.results
|
||||||
|
.iter()
|
||||||
|
.map(|result| format!("- {result}"))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("\n")
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(ContextEntry::new(
|
||||||
|
"",
|
||||||
|
ContextKind::SearchResult,
|
||||||
|
request
|
||||||
|
.title
|
||||||
|
.unwrap_or_else(|| format!("repository {mode} search: {query}")),
|
||||||
|
provenance,
|
||||||
|
content,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct GitDiffContextProvider {
|
pub struct GitDiffContextProvider {
|
||||||
pub max_characters: usize,
|
pub max_characters: usize,
|
||||||
|
|
@ -883,6 +991,191 @@ fn git_log_output(
|
||||||
)))
|
)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
struct SearchCollection {
|
||||||
|
engine: SearchEngine,
|
||||||
|
results: Vec<String>,
|
||||||
|
truncated: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
enum SearchEngine {
|
||||||
|
Ripgrep,
|
||||||
|
Fallback,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for SearchEngine {
|
||||||
|
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
match self {
|
||||||
|
Self::Ripgrep => formatter.write_str("ripgrep"),
|
||||||
|
Self::Fallback => formatter.write_str("fallback"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn search_text(
|
||||||
|
root: &Path,
|
||||||
|
query: &str,
|
||||||
|
max_results: usize,
|
||||||
|
max_file_bytes: u64,
|
||||||
|
) -> Result<SearchCollection, ContextError> {
|
||||||
|
let rg_output = Command::new("rg")
|
||||||
|
.arg("--line-number")
|
||||||
|
.arg("--column")
|
||||||
|
.arg("--no-heading")
|
||||||
|
.arg("--color=never")
|
||||||
|
.arg("--fixed-strings")
|
||||||
|
.arg("--")
|
||||||
|
.arg(query)
|
||||||
|
.arg(root)
|
||||||
|
.output();
|
||||||
|
if let Ok(output) = rg_output {
|
||||||
|
let usable_output = output.status.success() || output.status.code() == Some(1);
|
||||||
|
if usable_output {
|
||||||
|
let stdout = String::from_utf8(output.stdout).map_err(|error| {
|
||||||
|
ContextError::UnsupportedContent(format!("rg output was not valid UTF-8: {error}"))
|
||||||
|
})?;
|
||||||
|
let scan_limit = max_results.saturating_add(1);
|
||||||
|
let all_results: Vec<String> = stdout
|
||||||
|
.lines()
|
||||||
|
.take(scan_limit)
|
||||||
|
.map(|line| normalize_search_line(root, line))
|
||||||
|
.collect();
|
||||||
|
let truncated = all_results.len() > max_results;
|
||||||
|
return Ok(SearchCollection {
|
||||||
|
engine: SearchEngine::Ripgrep,
|
||||||
|
results: all_results.into_iter().take(max_results).collect(),
|
||||||
|
truncated,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fallback_text_search(root, query, max_results, max_file_bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn search_paths(
|
||||||
|
root: &Path,
|
||||||
|
query: &str,
|
||||||
|
max_results: usize,
|
||||||
|
) -> Result<SearchCollection, ContextError> {
|
||||||
|
let mut results = Vec::new();
|
||||||
|
let needle = query.to_lowercase();
|
||||||
|
let scan_limit = max_results.saturating_add(1);
|
||||||
|
visit_files(root, scan_limit, &mut |path| {
|
||||||
|
let relative = relative_path(root, path);
|
||||||
|
if relative.to_lowercase().contains(&needle) {
|
||||||
|
results.push(relative);
|
||||||
|
}
|
||||||
|
Ok(results.len() < scan_limit)
|
||||||
|
})?;
|
||||||
|
let truncated = results.len() > max_results;
|
||||||
|
results.truncate(max_results);
|
||||||
|
Ok(SearchCollection {
|
||||||
|
engine: SearchEngine::Fallback,
|
||||||
|
results,
|
||||||
|
truncated,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fallback_text_search(
|
||||||
|
root: &Path,
|
||||||
|
query: &str,
|
||||||
|
max_results: usize,
|
||||||
|
max_file_bytes: u64,
|
||||||
|
) -> Result<SearchCollection, ContextError> {
|
||||||
|
let mut results = Vec::new();
|
||||||
|
let needle = query.to_lowercase();
|
||||||
|
let scan_limit = max_results.saturating_add(1);
|
||||||
|
visit_files(root, scan_limit, &mut |path| {
|
||||||
|
let Ok(metadata) = fs::metadata(path) else {
|
||||||
|
return Ok(true);
|
||||||
|
};
|
||||||
|
if metadata.len() > max_file_bytes {
|
||||||
|
return Ok(true);
|
||||||
|
}
|
||||||
|
let bytes = match fs::read(path) {
|
||||||
|
Ok(bytes) => bytes,
|
||||||
|
Err(_) => return Ok(true),
|
||||||
|
};
|
||||||
|
if bytes.contains(&0) {
|
||||||
|
return Ok(true);
|
||||||
|
}
|
||||||
|
let Ok(content) = String::from_utf8(bytes) else {
|
||||||
|
return Ok(true);
|
||||||
|
};
|
||||||
|
for (index, line) in content.lines().enumerate() {
|
||||||
|
let lower_line = line.to_lowercase();
|
||||||
|
if let Some(column_index) = lower_line.find(&needle) {
|
||||||
|
results.push(format!(
|
||||||
|
"{}:{}:{}:{}",
|
||||||
|
relative_path(root, path),
|
||||||
|
index + 1,
|
||||||
|
column_index + 1,
|
||||||
|
line.trim()
|
||||||
|
));
|
||||||
|
if results.len() >= scan_limit {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(results.len() < scan_limit)
|
||||||
|
})?;
|
||||||
|
let truncated = results.len() > max_results;
|
||||||
|
results.truncate(max_results);
|
||||||
|
Ok(SearchCollection {
|
||||||
|
engine: SearchEngine::Fallback,
|
||||||
|
results,
|
||||||
|
truncated,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn visit_files<F>(root: &Path, max_results: usize, visitor: &mut F) -> Result<(), ContextError>
|
||||||
|
where
|
||||||
|
F: FnMut(&Path) -> Result<bool, ContextError>,
|
||||||
|
{
|
||||||
|
if max_results == 0 {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let mut stack = vec![root.to_path_buf()];
|
||||||
|
while let Some(path) = stack.pop() {
|
||||||
|
let entries = match fs::read_dir(&path) {
|
||||||
|
Ok(entries) => entries,
|
||||||
|
Err(_) => continue,
|
||||||
|
};
|
||||||
|
for entry in entries.filter_map(Result::ok) {
|
||||||
|
let name = entry.file_name().to_string_lossy().to_string();
|
||||||
|
if is_noisy_path(&name) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let entry_path = entry.path();
|
||||||
|
let Ok(file_type) = entry.file_type() else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if file_type.is_dir() {
|
||||||
|
stack.push(entry_path);
|
||||||
|
} else if file_type.is_file() && !visitor(&entry_path)? {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_search_line(root: &Path, line: &str) -> String {
|
||||||
|
let root = root.to_string_lossy();
|
||||||
|
line.strip_prefix(root.as_ref())
|
||||||
|
.and_then(|line| line.strip_prefix(std::path::MAIN_SEPARATOR))
|
||||||
|
.unwrap_or(line)
|
||||||
|
.replace('\\', "/")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn relative_path(root: &Path, path: &Path) -> String {
|
||||||
|
path.strip_prefix(root)
|
||||||
|
.unwrap_or(path)
|
||||||
|
.to_string_lossy()
|
||||||
|
.replace('\\', "/")
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
struct TruncatedContent {
|
struct TruncatedContent {
|
||||||
content: String,
|
content: String,
|
||||||
|
|
@ -1782,7 +2075,8 @@ mod tests {
|
||||||
"directory_summary".to_string(),
|
"directory_summary".to_string(),
|
||||||
"git_status".to_string(),
|
"git_status".to_string(),
|
||||||
"git_diff".to_string(),
|
"git_diff".to_string(),
|
||||||
"git_commits".to_string()
|
"git_commits".to_string(),
|
||||||
|
"repo_search".to_string()
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -1848,6 +2142,74 @@ mod tests {
|
||||||
assert_eq!(metadata.kind, ContextKind::GitHistory);
|
assert_eq!(metadata.kind, ContextKind::GitHistory);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn repository_search_provider_metadata_is_search_context() {
|
||||||
|
let metadata = RepositorySearchContextProvider::default().metadata();
|
||||||
|
|
||||||
|
assert_eq!(metadata.name, "repo_search");
|
||||||
|
assert_eq!(metadata.kind, ContextKind::SearchResult);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn repository_search_path_mode_collects_matching_paths() {
|
||||||
|
let tempdir = tempfile::tempdir().expect("tempdir");
|
||||||
|
let src = tempdir.path().join("src");
|
||||||
|
fs::create_dir(&src).expect("create src");
|
||||||
|
fs::write(src.join("ContextProvider.rs"), "needle").expect("write file");
|
||||||
|
fs::write(tempdir.path().join("README.md"), "readme").expect("write readme");
|
||||||
|
|
||||||
|
let provider = RepositorySearchContextProvider::default();
|
||||||
|
let mut provider_options = HashMap::new();
|
||||||
|
provider_options.insert("mode".into(), "path".into());
|
||||||
|
provider_options.insert("query".into(), "contextprovider".into());
|
||||||
|
|
||||||
|
let entry = provider
|
||||||
|
.collect(ContextProviderRequest {
|
||||||
|
path: Some(tempdir.path().to_path_buf()),
|
||||||
|
provider_options,
|
||||||
|
..ContextProviderRequest::default()
|
||||||
|
})
|
||||||
|
.expect("search context");
|
||||||
|
|
||||||
|
assert_eq!(entry.kind, ContextKind::SearchResult);
|
||||||
|
assert!(entry.content.contains("mode: path"));
|
||||||
|
assert!(entry.content.contains("src/ContextProvider.rs"));
|
||||||
|
assert_eq!(
|
||||||
|
entry.provenance.provider_details.get("engine"),
|
||||||
|
Some(&"fallback".to_string())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fallback_text_search_reports_locations_and_truncation() {
|
||||||
|
let tempdir = tempfile::tempdir().expect("tempdir");
|
||||||
|
fs::write(
|
||||||
|
tempdir.path().join("alpha.txt"),
|
||||||
|
"first Needle\nsecond needle\nthird needle\n",
|
||||||
|
)
|
||||||
|
.expect("write text");
|
||||||
|
|
||||||
|
let search =
|
||||||
|
fallback_text_search(tempdir.path(), "needle", 2, 256 * 1024).expect("fallback search");
|
||||||
|
|
||||||
|
assert_eq!(search.engine, SearchEngine::Fallback);
|
||||||
|
assert!(search.truncated);
|
||||||
|
assert_eq!(search.results.len(), 2);
|
||||||
|
assert!(search.results[0].contains("alpha.txt:1:7:first Needle"));
|
||||||
|
assert!(search.results[1].contains("alpha.txt:2:8:second needle"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fallback_text_search_skips_large_files() {
|
||||||
|
let tempdir = tempfile::tempdir().expect("tempdir");
|
||||||
|
fs::write(tempdir.path().join("large.txt"), "needle").expect("write large");
|
||||||
|
|
||||||
|
let search =
|
||||||
|
fallback_text_search(tempdir.path(), "needle", 10, 2).expect("fallback search");
|
||||||
|
|
||||||
|
assert!(search.results.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn git_commit_count_is_bounded() {
|
fn git_commit_count_is_bounded() {
|
||||||
assert_eq!(parse_git_commit_count(None).expect("default"), 5);
|
assert_eq!(parse_git_commit_count(None).expect("default"), 5);
|
||||||
|
|
|
||||||
|
|
@ -61,6 +61,8 @@ impl Repl {
|
||||||
|| input.starts_with("/add-diff ")
|
|| input.starts_with("/add-diff ")
|
||||||
|| input == "/add-commits"
|
|| input == "/add-commits"
|
||||||
|| input.starts_with("/add-commits ")
|
|| input.starts_with("/add-commits ")
|
||||||
|
|| input.starts_with("/search ")
|
||||||
|
|| input.starts_with("/search-path ")
|
||||||
{
|
{
|
||||||
match self.app.handle_command(&input) {
|
match self.app.handle_command(&input) {
|
||||||
Ok(message) => println!("{message}"),
|
Ok(message) => println!("{message}"),
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user