weavatrix_rust_refactor/operations/
mod.rs1mod apply;
12mod bulk_replace;
13mod change_signature;
14mod delete_readiness;
15mod edit_symbol;
16mod move_file;
17mod move_symbol;
18mod organize_imports;
19mod rename_related;
20mod rename_symbol;
21mod signature;
22
23use crate::contract;
24use crate::token::TokenStore;
25use blazingly_json::{Value, json};
26use weavatrix_rust::RepositoryState;
27
28pub struct RefactorSession {
34 tokens: TokenStore,
35 write_allowed: bool,
36}
37
38impl RefactorSession {
39 #[must_use]
41 pub fn new(write_allowed: bool) -> Self {
42 Self {
43 tokens: TokenStore::default(),
44 write_allowed,
45 }
46 }
47
48 #[must_use]
53 pub fn read_only() -> Self {
54 Self::new(false)
55 }
56
57 pub fn call(
65 &self,
66 state: &RepositoryState,
67 name: &str,
68 arguments: &Value,
69 ) -> Result<Value, String> {
70 let Some(operation) = Operation::from_name(name) else {
71 return Err(format!("unknown refactor operation: {name}"));
72 };
73 Ok(match operation {
74 Operation::DeleteReadiness => delete_readiness::delete_readiness(state, arguments),
75 Operation::EditSymbol => edit_symbol::edit_symbol(state, arguments),
76 Operation::BulkReplace => bulk_replace::bulk_replace(state, arguments),
77 Operation::MoveSymbol => move_symbol::move_symbol(state, arguments),
78 Operation::MoveFile => move_file::move_file(state, arguments),
79 Operation::OrganizeImports => organize_imports::organize_imports(state, arguments),
80 Operation::RenameSymbol => {
81 rename_symbol::rename_symbol(state, &self.tokens, arguments, self.write_allowed)
82 }
83 Operation::RenameRelatedSymbols => rename_related::rename_related_symbols(
84 state,
85 &self.tokens,
86 arguments,
87 self.write_allowed,
88 ),
89 Operation::ApplyEditPlan => {
90 apply::apply_edit_plan(state.root(), &self.tokens, arguments, self.write_allowed)
91 }
92 Operation::RollbackLastApply => {
93 apply::rollback_last_apply(state.root(), self.write_allowed)
94 }
95 Operation::ChangeSignature => change_signature::change_signature(state, arguments),
96 })
97 }
98}
99
100#[derive(Debug, Clone, Copy, PartialEq, Eq)]
102pub enum Operation {
103 RenameSymbol,
104 RenameRelatedSymbols,
105 ApplyEditPlan,
106 RollbackLastApply,
107 ChangeSignature,
108 EditSymbol,
109 BulkReplace,
110 OrganizeImports,
111 MoveFile,
112 MoveSymbol,
113 DeleteReadiness,
114}
115
116impl Operation {
117 #[must_use]
119 pub fn from_name(name: &str) -> Option<Self> {
120 match name {
121 "rename_symbol" => Some(Self::RenameSymbol),
122 "rename_related_symbols" => Some(Self::RenameRelatedSymbols),
123 "apply_edit_plan" => Some(Self::ApplyEditPlan),
124 "rollback_last_apply" => Some(Self::RollbackLastApply),
125 "change_signature" => Some(Self::ChangeSignature),
126 "edit_symbol" => Some(Self::EditSymbol),
127 "bulk_replace" => Some(Self::BulkReplace),
128 "organize_imports" => Some(Self::OrganizeImports),
129 "move_file" => Some(Self::MoveFile),
130 "move_symbol" => Some(Self::MoveSymbol),
131 "delete_readiness" => Some(Self::DeleteReadiness),
132 _ => None,
133 }
134 }
135
136 #[must_use]
141 pub const fn writes(self) -> bool {
142 matches!(
143 self,
144 Self::RenameSymbol
145 | Self::RenameRelatedSymbols
146 | Self::ApplyEditPlan
147 | Self::RollbackLastApply
148 )
149 }
150
151 #[must_use]
153 pub const fn name(self) -> &'static str {
154 match self {
155 Self::RenameSymbol => "rename_symbol",
156 Self::RenameRelatedSymbols => "rename_related_symbols",
157 Self::ApplyEditPlan => "apply_edit_plan",
158 Self::RollbackLastApply => "rollback_last_apply",
159 Self::ChangeSignature => "change_signature",
160 Self::EditSymbol => "edit_symbol",
161 Self::BulkReplace => "bulk_replace",
162 Self::OrganizeImports => "organize_imports",
163 Self::MoveFile => "move_file",
164 Self::MoveSymbol => "move_symbol",
165 Self::DeleteReadiness => "delete_readiness",
166 }
167 }
168}
169
170#[must_use]
172pub fn catalog() -> Value {
173 contract::catalog_value().clone()
174}
175
176#[must_use]
178pub fn catalog_names() -> Vec<String> {
179 contract::tools()
180 .iter()
181 .map(|tool| tool.name.clone())
182 .collect()
183}
184
185pub fn call(state: &RepositoryState, name: &str, arguments: &Value) -> Result<Value, String> {
193 RefactorSession::read_only().call(state, name, arguments)
194}
195
196pub(crate) fn not_found(graph: &weavatrix_graph::Graph, symbol: &str) -> Value {
201 let candidates = crate::resolve::candidate_ids(graph, symbol);
202 json!({
203 "status": "NOT_FOUND",
204 "reason": if candidates.len() > 1 {
205 "the name matches more than one symbol; pass one of the candidate ids"
206 } else {
207 "the selected symbol is not present in the active graph; pass an exact id"
208 },
209 "symbol": symbol,
210 "candidates": candidates,
211 })
212}
213
214pub(crate) fn stale_graph(file: &str) -> Value {
216 json!({
217 "status": "STALE_GRAPH",
218 "reason": format!(
219 "{file}: the recorded source range no longer matches the file. Rebuild the graph; \
220 nothing was planned from a range that cannot be located."
221 ),
222 })
223}
224
225pub(crate) fn invalid_args(operation: &str, missing: &[&str]) -> Value {
230 json!({
231 "status": "INVALID_ARGS",
232 "operation": operation,
233 "invalid": missing,
234 "reason": format!(
235 "missing or invalid required argument(s): {}. Nothing was planned or written.",
236 missing.join(", ")
237 ),
238 })
239}
240
241#[cfg(test)]
242mod tests {
243 use super::{Operation, call, catalog, catalog_names};
244 use crate::contract;
245
246 #[test]
247 fn every_contract_tool_resolves_to_an_operation() {
248 for tool in contract::tools() {
249 assert!(
250 Operation::from_name(&tool.name).is_some(),
251 "{} is in the contract with no operation arm",
252 tool.name
253 );
254 }
255 }
256
257 #[test]
258 fn no_operation_exists_outside_the_contract() {
259 for name in catalog_names() {
260 assert!(contract::declares(&name));
261 }
262 assert_eq!(catalog_names().len(), contract::tools().len());
263 }
264
265 #[test]
266 fn exactly_four_operations_can_write() {
267 let writers = contract::tools()
268 .iter()
269 .filter_map(|tool| Operation::from_name(&tool.name))
270 .filter(|operation| operation.writes())
271 .count();
272 assert_eq!(writers, 4);
273 }
274
275 #[test]
276 fn every_operation_answers_with_a_contract_status_ported_or_not() {
277 let state = crate::test_support::fixture_state();
278 for tool in contract::tools() {
279 let answer =
280 call(&state, &tool.name, &blazingly_json::json!({})).expect("declared tool");
281 let status = answer
282 .get("status")
283 .and_then(|value| value.as_str())
284 .unwrap_or_default();
285 assert!(
286 contract::permits_state(status),
287 "{} answered {status}, which is outside the contract",
288 tool.name
289 );
290 }
291 }
292
293 #[test]
294 fn an_undeclared_tool_is_an_error_not_a_status() {
295 let state = crate::test_support::fixture_state();
296 assert!(call(&state, "reformat_universe", &blazingly_json::json!({})).is_err());
297 }
298
299 #[test]
300 fn the_catalog_matches_the_contract() {
301 assert_eq!(
302 catalog().as_array().map(Vec::len),
303 Some(contract::tools().len())
304 );
305 }
306}