use std::fs;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
use crate::models::{ContentBlock, Message};
use anyhow::{Context, Result};
use ignore::WalkBuilder;
use serde_json::Value;
#[must_use]
pub fn is_key_file(path: &Path) -> bool {
let Some(file_name) = path.file_name().and_then(|n| n.to_str()) else {
return false;
};
matches!(
file_name.to_lowercase().as_str(),
"cargo.toml"
| "package.json"
| "requirements.txt"
| "build.gradle"
| "pom.xml"
| "readme.md"
| "agents.md"
| "claude.md"
| "makefile"
| "dockerfile"
| "main.rs"
| "lib.rs"
| "index.js"
| "index.ts"
| "app.py"
)
}
#[must_use]
pub fn summarize_project(root: &Path) -> String {
let mut key_files = Vec::new();
let mut builder = WalkBuilder::new(root);
builder.hidden(false).follow_links(true).max_depth(Some(2));
let walker = builder.build();
for entry in walker {
let entry = match entry {
Ok(entry) => entry,
Err(_) => continue,
};
if is_key_file(entry.path())
&& let Ok(rel) = entry.path().strip_prefix(root)
{
key_files.push(rel.to_string_lossy().to_string());
}
}
if key_files.is_empty() {
return "Unknown project type".to_string();
}
let mut types = Vec::new();
if key_files
.iter()
.any(|f| f.to_lowercase().contains("cargo.toml"))
{
types.push("Rust");
}
if key_files
.iter()
.any(|f| f.to_lowercase().contains("package.json"))
{
types.push("JavaScript/Node.js");
}
if key_files
.iter()
.any(|f| f.to_lowercase().contains("requirements.txt"))
{
types.push("Python");
}
if types.is_empty() {
format!("Project with key files: {}", key_files.join(", "))
} else {
format!("A {} project", types.join(" and "))
}
}
#[must_use]
pub fn project_tree(root: &Path, max_depth: usize) -> String {
let mut tree_lines = Vec::new();
let mut builder = WalkBuilder::new(root);
builder
.hidden(false)
.follow_links(true)
.max_depth(Some(max_depth + 1));
let walker = builder.build();
for entry in walker {
let entry = match entry {
Ok(entry) => entry,
Err(_) => continue,
};
let path = entry.path();
let depth = entry.depth();
if depth == 0 || depth > max_depth {
continue;
}
let rel_path = path.strip_prefix(root).unwrap_or(path);
let indent = " ".repeat(depth - 1);
let prefix = if entry.file_type().is_some_and(|ft| ft.is_dir()) {
"DIR: "
} else {
"FILE: "
};
tree_lines.push(format!(
"{}{}{}",
indent,
prefix,
rel_path.file_name().unwrap_or_default().to_string_lossy()
));
}
tree_lines.join("\n")
}
#[allow(dead_code)]
pub fn ensure_dir(path: &Path) -> Result<()> {
fs::create_dir_all(path)
.with_context(|| format!("Failed to create directory: {}", path.display()))
}
#[allow(dead_code)]
pub fn write_bytes(path: &Path, bytes: &[u8]) -> Result<()> {
if let Some(parent) = path.parent() {
ensure_dir(parent)?;
}
fs::write(path, bytes).with_context(|| format!("Failed to write {}", path.display()))
}
#[must_use]
#[allow(dead_code)]
pub fn timestamped_filename(prefix: &str, extension: &str) -> String {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
format!("{prefix}_{now}.{extension}")
}
#[must_use]
#[allow(dead_code)]
pub fn pretty_json(value: &Value) -> String {
serde_json::to_string_pretty(value).unwrap_or_else(|_| value.to_string())
}
#[must_use]
#[allow(dead_code)]
pub fn extension_from_url(url: &str) -> Option<String> {
let path = url.split('?').next().unwrap_or(url);
let ext = Path::new(path)
.extension()
.and_then(|ext| ext.to_str())
.map(str::to_lowercase);
ext.filter(|e| !e.is_empty())
}
#[must_use]
#[allow(dead_code)]
pub fn output_path(output_dir: &Path, filename: &str) -> PathBuf {
output_dir.join(filename)
}
#[must_use]
pub fn truncate_with_ellipsis(s: &str, max_len: usize, ellipsis: &str) -> String {
if s.len() <= max_len {
return s.to_string();
}
let budget = max_len.saturating_sub(ellipsis.len());
let safe_end = s
.char_indices()
.map(|(i, _)| i)
.take_while(|&i| i <= budget)
.last()
.unwrap_or(0);
format!("{}{}", &s[..safe_end], ellipsis)
}
#[must_use]
pub fn url_encode(input: &str) -> String {
let mut encoded = String::new();
for ch in input.bytes() {
match ch {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
encoded.push(ch as char)
}
b' ' => encoded.push('+'),
_ => encoded.push_str(&format!("%{ch:02X}")),
}
}
encoded
}
#[must_use]
pub fn estimate_message_chars(messages: &[Message]) -> usize {
let mut total = 0;
for msg in messages {
for block in &msg.content {
match block {
ContentBlock::Text { text, .. } => total += text.len(),
ContentBlock::Thinking { thinking } => total += thinking.len(),
ContentBlock::ToolUse { input, .. } => total += input.to_string().len(),
ContentBlock::ToolResult { content, .. } => total += content.len(),
ContentBlock::ServerToolUse { .. }
| ContentBlock::ToolSearchToolResult { .. }
| ContentBlock::CodeExecutionToolResult { .. } => {}
}
}
}
total
}