1use glob::glob;
2use std::fmt::Write as FmtWrite;
3use std::fs::{self, File};
4use std::io::{Read, Write};
5use std::path::{Path, PathBuf};
6use std::sync::Arc;
7use tokio::sync::Mutex;
8use tracing::{debug, warn};
9
10use crate::errors::{Result, WinxError};
11use crate::state::bash_state::BashState;
12use crate::state::persistence::{
13 load_bash_state_from_path, save_bash_state_to_path, BashStateSnapshot,
14};
15use crate::types::ContextSave;
16use crate::utils::path::{expand_user, validate_path_in_workspace};
17
18pub async fn handle_tool_call(
32 bash_state: &Arc<Mutex<Option<BashState>>>,
33 args: ContextSave,
34) -> Result<String> {
35 let bash_state_guard = bash_state.lock().await;
37
38 let bash_state = bash_state_guard.as_ref().ok_or(WinxError::BashStateNotInitialized)?;
39
40 let result = save_context(bash_state, args)?;
42
43 if should_open_context_file(&result) {
44 if let Err(e) = try_open_file(&result) {
45 debug!("Failed to open the context file: {}", e);
46 }
47 }
48
49 Ok(result)
50}
51
52fn save_context(bash_state: &BashState, mut context: ContextSave) -> Result<String> {
63 normalize_context(&mut context)?;
64
65 let (relevant_files, warnings) = collect_relevant_files(&context, &bash_state.workspace_root)?;
66 let memory_dir = resolve_memory_dir()?;
67 let relevant_files_data = read_files_content(&relevant_files, 10_000)?;
68 let memory_data =
71 crate::utils::redact::redact(&format_memory(&context, &relevant_files_data)).into_owned();
72 let safe_id = sanitize_filename(&context.id);
73
74 let (memory_file_path, state_file_path) = context_paths_from_dir(&memory_dir, &safe_id);
75 if let Some(response) = write_memory_file(&memory_file_path, &memory_data, &context)? {
76 return Ok(response);
77 }
78
79 write_bash_state_file(&state_file_path, bash_state);
80
81 Ok(context_save_response(&relevant_files, &warnings, &context, &memory_file_path))
82}
83
84fn normalize_context(context: &mut ContextSave) -> Result<()> {
85 if !context.project_root_path.is_empty() {
86 context.project_root_path = expand_user(&context.project_root_path);
87 }
88
89 if context.id.is_empty() {
90 return Err(WinxError::ArgumentParseError("Task ID cannot be empty".to_string()));
91 }
92
93 Ok(())
94}
95
96fn collect_relevant_files(
97 context: &ContextSave,
98 workspace_root: &Path,
99) -> Result<(Vec<PathBuf>, Vec<String>)> {
100 let mut relevant_files = Vec::new();
101 let mut warnings = Vec::new();
102 let mut skipped_outside = 0usize;
103
104 for glob_pattern in &context.relevant_file_globs {
105 let expanded_glob = expand_user(glob_pattern);
107
108 let final_glob =
110 if !Path::new(&expanded_glob).is_absolute() && !context.project_root_path.is_empty() {
111 PathBuf::from(&context.project_root_path)
112 .join(expanded_glob)
113 .to_string_lossy()
114 .to_string()
115 } else {
116 expanded_glob
117 };
118
119 debug!("Processing glob pattern: {}", final_glob);
120
121 let matches = glob(&final_glob).map_err(|e| {
123 WinxError::ArgumentParseError(format!("Invalid glob pattern '{final_glob}': {e}"))
124 })?;
125
126 let mut found_files = false;
127 for entry in matches {
128 match entry {
129 Ok(path) => {
130 if path.is_file() {
131 if validate_path_in_workspace(&path, workspace_root).is_err() {
138 skipped_outside += 1;
139 continue;
140 }
141 relevant_files.push(path);
142 found_files = true;
143 if relevant_files.len() >= 1000 {
145 warn!("Reached limit of 1000 files for glob '{}'", final_glob);
146 break;
147 }
148 }
149 }
150 Err(e) => {
151 warn!("Error matching glob '{}': {}", final_glob, e);
152 }
153 }
154 }
155
156 if !found_files {
157 warnings.push(format!("Warning: No files found for the glob: {glob_pattern}"));
158 }
159 }
160
161 if skipped_outside > 0 {
162 warnings.push(format!(
163 "Warning: skipped {skipped_outside} file(s) that resolved outside the workspace root."
164 ));
165 }
166
167 debug!("Found {} relevant files", relevant_files.len());
168 Ok((relevant_files, warnings))
169}
170
171fn resolve_memory_dir() -> Result<PathBuf> {
172 let app_dir = match get_app_dir_xdg() {
173 Ok(dir) => dir,
174 Err(e) => {
175 debug!("Failed to get primary app directory: {:?}", e);
176 let fallback = std::env::temp_dir().join("winx-memory");
178 debug!("Using fallback directory: {}", fallback.display());
179 fs::create_dir_all(&fallback).map_err(|e2| WinxError::FileAccessError {
180 path: fallback.clone(),
181 message: format!(
182 "Failed to create fallback directory: {e2} (after previous error: {e:?})"
183 ),
184 })?;
185 fallback
186 }
187 };
188
189 let mut memory_dir = app_dir.join("memory");
190 match fs::create_dir_all(&memory_dir) {
191 Ok(()) => {}
192 Err(e) => {
193 debug!("Failed to create memory directory: {}", e);
194 debug!("Using app_dir directly as memory_dir due to failed subdirectory creation");
196 memory_dir = app_dir;
197 }
198 }
199
200 Ok(memory_dir)
201}
202
203pub(crate) fn context_paths(id: &str) -> Result<(PathBuf, PathBuf)> {
204 let safe_id = sanitize_filename(id);
205 let memory_dir = resolve_memory_dir()?;
206 Ok(context_paths_from_dir(&memory_dir, &safe_id))
207}
208
209fn context_paths_from_dir(memory_dir: &Path, safe_id: &str) -> (PathBuf, PathBuf) {
210 (
211 memory_dir.join(format!("{safe_id}.txt")),
212 memory_dir.join(format!("{safe_id}_bash_state.json")),
213 )
214}
215
216pub(crate) fn load_saved_context(id: &str) -> Result<Option<(String, Option<BashStateSnapshot>)>> {
217 if id.is_empty() {
218 return Ok(None);
219 }
220
221 let (memory_file_path, state_file_path) = context_paths(id)?;
222 if !memory_file_path.exists() {
223 tracing::warn!(
224 task_id = %id,
225 path = %memory_file_path.display(),
226 "task_id_to_resume requested but no saved context found; starting fresh"
227 );
228 return Ok(None);
229 }
230
231 let memory_data =
232 fs::read_to_string(&memory_file_path).map_err(|e| WinxError::FileAccessError {
233 path: memory_file_path.clone(),
234 message: format!("Failed to read saved context: {e}"),
235 })?;
236 let memory_data = truncate_memory_to_tokens(memory_data, memory_token_budget());
239 let state = load_bash_state_from_path(&state_file_path).map_err(|e| {
240 WinxError::SerializationError(format!("Failed to load saved bash state: {e}"))
241 })?;
242
243 Ok(Some((memory_data, state)))
244}
245
246const MEMORY_MAX_TOKENS: usize = 8_000;
249
250fn memory_token_budget() -> usize {
253 crate::utils::encoder::budget_from_env("WINX_NONCODING_TOKEN_BUDGET", MEMORY_MAX_TOKENS)
254}
255
256const MIN_PER_FILE_SAVE_TOKENS: usize = 500;
259
260const MAX_CONTEXT_READ_BYTES: u64 = 2 * 1024 * 1024;
265
266fn read_text_for_context(path: &Path) -> std::io::Result<String> {
272 let len = fs::metadata(path)?.len();
273 if len <= MAX_CONTEXT_READ_BYTES {
274 return fs::read_to_string(path);
275 }
276 let mut buf = Vec::new();
277 File::open(path)?.take(MAX_CONTEXT_READ_BYTES).read_to_end(&mut buf)?;
278 match String::from_utf8(buf) {
279 Ok(s) => Ok(s),
280 Err(e) => {
281 let valid = e.utf8_error().valid_up_to();
282 if valid == 0 {
283 return Err(std::io::Error::new(
284 std::io::ErrorKind::InvalidData,
285 "stream did not contain valid UTF-8",
286 ));
287 }
288 let bytes = e.into_bytes();
291 Ok(String::from_utf8_lossy(&bytes[..valid]).into_owned())
292 }
293 }
294}
295
296fn cap_file_to_tokens(content: &str, max_tokens: usize) -> String {
299 if crate::utils::encoder::definitely_fits_token_budget(content, max_tokens) {
300 return content.to_string();
301 }
302 match crate::utils::encoder::encode_ids(content) {
303 Some(ids) if ids.len() > max_tokens => {
304 let omitted = ids.len() - max_tokens;
305 let head = crate::utils::encoder::decode_ids(&ids[..max_tokens])
306 .unwrap_or_else(|| content.chars().take(max_tokens.saturating_mul(4)).collect());
307 format!("{head}\n[... {omitted} tokens truncated for context save ...]")
308 }
309 _ => content.to_string(),
310 }
311}
312
313fn truncate_memory_to_tokens(text: String, max_tokens: usize) -> String {
317 if crate::utils::encoder::definitely_fits_token_budget(&text, max_tokens) {
318 return text;
319 }
320 let Some(ids) = crate::utils::encoder::encode_ids(&text) else {
321 return text;
322 };
323 if ids.len() <= max_tokens {
324 return text;
325 }
326 let keep = max_tokens.saturating_sub(8);
327 match crate::utils::encoder::decode_ids(&ids[..keep]) {
328 Some(decoded) => format!("{decoded}\n(...truncated saved context)"),
329 None => text,
330 }
331}
332
333pub(crate) fn extract_project_root(memory: &str) -> Option<PathBuf> {
336 memory
337 .lines()
338 .find_map(|line| line.strip_prefix("Project root path:").map(str::trim))
339 .filter(|root| !root.is_empty())
340 .map(PathBuf::from)
341}
342
343fn write_memory_file(
344 memory_file_path: &Path,
345 memory_data: &str,
346 context: &ContextSave,
347) -> Result<Option<String>> {
348 match File::create(memory_file_path) {
349 Ok(mut file) => {
350 if let Err(e) = file.write_all(memory_data.as_bytes()) {
351 warn!("Failed to write memory data: {}", e);
352 return Ok(Some(save_to_temp_file(memory_data, context)?));
353 }
354 }
355 Err(e) => {
356 warn!("Failed to create memory file: {}", e);
357 return Ok(Some(save_to_temp_file(memory_data, context)?));
358 }
359 }
360
361 Ok(None)
362}
363
364fn write_bash_state_file(state_file_path: &Path, bash_state: &BashState) {
365 if let Err(e) = save_bash_state_to_path(state_file_path, &bash_state.snapshot()) {
367 warn!("Failed to write bash state data: {}", e);
368 }
369}
370
371fn context_save_response(
372 relevant_files: &[PathBuf],
373 warnings: &[String],
374 context: &ContextSave,
375 memory_file_path: &Path,
376) -> String {
377 let memory_file_path_str = memory_file_path.to_string_lossy().to_string();
378 if !relevant_files.is_empty() || context.relevant_file_globs.is_empty() {
379 if warnings.is_empty() {
380 memory_file_path_str
381 } else {
382 format!(
383 "{}\n\nContext file successfully saved at {}",
384 warnings.join("\n"),
385 memory_file_path_str
386 )
387 }
388 } else {
389 format!(
390 "Error: No files found for the given globs. Context file successfully saved at \"{memory_file_path_str}\", but please fix the error."
391 )
392 }
393}
394
395fn relativize_glob(glob: &str, project_root: &str) -> String {
409 if project_root.is_empty() {
410 return glob.to_string();
411 }
412 Path::new(glob)
413 .strip_prefix(project_root)
414 .map_or_else(|_| glob.to_string(), |rel| rel.to_string_lossy().to_string())
415}
416
417fn format_memory(context: &ContextSave, relevant_files_data: &str) -> String {
418 let mut memory_data = String::new();
419
420 if !context.project_root_path.is_empty() {
422 let _ = write!(memory_data, "Project root path: {}\n\n", context.project_root_path);
423 }
424
425 memory_data.push_str(&context.description);
427 memory_data.push_str("\n\n");
428
429 let portable_globs = context
434 .relevant_file_globs
435 .iter()
436 .map(|glob| relativize_glob(glob, &context.project_root_path))
437 .collect::<Vec<_>>();
438 let _ = write!(memory_data, "Relevant file globs: {}\n\n", portable_globs.join(", "));
439
440 memory_data.push_str("File contents:\n\n");
442 memory_data.push_str(relevant_files_data);
443
444 memory_data
445}
446
447fn get_app_dir_xdg() -> Result<PathBuf> {
459 let app_dir = get_primary_app_dir().or_else(|_| get_fallback_app_dir())?;
461
462 debug!("Using app directory: {}", app_dir.display());
463 Ok(app_dir)
464}
465
466fn get_primary_app_dir() -> Result<PathBuf> {
468 if let Ok(xdg_path) = std::env::var("XDG_DATA_HOME") {
470 let app_dir = PathBuf::from(xdg_path).join("winx");
471 if let Ok(()) = fs::create_dir_all(&app_dir) {
472 return Ok(app_dir);
473 }
474 }
475
476 if let Ok(home) = std::env::var("HOME") {
478 let app_dir = PathBuf::from(home).join(".local/share/winx");
479 if let Ok(()) = fs::create_dir_all(&app_dir) {
480 return Ok(app_dir);
481 }
482 }
483
484 Err(WinxError::FileAccessError {
486 path: PathBuf::from("<primary-paths>"),
487 message: "Could not create app directory in primary locations".to_string(),
488 })
489}
490
491fn get_fallback_app_dir() -> Result<PathBuf> {
493 let current_dir = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
495
496 let app_dir = current_dir.join(".winx-data");
497 if let Ok(()) = fs::create_dir_all(&app_dir) {
498 return Ok(app_dir);
499 }
500
501 let temp_dir = std::env::temp_dir().join("winx-data");
503 fs::create_dir_all(&temp_dir).map_err(|e| WinxError::FileAccessError {
504 path: temp_dir.clone(),
505 message: format!("Failed to create app directory in any location: {e}"),
506 })?;
507
508 Ok(temp_dir)
509}
510
511fn read_files_content(file_paths: &[PathBuf], max_files: usize) -> Result<String> {
522 let mut result = String::new();
523 let mut skipped_binary = Vec::new();
524
525 let shown = file_paths.len().min(max_files).max(1);
530 let per_file_tokens = (memory_token_budget() / shown).max(MIN_PER_FILE_SAVE_TOKENS);
531
532 for (i, path) in file_paths.iter().take(max_files).enumerate() {
533 match read_text_for_context(path) {
536 Ok(file_content) => {
537 let _ = writeln!(result, "--- File {}: {} ---", i + 1, path.display());
538 result.push_str(&cap_file_to_tokens(&file_content, per_file_tokens));
539 result.push_str("\n\n");
540 }
541 Err(e) if e.kind() == std::io::ErrorKind::InvalidData => {
542 skipped_binary.push(path.display().to_string());
544 }
545 Err(e) => {
546 return Err(WinxError::FileAccessError {
547 path: path.clone(),
548 message: format!("Failed to read file: {e}"),
549 });
550 }
551 }
552 }
553
554 if !skipped_binary.is_empty() {
556 let _ = write!(
557 result,
558 "Note: Skipped {} binary/non-text file(s): {}\n\n",
559 skipped_binary.len(),
560 skipped_binary.join(", ")
561 );
562 }
563
564 if file_paths.len() > max_files {
565 let _ = writeln!(
566 result,
567 "Note: Only showing the first {} files out of {}.",
568 max_files,
569 file_paths.len()
570 );
571 }
572
573 Ok(result)
574}
575
576fn try_open_file(file_path: &str) -> Result<()> {
586 if std::env::consts::OS != "macos" && std::env::consts::OS != "linux" {
587 return Ok(());
589 }
590
591 let cmd = if std::env::consts::OS == "macos" {
593 "open"
594 } else {
595 for cmd in &["xdg-open", "gnome-open", "kde-open"] {
597 let status = std::process::Command::new("which")
598 .arg(cmd)
599 .stdout(std::process::Stdio::null())
600 .stderr(std::process::Stdio::null())
601 .status();
602
603 if let Ok(status) = status {
604 if status.success() {
605 let _ = std::process::Command::new(cmd)
607 .arg(file_path)
608 .stdout(std::process::Stdio::null())
609 .stderr(std::process::Stdio::null())
610 .spawn()
611 .map_err(|e| {
612 WinxError::CommandExecutionError(format!(
613 "Failed to spawn open command: {e}"
614 ))
615 })?;
616
617 return Ok(());
619 }
620 }
621 }
622
623 return Ok(());
625 };
626
627 let _ = std::process::Command::new(cmd)
629 .arg(file_path)
630 .stdout(std::process::Stdio::null())
631 .stderr(std::process::Stdio::null())
632 .spawn()
633 .map_err(|e| {
634 WinxError::CommandExecutionError(format!("Failed to spawn open command: {e}"))
635 })?;
636
637 Ok(())
642}
643
644fn should_open_context_file(file_path: &str) -> bool {
645 crate::config::env_flag("WINX_OPEN_CONTEXT") && Path::new(file_path).is_file() && !cfg!(test)
646}
647
648fn save_to_temp_file(memory_data: &str, context: &ContextSave) -> Result<String> {
650 let temp_dir = std::env::temp_dir();
651 let safe_id = sanitize_filename(&context.id);
652 let temp_file_path = temp_dir.join(format!("winx-{safe_id}.txt"));
653
654 let mut file = File::create(&temp_file_path).map_err(|e| WinxError::FileAccessError {
655 path: temp_file_path.clone(),
656 message: format!("Failed to create temporary file: {e}"),
657 })?;
658
659 file.write_all(memory_data.as_bytes()).map_err(|e| WinxError::FileAccessError {
660 path: temp_file_path.clone(),
661 message: format!("Failed to write to temporary file: {e}"),
662 })?;
663
664 let path_str = temp_file_path.to_string_lossy().to_string();
665
666 Ok(format!(
667 "Context was saved to temporary file at {path_str} due to permission issues with regular locations."
668 ))
669}
670
671fn sanitize_filename(input: &str) -> String {
673 let invalid_chars = vec!['/', '\\', ':', '*', '?', '"', '<', '>', '|'];
674 let mut result = input.to_string();
675
676 for c in invalid_chars {
677 result = result.replace(c, "_");
678 }
679
680 if result.chars().count() > 50 {
683 use rand::RngExt;
684 let head: String = result.chars().take(45).collect();
685 result = format!("{head}-{}", rand::rng().random_range(1000..9999));
686 }
687
688 result
689}
690
691#[cfg(test)]
692mod tests {
693 #![allow(clippy::unwrap_used)]
694 use super::{
695 cap_file_to_tokens, read_text_for_context, relativize_glob, MAX_CONTEXT_READ_BYTES,
696 };
697
698 #[test]
699 fn read_text_for_context_small_file_reads_whole() {
700 let dir = tempfile::tempdir().unwrap();
701 let p = dir.path().join("small.txt");
702 std::fs::write(&p, "hello\nworld\n").unwrap();
703 assert_eq!(read_text_for_context(&p).unwrap(), "hello\nworld\n");
704 }
705
706 #[test]
707 fn read_text_for_context_binary_is_invalid_data() {
708 let dir = tempfile::tempdir().unwrap();
709 let p = dir.path().join("bin");
710 std::fs::write(&p, [0xff, 0xfe, 0x00, 0x01]).unwrap();
711 assert_eq!(read_text_for_context(&p).unwrap_err().kind(), std::io::ErrorKind::InvalidData);
712 }
713
714 #[test]
715 fn read_text_for_context_caps_oversized_file() {
716 let dir = tempfile::tempdir().unwrap();
717 let p = dir.path().join("big.txt");
718 let capped = usize::try_from(MAX_CONTEXT_READ_BYTES).unwrap_or(usize::MAX - 4096);
719 std::fs::write(&p, vec![b'a'; capped + 4096]).unwrap();
720 assert_eq!(read_text_for_context(&p).unwrap().len() as u64, MAX_CONTEXT_READ_BYTES);
722 }
723
724 #[test]
725 fn relativize_glob_makes_absolute_portable() {
726 assert_eq!(relativize_glob("/home/u/proj/src/*.rs", "/home/u/proj"), "src/*.rs");
728 assert_eq!(relativize_glob("src/*.rs", "/home/u/proj"), "src/*.rs");
730 assert_eq!(relativize_glob("/etc/*.conf", "/home/u/proj"), "/etc/*.conf");
732 assert_eq!(relativize_glob("/abs/x", ""), "/abs/x");
734 }
735
736 #[test]
737 fn small_file_is_untouched() {
738 let content = "def add(a, b):\n return a + b\n";
739 assert_eq!(cap_file_to_tokens(content, 4_000), content);
740 }
741
742 #[test]
743 fn large_file_is_capped_with_a_note() {
744 use std::fmt::Write as _;
746 let mut big = String::new();
747 for i in 0..5_000 {
748 let _ = write!(big, "word{i} ");
749 }
750 let capped = cap_file_to_tokens(&big, 100);
751 assert!(capped.len() < big.len(), "should shrink");
752 assert!(capped.contains("truncated for context save"), "should note truncation");
753 assert!(capped.starts_with("word0"), "should keep the head");
754 }
755}