use crate::error::PathfinderError;
use serde::{Deserialize, Serialize};
use std::fmt;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SemanticPath {
pub file_path: PathBuf,
pub symbol_chain: Option<SymbolChain>,
}
impl SemanticPath {
#[must_use]
pub fn parse(input: &str) -> Option<Self> {
if input.is_empty() {
return None;
}
if let Some((file_part, symbol_part)) = input.split_once("::") {
if file_part.is_empty() {
return None;
}
let symbol_chain = SymbolChain::parse(symbol_part)?;
Some(Self {
file_path: PathBuf::from(file_part),
symbol_chain: Some(symbol_chain),
})
} else {
Some(Self {
file_path: PathBuf::from(input),
symbol_chain: None,
})
}
}
#[must_use]
pub const fn is_bare_file(&self) -> bool {
self.symbol_chain.is_none()
}
}
impl fmt::Display for SemanticPath {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.file_path.display())?;
if let Some(chain) = &self.symbol_chain {
write!(f, "::{chain}")?;
}
Ok(())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SymbolChain {
pub segments: Vec<Symbol>,
}
impl SymbolChain {
#[must_use]
pub fn parse(input: &str) -> Option<Self> {
if input.is_empty() {
return None;
}
let segments: Vec<Symbol> = input.split('.').filter_map(Symbol::parse).collect();
if segments.is_empty() {
return None;
}
Some(Self { segments })
}
}
impl fmt::Display for SymbolChain {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let parts: Vec<String> = self.segments.iter().map(ToString::to_string).collect();
write!(f, "{}", parts.join("."))
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Symbol {
pub name: String,
pub overload_index: Option<u32>,
}
impl Symbol {
#[must_use]
pub fn parse(input: &str) -> Option<Self> {
if input.is_empty() {
return None;
}
if let Some((name, suffix)) = input.split_once('#') {
let index = suffix.parse::<u32>().ok()?;
Some(Self {
name: name.to_owned(),
overload_index: Some(index),
})
} else {
Some(Self {
name: input.to_owned(),
overload_index: None,
})
}
}
}
impl fmt::Display for Symbol {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.name)?;
if let Some(idx) = self.overload_index {
write!(f, "#{idx}")?;
}
Ok(())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct VersionHash(String);
impl VersionHash {
const PREFIX: &'static str = "sha256:";
const MIN_HEX_CHARS: usize = 7;
#[must_use]
pub fn compute(content: &[u8]) -> Self {
use sha2::{Digest, Sha256};
let hash = Sha256::digest(content);
Self(format!("sha256:{hash:x}"))
}
#[must_use]
pub const fn from_raw(hash: String) -> Self {
Self(hash)
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
#[must_use]
pub fn short(&self) -> &str {
&self.0[Self::PREFIX.len()..Self::PREFIX.len() + Self::MIN_HEX_CHARS]
}
#[must_use]
pub fn matches(&self, agent_input: &str) -> bool {
let full_hex = &self.0[Self::PREFIX.len()..]; let input_hex = agent_input
.strip_prefix(Self::PREFIX)
.unwrap_or(agent_input);
input_hex.len() >= Self::MIN_HEX_CHARS && full_hex.starts_with(input_hex)
}
}
impl fmt::Display for VersionHash {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SymbolScope {
pub content: String,
pub start_line: usize,
pub end_line: usize,
pub name_column: usize,
pub language: String,
}
#[derive(Debug, Clone)]
pub struct WorkspaceRoot(PathBuf);
impl WorkspaceRoot {
pub fn new(path: impl Into<PathBuf>) -> std::io::Result<Self> {
let path = path.into();
let canonical = path.canonicalize()?;
Ok(Self(canonical))
}
#[must_use]
pub fn resolve(&self, relative: &Path) -> PathBuf {
let is_absolute = relative.is_absolute();
let has_traversal = relative
.components()
.any(|c| c == std::path::Component::ParentDir);
if is_absolute || has_traversal {
tracing::warn!(
relative = %relative.display(),
workspace = %self.0.display(),
"WorkspaceRoot::resolve: absolute path or traversal detected; sandbox will reject"
);
}
let mut normalized = PathBuf::default();
for comp in relative.components() {
if matches!(
comp,
std::path::Component::Prefix(_) | std::path::Component::RootDir
) {
continue;
}
normalized.push(comp);
}
self.0.join(normalized)
}
pub fn resolve_strict(&self, relative: &Path) -> Result<PathBuf, PathfinderError> {
let is_absolute = relative.is_absolute();
let has_traversal = relative
.components()
.any(|c| c == std::path::Component::ParentDir);
if is_absolute || has_traversal {
return Err(PathfinderError::PathTraversal {
path: relative.to_path_buf(),
workspace_root: self.0.clone(),
});
}
Ok(self.resolve(relative))
}
#[must_use]
pub fn path(&self) -> &Path {
&self.0
}
}
#[derive(
Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema,
)]
#[serde(rename_all = "snake_case")]
pub enum FilterMode {
#[default]
CodeOnly,
CommentsOnly,
All,
}
#[derive(
Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema,
)]
#[serde(rename_all = "snake_case")]
pub enum Visibility {
#[default]
Public,
All,
}
#[derive(
Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema,
)]
#[serde(rename_all = "snake_case")]
pub enum IncludeImports {
None,
#[default]
ThirdParty,
All,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum DegradedReason {
NoLsp,
LspWarmupEmptyUnverified,
LspWarmupGrepFallback,
LspTimeoutGrepFallback,
LspErrorGrepFallback,
NoLspGrepFallback,
GrepFallbackFileScoped,
GrepFallbackImplScoped,
GrepFallbackGlobal,
UnsupportedLanguageFilterBypassed,
UnsupportedLanguage,
GitError,
}
impl fmt::Display for DegradedReason {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let s = match self {
DegradedReason::NoLsp => "no_lsp",
DegradedReason::LspWarmupEmptyUnverified => "lsp_warmup_empty_unverified",
DegradedReason::LspWarmupGrepFallback => "lsp_warmup_grep_fallback",
DegradedReason::LspTimeoutGrepFallback => "lsp_timeout_grep_fallback",
DegradedReason::LspErrorGrepFallback => "lsp_error_grep_fallback",
DegradedReason::NoLspGrepFallback => "no_lsp_grep_fallback",
DegradedReason::GrepFallbackFileScoped => "grep_fallback_file_scoped",
DegradedReason::GrepFallbackImplScoped => "grep_fallback_impl_scoped",
DegradedReason::GrepFallbackGlobal => "grep_fallback_global",
DegradedReason::UnsupportedLanguageFilterBypassed => {
"unsupported_language_filter_bypassed"
}
DegradedReason::UnsupportedLanguage => "unsupported_language",
DegradedReason::GitError => "git_error",
};
write!(f, "{s}")
}
}
#[cfg(test)]
#[allow(clippy::expect_used)]
mod tests {
use super::*;
#[test]
fn test_semantic_path_with_symbol() {
let sp = SemanticPath::parse("src/auth.ts::AuthService.login").expect("should parse");
assert_eq!(sp.file_path, PathBuf::from("src/auth.ts"));
assert!(!sp.is_bare_file());
let chain = sp.symbol_chain.as_ref().expect("should have symbol chain");
assert_eq!(chain.segments.len(), 2);
assert_eq!(chain.segments[0].name, "AuthService");
assert_eq!(chain.segments[1].name, "login");
}
#[test]
fn test_semantic_path_bare_file() {
let sp = SemanticPath::parse("src/utils.ts").expect("should parse");
assert_eq!(sp.file_path, PathBuf::from("src/utils.ts"));
assert!(sp.is_bare_file());
}
#[test]
fn test_semantic_path_with_overload() {
let sp =
SemanticPath::parse("src/auth.ts::AuthService.refreshToken#2").expect("should parse");
let chain = sp.symbol_chain.as_ref().expect("should have symbol chain");
let last = chain.segments.last().expect("should have segments");
assert_eq!(last.name, "refreshToken");
assert_eq!(last.overload_index, Some(2));
}
#[test]
fn test_semantic_path_display_roundtrip() {
let input = "src/auth.ts::AuthService.login#2";
let sp = SemanticPath::parse(input).expect("should parse");
assert_eq!(sp.to_string(), input);
}
#[test]
fn test_semantic_path_empty_input() {
assert!(SemanticPath::parse("").is_none());
}
#[test]
fn test_semantic_path_empty_file_part() {
assert!(SemanticPath::parse("::AuthService").is_none());
}
#[test]
fn test_semantic_path_default_export() {
let sp = SemanticPath::parse("src/auth.ts::default").expect("should parse");
let chain = sp.symbol_chain.as_ref().expect("should have chain");
assert_eq!(chain.segments.len(), 1);
assert_eq!(chain.segments[0].name, "default");
}
#[test]
fn test_version_hash_compute() {
let hash = VersionHash::compute(b"hello world");
assert!(hash.as_str().starts_with("sha256:"));
assert!(hash.as_str().contains("b94d27b9934d3e08a52e52d7"));
}
#[test]
fn test_version_hash_equality() {
let h1 = VersionHash::compute(b"same content");
let h2 = VersionHash::compute(b"same content");
assert_eq!(h1, h2);
let h3 = VersionHash::compute(b"different content");
assert_ne!(h1, h3);
}
#[test]
fn test_version_hash_short_is_7_hex_chars() {
let hash = VersionHash::compute(b"hello world");
let s = hash.short();
assert_eq!(s.len(), 7, "short() must be exactly 7 chars");
assert!(
s.chars().all(|c| c.is_ascii_hexdigit()),
"short() must be hex chars only, got: {s}"
);
}
#[test]
fn test_version_hash_short_has_no_prefix() {
let hash = VersionHash::compute(b"test content");
assert!(
!hash.short().starts_with("sha256:"),
"short() must not start with 'sha256:'"
);
}
#[test]
fn test_version_hash_short_is_prefix_of_full_hex() {
let hash = VersionHash::compute(b"hello world");
let full = hash.as_str(); assert!(
full["sha256:".len()..].starts_with(hash.short()),
"full hex must start with short()"
);
}
#[test]
fn test_matches_short_no_prefix() {
let hash = VersionHash::compute(b"hello world");
assert!(
hash.matches(hash.short()),
"hash.matches(hash.short()) must be true — roundtrip test"
);
}
#[test]
fn test_matches_short_with_legacy_prefix() {
let hash = VersionHash::compute(b"hello world");
let with_prefix = format!("sha256:{}", hash.short());
assert!(
hash.matches(&with_prefix),
"7-char hash with sha256: prefix must match"
);
}
#[test]
fn test_matches_full_hash_with_prefix() {
let hash = VersionHash::compute(b"hello world");
assert!(
hash.matches(hash.as_str()),
"full hash as_str() must match itself"
);
}
#[test]
fn test_matches_8_char_prefix_accepted() {
let hash = VersionHash::compute(b"hello world");
let eight = &hash.as_str()["sha256:".len().."sha256:".len() + 8];
assert!(hash.matches(eight), "8-char prefix must be accepted");
}
#[test]
fn test_matches_too_short_rejected() {
let hash = VersionHash::compute(b"hello world");
assert!(!hash.matches("e3dc7f"), "6 hex chars must be rejected");
assert!(
!hash.matches("sha256:abc"),
"3 hex chars with prefix rejected"
);
assert!(!hash.matches(""), "empty string must be rejected");
}
#[test]
fn test_matches_wrong_hex_fails() {
let hash = VersionHash::compute(b"hello world");
assert!(!hash.matches("0000000"), "wrong 7-char hex must not match");
assert!(
!hash.matches("sha256:0000000"),
"wrong prefixed hex must not match"
);
}
#[test]
fn test_matches_different_content_fails() {
let hash_a = VersionHash::compute(b"content A");
let hash_b = VersionHash::compute(b"content B");
assert!(
!hash_a.matches(hash_b.short()),
"short hash from different content must not match"
);
}
#[test]
fn test_filter_mode_default() {
assert_eq!(FilterMode::default(), FilterMode::CodeOnly);
}
#[test]
fn test_resolve_path_traversal_is_detected() {
let dir = tempfile::tempdir().expect("create tempdir");
let root = WorkspaceRoot::new(dir.path()).expect("create workspace root");
let traversal = std::path::Path::new("../../etc/passwd");
let resolved = root.resolve(traversal);
assert!(resolved.to_string_lossy().contains("etc/passwd"));
}
#[test]
fn test_resolve_strict_rejects_traversal() {
let dir = tempfile::tempdir().expect("create tempdir");
let root = WorkspaceRoot::new(dir.path()).expect("create workspace root");
let traversal = std::path::Path::new("../../etc/passwd");
let result = root.resolve_strict(traversal);
assert!(result.is_err());
assert!(matches!(result, Err(PathfinderError::PathTraversal { .. })));
}
#[test]
fn test_resolve_strict_rejects_absolute_path() {
let dir = tempfile::tempdir().expect("create tempdir");
let root = WorkspaceRoot::new(dir.path()).expect("create workspace root");
let absolute = std::path::Path::new("/etc/passwd");
let result = root.resolve_strict(absolute);
assert!(result.is_err());
assert!(matches!(result, Err(PathfinderError::PathTraversal { .. })));
}
#[test]
fn test_resolve_strict_accepts_relative_path() {
let dir = tempfile::tempdir().expect("create tempdir");
let root = WorkspaceRoot::new(dir.path()).expect("create workspace root");
let relative = std::path::Path::new("src/main.rs");
let result = root.resolve_strict(relative);
assert!(result.is_ok());
let resolved = result.expect("should be Ok");
assert!(resolved.to_string_lossy().contains("src/main.rs"));
}
#[test]
fn test_degraded_reason_serde_snake_case() {
use super::DegradedReason;
assert_eq!(
serde_json::to_string(&DegradedReason::NoLsp).expect("NoLsp should serialize to JSON"),
"\"no_lsp\""
);
assert_eq!(
serde_json::to_string(&DegradedReason::LspWarmupGrepFallback)
.expect("LspWarmupGrepFallback should serialize to JSON"),
"\"lsp_warmup_grep_fallback\""
);
assert_eq!(
serde_json::to_string(&DegradedReason::GitError)
.expect("GitError should serialize to JSON"),
"\"git_error\""
);
}
#[test]
fn test_degraded_reason_display() {
use super::DegradedReason;
assert_eq!(DegradedReason::NoLsp.to_string(), "no_lsp");
assert_eq!(
DegradedReason::LspWarmupEmptyUnverified.to_string(),
"lsp_warmup_empty_unverified"
);
assert_eq!(
DegradedReason::GrepFallbackGlobal.to_string(),
"grep_fallback_global"
);
}
}