Skip to main content

rs_hack/
execute.rs

1//! Silent execution of an `Operation` across a set of files.
2//!
3//! Returns a structured `ExecuteResult` with everything a caller needs to render output, make
4//! decisions, or surface errors. No `println!`/`eprintln!` — embedders (MCP server, yah, tests)
5//! decide what to display; the CLI in `main.rs` wraps these calls with its own renderer.
6
7use std::collections::HashMap;
8use std::path::PathBuf;
9
10use anyhow::{Context, Result};
11use serde::{Deserialize, Serialize};
12
13use crate::editor::RustEditor;
14use crate::operations::{BackupNode, Operation};
15use crate::state::{
16    FileModification, RunMetadata, RunStatus, generate_run_id, get_state_dir, hash_file,
17    save_backup_nodes, save_run_metadata,
18};
19
20#[derive(Debug, Clone, Default)]
21pub struct ExecuteOpts {
22    /// When true, write modified files. When false, perform a dry run.
23    pub apply: bool,
24    /// Optional override of the destination path. Only meaningful with a single input file.
25    pub output: Option<PathBuf>,
26    /// Stop after this many modifications across all files.
27    pub limit: Option<usize>,
28}
29
30#[derive(Debug, Clone, Serialize, Deserialize)]
31pub struct FileChange {
32    pub path: PathBuf,
33    pub old_content: String,
34    pub new_content: String,
35    pub modified_nodes: Vec<BackupNode>,
36}
37
38#[derive(Debug, Default, Serialize, Deserialize)]
39pub struct ExecuteResult {
40    pub changes: Vec<FileChange>,
41    pub total_modifications: usize,
42    pub unmatched_qualified_paths: HashMap<String, usize>,
43    pub parse_errors: Vec<(PathBuf, String)>,
44    /// Last per-file apply error from a multi-file run (single-file errors bubble up).
45    pub last_error: Option<String>,
46    pub limit_hit: bool,
47    /// Set when `execute_with_state` applied changes successfully.
48    pub run_id: Option<String>,
49    /// Per-file metadata captured for state tracking. Empty for `execute()`.
50    pub files_modified: Vec<FileModification>,
51}
52
53/// Apply `op` across `files` without printing anything. When `opts.apply` is
54/// true, writes modified files in place (or to `opts.output` if set);
55/// otherwise performs a dry run and only fills the result.
56pub fn execute(files: &[PathBuf], op: &Operation, opts: &ExecuteOpts) -> Result<ExecuteResult> {
57    let mut result = ExecuteResult::default();
58
59    for file_path in files {
60        let content = std::fs::read_to_string(file_path)
61            .with_context(|| format!("Failed to read {}", file_path.display()))?;
62
63        let mut editor = match RustEditor::new(&content) {
64            Ok(editor) => editor,
65            Err(e) => {
66                if files.len() == 1 {
67                    return Err(e)
68                        .with_context(|| format!("Failed to parse {}", file_path.display()));
69                }
70                result
71                    .parse_errors
72                    .push((file_path.clone(), format!("{}", e)));
73                continue;
74            }
75        };
76
77        match editor.apply_operation(op) {
78            Ok(op_result) => {
79                if let Some(unmatched) = op_result.unmatched_qualified_paths {
80                    for (path, count) in unmatched {
81                        *result.unmatched_qualified_paths.entry(path).or_insert(0) += count;
82                    }
83                }
84
85                if op_result.changed {
86                    result.total_modifications += op_result.modified_nodes.len();
87                    let new_content = editor.to_string();
88
89                    if opts.apply {
90                        let write_path = opts.output.as_ref().unwrap_or(file_path);
91                        std::fs::write(write_path, &new_content)
92                            .with_context(|| format!("Failed to write {}", write_path.display()))?;
93                    }
94
95                    result.changes.push(FileChange {
96                        path: file_path.clone(),
97                        old_content: content,
98                        new_content,
99                        modified_nodes: op_result.modified_nodes,
100                    });
101
102                    if let Some(limit) = opts.limit
103                        && result.total_modifications >= limit
104                    {
105                        result.limit_hit = true;
106                        break;
107                    }
108                }
109            }
110            Err(e) => {
111                if files.len() == 1 {
112                    return Err(e);
113                }
114                result.last_error = Some(format!("{}", e));
115            }
116        }
117    }
118
119    Ok(result)
120}
121
122/// Like `execute` but records a revertible run.
123///
124/// Falls back to plain `execute` if `apply` is false or `output` is set (state tracking only
125/// applies to in-place writes). On success, populates `run_id` and `files_modified`.
126///
127/// `command_line` is stored verbatim in the run metadata so users can recall
128/// what produced a given run; pass `String::new()` if the caller has no
129/// meaningful command line to report.
130pub fn execute_with_state(
131    files: &[PathBuf],
132    op: &Operation,
133    opts: &ExecuteOpts,
134    local_state: bool,
135    command_line: String,
136) -> Result<ExecuteResult> {
137    if !opts.apply || opts.output.is_some() {
138        return execute(files, op, opts);
139    }
140
141    let run_id = generate_run_id();
142    let state_dir = get_state_dir(local_state)?;
143    let mut result = ExecuteResult::default();
144
145    for file_path in files {
146        let content = std::fs::read_to_string(file_path)
147            .with_context(|| format!("Failed to read {}", file_path.display()))?;
148
149        let mut editor = match RustEditor::new(&content) {
150            Ok(editor) => editor,
151            Err(e) => {
152                if files.len() == 1 {
153                    return Err(e)
154                        .with_context(|| format!("Failed to parse {}", file_path.display()));
155                }
156                result
157                    .parse_errors
158                    .push((file_path.clone(), format!("{}", e)));
159                continue;
160            }
161        };
162
163        match editor.apply_operation(op) {
164            Ok(op_result) => {
165                if let Some(unmatched) = op_result.unmatched_qualified_paths {
166                    for (path, count) in unmatched {
167                        *result.unmatched_qualified_paths.entry(path).or_insert(0) += count;
168                    }
169                }
170
171                if op_result.changed {
172                    result.total_modifications += op_result.modified_nodes.len();
173                    let new_content = editor.to_string();
174
175                    let hash_before = hash_file(file_path)?;
176                    save_backup_nodes(file_path, &op_result.modified_nodes, &run_id, &state_dir)?;
177
178                    std::fs::write(file_path, &new_content)
179                        .with_context(|| format!("Failed to write {}", file_path.display()))?;
180
181                    let hash_after = hash_file(file_path)?;
182
183                    result.files_modified.push(FileModification {
184                        path: file_path.clone(),
185                        hash_before,
186                        hash_after,
187                        backup_nodes: op_result.modified_nodes.clone(),
188                    });
189                    result.changes.push(FileChange {
190                        path: file_path.clone(),
191                        old_content: content,
192                        new_content,
193                        modified_nodes: op_result.modified_nodes,
194                    });
195
196                    if let Some(limit) = opts.limit
197                        && result.total_modifications >= limit
198                    {
199                        result.limit_hit = true;
200                        break;
201                    }
202                }
203            }
204            Err(e) => {
205                if files.len() == 1 {
206                    return Err(e);
207                }
208                result.last_error = Some(format!("{}", e));
209            }
210        }
211    }
212
213    if !result.files_modified.is_empty() {
214        let metadata = RunMetadata {
215            run_id: run_id.clone(),
216            timestamp: chrono::Utc::now(),
217            command: command_line,
218            operation: op.kind_name().to_string(),
219            files_modified: result.files_modified.clone(),
220            status: RunStatus::Applied,
221            can_revert: true,
222        };
223        save_run_metadata(&metadata, &state_dir)?;
224        result.run_id = Some(run_id);
225    }
226
227    Ok(result)
228}