Skip to main content

rs_hack/
execute.rs

1//! Silent execution of an `Operation` across a set of files. Returns a
2//! structured `ExecuteResult` with everything a caller needs to render output,
3//! make decisions, or surface errors. No `println!`/`eprintln!` — embedders
4//! (MCP server, yah, tests) decide what to display; the CLI in `main.rs`
5//! 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    generate_run_id, get_state_dir, hash_file, save_backup_nodes, save_run_metadata,
17    FileModification, RunMetadata, RunStatus,
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
82                            .unmatched_qualified_paths
83                            .entry(path)
84                            .or_insert(0) += count;
85                    }
86                }
87
88                if op_result.changed {
89                    result.total_modifications += op_result.modified_nodes.len();
90                    let new_content = editor.to_string();
91
92                    if opts.apply {
93                        let write_path = opts.output.as_ref().unwrap_or(file_path);
94                        std::fs::write(write_path, &new_content).with_context(|| {
95                            format!("Failed to write {}", write_path.display())
96                        })?;
97                    }
98
99                    result.changes.push(FileChange {
100                        path: file_path.clone(),
101                        old_content: content,
102                        new_content,
103                        modified_nodes: op_result.modified_nodes,
104                    });
105
106                    if let Some(limit) = opts.limit {
107                        if result.total_modifications >= limit {
108                            result.limit_hit = true;
109                            break;
110                        }
111                    }
112                }
113            }
114            Err(e) => {
115                if files.len() == 1 {
116                    return Err(e);
117                }
118                result.last_error = Some(format!("{}", e));
119            }
120        }
121    }
122
123    Ok(result)
124}
125
126/// Like `execute` but records a revertible run. Falls back to plain `execute`
127/// if `apply` is false or `output` is set (state tracking only applies to
128/// in-place writes). On success, populates `run_id` and `files_modified`.
129///
130/// `command_line` is stored verbatim in the run metadata so users can recall
131/// what produced a given run; pass `String::new()` if the caller has no
132/// meaningful command line to report.
133pub fn execute_with_state(
134    files: &[PathBuf],
135    op: &Operation,
136    opts: &ExecuteOpts,
137    local_state: bool,
138    command_line: String,
139) -> Result<ExecuteResult> {
140    if !opts.apply || opts.output.is_some() {
141        return execute(files, op, opts);
142    }
143
144    let run_id = generate_run_id();
145    let state_dir = get_state_dir(local_state)?;
146    let mut result = ExecuteResult::default();
147
148    for file_path in files {
149        let content = std::fs::read_to_string(file_path)
150            .with_context(|| format!("Failed to read {}", file_path.display()))?;
151
152        let mut editor = match RustEditor::new(&content) {
153            Ok(editor) => editor,
154            Err(e) => {
155                if files.len() == 1 {
156                    return Err(e)
157                        .with_context(|| format!("Failed to parse {}", file_path.display()));
158                }
159                result
160                    .parse_errors
161                    .push((file_path.clone(), format!("{}", e)));
162                continue;
163            }
164        };
165
166        match editor.apply_operation(op) {
167            Ok(op_result) => {
168                if let Some(unmatched) = op_result.unmatched_qualified_paths {
169                    for (path, count) in unmatched {
170                        *result
171                            .unmatched_qualified_paths
172                            .entry(path)
173                            .or_insert(0) += count;
174                    }
175                }
176
177                if op_result.changed {
178                    result.total_modifications += op_result.modified_nodes.len();
179                    let new_content = editor.to_string();
180
181                    let hash_before = hash_file(file_path)?;
182                    save_backup_nodes(file_path, &op_result.modified_nodes, &run_id, &state_dir)?;
183
184                    std::fs::write(file_path, &new_content)
185                        .with_context(|| format!("Failed to write {}", file_path.display()))?;
186
187                    let hash_after = hash_file(file_path)?;
188
189                    result.files_modified.push(FileModification {
190                        path: file_path.clone(),
191                        hash_before,
192                        hash_after,
193                        backup_nodes: op_result.modified_nodes.clone(),
194                    });
195                    result.changes.push(FileChange {
196                        path: file_path.clone(),
197                        old_content: content,
198                        new_content,
199                        modified_nodes: op_result.modified_nodes,
200                    });
201
202                    if let Some(limit) = opts.limit {
203                        if result.total_modifications >= limit {
204                            result.limit_hit = true;
205                            break;
206                        }
207                    }
208                }
209            }
210            Err(e) => {
211                if files.len() == 1 {
212                    return Err(e);
213                }
214                result.last_error = Some(format!("{}", e));
215            }
216        }
217    }
218
219    if !result.files_modified.is_empty() {
220        let metadata = RunMetadata {
221            run_id: run_id.clone(),
222            timestamp: chrono::Utc::now(),
223            command: command_line,
224            operation: op.kind_name().to_string(),
225            files_modified: result.files_modified.clone(),
226            status: RunStatus::Applied,
227            can_revert: true,
228        };
229        save_run_metadata(&metadata, &state_dir)?;
230        result.run_id = Some(run_id);
231    }
232
233    Ok(result)
234}