use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use crate::output::rich::{SpanChecksums, SpanContext, SpanRelationships, SpanSemantics};
pub const MAGELLAN_JSON_SCHEMA_VERSION: &str = "1.0.0";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct JsonResponse<T> {
pub schema_version: String,
pub execution_id: String,
pub data: T,
#[serde(skip_serializing_if = "Option::is_none")]
pub tool: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub timestamp: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub partial: Option<bool>,
}
impl<T> JsonResponse<T> {
pub fn new(data: T, execution_id: &str) -> Self {
JsonResponse {
schema_version: MAGELLAN_JSON_SCHEMA_VERSION.to_string(),
execution_id: execution_id.to_string(),
tool: Some("magellan".to_string()),
timestamp: Some(chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true)),
data,
partial: None,
}
}
pub fn with_partial(mut self, partial: bool) -> Self {
self.partial = Some(partial);
self
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct Span {
pub span_id: String,
pub file_path: String,
pub byte_start: usize,
pub byte_end: usize,
pub start_line: usize,
pub start_col: usize,
pub end_line: usize,
pub end_col: usize,
#[serde(skip_serializing_if = "Option::is_none")]
pub context: Option<SpanContext>,
#[serde(skip_serializing_if = "Option::is_none")]
pub semantics: Option<SpanSemantics>,
#[serde(skip_serializing_if = "Option::is_none")]
pub relationships: Option<SpanRelationships>,
#[serde(skip_serializing_if = "Option::is_none")]
pub checksums: Option<SpanChecksums>,
}
impl Span {
pub fn generate_id(file_path: &str, byte_start: usize, byte_end: usize) -> String {
let mut hasher = Sha256::new();
hasher.update(file_path.as_bytes());
hasher.update(b":");
hasher.update(byte_start.to_be_bytes());
hasher.update(b":");
hasher.update(byte_end.to_be_bytes());
let result = hasher.finalize();
format!(
"{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}",
result[0], result[1], result[2], result[3], result[4], result[5], result[6], result[7]
)
}
pub fn new(
file_path: String,
byte_start: usize,
byte_end: usize,
start_line: usize,
start_col: usize,
end_line: usize,
end_col: usize,
) -> Self {
let span_id = Self::generate_id(&file_path, byte_start, byte_end);
Span {
span_id,
file_path,
byte_start,
byte_end,
start_line,
start_col,
end_line,
end_col,
context: None,
semantics: None,
relationships: None,
checksums: None,
}
}
pub fn with_context(mut self, context: SpanContext) -> Self {
self.context = Some(context);
self
}
pub fn with_semantics(mut self, semantics: SpanSemantics) -> Self {
self.semantics = Some(semantics);
self
}
pub fn with_semantics_from(mut self, kind: String, language: String) -> Self {
self.semantics = Some(SpanSemantics::new(kind, language));
self
}
pub fn with_relationships(mut self, relationships: SpanRelationships) -> Self {
self.relationships = Some(relationships);
self
}
pub fn with_checksums(mut self, checksums: SpanChecksums) -> Self {
self.checksums = Some(checksums);
self
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SymbolMatch {
pub match_id: String,
pub span: Span,
pub name: String,
pub kind: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub parent: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub symbol_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub callers: Option<Vec<CallerInfo>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub callees: Option<Vec<CalleeInfo>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CallerInfo {
pub name: String,
pub file_path: String,
pub line: usize,
pub column: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CalleeInfo {
pub name: String,
pub file_path: String,
}
impl SymbolMatch {
pub fn generate_match_id(symbol_name: &str, file_path: &str, byte_start: usize) -> String {
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
let mut hasher = DefaultHasher::new();
symbol_name.hash(&mut hasher);
file_path.hash(&mut hasher);
byte_start.hash(&mut hasher);
format!("{:x}", hasher.finish())
}
pub fn new(
name: String,
kind: String,
span: Span,
parent: Option<String>,
symbol_id: Option<String>,
) -> Self {
let match_id = Self::generate_match_id(&name, &span.file_path, span.byte_start);
SymbolMatch {
match_id,
span,
name,
kind,
parent,
symbol_id,
callers: None,
callees: None,
}
}
pub fn with_callers_and_callees(
mut self,
callers: Option<Vec<CallerInfo>>,
callees: Option<Vec<CalleeInfo>>,
) -> Self {
self.callers = callers;
self.callees = callees;
self
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ReferenceMatch {
pub match_id: String,
pub span: Span,
pub referenced_symbol: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub reference_kind: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub target_symbol_id: Option<String>,
}
impl ReferenceMatch {
pub fn generate_match_id(
referenced_symbol: &str,
file_path: &str,
byte_start: usize,
) -> String {
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
let mut hasher = DefaultHasher::new();
referenced_symbol.hash(&mut hasher);
file_path.hash(&mut hasher);
byte_start.hash(&mut hasher);
format!("ref_{:x}", hasher.finish())
}
pub fn new(
span: Span,
referenced_symbol: String,
reference_kind: Option<String>,
target_symbol_id: Option<String>,
) -> Self {
let match_id =
Self::generate_match_id(&referenced_symbol, &span.file_path, span.byte_start);
ReferenceMatch {
match_id,
span,
referenced_symbol,
reference_kind,
target_symbol_id,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct QueryResponse {
pub symbols: Vec<SymbolMatch>,
pub file_path: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub kind_filter: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FindResponse {
pub matches: Vec<SymbolMatch>,
pub query_name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub file_filter: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RefsResponse {
pub references: Vec<ReferenceMatch>,
pub symbol_name: String,
pub file_path: String,
pub direction: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProjectCallerInfo {
pub project: String,
pub name: String,
pub file_path: String,
pub line: usize,
pub column: usize,
#[serde(skip_serializing_if = "Option::is_none")]
pub depth: Option<usize>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProjectCalleeInfo {
pub project: String,
pub name: String,
pub file_path: String,
pub line: u32,
#[serde(skip_serializing_if = "Option::is_none")]
pub depth: Option<usize>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProjectSymbolMatch {
pub project: String,
pub match_id: String,
pub span: Span,
pub name: String,
pub kind: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub parent: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub symbol_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub callers: Option<Vec<ProjectCallerInfo>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub callees: Option<Vec<ProjectCalleeInfo>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub source: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ContextResponse {
pub query: String,
pub projects: Vec<String>,
pub matches: Vec<ProjectSymbolMatch>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CollisionCandidate {
pub entity_id: i64,
pub symbol_id: Option<String>,
pub canonical_fqn: Option<String>,
pub display_fqn: Option<String>,
pub name: Option<String>,
pub file_path: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CollisionGroup {
pub field: String,
pub value: String,
pub count: usize,
pub candidates: Vec<CollisionCandidate>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CollisionsResponse {
pub field: String,
pub groups: Vec<CollisionGroup>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FilesResponse {
pub files: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub symbol_counts: Option<std::collections::HashMap<String, usize>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StatusResponse {
pub files: usize,
pub symbols: usize,
pub references: usize,
pub calls: usize,
pub code_chunks: usize,
pub coverage: CoverageInfo,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CoverageInfo {
pub available: bool,
pub covered_blocks: usize,
pub covered_edges: usize,
#[serde(skip_serializing_if = "Option::is_none")]
pub source: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub revision: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub ingested_at: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ValidationResponse {
pub passed: bool,
pub error_count: usize,
pub errors: Vec<ValidationError>,
pub warning_count: usize,
pub warnings: Vec<ValidationWarning>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MigrateResponse {
pub success: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub backup_path: Option<String>,
pub old_version: i64,
pub new_version: i64,
pub message: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ValidationError {
pub code: String,
pub message: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub entity_id: Option<String>,
#[serde(skip_serializing_if = "serde_json::Value::is_null")]
pub details: serde_json::Value,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ValidationWarning {
pub code: String,
pub message: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub entity_id: Option<String>,
#[serde(skip_serializing_if = "serde_json::Value::is_null")]
pub details: serde_json::Value,
}
impl From<crate::graph::validation::ValidationReport> for ValidationResponse {
fn from(report: crate::graph::validation::ValidationReport) -> Self {
ValidationResponse {
passed: report.passed,
error_count: report.errors.len(),
errors: report
.errors
.into_iter()
.map(|e| ValidationError {
code: e.code,
message: e.message,
entity_id: e.entity_id,
details: e.details,
})
.collect(),
warning_count: report.warnings.len(),
warnings: report
.warnings
.into_iter()
.map(|w| ValidationWarning {
code: w.code,
message: w.message,
entity_id: w.entity_id,
details: w.details,
})
.collect(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ErrorResponse {
#[serde(skip_serializing_if = "Option::is_none")]
pub code: Option<String>,
pub error: String,
pub message: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub span: Option<Span>,
#[serde(skip_serializing_if = "Option::is_none")]
pub remediation: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OutputFormat {
Human,
Json,
Pretty,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SliceResponse {
pub target: SymbolMatch,
pub direction: String,
pub included_symbols: Vec<SymbolMatch>,
pub statistics: SliceStats,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SliceStats {
pub total_symbols: usize,
pub data_dependencies: usize,
pub control_dependencies: usize,
}
impl OutputFormat {
pub fn parse(s: &str) -> Option<Self> {
match s.to_lowercase().as_str() {
"human" | "text" => Some(OutputFormat::Human),
"json" => Some(OutputFormat::Json),
"pretty" => Some(OutputFormat::Pretty),
_ => None,
}
}
}
pub fn generate_execution_id() -> String {
use std::process;
use std::time::{SystemTime, UNIX_EPOCH};
let timestamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let pid = process::id();
format!("{:x}-{:x}", timestamp, pid)
}
pub fn output_json<T: Serialize>(data: &T, format: OutputFormat) -> anyhow::Result<()> {
let json = match format {
OutputFormat::Json => serde_json::to_string(data)?,
OutputFormat::Pretty => serde_json::to_string_pretty(data)?,
OutputFormat::Human => anyhow::bail!("Human format not supported for JSON output"),
};
println!("{}", json);
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_span_generate_id_is_deterministic() {
let id1 = Span::generate_id("test.rs", 10, 20);
let id2 = Span::generate_id("test.rs", 10, 20);
let id3 = Span::generate_id("test.rs", 10, 21);
assert_eq!(id1, id2, "Same inputs should produce same ID");
assert_ne!(id1, id3, "Different inputs should produce different IDs");
}
#[test]
fn test_span_generate_id_format() {
let id = Span::generate_id("test.rs", 10, 20);
assert_eq!(id.len(), 16, "Span ID should be 16 characters: {}", id);
assert!(
id.chars().all(|c| c.is_ascii_hexdigit()),
"Span ID should be hex: {}",
id
);
let expected = Span::generate_id("test.rs", 10, 20);
assert_eq!(id, expected);
}
#[test]
fn test_symbol_match_generate_id_is_deterministic() {
let id1 = SymbolMatch::generate_match_id("foo", "test.rs", 10);
let id2 = SymbolMatch::generate_match_id("foo", "test.rs", 10);
let id3 = SymbolMatch::generate_match_id("bar", "test.rs", 10);
assert_eq!(id1, id2);
assert_ne!(id1, id3);
}
#[test]
fn test_reference_match_generate_id_is_deterministic() {
let id1 = ReferenceMatch::generate_match_id("foo", "test.rs", 10);
let id2 = ReferenceMatch::generate_match_id("foo", "test.rs", 10);
let id3 = ReferenceMatch::generate_match_id("bar", "test.rs", 10);
assert_eq!(id1, id2);
assert_ne!(id1, id3);
}
#[test]
fn test_execution_id_format() {
let id = generate_execution_id();
assert!(
id.contains('-'),
"Execution ID should contain separator: {}",
id
);
let parts: Vec<&str> = id.split('-').collect();
assert_eq!(parts.len(), 2, "Execution ID should have 2 parts: {}", id);
assert!(usize::from_str_radix(parts[0], 16).is_ok());
assert!(usize::from_str_radix(parts[1], 16).is_ok());
}
#[test]
fn test_json_response_serialization() {
let response = JsonResponse::new(
FilesResponse {
files: vec!["a.rs".to_string(), "b.rs".to_string()],
symbol_counts: None,
},
"test-exec-123",
);
let json = serde_json::to_string(&response).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(parsed["schema_version"], MAGELLAN_JSON_SCHEMA_VERSION);
assert_eq!(parsed["execution_id"], "test-exec-123");
assert_eq!(parsed["data"]["files"].as_array().unwrap().len(), 2);
}
#[test]
fn test_output_format_from_str() {
assert_eq!(OutputFormat::parse("json"), Some(OutputFormat::Json));
assert_eq!(OutputFormat::parse("JSON"), Some(OutputFormat::Json));
assert_eq!(OutputFormat::parse("pretty"), Some(OutputFormat::Pretty));
assert_eq!(OutputFormat::parse("PRETTY"), Some(OutputFormat::Pretty));
assert_eq!(OutputFormat::parse("human"), Some(OutputFormat::Human));
assert_eq!(OutputFormat::parse("text"), Some(OutputFormat::Human));
assert_eq!(OutputFormat::parse("invalid"), None);
}
#[test]
fn test_status_response_serialization_with_coverage() {
let response = StatusResponse {
files: 10,
symbols: 100,
references: 50,
calls: 25,
code_chunks: 200,
coverage: CoverageInfo {
available: true,
covered_blocks: 5,
covered_edges: 3,
source: Some("lcov".to_string()),
revision: Some("abc123".to_string()),
ingested_at: Some("2026-04-25T12:00:00Z".to_string()),
},
};
let json = serde_json::to_string(&response).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(parsed["files"], 10);
assert_eq!(parsed["symbols"], 100);
assert_eq!(parsed["references"], 50);
assert_eq!(parsed["calls"], 25);
assert_eq!(parsed["code_chunks"], 200);
assert_eq!(parsed["coverage"]["available"], true);
assert_eq!(parsed["coverage"]["covered_blocks"], 5);
assert_eq!(parsed["coverage"]["covered_edges"], 3);
assert_eq!(parsed["coverage"]["source"], "lcov");
assert_eq!(parsed["coverage"]["revision"], "abc123");
assert_eq!(parsed["coverage"]["ingested_at"], "2026-04-25T12:00:00Z");
}
#[test]
fn test_status_response_serialization_without_coverage() {
let response = StatusResponse {
files: 10,
symbols: 100,
references: 50,
calls: 25,
code_chunks: 200,
coverage: CoverageInfo {
available: false,
covered_blocks: 0,
covered_edges: 0,
source: None,
revision: None,
ingested_at: None,
},
};
let json = serde_json::to_string(&response).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(parsed["files"], 10);
assert_eq!(parsed["coverage"]["available"], false);
assert_eq!(parsed["coverage"]["covered_blocks"], 0);
assert_eq!(parsed["coverage"]["covered_edges"], 0);
assert!(
parsed["coverage"]["source"].is_null() || parsed["coverage"].get("source").is_none()
);
}
#[test]
fn test_error_response_serialization() {
let response = ErrorResponse {
code: None,
error: "file_not_found".to_string(),
message: "The requested file does not exist".to_string(),
span: None,
remediation: None,
};
let json = serde_json::to_string(&response).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(parsed["error"], "file_not_found");
assert_eq!(parsed["message"], "The requested file does not exist");
assert!(parsed.get("code").is_none() || parsed["code"].is_null());
assert!(parsed.get("span").is_none() || parsed["span"].is_null());
assert!(parsed.get("remediation").is_none() || parsed["remediation"].is_null());
}
#[test]
fn test_span_id_deterministic_multiple_calls() {
let file_path = "src/main.rs";
let byte_start = 42;
let byte_end = 100;
let first_id = Span::generate_id(file_path, byte_start, byte_end);
for _ in 0..100 {
let id = Span::generate_id(file_path, byte_start, byte_end);
assert_eq!(
id, first_id,
"generate_id() must return identical ID for same inputs every time"
);
}
}
#[test]
fn test_span_id_unique_different_files() {
let byte_start = 10;
let byte_end = 20;
let id1 = Span::generate_id("src/main.rs", byte_start, byte_end);
let id2 = Span::generate_id("lib/main.rs", byte_start, byte_end);
let id3 = Span::generate_id("src/helper.rs", byte_start, byte_end);
assert_ne!(
id1, id2,
"Different file paths should produce different IDs"
);
assert_ne!(
id1, id3,
"Different file paths should produce different IDs"
);
assert_ne!(
id2, id3,
"Different file paths should produce different IDs"
);
}
#[test]
fn test_span_id_unique_different_positions() {
let file_path = "test.rs";
let id1 = Span::generate_id(file_path, 0, 10);
let id2 = Span::generate_id(file_path, 10, 20);
let id3 = Span::generate_id(file_path, 0, 20);
let id4 = Span::generate_id(file_path, 5, 15);
assert_ne!(id1, id2, "Different positions should produce different IDs");
assert_ne!(
id1, id3,
"Different span lengths should produce different IDs"
);
assert_ne!(id2, id3, "Different positions should produce different IDs");
assert_ne!(
id1, id4,
"Different start positions should produce different IDs"
);
}
#[test]
fn test_span_id_zero_length_span() {
let file_path = "test.rs";
let position = 50;
let id1 = Span::generate_id(file_path, position, position);
let id2 = Span::generate_id(file_path, position, position);
assert_eq!(
id1.len(),
16,
"Zero-length span ID should still be 16 hex characters"
);
assert_eq!(id1, id2, "Zero-length span ID should be stable");
assert!(
id1.chars().all(|c| c.is_ascii_hexdigit()),
"Zero-length span ID should be valid hex"
);
}
#[test]
fn test_span_id_case_sensitive() {
let byte_start = 10;
let byte_end = 20;
let id_lower = Span::generate_id("test.rs", byte_start, byte_end);
let id_upper = Span::generate_id("TEST.rs", byte_start, byte_end);
let id_mixed = Span::generate_id("Test.rs", byte_start, byte_end);
assert_ne!(id_lower, id_upper, "File path case should affect span ID");
assert_ne!(id_lower, id_mixed, "File path case should affect span ID");
assert_ne!(id_upper, id_mixed, "File path case should affect span ID");
}
#[test]
fn test_span_id_large_offsets() {
let file_path = "large_file.rs";
let id1 = Span::generate_id(file_path, 1_000_000, 1_000_100);
let id2 = Span::generate_id(file_path, 1_000_000, 1_000_100);
assert_eq!(id1, id2, "Large offsets should produce stable IDs");
assert_eq!(
id1.len(),
16,
"Large offset span ID should be 16 characters"
);
let id3 = Span::generate_id(file_path, 1_000_001, 1_000_100);
assert_ne!(
id1, id3,
"Different start positions with large offsets should differ"
);
}
#[test]
fn test_span_id_utf8_file_path() {
let byte_start = 0;
let byte_end = 10;
let id1 = Span::generate_id("src/test.rs", byte_start, byte_end);
let id2 = Span::generate_id("src/test.rs", byte_start, byte_end);
let id3 = Span::generate_id("src/test文件.rs", byte_start, byte_end); let id4 = Span::generate_id("src/testфайл.rs", byte_start, byte_end);
assert_eq!(id1, id2, "ASCII path should produce stable ID");
assert_eq!(id1.len(), 16, "ASCII path span ID should be 16 characters");
assert_eq!(
id3.len(),
16,
"Chinese path span ID should be 16 characters"
);
assert_eq!(
id4.len(),
16,
"Cyrillic path span ID should be 16 characters"
);
assert_ne!(
id1, id3,
"Different paths (ASCII vs Chinese) should produce different IDs"
);
assert_ne!(
id1, id4,
"Different paths (ASCII vs Cyrillic) should produce different IDs"
);
assert_ne!(
id3, id4,
"Different paths (Chinese vs Cyrillic) should produce different IDs"
);
}
#[test]
fn test_span_id_multibyte_characters() {
let byte_start = 5;
let byte_end = 15;
let id_emoji = Span::generate_id("src/test.rs", byte_start, byte_end);
let id_with_emoji = Span::generate_id("src/test-test.rs", byte_start, byte_end);
assert_eq!(
id_emoji.len(),
16,
"Span ID with emoji path should be 16 characters"
);
assert_eq!(
id_with_emoji.len(),
16,
"Span ID with emoji in name should be 16 characters"
);
assert_ne!(
id_emoji, id_with_emoji,
"Different paths should produce different IDs"
);
let id_cjk = Span::generate_id("src/テスト.rs", byte_start, byte_end);
assert_eq!(id_cjk.len(), 16, "CJK path span ID should be 16 characters");
let id_korean = Span::generate_id("src/테스트.rs", byte_start, byte_end);
assert_eq!(
id_korean.len(),
16,
"Korean path span ID should be 16 characters"
);
assert_ne!(
id_emoji, id_cjk,
"Different paths (ASCII vs CJK) should differ"
);
assert_ne!(
id_cjk, id_korean,
"Different paths (Japanese vs Korean) should differ"
);
}
#[test]
fn test_utf8_safe_extraction() {
let source = "fn main() { let x = 42; }";
let byte_start = 3;
let byte_end = 7;
let extracted = source.get(byte_start..byte_end);
assert_eq!(
extracted,
Some("main"),
"Safe extraction should work for valid UTF-8"
);
let out_of_bounds = source.get(10..1000);
assert_eq!(
out_of_bounds, None,
"Out of bounds extraction should return None"
);
}
#[test]
fn test_utf8_validation() {
let source = "Hello\u{e9}";
assert!(
source.is_char_boundary(0),
"Byte 0 is always a valid boundary"
);
assert!(
source.is_char_boundary(5),
"After 'Hello' is valid (start of multi-byte char)"
);
assert!(
source.is_char_boundary(7),
"After 'é' is valid (end of string)"
);
assert!(
source.is_char_boundary(source.len()),
"End of string is valid boundary"
);
assert!(
!source.is_char_boundary(6),
"Byte 6 is in the middle of the 2-byte 'é'"
);
}
#[test]
fn test_utf8_validation_three_byte_char() {
let source = "test\u{4e2d}";
assert!(source.is_char_boundary(0), "Start is boundary");
assert!(source.is_char_boundary(4), "After 'test' is boundary");
assert!(source.is_char_boundary(7), "After Chinese char is boundary");
assert!(
source.is_char_boundary(source.len()),
"End of string is valid"
);
assert!(
!source.is_char_boundary(5),
"Byte 5 is in the middle of 3-byte char"
);
assert!(
!source.is_char_boundary(6),
"Byte 6 is in the middle of 3-byte char"
);
}
#[test]
fn test_span_id_unicode_normalization_difference() {
let byte_start = 0;
let byte_end = 10;
let decomposed = "cafe\u{0301}.rs"; let id1 = Span::generate_id(decomposed, byte_start, byte_end);
let precomposed = "caf\u{e9}.rs"; let id2 = Span::generate_id(precomposed, byte_start, byte_end);
assert_ne!(
id1, id2,
"Different Unicode representations should produce different span IDs (by design)"
);
}
#[test]
fn test_span_id_with_path_separator_variants() {
let byte_start = 10;
let byte_end = 20;
let id1 = Span::generate_id("src/test.rs", byte_start, byte_end);
let id2 = Span::generate_id("./src/test.rs", byte_start, byte_end);
let id3 = Span::generate_id("/abs/path/src/test.rs", byte_start, byte_end);
assert_ne!(id1, id2, "Relative vs explicit path should differ");
assert_ne!(id1, id3, "Relative vs absolute path should differ");
assert_ne!(id2, id3, "Different path forms should differ");
}
#[test]
fn test_symbol_match_with_symbol_id() {
let span = Span::new("main.rs".into(), 3, 7, 1, 3, 1, 7);
let symbol_id = Some("a1b2c3d4e5f6g7h8".to_string());
let symbol = SymbolMatch::new(
"main".into(),
"Function".into(),
span,
None,
symbol_id.clone(),
);
assert_eq!(symbol.symbol_id, symbol_id);
assert_eq!(symbol.name, "main");
assert_eq!(symbol.kind, "Function");
}
#[test]
fn test_symbol_match_without_symbol_id() {
let span = Span::new("lib.rs".into(), 10, 20, 2, 5, 2, 10);
let symbol = SymbolMatch::new("helper".into(), "Function".into(), span, None, None);
assert_eq!(symbol.symbol_id, None);
assert_eq!(symbol.name, "helper");
}
#[test]
fn test_symbol_match_symbol_id_serialization_includes_when_present() {
let span = Span::new("test.rs".into(), 0, 10, 1, 0, 1, 10);
let symbol = SymbolMatch::new(
"foo".into(),
"Function".into(),
span,
None,
Some("abc123def456".to_string()),
);
let json = serde_json::to_string(&symbol).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
assert!(parsed["symbol_id"].is_string());
assert_eq!(parsed["symbol_id"], "abc123def456");
}
#[test]
fn test_symbol_match_symbol_id_serialization_skips_when_none() {
let span = Span::new("test.rs".into(), 0, 10, 1, 0, 1, 10);
let symbol = SymbolMatch::new("foo".into(), "Function".into(), span, None, None);
let json = serde_json::to_string(&symbol).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
assert!(parsed.get("symbol_id").is_none());
}
#[test]
fn test_symbol_match_symbol_id_deserialization() {
let json_with_id = r#"{
"match_id": "12345",
"span": {
"span_id": "abcd1234",
"file_path": "main.rs",
"byte_start": 3,
"byte_end": 7,
"start_line": 1,
"start_col": 3,
"end_line": 1,
"end_col": 7
},
"name": "main",
"kind": "Function",
"symbol_id": "xyz789"
}"#;
let symbol: SymbolMatch = serde_json::from_str(json_with_id).unwrap();
assert_eq!(symbol.symbol_id, Some("xyz789".to_string()));
assert_eq!(symbol.name, "main");
}
#[test]
fn test_symbol_match_symbol_id_deserialization_without_id() {
let json_without_id = r#"{
"match_id": "12345",
"span": {
"span_id": "abcd1234",
"file_path": "main.rs",
"byte_start": 3,
"byte_end": 7,
"start_line": 1,
"start_col": 3,
"end_line": 1,
"end_col": 7
},
"name": "main",
"kind": "Function"
}"#;
let symbol: SymbolMatch = serde_json::from_str(json_without_id).unwrap();
assert_eq!(symbol.symbol_id, None);
assert_eq!(symbol.name, "main");
}
#[test]
fn test_span_builder_with_context() {
use crate::output::rich::SpanContext;
let context = SpanContext {
before: vec!["before".to_string()],
selected: vec!["selected".to_string()],
after: vec!["after".to_string()],
};
let span = Span::new("test.rs".into(), 0, 10, 1, 0, 1, 10).with_context(context.clone());
assert!(span.context.is_some());
assert_eq!(span.context.as_ref().unwrap().before[0], "before");
assert_eq!(span.context.as_ref().unwrap().selected[0], "selected");
assert_eq!(span.context.as_ref().unwrap().after[0], "after");
}
#[test]
fn test_span_builder_with_semantics() {
use crate::output::rich::SpanSemantics;
let semantics = SpanSemantics::new("function".to_string(), "rust".to_string());
let span =
Span::new("test.rs".into(), 0, 10, 1, 0, 1, 10).with_semantics(semantics.clone());
assert!(span.semantics.is_some());
assert_eq!(
span.semantics.as_ref().unwrap().kind,
Some("function".to_string())
);
assert_eq!(
span.semantics.as_ref().unwrap().language,
Some("rust".to_string())
);
}
#[test]
fn test_span_builder_with_semantics_from() {
let span = Span::new("test.rs".into(), 0, 10, 1, 0, 1, 10)
.with_semantics_from("function".to_string(), "rust".to_string());
assert!(span.semantics.is_some());
assert_eq!(
span.semantics.as_ref().unwrap().kind,
Some("function".to_string())
);
assert_eq!(
span.semantics.as_ref().unwrap().language,
Some("rust".to_string())
);
}
#[test]
fn test_span_builder_with_relationships() {
use crate::output::rich::{SpanRelationships, SymbolReference};
let relationships = SpanRelationships {
callers: vec![SymbolReference {
file: "caller.rs".to_string(),
symbol: "caller".to_string(),
byte_start: 0,
byte_end: 10,
line: 1,
}],
..Default::default()
};
let span =
Span::new("test.rs".into(), 0, 10, 1, 0, 1, 10).with_relationships(relationships);
assert!(span.relationships.is_some());
assert_eq!(span.relationships.as_ref().unwrap().callers.len(), 1);
assert_eq!(
span.relationships.as_ref().unwrap().callers[0].symbol,
"caller"
);
}
#[test]
fn test_span_builder_with_checksums() {
use crate::output::rich::SpanChecksums;
let checksums = SpanChecksums {
checksum_before: Some("sha256:abc123".to_string()),
file_checksum_before: Some("sha256:def456".to_string()),
};
let span = Span::new("test.rs".into(), 0, 10, 1, 0, 1, 10).with_checksums(checksums);
assert!(span.checksums.is_some());
assert_eq!(
span.checksums.as_ref().unwrap().checksum_before,
Some("sha256:abc123".to_string())
);
assert_eq!(
span.checksums.as_ref().unwrap().file_checksum_before,
Some("sha256:def456".to_string())
);
}
#[test]
fn test_span_serialization_skips_none_rich_fields() {
let span = Span::new("test.rs".into(), 0, 10, 1, 0, 1, 10);
let json = serde_json::to_string(&span).unwrap();
let value: serde_json::Value = serde_json::from_str(&json).unwrap();
assert!(value.get("context").is_none() || value["context"].is_null());
assert!(value.get("semantics").is_none() || value["semantics"].is_null());
assert!(value.get("relationships").is_none() || value["relationships"].is_null());
assert!(value.get("checksums").is_none() || value["checksums"].is_null());
}
#[test]
fn test_span_serialization_includes_rich_fields_when_set() {
use crate::output::rich::{SpanContext, SpanSemantics};
let context = SpanContext {
before: vec!["before".to_string()],
selected: vec!["selected".to_string()],
after: vec!["after".to_string()],
};
let semantics = SpanSemantics::new("function".to_string(), "rust".to_string());
let span = Span::new("test.rs".into(), 0, 10, 1, 0, 1, 10)
.with_context(context)
.with_semantics(semantics);
let json = serde_json::to_string(&span).unwrap();
let value: serde_json::Value = serde_json::from_str(&json).unwrap();
assert!(value.get("context").is_some() && !value["context"].is_null());
assert!(value.get("semantics").is_some() && !value["semantics"].is_null());
assert_eq!(value["semantics"]["kind"], "function");
assert_eq!(value["semantics"]["language"], "rust");
}
#[test]
fn test_span_matches_standard_spec() {
let span = Span::new(
"test.rs".to_string(),
10, 20, 1, 5, 2, 3, );
assert_eq!(span.byte_end - span.byte_start, 10);
assert_eq!(span.span_id.len(), 16); assert_eq!(span.file_path, "test.rs");
assert_eq!(span.byte_start, 10);
assert_eq!(span.byte_end, 20);
assert_eq!(span.start_line, 1);
assert_eq!(span.start_col, 5);
assert_eq!(span.end_line, 2);
assert_eq!(span.end_col, 3);
let span2 = Span::new("test.rs".to_string(), 10, 20, 1, 5, 2, 3);
assert_eq!(span.span_id, span2.span_id);
let span3 = Span::new("other.rs".to_string(), 10, 20, 1, 5, 2, 3);
assert_ne!(span.span_id, span3.span_id);
}
#[test]
fn test_span_serialization_includes_all_required_fields() {
let span = Span::new("test.rs".to_string(), 0, 10, 1, 0, 1, 10);
let json = serde_json::to_string(&span).unwrap();
let value: serde_json::Value = serde_json::from_str(&json).unwrap();
assert!(value.get("span_id").is_some());
assert!(value.get("file_path").is_some());
assert!(value.get("byte_start").is_some());
assert!(value.get("byte_end").is_some());
assert!(value.get("start_line").is_some());
assert!(value.get("start_col").is_some());
assert!(value.get("end_line").is_some());
assert!(value.get("end_col").is_some());
assert!(value.get("line_start").is_none());
assert!(value.get("col_start").is_none());
assert!(value.get("line_end").is_none());
assert!(value.get("col_end").is_none());
}
#[test]
fn test_json_response_includes_metadata() {
let response = JsonResponse::new(serde_json::json!({"test": "data"}), "test-execution-123");
assert_eq!(response.schema_version, MAGELLAN_JSON_SCHEMA_VERSION);
assert_eq!(response.execution_id, "test-execution-123");
assert_eq!(response.tool, Some("magellan".to_string()));
assert!(response.timestamp.is_some());
let json_str = serde_json::to_string(&response).unwrap();
let value: serde_json::Value = serde_json::from_str(&json_str).unwrap();
assert_eq!(value["schema_version"], MAGELLAN_JSON_SCHEMA_VERSION);
assert_eq!(value["execution_id"], "test-execution-123");
assert_eq!(value["tool"], "magellan");
assert!(value["timestamp"].is_string());
assert_eq!(value["data"]["test"], "data");
}
#[test]
fn test_json_response_without_optional_fields() {
let mut response =
JsonResponse::new(serde_json::json!({"test": "data"}), "test-execution-123");
response.tool = None;
response.timestamp = None;
let json_str = serde_json::to_string(&response).unwrap();
let value: serde_json::Value = serde_json::from_str(&json_str).unwrap();
assert!(value.get("tool").is_none() || value["tool"].is_null());
assert!(value.get("timestamp").is_none() || value["timestamp"].is_null());
}
}