Skip to main content

weavatrix_rust_refactor/operations/
mod.rs

1//! The refactor operation surface.
2//!
3//! Dispatch is total and every arm is a native engine — all eleven of the frozen tools answer
4//! for themselves, and the match has no fallback arm, so adding a tool to the contract fails to
5//! compile rather than quietly returning "not supported" at run time.
6//!
7//! `NOT_SUPPORTED` survives as a per-call answer where an engine genuinely cannot prove
8//! something about the input it was given — a symbol the graph records under a name that is not
9//! an identifier, for instance. It is never the answer for a tool as a whole.
10
11mod 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
28/// One server's refactor surface: the confirmations it has issued and whether it may write.
29///
30/// The write gate lives here rather than at each call site so there is exactly one place that
31/// decides it, and it is fixed when the session is created — a gate re-read per call could be
32/// changed underneath a running server.
33pub struct RefactorSession {
34    tokens: TokenStore,
35    write_allowed: bool,
36}
37
38impl RefactorSession {
39    /// Opens a session. `write_allowed` is the environment gate, already decided by the host.
40    #[must_use]
41    pub fn new(write_allowed: bool) -> Self {
42        Self {
43            tokens: TokenStore::default(),
44            write_allowed,
45        }
46    }
47
48    /// A session that will never write, for callers that only plan.
49    ///
50    /// Named rather than a bare `new(false)` so a caller that meant to pass a real gate cannot
51    /// silently get a closed one — which is exactly the bug this replaced.
52    #[must_use]
53    pub fn read_only() -> Self {
54        Self::new(false)
55    }
56
57    /// Calls one refactor operation.
58    ///
59    /// # Errors
60    ///
61    /// Returns an error only when `name` is not a tool the contract declares. Every other
62    /// refusal is a value carrying a contract status, because an agent branches on statuses and
63    /// cannot branch on a transport error.
64    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/// A refactor operation, resolved from a tool name the contract declares.
101#[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    /// Resolves a tool name, or `None` when the contract does not declare it.
118    #[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    /// Whether the operation can write to the repository once every gate is satisfied.
137    ///
138    /// The plan producers are reads. Only these four ever touch a file, and only behind the
139    /// environment gate plus a plan-bound single-use token.
140    #[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    /// The tool name this operation answers to.
152    #[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/// The tool catalog, taken from the frozen contract.
171#[must_use]
172pub fn catalog() -> Value {
173    contract::catalog_value().clone()
174}
175
176/// Names of every tool this crate exposes.
177#[must_use]
178pub fn catalog_names() -> Vec<String> {
179    contract::tools()
180        .iter()
181        .map(|tool| tool.name.clone())
182        .collect()
183}
184
185/// Calls one refactor operation.
186///
187/// # Errors
188///
189/// Returns an error only when `name` is not a tool the contract declares. Every other refusal
190/// is a value carrying a contract status, because an agent branches on statuses and cannot
191/// branch on a transport error.
192pub fn call(state: &RepositoryState, name: &str, arguments: &Value) -> Result<Value, String> {
193    RefactorSession::read_only().call(state, name, arguments)
194}
195
196/// The symbol an agent named is not in the graph, with every id it could have meant.
197///
198/// The candidates ride in the refusal because without them the refusal costs a round trip: the
199/// agent has to run a graph query — measured at ~26 KB — to learn ids the resolver already saw.
200pub(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
214/// The graph and the file no longer agree, so no range from it can be trusted.
215pub(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
225/// A missing or wrongly typed argument, named rather than described.
226///
227/// The engines below state their preconditions by returning this, never by panicking: an agent
228/// branches on a status and cannot branch on a crash.
229pub(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}