use std::fs;
use std::io::{self, IsTerminal, Read};
use std::path::{Path, PathBuf};
use crate::ai::connections::ConnectionConfig;
const MAX_FILE_BYTES: usize = 64 * 1024;
#[derive(Debug, Clone)]
pub struct LoadedFileContext {
pub display_name: String,
pub size_bytes: usize,
pub content: String,
pub truncated: bool,
}
#[derive(Debug, Clone)]
pub struct ProcessedChatInput {
pub display_messages: Vec<String>,
pub prompt: String,
}
pub fn load_explicit_file_context(path: &Path) -> Result<LoadedFileContext, String> {
load_file_context_from_path(path)
}
pub fn read_piped_stdin() -> Result<Option<String>, String> {
if io::stdin().is_terminal() {
return Ok(None);
}
let mut buffer = String::new();
io::stdin()
.read_to_string(&mut buffer)
.map_err(|err| format!("Failed to read stdin: {err}"))?;
if buffer.trim().is_empty() {
return Ok(None);
}
Ok(Some(buffer))
}
pub fn build_stdin_analysis_prompt(
stdin_input: &str,
connection: Option<&ConnectionConfig>,
) -> ProcessedChatInput {
let size_kb = stdin_input.len() as f64 / 1024.0;
let kind = classify_stdin_input(stdin_input);
let mut display_messages = vec![
format!("Input detected ({kind}, {size_kb:.1}KB)"),
"Analyzing...".to_string(),
];
if let Some(connection) = connection {
display_messages.push(format!("Attached connection: {}", connection.url));
}
let prompt = format!(
"---\nUser input:\n\n{}\n\n---\n\nPlease explain what this is, identify any issues, and suggest fixes.",
stdin_input.trim()
);
ProcessedChatInput {
display_messages,
prompt,
}
}
fn classify_stdin_input(input: &str) -> &'static str {
let sample = input.trim();
let lower = sample.to_ascii_lowercase();
let non_empty_lines = sample
.lines()
.map(str::trim)
.filter(|line| !line.is_empty())
.collect::<Vec<_>>();
if lower.contains("traceback")
|| lower.contains("exception")
|| lower.contains("stack trace")
|| lower.contains("error:")
|| lower.contains("warn")
|| lower.contains("failed")
|| sample.lines().any(|line| {
let trimmed = line.trim_start();
trimmed.starts_with('[')
|| trimmed.starts_with("ERROR")
|| trimmed.starts_with("WARN")
|| trimmed.starts_with("INFO")
})
{
return "log";
}
if looks_like_markdown(&non_empty_lines) {
return "markdown";
}
if lower.contains("fn ")
|| lower.contains("class ")
|| lower.contains("import ")
|| lower.contains("const ")
|| lower.contains("let ")
|| lower.contains("#include")
|| lower.contains("public static")
{
return "code";
}
if looks_like_toml(&non_empty_lines)
|| looks_like_json(sample)
|| looks_like_yaml(&non_empty_lines)
{
return "config";
}
"text"
}
fn looks_like_markdown(lines: &[&str]) -> bool {
if lines.is_empty() {
return false;
}
let mut score = 0usize;
for line in lines.iter().take(40) {
if line.starts_with("# ")
|| line.starts_with("## ")
|| line.starts_with("### ")
|| line.starts_with("- ")
|| line.starts_with("* ")
|| line.starts_with("> ")
|| line.starts_with("```")
{
score += 2;
}
if line.contains('|') && line.matches('|').count() >= 2 {
score += 1;
}
if line.starts_with('[') && (line.contains("](") || line.contains("]: ")) {
score += 1;
}
if split_numbered_list_marker(line).is_some() {
score += 1;
}
}
score >= 2
}
fn looks_like_toml(lines: &[&str]) -> bool {
if lines.is_empty() {
return false;
}
let mut section_headers = 0usize;
let mut assignments = 0usize;
for line in lines.iter().take(40) {
if line.starts_with('[') && line.ends_with(']') {
section_headers += 1;
continue;
}
if line.contains(" = ") || line.contains("=\"") || line.contains(" =\"") {
assignments += 1;
}
}
section_headers >= 1 || assignments >= 3
}
fn looks_like_json(sample: &str) -> bool {
let trimmed = sample.trim();
(trimmed.starts_with('{') && trimmed.ends_with('}'))
|| (trimmed.starts_with('[') && trimmed.ends_with(']'))
}
fn looks_like_yaml(lines: &[&str]) -> bool {
if lines.is_empty() {
return false;
}
let mut mappings = 0usize;
for line in lines.iter().take(40) {
if line.starts_with("- ") || line.starts_with("---") {
return true;
}
if line.contains(':') && !line.starts_with("http://") && !line.starts_with("https://") {
mappings += 1;
}
}
mappings >= 3
}
fn split_numbered_list_marker(value: &str) -> Option<&str> {
let mut chars = value.char_indices();
let mut end = 0usize;
for (idx, ch) in &mut chars {
if ch.is_ascii_digit() {
end = idx + ch.len_utf8();
continue;
}
if ch == '.' && end > 0 {
let rest = &value[idx + ch.len_utf8()..];
return rest.strip_prefix(' ');
}
break;
}
None
}
pub fn process_chat_input(
input: &str,
connection_name: Option<&str>,
connection: Option<&ConnectionConfig>,
) -> Result<ProcessedChatInput, String> {
let file_refs = extract_file_references(input);
if file_refs.is_empty() {
return Ok(ProcessedChatInput {
display_messages: Vec::new(),
prompt: input.trim().to_string(),
});
}
let mut loaded = Vec::new();
let mut display_messages = Vec::new();
for reference in &file_refs {
let file = load_file_context(reference)?;
let size_kb = file.size_bytes as f64 / 1024.0;
if file.truncated {
display_messages.push(format!(
"Loaded file: {} ({size_kb:.1} KB, truncated)",
file.display_name
));
} else {
display_messages.push(format!("Loaded file: {} ({size_kb:.1} KB)", file.display_name));
}
loaded.push(file);
}
if let Some(name) = connection_name {
display_messages.push(format!("Attached connection: {name}"));
} else if let Some(conn) = connection {
display_messages.push(format!("Attached connection: {}", conn.url));
}
let stripped_question = strip_file_references(input).trim().to_string();
let question = if stripped_question.is_empty() {
"Please analyze the referenced file(s).".to_string()
} else {
stripped_question
};
let mut prompt = String::new();
for file in &loaded {
prompt.push_str("---\n");
prompt.push_str(&format!("File: {}\n\n", file.display_name));
prompt.push_str(file.content.trim_end());
prompt.push_str("\n\n");
}
prompt.push_str("---\n");
prompt.push_str(&format!("User question:\n{}\n\n---", question));
Ok(ProcessedChatInput {
display_messages,
prompt,
})
}
fn extract_file_references(input: &str) -> Vec<String> {
let mut refs = Vec::new();
for token in input.split_whitespace() {
if let Some(reference) = token.strip_prefix('@') {
let cleaned = reference
.trim_matches(|ch: char| matches!(ch, '"' | '\'' | ',' | ';' | ')' | '('));
if !cleaned.is_empty() {
refs.push(cleaned.to_string());
}
}
}
refs.sort();
refs.dedup();
refs
}
fn strip_file_references(input: &str) -> String {
input.split_whitespace()
.filter(|token| !token.starts_with('@'))
.collect::<Vec<_>>()
.join(" ")
}
fn load_file_context(reference: &str) -> Result<LoadedFileContext, String> {
let path = resolve_reference_path(reference)?;
load_file_context_from_path(&path)
}
fn load_file_context_from_path(path: &Path) -> Result<LoadedFileContext, String> {
let bytes = fs::read(path)
.map_err(|err| format!("Failed to read {}: {err}", path.display()))?;
let truncated = bytes.len() > MAX_FILE_BYTES;
let visible = if truncated {
&bytes[..MAX_FILE_BYTES]
} else {
&bytes[..]
};
let content = String::from_utf8_lossy(visible).to_string();
Ok(LoadedFileContext {
display_name: display_path(&path),
size_bytes: bytes.len(),
content,
truncated,
})
}
fn resolve_reference_path(reference: &str) -> Result<PathBuf, String> {
let path = PathBuf::from(reference);
let absolute = if path.is_absolute() {
path
} else {
std::env::current_dir()
.map_err(|err| format!("Failed to resolve current directory: {err}"))?
.join(path)
};
if !absolute.exists() {
return Err(format!("Referenced file '{}' was not found.", reference));
}
if !absolute.is_file() {
return Err(format!("Referenced path '{}' is not a file.", reference));
}
Ok(absolute)
}
fn display_path(path: &Path) -> String {
std::env::current_dir()
.ok()
.and_then(|cwd| path.strip_prefix(cwd).ok().map(PathBuf::from))
.unwrap_or_else(|| path.to_path_buf())
.display()
.to_string()
}