Skip to main content

winx_code_agent/tools/
context_save.rs

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::types::ContextSave;
13use crate::utils::path::expand_user;
14
15/// Handle a call to the `ContextSave` tool
16///
17/// This function processes a `ContextSave` request, saves context information about a task,
18/// including file contents from specified globs, to a single file.
19///
20/// # Arguments
21///
22/// * `bash_state` - Shared reference to the bash state
23/// * `args` - Parameters for the `ContextSave` operation
24///
25/// # Returns
26///
27/// A Result with the path where the context file was saved, or an error
28pub async fn handle_tool_call(
29    bash_state: &Arc<Mutex<Option<BashState>>>,
30    args: ContextSave,
31) -> Result<String> {
32    // Ensure bash state is initialized
33    let bash_state_guard = bash_state.lock().await;
34
35    let bash_state = bash_state_guard.as_ref().ok_or(WinxError::BashStateNotInitialized)?;
36
37    // Process the ContextSave request
38    let result = save_context(bash_state, args)?;
39
40    // Try to open the file with the default application if possible
41    if let Err(e) = try_open_file(&result) {
42        debug!("Failed to open the context file: {}", e);
43        // This is non-fatal, just log it
44    }
45
46    Ok(result)
47}
48
49/// Save the context information to a file
50///
51/// # Arguments
52///
53/// * `bash_state` - Reference to the bash state
54/// * `context` - The `ContextSave` parameters
55///
56/// # Returns
57///
58/// A Result with the path where the context file was saved, or an error
59fn save_context(bash_state: &BashState, mut context: ContextSave) -> Result<String> {
60    normalize_context(&mut context)?;
61
62    let (relevant_files, warnings) = collect_relevant_files(&context)?;
63    let memory_dir = resolve_memory_dir()?;
64    let relevant_files_data = read_files_content(&relevant_files, 10_000)?;
65    let memory_data = format_memory(&context, &relevant_files_data);
66    let safe_id = sanitize_filename(&context.id);
67
68    let memory_file_path = memory_dir.join(format!("{safe_id}.txt"));
69    if let Some(response) = write_memory_file(&memory_file_path, &memory_data, &context)? {
70        return Ok(response);
71    }
72
73    let state_file_path = memory_dir.join(format!("{safe_id}_bash_state.json"));
74    write_bash_state_file(&state_file_path, bash_state)?;
75
76    Ok(context_save_response(&relevant_files, &warnings, &context, &memory_file_path))
77}
78
79fn normalize_context(context: &mut ContextSave) -> Result<()> {
80    if !context.project_root_path.is_empty() {
81        context.project_root_path = expand_user(&context.project_root_path);
82    }
83
84    if context.id.is_empty() {
85        return Err(WinxError::ArgumentParseError("Task ID cannot be empty".to_string()));
86    }
87
88    Ok(())
89}
90
91fn collect_relevant_files(context: &ContextSave) -> Result<(Vec<PathBuf>, Vec<String>)> {
92    let mut relevant_files = Vec::new();
93    let mut warnings = Vec::new();
94
95    for glob_pattern in &context.relevant_file_globs {
96        // Expand the glob pattern if it contains a tilde
97        let expanded_glob = expand_user(glob_pattern);
98
99        // If the glob is not absolute and we have a project root, make it relative to the project root
100        let final_glob =
101            if !Path::new(&expanded_glob).is_absolute() && !context.project_root_path.is_empty() {
102                PathBuf::from(&context.project_root_path)
103                    .join(expanded_glob)
104                    .to_string_lossy()
105                    .to_string()
106            } else {
107                expanded_glob
108            };
109
110        debug!("Processing glob pattern: {}", final_glob);
111
112        // Use the glob crate to find matching files
113        let matches = glob(&final_glob).map_err(|e| {
114            WinxError::ArgumentParseError(format!("Invalid glob pattern '{final_glob}': {e}"))
115        })?;
116
117        let mut found_files = false;
118        for entry in matches {
119            match entry {
120                Ok(path) => {
121                    if path.is_file() {
122                        relevant_files.push(path);
123                        found_files = true;
124                        // Limit to 1000 files per glob to avoid excessive processing
125                        if relevant_files.len() >= 1000 {
126                            warn!("Reached limit of 1000 files for glob '{}'", final_glob);
127                            break;
128                        }
129                    }
130                }
131                Err(e) => {
132                    warn!("Error matching glob '{}': {}", final_glob, e);
133                }
134            }
135        }
136
137        if !found_files {
138            warnings.push(format!("Warning: No files found for the glob: {glob_pattern}"));
139        }
140    }
141
142    debug!("Found {} relevant files", relevant_files.len());
143    Ok((relevant_files, warnings))
144}
145
146fn resolve_memory_dir() -> Result<PathBuf> {
147    let app_dir = match get_app_dir_xdg() {
148        Ok(dir) => dir,
149        Err(e) => {
150            debug!("Failed to get primary app directory: {:?}", e);
151            // Try using temporary directory directly as a last resort
152            let fallback = std::env::temp_dir().join("winx-memory");
153            debug!("Using fallback directory: {}", fallback.display());
154            fs::create_dir_all(&fallback).map_err(|e2| WinxError::FileAccessError {
155                path: fallback.clone(),
156                message: format!(
157                    "Failed to create fallback directory: {e2} (after previous error: {e:?})"
158                ),
159            })?;
160            fallback
161        }
162    };
163
164    let mut memory_dir = app_dir.join("memory");
165    match fs::create_dir_all(&memory_dir) {
166        Ok(()) => {}
167        Err(e) => {
168            debug!("Failed to create memory directory: {}", e);
169            // If we can't create the memory subdirectory, use the app dir directly as the memory_dir
170            debug!("Using app_dir directly as memory_dir due to failed subdirectory creation");
171            memory_dir = app_dir;
172        }
173    }
174
175    Ok(memory_dir)
176}
177
178fn write_memory_file(
179    memory_file_path: &Path,
180    memory_data: &str,
181    context: &ContextSave,
182) -> Result<Option<String>> {
183    match File::create(memory_file_path) {
184        Ok(mut file) => {
185            if let Err(e) = file.write_all(memory_data.as_bytes()) {
186                warn!("Failed to write memory data: {}", e);
187                return Ok(Some(save_to_temp_file(memory_data, context)?));
188            }
189        }
190        Err(e) => {
191            warn!("Failed to create memory file: {}", e);
192            return Ok(Some(save_to_temp_file(memory_data, context)?));
193        }
194    }
195
196    Ok(None)
197}
198
199fn write_bash_state_file(state_file_path: &Path, bash_state: &BashState) -> Result<()> {
200    let bash_state_dict = serde_json::json!({
201        "cwd": bash_state.cwd.to_string_lossy().to_string(),
202        "workspace_root": bash_state.workspace_root.to_string_lossy().to_string(),
203        "mode": match bash_state.mode {
204            crate::types::Modes::Wcgw => "wcgw",
205            crate::types::Modes::Architect => "architect",
206            crate::types::Modes::CodeWriter => "code_writer",
207        }
208    });
209
210    let state_json = serde_json::to_string_pretty(&bash_state_dict).map_err(|e| {
211        WinxError::SerializationError(format!("Failed to serialize bash state: {e}"))
212    })?;
213
214    // Try to create and write state file, but don't fail if it doesn't work
215    match File::create(state_file_path) {
216        Ok(mut state_file) => {
217            if let Err(e) = state_file.write_all(state_json.as_bytes()) {
218                warn!("Failed to write bash state data: {}", e);
219                // Non-fatal, continue
220            }
221        }
222        Err(e) => {
223            warn!("Failed to create bash state file: {}", e);
224            // Non-fatal, continue
225        }
226    }
227
228    Ok(())
229}
230
231fn context_save_response(
232    relevant_files: &[PathBuf],
233    warnings: &[String],
234    context: &ContextSave,
235    memory_file_path: &Path,
236) -> String {
237    let memory_file_path_str = memory_file_path.to_string_lossy().to_string();
238    if !relevant_files.is_empty() || context.relevant_file_globs.is_empty() {
239        if warnings.is_empty() {
240            memory_file_path_str
241        } else {
242            format!(
243                "{}\n\nContext file successfully saved at {}",
244                warnings.join("\n"),
245                memory_file_path_str
246            )
247        }
248    } else {
249        format!(
250            "Error: No files found for the given globs. Context file successfully saved at \"{memory_file_path_str}\", but please fix the error."
251        )
252    }
253}
254
255/// Format the memory data for saving
256///
257/// # Arguments
258///
259/// * `context` - The `ContextSave` parameters
260/// * `relevant_files_data` - The content of the relevant files
261///
262/// # Returns
263///
264/// A formatted string containing the memory data
265fn format_memory(context: &ContextSave, relevant_files_data: &str) -> String {
266    let mut memory_data = String::new();
267
268    // Add project root path if provided
269    if !context.project_root_path.is_empty() {
270        let _ = write!(memory_data, "Project root path: {}\n\n", context.project_root_path);
271    }
272
273    // Add the description
274    memory_data.push_str(&context.description);
275    memory_data.push_str("\n\n");
276
277    // Add the relevant file globs
278    let _ =
279        write!(memory_data, "Relevant file globs: {}\n\n", context.relevant_file_globs.join(", "));
280
281    // Add the content of the relevant files
282    memory_data.push_str("File contents:\n\n");
283    memory_data.push_str(relevant_files_data);
284
285    memory_data
286}
287
288/// Get the application directory for storing data
289///
290/// This function tries multiple locations in order of preference:
291/// 1. `XDG_DATA_HOME/winx` if `XDG_DATA_HOME` is set
292/// 2. HOME/.local/share/winx if HOME is set
293/// 3. Current directory/.winx-data as a fallback
294/// 4. Temporary directory as a last resort
295///
296/// # Returns
297///
298/// A Result with the path to the app directory
299fn get_app_dir_xdg() -> Result<PathBuf> {
300    // Try multiple locations in order of preference
301    let app_dir = get_primary_app_dir().or_else(|_| get_fallback_app_dir())?;
302
303    debug!("Using app directory: {}", app_dir.display());
304    Ok(app_dir)
305}
306
307/// Try to get the primary application directory
308fn get_primary_app_dir() -> Result<PathBuf> {
309    // Try XDG_DATA_HOME first
310    if let Ok(xdg_path) = std::env::var("XDG_DATA_HOME") {
311        let app_dir = PathBuf::from(xdg_path).join("winx");
312        if let Ok(()) = fs::create_dir_all(&app_dir) {
313            return Ok(app_dir);
314        }
315    }
316
317    // Try HOME/.local/share next
318    if let Ok(home) = std::env::var("HOME") {
319        let app_dir = PathBuf::from(home).join(".local/share/winx");
320        if let Ok(()) = fs::create_dir_all(&app_dir) {
321            return Ok(app_dir);
322        }
323    }
324
325    // No successful primary location
326    Err(WinxError::FileAccessError {
327        path: PathBuf::from("<primary-paths>"),
328        message: "Could not create app directory in primary locations".to_string(),
329    })
330}
331
332/// Get a fallback application directory when primary ones fail
333fn get_fallback_app_dir() -> Result<PathBuf> {
334    // Try current directory
335    let current_dir = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
336
337    let app_dir = current_dir.join(".winx-data");
338    if let Ok(()) = fs::create_dir_all(&app_dir) {
339        return Ok(app_dir);
340    }
341
342    // Try temporary directory as a last resort
343    let temp_dir = std::env::temp_dir().join("winx-data");
344    fs::create_dir_all(&temp_dir).map_err(|e| WinxError::FileAccessError {
345        path: temp_dir.clone(),
346        message: format!("Failed to create app directory in any location: {e}"),
347    })?;
348
349    Ok(temp_dir)
350}
351
352/// Read the content of multiple files
353///
354/// # Arguments
355///
356/// * `file_paths` - List of paths to the files to read
357/// * `max_files` - Maximum number of files to read
358///
359/// # Returns
360///
361/// A Result with the content of the files, or an error
362fn read_files_content(file_paths: &[PathBuf], max_files: usize) -> Result<String> {
363    let mut result = String::new();
364    let mut skipped_binary = Vec::new();
365
366    for (i, path) in file_paths.iter().take(max_files).enumerate() {
367        // Try to read as UTF-8 text, skip binary files
368        match fs::read_to_string(path) {
369            Ok(file_content) => {
370                let _ = writeln!(result, "--- File {}: {} ---", i + 1, path.display());
371                result.push_str(&file_content);
372                result.push_str("\n\n");
373            }
374            Err(e) if e.kind() == std::io::ErrorKind::InvalidData => {
375                // Binary file or invalid UTF-8 - skip with note
376                skipped_binary.push(path.display().to_string());
377            }
378            Err(e) => {
379                return Err(WinxError::FileAccessError {
380                    path: path.clone(),
381                    message: format!("Failed to read file: {e}"),
382                });
383            }
384        }
385    }
386
387    // Add note about skipped binary files
388    if !skipped_binary.is_empty() {
389        let _ = write!(
390            result,
391            "Note: Skipped {} binary/non-text file(s): {}\n\n",
392            skipped_binary.len(),
393            skipped_binary.join(", ")
394        );
395    }
396
397    if file_paths.len() > max_files {
398        let _ = writeln!(
399            result,
400            "Note: Only showing the first {} files out of {}.",
401            max_files,
402            file_paths.len()
403        );
404    }
405
406    Ok(result)
407}
408
409/// Try to open a file with the default application
410///
411/// # Arguments
412///
413/// * `file_path` - Path to the file to open
414///
415/// # Returns
416///
417/// A Result indicating success or failure
418fn try_open_file(file_path: &str) -> Result<()> {
419    if std::env::consts::OS != "macos" && std::env::consts::OS != "linux" {
420        // Skip on unsupported platforms
421        return Ok(());
422    }
423
424    // Get the command to use based on the OS
425    let cmd = if std::env::consts::OS == "macos" {
426        "open"
427    } else {
428        // Try to find which command is available on Linux
429        for cmd in &["xdg-open", "gnome-open", "kde-open"] {
430            let status = std::process::Command::new("which")
431                .arg(cmd)
432                .stdout(std::process::Stdio::null())
433                .stderr(std::process::Stdio::null())
434                .status();
435
436            if let Ok(status) = status {
437                if status.success() {
438                    // Found an available command, use it
439                    let _ =
440                        std::process::Command::new(cmd).arg(file_path).spawn().map_err(|e| {
441                            WinxError::CommandExecutionError(format!(
442                                "Failed to spawn open command: {e}"
443                            ))
444                        })?;
445
446                    // We don't wait for the command to complete
447                    return Ok(());
448                }
449            }
450        }
451
452        // If no command is available, just return success
453        return Ok(());
454    };
455
456    // Try to open the file
457    let _ = std::process::Command::new(cmd).arg(file_path).spawn().map_err(|e| {
458        WinxError::CommandExecutionError(format!("Failed to spawn open command: {e}"))
459    })?;
460
461    // We don't actually need to wait for the command to complete
462    // Just let it run in the background
463    // (This mimics the Python implementation)
464
465    Ok(())
466}
467
468/// Save context data to a temporary file as a last resort
469fn save_to_temp_file(memory_data: &str, context: &ContextSave) -> Result<String> {
470    let temp_dir = std::env::temp_dir();
471    let safe_id = sanitize_filename(&context.id);
472    let temp_file_path = temp_dir.join(format!("winx-{safe_id}.txt"));
473
474    let mut file = File::create(&temp_file_path).map_err(|e| WinxError::FileAccessError {
475        path: temp_file_path.clone(),
476        message: format!("Failed to create temporary file: {e}"),
477    })?;
478
479    file.write_all(memory_data.as_bytes()).map_err(|e| WinxError::FileAccessError {
480        path: temp_file_path.clone(),
481        message: format!("Failed to write to temporary file: {e}"),
482    })?;
483
484    let path_str = temp_file_path.to_string_lossy().to_string();
485
486    Ok(format!(
487        "Context was saved to temporary file at {path_str} due to permission issues with regular locations."
488    ))
489}
490
491/// Sanitize a filename to ensure it's valid on all platforms
492fn sanitize_filename(input: &str) -> String {
493    let invalid_chars = vec!['/', '\\', ':', '*', '?', '"', '<', '>', '|'];
494    let mut result = input.to_string();
495
496    for c in invalid_chars {
497        result = result.replace(c, "_");
498    }
499
500    // Limit length to avoid issues
501    if result.len() > 50 {
502        use rand::RngExt;
503        result = format!("{}-{}", &result[0..45], rand::rng().random_range(1000..9999));
504    }
505
506    result
507}