use glob::glob;
use std::fmt::Write as FmtWrite;
use std::fs::{self, File};
use std::io::{Read, Write};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use tokio::sync::Mutex;
use tracing::{debug, warn};
use crate::errors::{Result, WinxError};
use crate::state::bash_state::BashState;
use crate::state::persistence::{
load_bash_state_from_path, save_bash_state_to_path, BashStateSnapshot,
};
use crate::types::ContextSave;
use crate::utils::path::{expand_user, validate_path_in_workspace};
pub async fn handle_tool_call(
bash_state: &Arc<Mutex<Option<BashState>>>,
args: ContextSave,
) -> Result<String> {
let bash_state_guard = bash_state.lock().await;
let bash_state = bash_state_guard.as_ref().ok_or(WinxError::BashStateNotInitialized)?;
let result = save_context(bash_state, args)?;
if should_open_context_file(&result) {
if let Err(e) = try_open_file(&result) {
debug!("Failed to open the context file: {}", e);
}
}
Ok(result)
}
fn save_context(bash_state: &BashState, mut context: ContextSave) -> Result<String> {
normalize_context(&mut context)?;
let (relevant_files, warnings) = collect_relevant_files(&context, &bash_state.workspace_root)?;
let memory_dir = resolve_memory_dir()?;
let relevant_files_data = read_files_content(&relevant_files, 10_000)?;
let memory_data =
crate::utils::redact::redact(&format_memory(&context, &relevant_files_data)).into_owned();
let safe_id = sanitize_filename(&context.id);
let (memory_file_path, state_file_path) = context_paths_from_dir(&memory_dir, &safe_id);
if let Some(response) = write_memory_file(&memory_file_path, &memory_data, &context)? {
return Ok(response);
}
write_bash_state_file(&state_file_path, bash_state);
Ok(context_save_response(&relevant_files, &warnings, &context, &memory_file_path))
}
fn normalize_context(context: &mut ContextSave) -> Result<()> {
if !context.project_root_path.is_empty() {
context.project_root_path = expand_user(&context.project_root_path);
}
if context.id.trim().is_empty() {
return Err(WinxError::ArgumentParseError("Task ID cannot be empty".to_string()));
}
Ok(())
}
fn collect_relevant_files(
context: &ContextSave,
workspace_root: &Path,
) -> Result<(Vec<PathBuf>, Vec<String>)> {
let mut relevant_files = Vec::new();
let mut warnings = Vec::new();
let mut skipped_outside = 0usize;
for glob_pattern in &context.relevant_file_globs {
let expanded_glob = expand_user(glob_pattern);
let final_glob =
if !Path::new(&expanded_glob).is_absolute() && !context.project_root_path.is_empty() {
PathBuf::from(&context.project_root_path)
.join(expanded_glob)
.to_string_lossy()
.to_string()
} else {
expanded_glob
};
debug!("Processing glob pattern: {}", final_glob);
let matches = glob(&final_glob).map_err(|e| {
WinxError::ArgumentParseError(format!("Invalid glob pattern '{final_glob}': {e}"))
})?;
let mut found_files = false;
for entry in matches {
match entry {
Ok(path) => {
if path.is_file() {
if validate_path_in_workspace(&path, workspace_root).is_err() {
skipped_outside += 1;
continue;
}
relevant_files.push(path);
found_files = true;
if relevant_files.len() >= 1000 {
warn!("Reached limit of 1000 files for glob '{}'", final_glob);
break;
}
}
}
Err(e) => {
warn!("Error matching glob '{}': {}", final_glob, e);
}
}
}
if !found_files {
warnings.push(format!("Warning: No files found for the glob: {glob_pattern}"));
}
}
if skipped_outside > 0 {
warnings.push(format!(
"Warning: skipped {skipped_outside} file(s) that resolved outside the workspace root."
));
}
debug!("Found {} relevant files", relevant_files.len());
Ok((relevant_files, warnings))
}
fn resolve_memory_dir() -> Result<PathBuf> {
let app_dir = match get_app_dir_xdg() {
Ok(dir) => dir,
Err(e) => {
debug!("Failed to get primary app directory: {:?}", e);
let fallback = std::env::temp_dir().join("winx-memory");
debug!("Using fallback directory: {}", fallback.display());
fs::create_dir_all(&fallback).map_err(|e2| WinxError::FileAccessError {
path: fallback.clone(),
message: format!(
"Failed to create fallback directory: {e2} (after previous error: {e:?})"
),
})?;
fallback
}
};
let mut memory_dir = app_dir.join("memory");
match fs::create_dir_all(&memory_dir) {
Ok(()) => {}
Err(e) => {
debug!("Failed to create memory directory: {}", e);
debug!("Using app_dir directly as memory_dir due to failed subdirectory creation");
memory_dir = app_dir;
}
}
Ok(memory_dir)
}
pub(crate) fn context_paths(id: &str) -> Result<(PathBuf, PathBuf)> {
let safe_id = sanitize_filename(id);
let memory_dir = resolve_memory_dir()?;
Ok(context_paths_from_dir(&memory_dir, &safe_id))
}
fn context_paths_from_dir(memory_dir: &Path, safe_id: &str) -> (PathBuf, PathBuf) {
(
memory_dir.join(format!("{safe_id}.txt")),
memory_dir.join(format!("{safe_id}_bash_state.json")),
)
}
pub(crate) fn load_saved_context(id: &str) -> Result<Option<(String, Option<BashStateSnapshot>)>> {
if id.is_empty() {
return Ok(None);
}
let (memory_file_path, state_file_path) = context_paths(id)?;
if !memory_file_path.exists() {
tracing::warn!(
task_id = %id,
path = %memory_file_path.display(),
"task_id_to_resume requested but no saved context found; starting fresh"
);
return Ok(None);
}
let memory_data =
fs::read_to_string(&memory_file_path).map_err(|e| WinxError::FileAccessError {
path: memory_file_path.clone(),
message: format!("Failed to read saved context: {e}"),
})?;
let memory_data = truncate_memory_to_tokens(memory_data, memory_token_budget());
let state = load_bash_state_from_path(&state_file_path).map_err(|e| {
WinxError::SerializationError(format!("Failed to load saved bash state: {e}"))
})?;
Ok(Some((memory_data, state)))
}
const MEMORY_MAX_TOKENS: usize = 8_000;
fn memory_token_budget() -> usize {
crate::utils::encoder::budget_from_env("WINX_NONCODING_TOKEN_BUDGET", MEMORY_MAX_TOKENS)
}
const MIN_PER_FILE_SAVE_TOKENS: usize = 500;
const MAX_CONTEXT_READ_BYTES: u64 = 2 * 1024 * 1024;
fn read_text_for_context(path: &Path) -> std::io::Result<String> {
let len = fs::metadata(path)?.len();
if len <= MAX_CONTEXT_READ_BYTES {
return fs::read_to_string(path);
}
let mut buf = Vec::new();
File::open(path)?.take(MAX_CONTEXT_READ_BYTES).read_to_end(&mut buf)?;
match String::from_utf8(buf) {
Ok(s) => Ok(s),
Err(e) => {
let valid = e.utf8_error().valid_up_to();
if valid == 0 {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"stream did not contain valid UTF-8",
));
}
let bytes = e.into_bytes();
Ok(String::from_utf8_lossy(&bytes[..valid]).into_owned())
}
}
}
fn cap_file_to_tokens(content: &str, max_tokens: usize) -> String {
if crate::utils::encoder::definitely_fits_token_budget(content, max_tokens) {
return content.to_string();
}
match crate::utils::encoder::encode_ids(content) {
Some(ids) if ids.len() > max_tokens => {
let omitted = ids.len() - max_tokens;
let head = crate::utils::encoder::decode_ids(&ids[..max_tokens])
.unwrap_or_else(|| content.chars().take(max_tokens.saturating_mul(4)).collect());
format!("{head}\n[... {omitted} tokens truncated for context save ...]")
}
_ => content.to_string(),
}
}
fn truncate_memory_to_tokens(text: String, max_tokens: usize) -> String {
if crate::utils::encoder::definitely_fits_token_budget(&text, max_tokens) {
return text;
}
let Some(ids) = crate::utils::encoder::encode_ids(&text) else {
return text;
};
if ids.len() <= max_tokens {
return text;
}
let keep = max_tokens.saturating_sub(8);
match crate::utils::encoder::decode_ids(&ids[..keep]) {
Some(decoded) => format!("{decoded}\n(...truncated saved context)"),
None => text,
}
}
pub(crate) fn extract_project_root(memory: &str) -> Option<PathBuf> {
memory
.lines()
.find_map(|line| line.strip_prefix("Project root path:").map(str::trim))
.filter(|root| !root.is_empty())
.map(PathBuf::from)
}
fn write_memory_file(
memory_file_path: &Path,
memory_data: &str,
context: &ContextSave,
) -> Result<Option<String>> {
match File::create(memory_file_path) {
Ok(mut file) => {
if let Err(e) = file.write_all(memory_data.as_bytes()) {
warn!("Failed to write memory data: {}", e);
return Ok(Some(save_to_temp_file(memory_data, context)?));
}
}
Err(e) => {
warn!("Failed to create memory file: {}", e);
return Ok(Some(save_to_temp_file(memory_data, context)?));
}
}
Ok(None)
}
fn write_bash_state_file(state_file_path: &Path, bash_state: &BashState) {
if let Err(e) = save_bash_state_to_path(state_file_path, &bash_state.snapshot()) {
warn!("Failed to write bash state data: {}", e);
}
}
fn context_save_response(
relevant_files: &[PathBuf],
warnings: &[String],
context: &ContextSave,
memory_file_path: &Path,
) -> String {
let memory_file_path_str = memory_file_path.to_string_lossy().to_string();
if !relevant_files.is_empty() || context.relevant_file_globs.is_empty() {
if warnings.is_empty() {
memory_file_path_str
} else {
format!(
"{}\n\nContext file successfully saved at {}",
warnings.join("\n"),
memory_file_path_str
)
}
} else {
format!(
"Error: No files found for the given globs. Context file successfully saved at \"{memory_file_path_str}\", but please fix the error."
)
}
}
fn relativize_glob(glob: &str, project_root: &str) -> String {
if project_root.is_empty() {
return glob.to_string();
}
Path::new(glob)
.strip_prefix(project_root)
.map_or_else(|_| glob.to_string(), |rel| rel.to_string_lossy().to_string())
}
fn format_memory(context: &ContextSave, relevant_files_data: &str) -> String {
let mut memory_data = String::new();
if !context.project_root_path.is_empty() {
let _ = write!(memory_data, "Project root path: {}\n\n", context.project_root_path);
}
memory_data.push_str(&context.description);
memory_data.push_str("\n\n");
let portable_globs = context
.relevant_file_globs
.iter()
.map(|glob| relativize_glob(glob, &context.project_root_path))
.collect::<Vec<_>>();
let _ = write!(memory_data, "Relevant file globs: {}\n\n", portable_globs.join(", "));
memory_data.push_str("File contents:\n\n");
memory_data.push_str(relevant_files_data);
memory_data
}
fn get_app_dir_xdg() -> Result<PathBuf> {
let app_dir = get_primary_app_dir().or_else(|_| get_fallback_app_dir())?;
debug!("Using app directory: {}", app_dir.display());
Ok(app_dir)
}
fn get_primary_app_dir() -> Result<PathBuf> {
if let Ok(xdg_path) = std::env::var("XDG_DATA_HOME") {
let app_dir = PathBuf::from(xdg_path).join("winx");
if let Ok(()) = fs::create_dir_all(&app_dir) {
return Ok(app_dir);
}
}
if let Ok(home) = std::env::var("HOME") {
let app_dir = PathBuf::from(home).join(".local/share/winx");
if let Ok(()) = fs::create_dir_all(&app_dir) {
return Ok(app_dir);
}
}
Err(WinxError::FileAccessError {
path: PathBuf::from("<primary-paths>"),
message: "Could not create app directory in primary locations".to_string(),
})
}
fn get_fallback_app_dir() -> Result<PathBuf> {
let current_dir = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
let app_dir = current_dir.join(".winx-data");
if let Ok(()) = fs::create_dir_all(&app_dir) {
return Ok(app_dir);
}
let temp_dir = std::env::temp_dir().join("winx-data");
fs::create_dir_all(&temp_dir).map_err(|e| WinxError::FileAccessError {
path: temp_dir.clone(),
message: format!("Failed to create app directory in any location: {e}"),
})?;
Ok(temp_dir)
}
fn read_files_content(file_paths: &[PathBuf], max_files: usize) -> Result<String> {
let mut result = String::new();
let mut skipped_binary = Vec::new();
let shown = file_paths.len().min(max_files).max(1);
let per_file_tokens = (memory_token_budget() / shown).max(MIN_PER_FILE_SAVE_TOKENS);
for (i, path) in file_paths.iter().take(max_files).enumerate() {
match read_text_for_context(path) {
Ok(file_content) => {
let _ = writeln!(result, "--- File {}: {} ---", i + 1, path.display());
result.push_str(&cap_file_to_tokens(&file_content, per_file_tokens));
result.push_str("\n\n");
}
Err(e) if e.kind() == std::io::ErrorKind::InvalidData => {
skipped_binary.push(path.display().to_string());
}
Err(e) => {
return Err(WinxError::FileAccessError {
path: path.clone(),
message: format!("Failed to read file: {e}"),
});
}
}
}
if !skipped_binary.is_empty() {
let _ = write!(
result,
"Note: Skipped {} binary/non-text file(s): {}\n\n",
skipped_binary.len(),
skipped_binary.join(", ")
);
}
if file_paths.len() > max_files {
let _ = writeln!(
result,
"Note: Only showing the first {} files out of {}.",
max_files,
file_paths.len()
);
}
Ok(result)
}
fn try_open_file(file_path: &str) -> Result<()> {
if std::env::consts::OS != "macos" && std::env::consts::OS != "linux" {
return Ok(());
}
let cmd = if std::env::consts::OS == "macos" {
"open"
} else {
for cmd in &["xdg-open", "gnome-open", "kde-open"] {
let status = std::process::Command::new("which")
.arg(cmd)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status();
if let Ok(status) = status {
if status.success() {
let _ = std::process::Command::new(cmd)
.arg(file_path)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn()
.map_err(|e| {
WinxError::CommandExecutionError(format!(
"Failed to spawn open command: {e}"
))
})?;
return Ok(());
}
}
}
return Ok(());
};
let _ = std::process::Command::new(cmd)
.arg(file_path)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn()
.map_err(|e| {
WinxError::CommandExecutionError(format!("Failed to spawn open command: {e}"))
})?;
Ok(())
}
fn should_open_context_file(file_path: &str) -> bool {
crate::config::env_flag("WINX_OPEN_CONTEXT") && Path::new(file_path).is_file() && !cfg!(test)
}
fn save_to_temp_file(memory_data: &str, context: &ContextSave) -> Result<String> {
let temp_dir = std::env::temp_dir();
let safe_id = sanitize_filename(&context.id);
let temp_file_path = temp_dir.join(format!("winx-{safe_id}.txt"));
let mut file = File::create(&temp_file_path).map_err(|e| WinxError::FileAccessError {
path: temp_file_path.clone(),
message: format!("Failed to create temporary file: {e}"),
})?;
file.write_all(memory_data.as_bytes()).map_err(|e| WinxError::FileAccessError {
path: temp_file_path.clone(),
message: format!("Failed to write to temporary file: {e}"),
})?;
let path_str = temp_file_path.to_string_lossy().to_string();
Ok(format!(
"Context was saved to temporary file at {path_str} due to permission issues with regular locations."
))
}
fn sanitize_filename(input: &str) -> String {
let invalid_chars = vec!['/', '\\', ':', '*', '?', '"', '<', '>', '|'];
let mut result = input.to_string();
for c in invalid_chars {
result = result.replace(c, "_");
}
if result.chars().count() > 50 {
use rand::RngExt;
let head: String = result.chars().take(45).collect();
result = format!("{head}-{}", rand::rng().random_range(1000..9999));
}
result
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used)]
use super::{
cap_file_to_tokens, read_text_for_context, relativize_glob, MAX_CONTEXT_READ_BYTES,
};
#[test]
fn read_text_for_context_small_file_reads_whole() {
let dir = tempfile::tempdir().unwrap();
let p = dir.path().join("small.txt");
std::fs::write(&p, "hello\nworld\n").unwrap();
assert_eq!(read_text_for_context(&p).unwrap(), "hello\nworld\n");
}
#[test]
fn read_text_for_context_binary_is_invalid_data() {
let dir = tempfile::tempdir().unwrap();
let p = dir.path().join("bin");
std::fs::write(&p, [0xff, 0xfe, 0x00, 0x01]).unwrap();
assert_eq!(read_text_for_context(&p).unwrap_err().kind(), std::io::ErrorKind::InvalidData);
}
#[test]
fn read_text_for_context_caps_oversized_file() {
let dir = tempfile::tempdir().unwrap();
let p = dir.path().join("big.txt");
let capped = usize::try_from(MAX_CONTEXT_READ_BYTES).unwrap_or(usize::MAX - 4096);
std::fs::write(&p, vec![b'a'; capped + 4096]).unwrap();
assert_eq!(read_text_for_context(&p).unwrap().len() as u64, MAX_CONTEXT_READ_BYTES);
}
#[test]
fn relativize_glob_makes_absolute_portable() {
assert_eq!(relativize_glob("/home/u/proj/src/*.rs", "/home/u/proj"), "src/*.rs");
assert_eq!(relativize_glob("src/*.rs", "/home/u/proj"), "src/*.rs");
assert_eq!(relativize_glob("/etc/*.conf", "/home/u/proj"), "/etc/*.conf");
assert_eq!(relativize_glob("/abs/x", ""), "/abs/x");
}
#[test]
fn small_file_is_untouched() {
let content = "def add(a, b):\n return a + b\n";
assert_eq!(cap_file_to_tokens(content, 4_000), content);
}
#[test]
fn large_file_is_capped_with_a_note() {
use std::fmt::Write as _;
let mut big = String::new();
for i in 0..5_000 {
let _ = write!(big, "word{i} ");
}
let capped = cap_file_to_tokens(&big, 100);
assert!(capped.len() < big.len(), "should shrink");
assert!(capped.contains("truncated for context save"), "should note truncation");
assert!(capped.starts_with("word0"), "should keep the head");
}
}