use std::collections::BTreeMap;
use std::fmt::Write;
use rmcp::schemars::{self, JsonSchema};
use serde::{Deserialize, Serialize};
use crate::index::format::{FileEntry, ReferenceEntry, SymbolOutput, TextEntry};
use crate::utils::manifest::ProjectMetadata;
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, JsonSchema)]
#[serde(rename_all = "lowercase")]
pub enum OutputFormat {
#[default]
Json,
Text,
}
impl std::str::FromStr for OutputFormat {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
"json" => Ok(OutputFormat::Json),
"text" => Ok(OutputFormat::Text),
_ => Err(format!("invalid format '{}', expected 'json' or 'text'", s)),
}
}
}
pub fn format_search_results(
results: &[EnrichedSearchResult],
format: OutputFormat,
) -> Result<String, serde_json::Error> {
match format {
OutputFormat::Json => serde_json::to_string_pretty(results),
OutputFormat::Text => Ok(format_search_results_text(results)),
}
}
#[derive(Debug, Serialize)]
#[serde(tag = "type", rename_all = "lowercase")]
pub enum EnrichedSearchResult {
Symbol(SymbolOutput),
File(FileEntry),
Text(TextEntry),
}
fn format_search_results_text(results: &[EnrichedSearchResult]) -> String {
let mut out = String::new();
for result in results {
match result {
EnrichedSearchResult::Symbol(symbol) => {
let location = format_location(&symbol.file, symbol.line);
let _ = writeln!(out, "{} symbol {}", location, symbol.name);
if let Some(snip) = &symbol.context {
write_snippet(&mut out, snip);
}
}
EnrichedSearchResult::File(file) => {
let lang = file.lang.as_deref().unwrap_or("-");
let _ = writeln!(out, "{} file ({}, {} lines)", file.path, lang, file.lines);
}
EnrichedSearchResult::Text(text) => {
let location = format_location(&text.file, text.line);
let preview: String = text.text.chars().take(40).collect();
let _ = writeln!(out, "{} text {} {}...", location, text.kind, preview);
}
}
}
out
}
fn format_location(file: &str, line: [u32; 2]) -> String {
if line[0] == line[1] {
format!("{}[{}]", file, line[0])
} else {
format!("{}[{}-{}]", file, line[0], line[1])
}
}
fn write_snippet(out: &mut String, snippet: &str) {
let dedented = dedent(snippet);
for line in dedented.lines() {
let _ = writeln!(out, " │ {}", line);
}
out.push('\n');
}
fn dedent(text: &str) -> String {
let lines: Vec<&str> = text.lines().collect();
if lines.is_empty() {
return String::new();
}
let min_indent = lines
.iter()
.filter(|line| !line.trim().is_empty())
.map(|line| line.len() - line.trim_start().len())
.min()
.unwrap_or(0);
if min_indent == 0 {
return text.to_string();
}
lines
.iter()
.map(|line| {
if line.len() >= min_indent {
&line[min_indent..]
} else {
*line }
})
.collect::<Vec<_>>()
.join("\n")
}
pub type SymbolWithSnippet = SymbolOutput;
pub fn format_symbols(
symbols: &[SymbolWithSnippet],
format: OutputFormat,
) -> Result<String, serde_json::Error> {
match format {
OutputFormat::Json => serde_json::to_string_pretty(symbols),
OutputFormat::Text => Ok(format_symbols_text(symbols)),
}
}
fn format_symbols_text(symbols: &[SymbolWithSnippet]) -> String {
if symbols.is_empty() {
return String::new();
}
let mut out = String::new();
for sym in symbols {
let location = format_location(&sym.file, sym.line);
let _ = writeln!(out, "{} symbol {}", location, sym.name);
if let Some(snip) = &sym.context {
write_snippet(&mut out, snip);
}
}
out
}
#[derive(Debug, Serialize)]
pub struct ReferenceWithSnippet {
#[serde(flatten)]
pub reference: ReferenceEntry,
#[serde(skip_serializing_if = "Option::is_none")]
pub context: Option<String>,
}
pub fn format_references(
refs: &[ReferenceWithSnippet],
format: OutputFormat,
) -> Result<String, serde_json::Error> {
match format {
OutputFormat::Json => serde_json::to_string_pretty(refs),
OutputFormat::Text => Ok(format_references_text(refs)),
}
}
fn format_references_text(refs: &[ReferenceWithSnippet]) -> String {
let mut out = String::new();
for r in refs {
let location = format_location(&r.reference.file, r.reference.line);
let caller = r.reference.caller.as_deref().unwrap_or("(top-level)");
let _ = writeln!(
out,
"{} ref {} {} -> {}",
location, r.reference.kind, caller, r.reference.name
);
if let Some(snip) = &r.context {
write_snippet(&mut out, snip);
}
}
out
}
#[derive(Debug, Serialize)]
pub struct ExploreResult {
#[serde(skip_serializing_if = "Option::is_none")]
pub project: Option<String>,
#[serde(flatten)]
pub metadata: ProjectMetadata,
#[serde(skip_serializing_if = "Option::is_none")]
pub subprojects: Option<Vec<String>>,
pub directories: BTreeMap<String, Vec<String>>,
}
pub fn format_explore(
result: &ExploreResult,
format: OutputFormat,
) -> Result<String, serde_json::Error> {
match format {
OutputFormat::Json => serde_json::to_string_pretty(result),
OutputFormat::Text => Ok(format_explore_text(result)),
}
}
#[derive(Debug, Default)]
struct TreeNode {
files: Vec<String>,
children: BTreeMap<String, TreeNode>,
}
impl TreeNode {
fn insert(&mut self, path: &str, files: Vec<String>) {
if path.is_empty() {
self.files = files;
return;
}
let mut matching_key: Option<String> = None;
let mut common_len = 0;
for key in self.children.keys() {
let prefix_len = common_prefix_len(key, path);
if prefix_len > 0 && (matching_key.is_none() || prefix_len > common_len) {
matching_key = Some(key.clone());
common_len = prefix_len;
}
}
if let Some(key) = matching_key {
if path == key {
self.children.get_mut(&key).unwrap().files = files;
} else if path.starts_with(&format!("{}/", key)) {
let remaining = &path[key.len() + 1..];
self.children
.get_mut(&key)
.unwrap()
.insert(remaining, files);
} else if key.starts_with(&format!("{}/", path)) {
let old_child = self.children.remove(&key).unwrap();
let remaining = &key[path.len() + 1..];
let mut new_node = TreeNode {
files,
children: BTreeMap::new(),
};
new_node.children.insert(remaining.to_string(), old_child);
self.children.insert(path.to_string(), new_node);
} else {
let common = &path[..common_len];
let common = if let Some(pos) = common.rfind('/') {
&common[..pos]
} else {
self.children.insert(
path.to_string(),
TreeNode {
files,
children: BTreeMap::new(),
},
);
return;
};
let old_child = self.children.remove(&key).unwrap();
let key_remaining = &key[common.len() + 1..];
let path_remaining = &path[common.len() + 1..];
let mut new_node = TreeNode::default();
new_node
.children
.insert(key_remaining.to_string(), old_child);
new_node.insert(path_remaining, files);
self.children.insert(common.to_string(), new_node);
}
} else {
self.children.insert(
path.to_string(),
TreeNode {
files,
children: BTreeMap::new(),
},
);
}
}
fn render(&self, out: &mut String, prefix: &str, is_last: bool, name: Option<&str>) {
if let Some(dir_name) = name {
let connector = if is_last { "└── " } else { "├── " };
let _ = writeln!(out, "{}{}{}/", prefix, connector, dir_name);
}
let child_prefix = if name.is_some() {
format!("{}{}", prefix, if is_last { " " } else { "│ " })
} else {
prefix.to_string()
};
let child_dirs: Vec<_> = self.children.keys().collect();
let total_items = child_dirs.len() + self.files.len();
let mut item_idx = 0;
for dir_name in &child_dirs {
let is_last_item = item_idx == total_items - 1;
if let Some(child) = self.children.get(*dir_name) {
child.render(out, &child_prefix, is_last_item, Some(dir_name));
}
item_idx += 1;
}
for file in &self.files {
let is_last_item = item_idx == total_items - 1;
let connector = if is_last_item {
"└── "
} else {
"├── "
};
let _ = writeln!(out, "{}{}{}", child_prefix, connector, file);
item_idx += 1;
}
}
}
fn common_prefix_len(a: &str, b: &str) -> usize {
a.chars()
.zip(b.chars())
.take_while(|(ca, cb)| ca == cb)
.count()
}
fn format_explore_text(result: &ExploreResult) -> String {
let mut out = String::new();
let name = if result.metadata.name.is_empty() {
result.project.as_deref().unwrap_or(".")
} else {
&result.metadata.name
};
if let Some(desc) = &result.metadata.description {
let _ = writeln!(out, "{}: {}", name, desc);
} else {
let _ = writeln!(out, "{}", name);
}
if let Some(subs) = &result.subprojects {
let _ = writeln!(out, "subprojects: {}", subs.join(", "));
}
let mut root = TreeNode::default();
for (dir, files) in &result.directories {
if dir == "." {
root.files = files.clone();
} else {
root.insert(dir, files.clone());
}
}
root.render(&mut out, "", true, None);
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_output_format_parse() {
assert_eq!("json".parse::<OutputFormat>().unwrap(), OutputFormat::Json);
assert_eq!("text".parse::<OutputFormat>().unwrap(), OutputFormat::Text);
assert_eq!("JSON".parse::<OutputFormat>().unwrap(), OutputFormat::Json);
assert_eq!("TEXT".parse::<OutputFormat>().unwrap(), OutputFormat::Text);
assert!("invalid".parse::<OutputFormat>().is_err());
}
#[test]
fn test_output_format_default() {
assert_eq!(OutputFormat::default(), OutputFormat::Json);
}
}