use crate::error::LlmError;
use rusqlite::Connection;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::HashMap;
use std::path::Path;
use std::process::Command;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SymbolSet {
pub symbol_ids: Vec<String>,
}
impl SymbolSet {
pub fn from_file(path: &Path) -> Result<Self, LlmError> {
let content = std::fs::read_to_string(path).map_err(LlmError::IoError)?;
serde_json::from_str(&content).map_err(LlmError::JsonError)
}
pub fn validate(&self) -> Result<(), LlmError> {
for symbol_id in &self.symbol_ids {
if symbol_id.len() != 32 {
return Err(LlmError::InvalidQuery {
query: format!(
"Invalid SymbolId format: '{}'. Expected 32 hex characters.",
symbol_id
),
});
}
if !symbol_id.chars().all(|c| c.is_ascii_hexdigit()) {
return Err(LlmError::InvalidQuery {
query: format!(
"Invalid SymbolId format: '{}'. Expected only hexadecimal characters.",
symbol_id
),
});
}
}
Ok(())
}
pub fn is_empty(&self) -> bool {
self.symbol_ids.is_empty()
}
pub fn len(&self) -> usize {
self.symbol_ids.len()
}
}
pub fn check_magellan_available() -> Result<(), LlmError> {
check_magellan_version()
}
fn check_magellan_version() -> Result<(), LlmError> {
let output = Command::new("magellan")
.arg("--version")
.output()
.map_err(|e| match e.kind() {
std::io::ErrorKind::NotFound => LlmError::MagellanNotFound,
_ => LlmError::MagellanExecutionFailed {
algorithm: "version check".to_string(),
stderr: e.to_string(),
},
})?;
if !output.status.success() {
return Err(LlmError::MagellanExecutionFailed {
algorithm: "version check".to_string(),
stderr: String::from_utf8_lossy(&output.stderr).to_string(),
});
}
let version_str = String::from_utf8_lossy(&output.stdout);
let version = parse_magellan_version(&version_str)?;
if version < (2, 1, 0) {
return Err(LlmError::MagellanVersionMismatch {
current: format!("{}.{}.{}", version.0, version.1, version.2),
required: "2.1.0".to_string(),
});
}
Ok(())
}
fn parse_magellan_version(output: &str) -> Result<(u32, u32, u32), LlmError> {
let first_line = output.lines().next().unwrap_or("");
let version_part = first_line
.strip_prefix("magellan")
.unwrap_or("")
.split_whitespace()
.next()
.unwrap_or("");
let parts: Vec<&str> = version_part.split('.').collect();
if parts.len() != 3 {
return Err(LlmError::MagellanExecutionFailed {
algorithm: "version parsing".to_string(),
stderr: format!("Unable to parse version from: {}", first_line),
});
}
let major: u32 = parts[0]
.parse()
.map_err(|_| LlmError::MagellanExecutionFailed {
algorithm: "version parsing".to_string(),
stderr: format!("Invalid major version: {}", parts[0]),
})?;
let minor: u32 = parts[1]
.parse()
.map_err(|_| LlmError::MagellanExecutionFailed {
algorithm: "version parsing".to_string(),
stderr: format!("Invalid minor version: {}", parts[1]),
})?;
let patch: u32 = parts[2]
.parse()
.map_err(|_| LlmError::MagellanExecutionFailed {
algorithm: "version parsing".to_string(),
stderr: format!("Invalid patch version: {}", parts[2]),
})?;
Ok((major, minor, patch))
}
pub fn run_magellan_algorithm(
db_path: &Path,
algorithm: &str,
args: &[&str],
) -> Result<SymbolSet, LlmError> {
check_magellan_available()?;
let mut cmd = Command::new("magellan");
cmd.arg(algorithm)
.arg("--db")
.arg(db_path)
.arg("--output")
.arg("json")
.args(args);
let output = cmd.output().map_err(|e| match e.kind() {
std::io::ErrorKind::NotFound => LlmError::MagellanNotFound,
_ => LlmError::MagellanExecutionFailed {
algorithm: algorithm.to_string(),
stderr: e.to_string(),
},
})?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(LlmError::MagellanExecutionFailed {
algorithm: algorithm.to_string(),
stderr: stderr.to_string(),
});
}
let json_str = String::from_utf8_lossy(&output.stdout);
extract_symbol_ids_from_magellan_json(&json_str, algorithm)
}
fn extract_symbol_ids_from_magellan_json(
json: &str,
algorithm: &str,
) -> Result<SymbolSet, LlmError> {
let parsed: Value = serde_json::from_str(json).map_err(LlmError::JsonError)?;
let symbol_ids = match algorithm {
"reachable" => {
let symbols =
parsed["result"]["symbols"]
.as_array()
.ok_or_else(|| LlmError::InvalidQuery {
query: "Missing 'symbols' array in reachable output".to_string(),
})?;
symbols
.iter()
.filter_map(|s| s["symbol_id"].as_str().map(|id| id.to_string()))
.collect()
}
"dead-code" => {
let dead_symbols = parsed["result"]["dead_symbols"].as_array().ok_or_else(|| {
LlmError::InvalidQuery {
query: "Missing 'dead_symbols' array in dead-code output".to_string(),
}
})?;
dead_symbols
.iter()
.filter_map(|s| s["symbol"]["symbol_id"].as_str().map(|id| id.to_string()))
.collect()
}
"slice" => {
let included_symbols =
parsed["result"]["included_symbols"]
.as_array()
.ok_or_else(|| LlmError::InvalidQuery {
query: "Missing 'included_symbols' array in slice output".to_string(),
})?;
included_symbols
.iter()
.filter_map(|s| s["symbol_id"].as_str().map(|id| id.to_string()))
.collect()
}
"cycles" => {
let cycles =
parsed["result"]["cycles"]
.as_array()
.ok_or_else(|| LlmError::InvalidQuery {
query: "Missing 'cycles' array in cycles output".to_string(),
})?;
let mut ids = Vec::new();
for cycle in cycles {
if let Some(members) = cycle["members"].as_array() {
for member in members {
if let Some(id) = member["symbol_id"].as_str() {
ids.push(id.to_string());
}
}
}
}
ids
}
_ => {
return Err(LlmError::InvalidQuery {
query: format!("Unknown algorithm: {}", algorithm),
});
}
};
Ok(SymbolSet { symbol_ids })
}
pub fn parse_condense_output(
json: &str,
) -> Result<(Vec<String>, std::collections::HashMap<String, String>), LlmError> {
use std::collections::HashMap;
let parsed: Value = serde_json::from_str(json).map_err(LlmError::JsonError)?;
let supernodes = if parsed["data"]["supernodes"].is_array() {
parsed["data"]["supernodes"].as_array()
} else {
parsed["supernodes"].as_array()
}
.ok_or_else(|| LlmError::MagellanExecutionFailed {
algorithm: "condense".to_string(),
stderr: "Missing 'supernodes' array in condense output".to_string(),
})?;
let mut all_symbol_ids = Vec::new();
let mut supernode_map = HashMap::new();
for supernode in supernodes {
let supernode_id = if let Some(id_str) = supernode["id"].as_str() {
id_str.to_string()
} else if let Some(id_num) = supernode["id"].as_u64() {
format!("supernode_{}", id_num)
} else if let Some(id_i64) = supernode["id"].as_i64() {
format!("supernode_{}", id_i64)
} else {
return Err(LlmError::MagellanExecutionFailed {
algorithm: "condense".to_string(),
stderr: "Supernode missing 'id' field".to_string(),
});
};
let members =
supernode["members"]
.as_array()
.ok_or_else(|| LlmError::MagellanExecutionFailed {
algorithm: "condense".to_string(),
stderr: "Supernode missing 'members' array".to_string(),
})?;
for member in members {
let symbol_id =
member["symbol_id"]
.as_str()
.ok_or_else(|| LlmError::MagellanExecutionFailed {
algorithm: "condense".to_string(),
stderr: "Member missing 'symbol_id' field".to_string(),
})?;
all_symbol_ids.push(symbol_id.to_string());
supernode_map.insert(symbol_id.to_string(), supernode_id.to_string());
}
}
Ok((all_symbol_ids, supernode_map))
}
pub fn parse_paths_output(json: &str) -> Result<(Vec<String>, bool), LlmError> {
use std::collections::HashSet;
let parsed: Value = serde_json::from_str(json).map_err(LlmError::JsonError)?;
let paths = if parsed["data"]["paths"].is_array() {
parsed["data"]["paths"].as_array()
} else {
parsed["paths"].as_array()
}
.ok_or_else(|| LlmError::MagellanExecutionFailed {
algorithm: "paths".to_string(),
stderr: "Missing 'paths' array in output".to_string(),
})?;
let mut all_symbol_ids = HashSet::new();
for path in paths {
let symbols =
path["symbols"]
.as_array()
.ok_or_else(|| LlmError::MagellanExecutionFailed {
algorithm: "paths".to_string(),
stderr: "Path missing 'symbols' array".to_string(),
})?;
for symbol in symbols {
let symbol_id =
symbol["symbol_id"]
.as_str()
.ok_or_else(|| LlmError::MagellanExecutionFailed {
algorithm: "paths".to_string(),
stderr: "Symbol missing 'symbol_id' field".to_string(),
})?;
all_symbol_ids.insert(symbol_id.to_string());
}
}
let bounded_hit = parsed["bounded_hit"]
.as_bool()
.or_else(|| parsed["data"]["bounded_hit"].as_bool())
.unwrap_or(false);
Ok((all_symbol_ids.into_iter().collect(), bounded_hit))
}
pub fn parse_symbol_set_file(path: &Path) -> Result<SymbolSet, LlmError> {
let symbol_set = SymbolSet::from_file(path)?;
symbol_set.validate()?;
Ok(symbol_set)
}
#[derive(Debug, Clone, Default)]
pub struct AlgorithmOptions<'a> {
pub from_symbol_set: Option<&'a str>,
pub reachable_from: Option<&'a str>,
pub dead_code_in: Option<&'a str>,
pub in_cycle: Option<&'a str>,
pub slice_backward_from: Option<&'a str>,
pub slice_forward_from: Option<&'a str>,
pub condense: bool,
pub paths_from: Option<&'a str>,
pub paths_to: Option<&'a str>,
}
impl<'a> AlgorithmOptions<'a> {
pub fn is_active(&self) -> bool {
self.from_symbol_set.is_some()
|| self.reachable_from.is_some()
|| self.dead_code_in.is_some()
|| self.in_cycle.is_some()
|| self.slice_backward_from.is_some()
|| self.slice_forward_from.is_some()
|| self.condense
|| self.paths_from.is_some()
}
pub fn new() -> Self {
Self::default()
}
}
pub type AlgorithmFilterResult = Result<(Vec<String>, HashMap<String, String>, bool), LlmError>;
pub fn apply_algorithm_filters(
db_path: &Path,
options: &AlgorithmOptions<'_>,
) -> AlgorithmFilterResult {
if let Some(file_path) = options.from_symbol_set {
let symbol_set = parse_symbol_set_file(Path::new(file_path))?;
symbol_set.validate()?;
return Ok((symbol_set.symbol_ids, HashMap::new(), false));
}
let active_count = [
options.reachable_from.is_some(),
options.dead_code_in.is_some(),
options.in_cycle.is_some(),
options.slice_backward_from.is_some(),
options.slice_forward_from.is_some(),
options.condense,
options.paths_from.is_some(),
]
.iter()
.filter(|&&x| x)
.count();
if active_count > 1 {
return Err(LlmError::InvalidQuery {
query: "Only one one-shot algorithm filter may be specified. Use --from-symbol-set for composed filters.".to_string(),
});
}
if let Some(symbol) = options.reachable_from {
let symbol_id = resolve_fqn_to_symbol_id(db_path, symbol)?;
let args = ["--from", &symbol_id];
return Ok((
run_magellan_algorithm(db_path, "reachable", &args)?.symbol_ids,
HashMap::new(),
false,
));
}
if let Some(symbol) = options.dead_code_in {
let symbol_id = resolve_fqn_to_symbol_id(db_path, symbol)?;
let args = ["--entry", &symbol_id];
return Ok((
run_magellan_algorithm(db_path, "dead-code", &args)?.symbol_ids,
HashMap::new(),
false,
));
}
if let Some(symbol) = options.in_cycle {
let symbol_id = resolve_fqn_to_symbol_id(db_path, symbol)?;
let args = ["--symbol", &symbol_id];
return Ok((
run_magellan_algorithm(db_path, "cycles", &args)?.symbol_ids,
HashMap::new(),
false,
));
}
if let Some(symbol) = options.slice_backward_from {
let symbol_id = resolve_fqn_to_symbol_id(db_path, symbol)?;
let args = ["--target", &symbol_id, "--direction", "backward"];
return Ok((
run_magellan_algorithm(db_path, "slice", &args)?.symbol_ids,
HashMap::new(),
false,
));
}
if let Some(symbol) = options.slice_forward_from {
let symbol_id = resolve_fqn_to_symbol_id(db_path, symbol)?;
let args = ["--target", &symbol_id, "--direction", "forward"];
return Ok((
run_magellan_algorithm(db_path, "slice", &args)?.symbol_ids,
HashMap::new(),
false,
));
}
if options.condense {
let output = Command::new("magellan")
.args([
"condense",
"--db",
&db_path.to_string_lossy(),
"--output",
"json",
])
.output()
.map_err(|e| match e.kind() {
std::io::ErrorKind::NotFound => LlmError::MagellanNotFound,
_ => LlmError::MagellanExecutionFailed {
algorithm: "condense".to_string(),
stderr: format!(
"{}\n\nTry running: magellan condense --db {} for more details",
e,
db_path.to_string_lossy()
),
},
})?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(LlmError::MagellanExecutionFailed {
algorithm: "condense".to_string(),
stderr: format!(
"{}\n\nTry running: magellan condense --db {} for more details",
stderr,
db_path.to_string_lossy()
),
});
}
let json = String::from_utf8_lossy(&output.stdout);
let (symbol_ids, supernode_map) = parse_condense_output(&json)?;
return Ok((symbol_ids, supernode_map, false));
}
if let Some(start_symbol) = options.paths_from {
let start_id = resolve_fqn_to_symbol_id(db_path, start_symbol)?;
let db_path_str = db_path.to_string_lossy().to_string();
let mut args = vec![
"paths".to_string(),
"--db".to_string(),
db_path_str.clone(),
"--start".to_string(),
start_id.clone(),
"--max-depth".to_string(),
"100".to_string(),
"--max-paths".to_string(),
"1000".to_string(),
"--output".to_string(),
"json".to_string(),
];
if let Some(end_symbol) = options.paths_to {
let end_id = resolve_fqn_to_symbol_id(db_path, end_symbol)?;
args.push("--end".to_string());
args.push(end_id);
}
let args_refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
let output = Command::new("magellan")
.args(&args_refs)
.output()
.map_err(|e| match e.kind() {
std::io::ErrorKind::NotFound => LlmError::MagellanNotFound,
_ => LlmError::MagellanExecutionFailed {
algorithm: "paths".to_string(),
stderr: format!(
"{}\n\nTry running: magellan paths --db {} --start {} for more details",
e, db_path_str, start_id
),
},
})?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(LlmError::MagellanExecutionFailed {
algorithm: "paths".to_string(),
stderr: format!(
"{}\n\nTry running: magellan paths --db {} --start {} for more details",
stderr, db_path_str, start_id
),
});
}
let json = String::from_utf8_lossy(&output.stdout);
let (symbol_ids, bounded_hit) = parse_paths_output(&json)?;
return Ok((symbol_ids, HashMap::new(), bounded_hit));
}
Ok((Vec::new(), HashMap::new(), false))
}
const SYMBOL_SET_TEMP_TABLE_THRESHOLD: usize = 1000;
pub fn create_symbol_set_temp_table(
conn: &Connection,
symbol_ids: &[String],
) -> Result<String, LlmError> {
let table_name = format!("symbol_set_filter_{}", std::process::id());
conn.execute(
&format!(
"CREATE TEMP TABLE {} (symbol_id TEXT PRIMARY KEY)",
table_name
),
[],
)
.map_err(LlmError::SqliteError)?;
let mut stmt = conn
.prepare(&format!(
"INSERT INTO {} (symbol_id) VALUES (?)",
table_name
))
.map_err(LlmError::SqliteError)?;
for symbol_id in symbol_ids {
stmt.execute([symbol_id]).map_err(LlmError::SqliteError)?;
}
Ok(table_name)
}
pub fn symbol_set_filter_strategy(symbol_ids: &[String]) -> SymbolSetStrategy {
if symbol_ids.is_empty() {
SymbolSetStrategy::None
} else if symbol_ids.len() > SYMBOL_SET_TEMP_TABLE_THRESHOLD {
SymbolSetStrategy::TempTable
} else {
SymbolSetStrategy::InClause
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum SymbolSetStrategy {
None,
InClause,
TempTable,
}
pub fn resolve_fqn_to_symbol_id(db_path: &Path, name: &str) -> Result<String, LlmError> {
check_magellan_available()?;
let output = Command::new("magellan")
.args([
"find",
"--db",
&db_path.to_string_lossy(),
"--name",
name,
"--output",
"json",
])
.output()
.map_err(|e| match e.kind() {
std::io::ErrorKind::NotFound => LlmError::MagellanNotFound,
_ => LlmError::SearchFailed {
reason: format!("Failed to execute magellan find: {}", e),
},
})?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(LlmError::SearchFailed {
reason: format!("magellan find failed: {}", stderr),
});
}
let json: Value = serde_json::from_slice(&output.stdout).map_err(LlmError::JsonError)?;
let matches = json
.get("data")
.and_then(|d| d.get("matches"))
.and_then(|m| m.as_array())
.ok_or_else(|| LlmError::InvalidQuery {
query: "Invalid magellan find output: missing 'data.matches' array".to_string(),
})?;
if matches.len() > 1 {
return Err(LlmError::AmbiguousSymbolName {
name: name.to_string(),
count: matches.len(),
});
}
if matches.is_empty() {
return Err(LlmError::InvalidQuery {
query: format!("Symbol '{}' not found in database", name),
});
}
matches[0]["symbol_id"]
.as_str()
.map(|id| id.to_string())
.ok_or_else(|| LlmError::InvalidQuery {
query: format!(
"Symbol '{}' found but missing symbol_id in magellan output",
name
),
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_symbol_set_validation_valid() {
let symbol_set = SymbolSet {
symbol_ids: vec![
"abc123def456789012345678901234ab".to_string(),
"0123456789abcdef0123456789abcdef".to_string(),
"ffffffffffffffffffffffffffffffff".to_string(),
],
};
assert!(symbol_set.validate().is_ok());
assert_eq!(symbol_set.len(), 3);
assert!(!symbol_set.is_empty());
}
#[test]
fn test_symbol_set_validation_invalid_length() {
let symbol_set = SymbolSet {
symbol_ids: vec!["abc123".to_string()],
};
assert!(symbol_set.validate().is_err());
}
#[test]
fn test_symbol_set_validation_invalid_chars() {
let symbol_set = SymbolSet {
symbol_ids: vec!["abc123def456789012345678901234g!".to_string()],
};
assert!(symbol_set.validate().is_err());
}
#[test]
fn test_symbol_set_empty() {
let symbol_set = SymbolSet { symbol_ids: vec![] };
assert!(symbol_set.validate().is_ok());
assert_eq!(symbol_set.len(), 0);
assert!(symbol_set.is_empty());
}
#[test]
fn test_symbol_set_json_deserialize() {
let json = r#"{"symbol_ids": ["abc123def456789012345678901234ab"]}"#;
let symbol_set: SymbolSet = serde_json::from_str(json).unwrap();
assert_eq!(symbol_set.symbol_ids.len(), 1);
assert_eq!(symbol_set.symbol_ids[0], "abc123def456789012345678901234ab");
}
#[test]
fn test_symbol_set_json_serialize() {
let symbol_set = SymbolSet {
symbol_ids: vec!["abc123def456789012345678901234ab".to_string()],
};
let json = serde_json::to_string(&symbol_set).unwrap();
assert!(json.contains("symbol_ids"));
assert!(json.contains("abc123def456789012345678901234ab"));
}
}