use serde_json::{json, Value};
use crate::chunking::core::split_lines_keep_ends;
use crate::search::SearchResult;
use crate::types::Chunk;
pub fn resolve_snippet_lines(value: Option<i64>) -> Option<usize> {
value.map(|n| usize::try_from(n.max(0)).unwrap_or(usize::MAX))
}
pub fn result_to_dict(result: &SearchResult, max_snippet_lines: Option<usize>) -> Value {
let c = &result.chunk;
let mut entry = json!({
"file_path": c.file_path,
"start_line": c.start_line,
"end_line": c.end_line,
"score": result.score,
});
match max_snippet_lines {
None => {
entry["content"] = json!(c.content);
}
Some(0) => {}
Some(n) => {
let snippet: Vec<&str> = split_lines_keep_ends(&c.content)
.into_iter()
.take(n)
.map(|l| l.trim_end_matches(['\r', '\n']))
.collect();
entry["content"] = json!(snippet.join("\n"));
}
}
entry
}
pub fn format_results(
query: &str,
results: &[SearchResult],
max_snippet_lines: Option<usize>,
) -> Value {
json!({
"query": query,
"results": results
.iter()
.map(|r| result_to_dict(r, max_snippet_lines))
.collect::<Vec<_>>(),
})
}
const GIT_URL_SCHEMES: [&str; 6] = [
"https://",
"http://",
"ssh://",
"git://",
"git+ssh://",
"file://",
];
pub fn is_git_url(path: &str) -> bool {
if GIT_URL_SCHEMES
.iter()
.any(|scheme| path.starts_with(scheme))
{
return true;
}
is_scp_git_url(path)
}
fn is_scp_git_url(path: &str) -> bool {
let b = path.as_bytes();
let n = b.len();
let is_word = |c: u8| c.is_ascii_alphanumeric() || c == b'_' || c == b'.' || c == b'-';
let mut i = 0;
while i < n && is_word(b[i]) {
i += 1;
}
if i == 0 {
return false;
}
if i >= n || b[i] != b'@' {
return false;
}
i += 1;
let host_start = i;
while i < n && is_word(b[i]) {
i += 1;
}
if i == host_start {
return false;
}
if i >= n || b[i] != b':' {
return false;
}
i += 1;
!(i < n && b[i] == b'/')
}
pub fn resolve_chunk<'a>(chunks: &'a [Chunk], file_path: &str, line: u32) -> Option<&'a Chunk> {
let mut fallback: Option<&Chunk> = None;
for chunk in chunks {
if paths_eq_normalized(&chunk.file_path, file_path)
&& chunk.start_line <= line
&& line <= chunk.end_line
{
if line < chunk.end_line {
return Some(chunk);
}
if fallback.is_none() {
fallback = Some(chunk);
}
}
}
fallback
}
fn paths_eq_normalized(a: &str, b: &str) -> bool {
fn is_sep(c: u8) -> bool {
c == b'/' || c == b'\\'
}
a.len() == b.len()
&& a.bytes()
.zip(b.bytes())
.all(|(x, y)| x == y || (is_sep(x) && is_sep(y)))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn result_to_dict_cap_splits_crlf_and_bare_cr_like_python_splitlines() {
let r = result("a\r\nb\rc\nd");
let d = result_to_dict(&r, Some(3));
assert_eq!(d["content"], json!("a\nb\nc"));
}
#[test]
fn resolve_snippet_lines_maps_wire_value_to_cap() {
assert!(resolve_snippet_lines(None).is_none());
assert_eq!(resolve_snippet_lines(Some(3)), Some(3));
assert_eq!(resolve_snippet_lines(Some(0)), Some(0));
assert_eq!(resolve_snippet_lines(Some(-4)), Some(0));
}
fn chunk(file_path: &str, start_line: u32, end_line: u32) -> Chunk {
Chunk {
content: String::new(),
file_path: file_path.to_string(),
start_line,
end_line,
language: None,
}
}
fn result(content: &str) -> SearchResult {
SearchResult {
chunk: Chunk {
content: content.to_string(),
file_path: "a.ts".to_string(),
start_line: 1,
end_line: 3,
language: Some("ts".to_string()),
},
score: 0.5,
}
}
#[test]
fn format_results_flat_shape_with_full_content() {
let out = format_results("q", &[result("line1\nline2\nline3")], None);
let entry = &out["results"][0];
assert!(entry.get("chunk").is_none());
assert_eq!(entry["file_path"], "a.ts");
assert_eq!(entry["start_line"], 1);
assert_eq!(entry["end_line"], 3);
assert_eq!(entry["score"], 0.5);
assert_eq!(entry["content"], "line1\nline2\nline3");
assert_eq!(out["query"], "q");
}
#[test]
fn result_to_dict_truncates_to_n_lines() {
let entry = result_to_dict(&result("line1\nline2\nline3\nline4"), Some(2));
assert_eq!(entry["content"], "line1\nline2");
}
#[test]
fn result_to_dict_omits_content_when_zero() {
let entry = result_to_dict(&result("line1\nline2"), Some(0));
assert!(entry.get("content").is_none());
assert_eq!(entry["file_path"], "a.ts");
assert_eq!(entry["start_line"], 1);
assert_eq!(entry["end_line"], 3);
}
#[test]
fn result_to_dict_more_lines_than_content_returns_all() {
let entry = result_to_dict(&result("only\ntwo"), Some(10));
assert_eq!(entry["content"], "only\ntwo");
}
#[test]
fn recognises_scheme_git_urls() {
for url in [
"https://github.com/owner/repo.git",
"http://example.com/repo",
"ssh://git@host/repo",
"git://host/repo",
"git+ssh://git@host/repo",
"file:///tmp/repo",
] {
assert!(is_git_url(url), "{url} should be a git url");
}
}
#[test]
fn recognises_scp_style_git_urls() {
assert!(is_git_url("git@github.com:owner/repo.git"));
assert!(is_git_url("user@host:repo"));
}
#[test]
fn rejects_local_paths() {
assert!(!is_git_url("/abs/path/to/repo"));
assert!(!is_git_url("./relative/repo"));
assert!(!is_git_url("repo"));
assert!(!is_git_url("user@host:/abs/path"));
}
#[test]
fn resolve_chunk_inner_match_wins() {
let chunks = [chunk("a.ts", 1, 10), chunk("a.ts", 5, 20)];
assert_eq!(resolve_chunk(&chunks, "a.ts", 5), Some(&chunks[0]));
}
#[test]
fn resolve_chunk_boundary_is_fallback() {
let chunks = [chunk("a.ts", 1, 5), chunk("a.ts", 5, 20)];
assert_eq!(resolve_chunk(&chunks, "a.ts", 5), Some(&chunks[1]));
}
#[test]
fn resolve_chunk_returns_boundary_when_only_match() {
let chunks = [chunk("a.ts", 1, 5)];
assert_eq!(resolve_chunk(&chunks, "a.ts", 5), Some(&chunks[0]));
}
#[test]
fn resolve_chunk_none_when_no_match() {
let chunks = [chunk("a.ts", 1, 5)];
assert_eq!(resolve_chunk(&chunks, "b.ts", 3), None);
assert_eq!(resolve_chunk(&chunks, "a.ts", 99), None);
}
#[test]
fn resolve_chunk_backslash_input_matches_forward_slash_chunk() {
let chunks = [chunk("src/lib/a.ts", 1, 10)];
assert_eq!(
resolve_chunk(&chunks, "src\\lib\\a.ts", 5),
Some(&chunks[0])
);
}
#[test]
fn resolve_chunk_forward_slash_input_matches_backslash_chunk() {
let chunks = [chunk("src\\lib\\a.ts", 1, 5)];
assert_eq!(resolve_chunk(&chunks, "src/lib/a.ts", 3), Some(&chunks[0]));
assert_eq!(resolve_chunk(&chunks, "src/lib/a.ts", 5), Some(&chunks[0]));
assert_eq!(resolve_chunk(&chunks, "src/lib/b.ts", 3), None);
}
}