1use glob::glob;
2use std::fmt::Write as FmtWrite;
3use std::fs::{self, File};
4use std::io::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 = format_memory(&context, &relevant_files_data);
69 let safe_id = sanitize_filename(&context.id);
70
71 let (memory_file_path, state_file_path) = context_paths_from_dir(&memory_dir, &safe_id);
72 if let Some(response) = write_memory_file(&memory_file_path, &memory_data, &context)? {
73 return Ok(response);
74 }
75
76 write_bash_state_file(&state_file_path, bash_state);
77
78 Ok(context_save_response(&relevant_files, &warnings, &context, &memory_file_path))
79}
80
81fn normalize_context(context: &mut ContextSave) -> Result<()> {
82 if !context.project_root_path.is_empty() {
83 context.project_root_path = expand_user(&context.project_root_path);
84 }
85
86 if context.id.is_empty() {
87 return Err(WinxError::ArgumentParseError("Task ID cannot be empty".to_string()));
88 }
89
90 Ok(())
91}
92
93fn collect_relevant_files(
94 context: &ContextSave,
95 workspace_root: &Path,
96) -> Result<(Vec<PathBuf>, Vec<String>)> {
97 let mut relevant_files = Vec::new();
98 let mut warnings = Vec::new();
99 let mut skipped_outside = 0usize;
100
101 for glob_pattern in &context.relevant_file_globs {
102 let expanded_glob = expand_user(glob_pattern);
104
105 let final_glob =
107 if !Path::new(&expanded_glob).is_absolute() && !context.project_root_path.is_empty() {
108 PathBuf::from(&context.project_root_path)
109 .join(expanded_glob)
110 .to_string_lossy()
111 .to_string()
112 } else {
113 expanded_glob
114 };
115
116 debug!("Processing glob pattern: {}", final_glob);
117
118 let matches = glob(&final_glob).map_err(|e| {
120 WinxError::ArgumentParseError(format!("Invalid glob pattern '{final_glob}': {e}"))
121 })?;
122
123 let mut found_files = false;
124 for entry in matches {
125 match entry {
126 Ok(path) => {
127 if path.is_file() {
128 if validate_path_in_workspace(&path, workspace_root).is_err() {
135 skipped_outside += 1;
136 continue;
137 }
138 relevant_files.push(path);
139 found_files = true;
140 if relevant_files.len() >= 1000 {
142 warn!("Reached limit of 1000 files for glob '{}'", final_glob);
143 break;
144 }
145 }
146 }
147 Err(e) => {
148 warn!("Error matching glob '{}': {}", final_glob, e);
149 }
150 }
151 }
152
153 if !found_files {
154 warnings.push(format!("Warning: No files found for the glob: {glob_pattern}"));
155 }
156 }
157
158 if skipped_outside > 0 {
159 warnings.push(format!(
160 "Warning: skipped {skipped_outside} file(s) that resolved outside the workspace root."
161 ));
162 }
163
164 debug!("Found {} relevant files", relevant_files.len());
165 Ok((relevant_files, warnings))
166}
167
168fn resolve_memory_dir() -> Result<PathBuf> {
169 let app_dir = match get_app_dir_xdg() {
170 Ok(dir) => dir,
171 Err(e) => {
172 debug!("Failed to get primary app directory: {:?}", e);
173 let fallback = std::env::temp_dir().join("winx-memory");
175 debug!("Using fallback directory: {}", fallback.display());
176 fs::create_dir_all(&fallback).map_err(|e2| WinxError::FileAccessError {
177 path: fallback.clone(),
178 message: format!(
179 "Failed to create fallback directory: {e2} (after previous error: {e:?})"
180 ),
181 })?;
182 fallback
183 }
184 };
185
186 let mut memory_dir = app_dir.join("memory");
187 match fs::create_dir_all(&memory_dir) {
188 Ok(()) => {}
189 Err(e) => {
190 debug!("Failed to create memory directory: {}", e);
191 debug!("Using app_dir directly as memory_dir due to failed subdirectory creation");
193 memory_dir = app_dir;
194 }
195 }
196
197 Ok(memory_dir)
198}
199
200pub(crate) fn context_paths(id: &str) -> Result<(PathBuf, PathBuf)> {
201 let safe_id = sanitize_filename(id);
202 let memory_dir = resolve_memory_dir()?;
203 Ok(context_paths_from_dir(&memory_dir, &safe_id))
204}
205
206fn context_paths_from_dir(memory_dir: &Path, safe_id: &str) -> (PathBuf, PathBuf) {
207 (
208 memory_dir.join(format!("{safe_id}.txt")),
209 memory_dir.join(format!("{safe_id}_bash_state.json")),
210 )
211}
212
213pub(crate) fn load_saved_context(id: &str) -> Result<Option<(String, Option<BashStateSnapshot>)>> {
214 if id.is_empty() {
215 return Ok(None);
216 }
217
218 let (memory_file_path, state_file_path) = context_paths(id)?;
219 if !memory_file_path.exists() {
220 tracing::warn!(
221 task_id = %id,
222 path = %memory_file_path.display(),
223 "task_id_to_resume requested but no saved context found; starting fresh"
224 );
225 return Ok(None);
226 }
227
228 let memory_data =
229 fs::read_to_string(&memory_file_path).map_err(|e| WinxError::FileAccessError {
230 path: memory_file_path.clone(),
231 message: format!("Failed to read saved context: {e}"),
232 })?;
233 let memory_data = truncate_memory_to_tokens(memory_data, memory_token_budget());
236 let state = load_bash_state_from_path(&state_file_path).map_err(|e| {
237 WinxError::SerializationError(format!("Failed to load saved bash state: {e}"))
238 })?;
239
240 Ok(Some((memory_data, state)))
241}
242
243const MEMORY_MAX_TOKENS: usize = 8_000;
246
247fn memory_token_budget() -> usize {
250 crate::utils::encoder::budget_from_env("WINX_NONCODING_TOKEN_BUDGET", MEMORY_MAX_TOKENS)
251}
252
253const MIN_PER_FILE_SAVE_TOKENS: usize = 500;
256
257fn cap_file_to_tokens(content: &str, max_tokens: usize) -> String {
260 match crate::utils::encoder::encode_ids(content) {
261 Some(ids) if ids.len() > max_tokens => {
262 let omitted = ids.len() - max_tokens;
263 let head = crate::utils::encoder::decode_ids(&ids[..max_tokens])
264 .unwrap_or_else(|| content.chars().take(max_tokens.saturating_mul(4)).collect());
265 format!("{head}\n[... {omitted} tokens truncated for context save ...]")
266 }
267 _ => content.to_string(),
268 }
269}
270
271fn truncate_memory_to_tokens(text: String, max_tokens: usize) -> String {
275 let Some(ids) = crate::utils::encoder::encode_ids(&text) else {
276 return text;
277 };
278 if ids.len() <= max_tokens {
279 return text;
280 }
281 let keep = max_tokens.saturating_sub(8);
282 match crate::utils::encoder::decode_ids(&ids[..keep]) {
283 Some(decoded) => format!("{decoded}\n(...truncated saved context)"),
284 None => text,
285 }
286}
287
288pub(crate) fn extract_project_root(memory: &str) -> Option<PathBuf> {
291 memory
292 .lines()
293 .find_map(|line| line.strip_prefix("Project root path:").map(str::trim))
294 .filter(|root| !root.is_empty())
295 .map(PathBuf::from)
296}
297
298fn write_memory_file(
299 memory_file_path: &Path,
300 memory_data: &str,
301 context: &ContextSave,
302) -> Result<Option<String>> {
303 match File::create(memory_file_path) {
304 Ok(mut file) => {
305 if let Err(e) = file.write_all(memory_data.as_bytes()) {
306 warn!("Failed to write memory data: {}", e);
307 return Ok(Some(save_to_temp_file(memory_data, context)?));
308 }
309 }
310 Err(e) => {
311 warn!("Failed to create memory file: {}", e);
312 return Ok(Some(save_to_temp_file(memory_data, context)?));
313 }
314 }
315
316 Ok(None)
317}
318
319fn write_bash_state_file(state_file_path: &Path, bash_state: &BashState) {
320 if let Err(e) = save_bash_state_to_path(state_file_path, &bash_state.snapshot()) {
322 warn!("Failed to write bash state data: {}", e);
323 }
324}
325
326fn context_save_response(
327 relevant_files: &[PathBuf],
328 warnings: &[String],
329 context: &ContextSave,
330 memory_file_path: &Path,
331) -> String {
332 let memory_file_path_str = memory_file_path.to_string_lossy().to_string();
333 if !relevant_files.is_empty() || context.relevant_file_globs.is_empty() {
334 if warnings.is_empty() {
335 memory_file_path_str
336 } else {
337 format!(
338 "{}\n\nContext file successfully saved at {}",
339 warnings.join("\n"),
340 memory_file_path_str
341 )
342 }
343 } else {
344 format!(
345 "Error: No files found for the given globs. Context file successfully saved at \"{memory_file_path_str}\", but please fix the error."
346 )
347 }
348}
349
350fn relativize_glob(glob: &str, project_root: &str) -> String {
364 if project_root.is_empty() {
365 return glob.to_string();
366 }
367 Path::new(glob)
368 .strip_prefix(project_root)
369 .map_or_else(|_| glob.to_string(), |rel| rel.to_string_lossy().to_string())
370}
371
372fn format_memory(context: &ContextSave, relevant_files_data: &str) -> String {
373 let mut memory_data = String::new();
374
375 if !context.project_root_path.is_empty() {
377 let _ = write!(memory_data, "Project root path: {}\n\n", context.project_root_path);
378 }
379
380 memory_data.push_str(&context.description);
382 memory_data.push_str("\n\n");
383
384 let portable_globs = context
389 .relevant_file_globs
390 .iter()
391 .map(|glob| relativize_glob(glob, &context.project_root_path))
392 .collect::<Vec<_>>();
393 let _ = write!(memory_data, "Relevant file globs: {}\n\n", portable_globs.join(", "));
394
395 memory_data.push_str("File contents:\n\n");
397 memory_data.push_str(relevant_files_data);
398
399 memory_data
400}
401
402fn get_app_dir_xdg() -> Result<PathBuf> {
414 let app_dir = get_primary_app_dir().or_else(|_| get_fallback_app_dir())?;
416
417 debug!("Using app directory: {}", app_dir.display());
418 Ok(app_dir)
419}
420
421fn get_primary_app_dir() -> Result<PathBuf> {
423 if let Ok(xdg_path) = std::env::var("XDG_DATA_HOME") {
425 let app_dir = PathBuf::from(xdg_path).join("winx");
426 if let Ok(()) = fs::create_dir_all(&app_dir) {
427 return Ok(app_dir);
428 }
429 }
430
431 if let Ok(home) = std::env::var("HOME") {
433 let app_dir = PathBuf::from(home).join(".local/share/winx");
434 if let Ok(()) = fs::create_dir_all(&app_dir) {
435 return Ok(app_dir);
436 }
437 }
438
439 Err(WinxError::FileAccessError {
441 path: PathBuf::from("<primary-paths>"),
442 message: "Could not create app directory in primary locations".to_string(),
443 })
444}
445
446fn get_fallback_app_dir() -> Result<PathBuf> {
448 let current_dir = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
450
451 let app_dir = current_dir.join(".winx-data");
452 if let Ok(()) = fs::create_dir_all(&app_dir) {
453 return Ok(app_dir);
454 }
455
456 let temp_dir = std::env::temp_dir().join("winx-data");
458 fs::create_dir_all(&temp_dir).map_err(|e| WinxError::FileAccessError {
459 path: temp_dir.clone(),
460 message: format!("Failed to create app directory in any location: {e}"),
461 })?;
462
463 Ok(temp_dir)
464}
465
466fn read_files_content(file_paths: &[PathBuf], max_files: usize) -> Result<String> {
477 let mut result = String::new();
478 let mut skipped_binary = Vec::new();
479
480 let shown = file_paths.len().min(max_files).max(1);
485 let per_file_tokens = (memory_token_budget() / shown).max(MIN_PER_FILE_SAVE_TOKENS);
486
487 for (i, path) in file_paths.iter().take(max_files).enumerate() {
488 match fs::read_to_string(path) {
490 Ok(file_content) => {
491 let _ = writeln!(result, "--- File {}: {} ---", i + 1, path.display());
492 result.push_str(&cap_file_to_tokens(&file_content, per_file_tokens));
493 result.push_str("\n\n");
494 }
495 Err(e) if e.kind() == std::io::ErrorKind::InvalidData => {
496 skipped_binary.push(path.display().to_string());
498 }
499 Err(e) => {
500 return Err(WinxError::FileAccessError {
501 path: path.clone(),
502 message: format!("Failed to read file: {e}"),
503 });
504 }
505 }
506 }
507
508 if !skipped_binary.is_empty() {
510 let _ = write!(
511 result,
512 "Note: Skipped {} binary/non-text file(s): {}\n\n",
513 skipped_binary.len(),
514 skipped_binary.join(", ")
515 );
516 }
517
518 if file_paths.len() > max_files {
519 let _ = writeln!(
520 result,
521 "Note: Only showing the first {} files out of {}.",
522 max_files,
523 file_paths.len()
524 );
525 }
526
527 Ok(result)
528}
529
530fn try_open_file(file_path: &str) -> Result<()> {
540 if std::env::consts::OS != "macos" && std::env::consts::OS != "linux" {
541 return Ok(());
543 }
544
545 let cmd = if std::env::consts::OS == "macos" {
547 "open"
548 } else {
549 for cmd in &["xdg-open", "gnome-open", "kde-open"] {
551 let status = std::process::Command::new("which")
552 .arg(cmd)
553 .stdout(std::process::Stdio::null())
554 .stderr(std::process::Stdio::null())
555 .status();
556
557 if let Ok(status) = status {
558 if status.success() {
559 let _ = std::process::Command::new(cmd)
561 .arg(file_path)
562 .stdout(std::process::Stdio::null())
563 .stderr(std::process::Stdio::null())
564 .spawn()
565 .map_err(|e| {
566 WinxError::CommandExecutionError(format!(
567 "Failed to spawn open command: {e}"
568 ))
569 })?;
570
571 return Ok(());
573 }
574 }
575 }
576
577 return Ok(());
579 };
580
581 let _ = std::process::Command::new(cmd)
583 .arg(file_path)
584 .stdout(std::process::Stdio::null())
585 .stderr(std::process::Stdio::null())
586 .spawn()
587 .map_err(|e| {
588 WinxError::CommandExecutionError(format!("Failed to spawn open command: {e}"))
589 })?;
590
591 Ok(())
596}
597
598fn should_open_context_file(file_path: &str) -> bool {
599 std::env::var("WINX_OPEN_CONTEXT").is_ok_and(|value| value == "1" || value == "true")
600 && Path::new(file_path).is_file()
601 && !cfg!(test)
602}
603
604fn save_to_temp_file(memory_data: &str, context: &ContextSave) -> Result<String> {
606 let temp_dir = std::env::temp_dir();
607 let safe_id = sanitize_filename(&context.id);
608 let temp_file_path = temp_dir.join(format!("winx-{safe_id}.txt"));
609
610 let mut file = File::create(&temp_file_path).map_err(|e| WinxError::FileAccessError {
611 path: temp_file_path.clone(),
612 message: format!("Failed to create temporary file: {e}"),
613 })?;
614
615 file.write_all(memory_data.as_bytes()).map_err(|e| WinxError::FileAccessError {
616 path: temp_file_path.clone(),
617 message: format!("Failed to write to temporary file: {e}"),
618 })?;
619
620 let path_str = temp_file_path.to_string_lossy().to_string();
621
622 Ok(format!(
623 "Context was saved to temporary file at {path_str} due to permission issues with regular locations."
624 ))
625}
626
627fn sanitize_filename(input: &str) -> String {
629 let invalid_chars = vec!['/', '\\', ':', '*', '?', '"', '<', '>', '|'];
630 let mut result = input.to_string();
631
632 for c in invalid_chars {
633 result = result.replace(c, "_");
634 }
635
636 if result.chars().count() > 50 {
639 use rand::RngExt;
640 let head: String = result.chars().take(45).collect();
641 result = format!("{head}-{}", rand::rng().random_range(1000..9999));
642 }
643
644 result
645}
646
647#[cfg(test)]
648mod tests {
649 use super::{cap_file_to_tokens, relativize_glob};
650
651 #[test]
652 fn relativize_glob_makes_absolute_portable() {
653 assert_eq!(relativize_glob("/home/u/proj/src/*.rs", "/home/u/proj"), "src/*.rs");
655 assert_eq!(relativize_glob("src/*.rs", "/home/u/proj"), "src/*.rs");
657 assert_eq!(relativize_glob("/etc/*.conf", "/home/u/proj"), "/etc/*.conf");
659 assert_eq!(relativize_glob("/abs/x", ""), "/abs/x");
661 }
662
663 #[test]
664 fn small_file_is_untouched() {
665 let content = "def add(a, b):\n return a + b\n";
666 assert_eq!(cap_file_to_tokens(content, 4_000), content);
667 }
668
669 #[test]
670 fn large_file_is_capped_with_a_note() {
671 use std::fmt::Write as _;
673 let mut big = String::new();
674 for i in 0..5_000 {
675 let _ = write!(big, "word{i} ");
676 }
677 let capped = cap_file_to_tokens(&big, 100);
678 assert!(capped.len() < big.len(), "should shrink");
679 assert!(capped.contains("truncated for context save"), "should note truncation");
680 assert!(capped.starts_with("word0"), "should keep the head");
681 }
682}