mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
## Why The memories extension has several distinct responsibilities: registering its prompt and tool contributors, enforcing local-memory filesystem boundaries, implementing list/read/search behavior, and wrapping that backend as extension tools. Those responsibilities were concentrated in `lib.rs`, `local.rs`, and the tool modules, which made follow-up work harder to review and risked growing files through unrelated edits. This PR reorganizes the crate so each responsibility has a narrower owner while preserving the same extension entrypoint and memory tool behavior. ## What Changed - Moved extension lifecycle, prompt, and tool registration into `src/extension.rs`, leaving `src/lib.rs` as the small crate entrypoint. - Split `LocalMemoriesBackend` helpers into `local/list.rs`, `local/path.rs`, `local/read.rs`, and `local/search.rs`. - Centralized tool names and limits at the crate level, and kept the backend and extension implementation crate-private. - Made `memory_list`, `memory_read`, and `memory_search` tool executors generic over `MemoriesBackend`, so tests can exercise the full executor path without depending on tool internals. - Consolidated and expanded memory extension tests in `src/tests.rs`, including read/search tool output coverage, multi-query search, windowed `all_within_lines`, and legacy `query` rejection. ## Testing - Not run locally.
337 lines
11 KiB
Rust
337 lines
11 KiB
Rust
use std::borrow::Cow;
|
|
use std::path::Path;
|
|
|
|
use crate::MAX_SEARCH_RESULTS;
|
|
use crate::backend::MemoriesBackendError;
|
|
use crate::backend::MemorySearchMatch;
|
|
use crate::backend::SearchMatchMode;
|
|
use crate::backend::SearchMemoriesRequest;
|
|
use crate::backend::SearchMemoriesResponse;
|
|
|
|
use super::LocalMemoriesBackend;
|
|
use super::path::display_relative_path;
|
|
use super::path::is_hidden_path;
|
|
use super::path::read_sorted_dir_paths;
|
|
use super::path::reject_symlink;
|
|
|
|
pub(super) async fn search(
|
|
backend: &LocalMemoriesBackend,
|
|
request: SearchMemoriesRequest,
|
|
) -> Result<SearchMemoriesResponse, MemoriesBackendError> {
|
|
let queries = request
|
|
.queries
|
|
.iter()
|
|
.map(|query| query.trim().to_string())
|
|
.collect::<Vec<_>>();
|
|
if queries.is_empty() || queries.iter().any(std::string::String::is_empty) {
|
|
return Err(MemoriesBackendError::EmptyQuery);
|
|
}
|
|
if matches!(
|
|
request.match_mode,
|
|
SearchMatchMode::AllWithinLines { line_count: 0 }
|
|
) {
|
|
return Err(MemoriesBackendError::InvalidMatchWindow);
|
|
}
|
|
|
|
let max_results = request.max_results.min(MAX_SEARCH_RESULTS);
|
|
let start = backend.resolve_scoped_path(request.path.as_deref()).await?;
|
|
let start_index = match request.cursor.as_deref() {
|
|
Some(cursor) => cursor.parse::<usize>().map_err(|_| {
|
|
MemoriesBackendError::invalid_cursor(cursor, "must be a non-negative integer")
|
|
})?,
|
|
None => 0,
|
|
};
|
|
let Some(metadata) = LocalMemoriesBackend::metadata_or_none(&start).await? else {
|
|
return Err(MemoriesBackendError::NotFound {
|
|
path: request.path.unwrap_or_default(),
|
|
});
|
|
};
|
|
reject_symlink(&display_relative_path(&backend.root, &start), &metadata)?;
|
|
|
|
let matcher = SearchMatcher::new(
|
|
queries.clone(),
|
|
request.match_mode.clone(),
|
|
request.case_sensitive,
|
|
request.normalized,
|
|
)?;
|
|
let mut matches = Vec::new();
|
|
search_entries(
|
|
&backend.root,
|
|
&start,
|
|
&metadata,
|
|
&matcher,
|
|
request.context_lines,
|
|
&mut matches,
|
|
)
|
|
.await?;
|
|
matches.sort_by(|left, right| {
|
|
left.path
|
|
.cmp(&right.path)
|
|
.then(left.match_line_number.cmp(&right.match_line_number))
|
|
});
|
|
if start_index > matches.len() {
|
|
return Err(MemoriesBackendError::invalid_cursor(
|
|
start_index.to_string(),
|
|
"exceeds result count",
|
|
));
|
|
}
|
|
let end_index = start_index.saturating_add(max_results).min(matches.len());
|
|
let next_cursor = (end_index < matches.len()).then(|| end_index.to_string());
|
|
let truncated = next_cursor.is_some();
|
|
Ok(SearchMemoriesResponse {
|
|
queries,
|
|
match_mode: request.match_mode,
|
|
path: request.path,
|
|
matches: matches.drain(start_index..end_index).collect(),
|
|
next_cursor,
|
|
truncated,
|
|
})
|
|
}
|
|
|
|
async fn search_entries(
|
|
root: &Path,
|
|
current: &Path,
|
|
current_metadata: &std::fs::Metadata,
|
|
matcher: &SearchMatcher,
|
|
context_lines: usize,
|
|
matches: &mut Vec<MemorySearchMatch>,
|
|
) -> Result<(), MemoriesBackendError> {
|
|
if current_metadata.is_file() {
|
|
search_file(root, current, matcher, context_lines, matches).await?;
|
|
return Ok(());
|
|
}
|
|
if !current_metadata.is_dir() {
|
|
return Ok(());
|
|
}
|
|
|
|
let mut pending = vec![current.to_path_buf()];
|
|
while let Some(dir_path) = pending.pop() {
|
|
for path in read_sorted_dir_paths(&dir_path).await? {
|
|
if is_hidden_path(&path) {
|
|
continue;
|
|
}
|
|
let Some(metadata) = LocalMemoriesBackend::metadata_or_none(&path).await? else {
|
|
continue;
|
|
};
|
|
if metadata.file_type().is_symlink() {
|
|
continue;
|
|
}
|
|
if metadata.is_dir() {
|
|
pending.push(path);
|
|
} else if metadata.is_file() {
|
|
search_file(root, &path, matcher, context_lines, matches).await?;
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
async fn search_file(
|
|
root: &Path,
|
|
path: &Path,
|
|
matcher: &SearchMatcher,
|
|
context_lines: usize,
|
|
matches: &mut Vec<MemorySearchMatch>,
|
|
) -> Result<(), MemoriesBackendError> {
|
|
let content = match tokio::fs::read_to_string(path).await {
|
|
Ok(content) => content,
|
|
Err(err) if err.kind() == std::io::ErrorKind::InvalidData => return Ok(()),
|
|
Err(err) => return Err(err.into()),
|
|
};
|
|
let lines = content.lines().collect::<Vec<_>>();
|
|
let line_matches = lines
|
|
.iter()
|
|
.map(|line| matcher.matched_query_flags(line))
|
|
.collect::<Vec<_>>();
|
|
match &matcher.match_mode {
|
|
SearchMatchMode::Any => {
|
|
for (idx, matched_query_flags) in line_matches.iter().enumerate() {
|
|
if matched_query_flags.iter().any(|matched| *matched) {
|
|
matches.push(build_search_match(
|
|
root,
|
|
path,
|
|
&lines,
|
|
idx,
|
|
idx,
|
|
context_lines,
|
|
matcher.matched_queries(matched_query_flags),
|
|
));
|
|
}
|
|
}
|
|
}
|
|
SearchMatchMode::AllOnSameLine => {
|
|
for (idx, matched_query_flags) in line_matches.iter().enumerate() {
|
|
if matched_query_flags.iter().all(|matched| *matched) {
|
|
matches.push(build_search_match(
|
|
root,
|
|
path,
|
|
&lines,
|
|
idx,
|
|
idx,
|
|
context_lines,
|
|
matcher.matched_queries(matched_query_flags),
|
|
));
|
|
}
|
|
}
|
|
}
|
|
SearchMatchMode::AllWithinLines { line_count } => {
|
|
let mut windows = Vec::new();
|
|
for start_index in 0..lines.len() {
|
|
if !line_matches[start_index].iter().any(|matched| *matched) {
|
|
continue;
|
|
}
|
|
let last_allowed_index = start_index
|
|
.saturating_add(line_count.saturating_sub(1))
|
|
.min(lines.len().saturating_sub(1));
|
|
let mut matched_query_flags = vec![false; matcher.queries.len()];
|
|
for (end_index, line_match_flags) in line_matches
|
|
.iter()
|
|
.enumerate()
|
|
.take(last_allowed_index + 1)
|
|
.skip(start_index)
|
|
{
|
|
for (idx, matched) in line_match_flags.iter().enumerate() {
|
|
matched_query_flags[idx] |= matched;
|
|
}
|
|
if matched_query_flags.iter().all(|matched| *matched) {
|
|
windows.push((start_index, end_index, matched_query_flags));
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
for (idx, (start_index, end_index, matched_query_flags)) in windows.iter().enumerate() {
|
|
let strictly_contains_another_window = windows.iter().enumerate().any(
|
|
|(other_idx, (other_start_index, other_end_index, _))| {
|
|
idx != other_idx
|
|
&& start_index <= other_start_index
|
|
&& end_index >= other_end_index
|
|
&& (start_index != other_start_index || end_index != other_end_index)
|
|
},
|
|
);
|
|
if strictly_contains_another_window {
|
|
continue;
|
|
}
|
|
matches.push(build_search_match(
|
|
root,
|
|
path,
|
|
&lines,
|
|
*start_index,
|
|
*end_index,
|
|
context_lines,
|
|
matcher.matched_queries(matched_query_flags),
|
|
));
|
|
}
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn build_search_match(
|
|
root: &Path,
|
|
path: &Path,
|
|
lines: &[&str],
|
|
match_start_index: usize,
|
|
match_end_index: usize,
|
|
context_lines: usize,
|
|
matched_queries: Vec<String>,
|
|
) -> MemorySearchMatch {
|
|
let content_start_index = match_start_index.saturating_sub(context_lines);
|
|
let content_end_index = match_end_index
|
|
.saturating_add(context_lines)
|
|
.saturating_add(1)
|
|
.min(lines.len());
|
|
MemorySearchMatch {
|
|
path: display_relative_path(root, path),
|
|
match_line_number: match_start_index + 1,
|
|
content_start_line_number: content_start_index + 1,
|
|
content: lines[content_start_index..content_end_index].join("\n"),
|
|
matched_queries,
|
|
}
|
|
}
|
|
|
|
struct SearchMatcher {
|
|
queries: Vec<String>,
|
|
prepared_queries: Vec<String>,
|
|
comparison: SearchComparison,
|
|
match_mode: SearchMatchMode,
|
|
}
|
|
|
|
impl SearchMatcher {
|
|
fn new(
|
|
queries: Vec<String>,
|
|
match_mode: SearchMatchMode,
|
|
case_sensitive: bool,
|
|
normalized: bool,
|
|
) -> Result<Self, MemoriesBackendError> {
|
|
let comparison = SearchComparison::new(case_sensitive, normalized);
|
|
let prepared_queries = queries
|
|
.iter()
|
|
.map(|query| comparison.prepare(query))
|
|
.map(Cow::into_owned)
|
|
.collect::<Vec<_>>();
|
|
if prepared_queries.iter().any(std::string::String::is_empty) {
|
|
return Err(MemoriesBackendError::EmptyQuery);
|
|
}
|
|
Ok(Self {
|
|
queries,
|
|
prepared_queries,
|
|
comparison,
|
|
match_mode,
|
|
})
|
|
}
|
|
|
|
fn matched_query_flags(&self, line: &str) -> Vec<bool> {
|
|
let line = self.comparison.prepare(line);
|
|
self.prepared_queries
|
|
.iter()
|
|
.map(|query| line.as_ref().contains(query))
|
|
.collect()
|
|
}
|
|
|
|
fn matched_queries(&self, matched_query_flags: &[bool]) -> Vec<String> {
|
|
self.queries
|
|
.iter()
|
|
.zip(matched_query_flags)
|
|
.filter_map(|(query, matched)| matched.then_some(query.clone()))
|
|
.collect()
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Copy)]
|
|
struct SearchComparison {
|
|
case_sensitive: bool,
|
|
normalized: bool,
|
|
}
|
|
|
|
impl SearchComparison {
|
|
fn new(case_sensitive: bool, normalized: bool) -> Self {
|
|
Self {
|
|
case_sensitive,
|
|
normalized,
|
|
}
|
|
}
|
|
|
|
fn prepare<'a>(self, value: &'a str) -> Cow<'a, str> {
|
|
if self.case_sensitive && !self.normalized {
|
|
return Cow::Borrowed(value);
|
|
}
|
|
|
|
let value = if self.case_sensitive {
|
|
Cow::Borrowed(value)
|
|
} else {
|
|
Cow::Owned(value.to_lowercase())
|
|
};
|
|
if !self.normalized {
|
|
return value;
|
|
}
|
|
|
|
Cow::Owned(
|
|
value
|
|
.chars()
|
|
.filter(|ch| ch.is_alphanumeric())
|
|
.collect::<String>(),
|
|
)
|
|
}
|
|
}
|