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 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 {
#[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 fn from_raw(hash: String) -> Self {
Self(hash)
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
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 version_hash: VersionHash,
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::new();
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,
}
#[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_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"));
}
}