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 => rename_symbol::rename_symbol(state, arguments),
81            Operation::RenameRelatedSymbols => rename_related::rename_related_symbols(
82                state,
83                &self.tokens,
84                arguments,
85                self.write_allowed,
86            ),
87            Operation::ApplyEditPlan => {
88                apply::apply_edit_plan(state.root(), &self.tokens, arguments, self.write_allowed)
89            }
90            Operation::RollbackLastApply => {
91                apply::rollback_last_apply(state.root(), self.write_allowed)
92            }
93            Operation::ChangeSignature => change_signature::change_signature(state, arguments),
94        })
95    }
96}
97
98/// A refactor operation, resolved from a tool name the contract declares.
99#[derive(Debug, Clone, Copy, PartialEq, Eq)]
100pub enum Operation {
101    RenameSymbol,
102    RenameRelatedSymbols,
103    ApplyEditPlan,
104    RollbackLastApply,
105    ChangeSignature,
106    EditSymbol,
107    BulkReplace,
108    OrganizeImports,
109    MoveFile,
110    MoveSymbol,
111    DeleteReadiness,
112}
113
114impl Operation {
115    /// Resolves a tool name, or `None` when the contract does not declare it.
116    #[must_use]
117    pub fn from_name(name: &str) -> Option<Self> {
118        match name {
119            "rename_symbol" => Some(Self::RenameSymbol),
120            "rename_related_symbols" => Some(Self::RenameRelatedSymbols),
121            "apply_edit_plan" => Some(Self::ApplyEditPlan),
122            "rollback_last_apply" => Some(Self::RollbackLastApply),
123            "change_signature" => Some(Self::ChangeSignature),
124            "edit_symbol" => Some(Self::EditSymbol),
125            "bulk_replace" => Some(Self::BulkReplace),
126            "organize_imports" => Some(Self::OrganizeImports),
127            "move_file" => Some(Self::MoveFile),
128            "move_symbol" => Some(Self::MoveSymbol),
129            "delete_readiness" => Some(Self::DeleteReadiness),
130            _ => None,
131        }
132    }
133
134    /// Whether the operation can write to the repository once every gate is satisfied.
135    ///
136    /// The plan producers are reads. Only these four ever touch a file, and only behind the
137    /// environment gate plus a plan-bound single-use token.
138    #[must_use]
139    pub const fn writes(self) -> bool {
140        matches!(
141            self,
142            Self::RenameSymbol
143                | Self::RenameRelatedSymbols
144                | Self::ApplyEditPlan
145                | Self::RollbackLastApply
146        )
147    }
148
149    /// The tool name this operation answers to.
150    #[must_use]
151    pub const fn name(self) -> &'static str {
152        match self {
153            Self::RenameSymbol => "rename_symbol",
154            Self::RenameRelatedSymbols => "rename_related_symbols",
155            Self::ApplyEditPlan => "apply_edit_plan",
156            Self::RollbackLastApply => "rollback_last_apply",
157            Self::ChangeSignature => "change_signature",
158            Self::EditSymbol => "edit_symbol",
159            Self::BulkReplace => "bulk_replace",
160            Self::OrganizeImports => "organize_imports",
161            Self::MoveFile => "move_file",
162            Self::MoveSymbol => "move_symbol",
163            Self::DeleteReadiness => "delete_readiness",
164        }
165    }
166}
167
168/// The tool catalog, taken from the frozen contract.
169#[must_use]
170pub fn catalog() -> Value {
171    contract::catalog_value().clone()
172}
173
174/// Names of every tool this crate exposes.
175#[must_use]
176pub fn catalog_names() -> Vec<String> {
177    contract::tools()
178        .iter()
179        .map(|tool| tool.name.clone())
180        .collect()
181}
182
183/// Calls one refactor operation.
184///
185/// # Errors
186///
187/// Returns an error only when `name` is not a tool the contract declares. Every other refusal
188/// is a value carrying a contract status, because an agent branches on statuses and cannot
189/// branch on a transport error.
190pub fn call(state: &RepositoryState, name: &str, arguments: &Value) -> Result<Value, String> {
191    RefactorSession::read_only().call(state, name, arguments)
192}
193
194/// The symbol an agent named is not in the graph.
195pub(crate) fn not_found(symbol: &str) -> Value {
196    json!({
197        "status": "NOT_FOUND",
198        "reason": "the selected symbol is not present in the active graph, or the name matches \
199                   more than one symbol; pass an exact id",
200        "symbol": symbol,
201    })
202}
203
204/// The graph and the file no longer agree, so no range from it can be trusted.
205pub(crate) fn stale_graph(file: &str) -> Value {
206    json!({
207        "status": "STALE_GRAPH",
208        "reason": format!(
209            "{file}: the recorded source range no longer matches the file. Rebuild the graph; \
210             nothing was planned from a range that cannot be located."
211        ),
212    })
213}
214
215/// A missing or wrongly typed argument, named rather than described.
216///
217/// The engines below state their preconditions by returning this, never by panicking: an agent
218/// branches on a status and cannot branch on a crash.
219pub(crate) fn invalid_args(operation: &str, missing: &[&str]) -> Value {
220    json!({
221        "status": "INVALID_ARGS",
222        "operation": operation,
223        "invalid": missing,
224        "reason": format!(
225            "missing or invalid required argument(s): {}. Nothing was planned or written.",
226            missing.join(", ")
227        ),
228    })
229}
230
231#[cfg(test)]
232mod tests {
233    use super::{Operation, call, catalog, catalog_names};
234    use crate::contract;
235
236    #[test]
237    fn every_contract_tool_resolves_to_an_operation() {
238        for tool in contract::tools() {
239            assert!(
240                Operation::from_name(&tool.name).is_some(),
241                "{} is in the contract with no operation arm",
242                tool.name
243            );
244        }
245    }
246
247    #[test]
248    fn no_operation_exists_outside_the_contract() {
249        for name in catalog_names() {
250            assert!(contract::declares(&name));
251        }
252        assert_eq!(catalog_names().len(), contract::tools().len());
253    }
254
255    #[test]
256    fn exactly_four_operations_can_write() {
257        let writers = contract::tools()
258            .iter()
259            .filter_map(|tool| Operation::from_name(&tool.name))
260            .filter(|operation| operation.writes())
261            .count();
262        assert_eq!(writers, 4);
263    }
264
265    #[test]
266    fn every_operation_answers_with_a_contract_status_ported_or_not() {
267        let state = crate::test_support::fixture_state();
268        for tool in contract::tools() {
269            let answer =
270                call(&state, &tool.name, &blazingly_json::json!({})).expect("declared tool");
271            let status = answer
272                .get("status")
273                .and_then(|value| value.as_str())
274                .unwrap_or_default();
275            assert!(
276                contract::permits_state(status),
277                "{} answered {status}, which is outside the contract",
278                tool.name
279            );
280        }
281    }
282
283    #[test]
284    fn an_undeclared_tool_is_an_error_not_a_status() {
285        let state = crate::test_support::fixture_state();
286        assert!(call(&state, "reformat_universe", &blazingly_json::json!({})).is_err());
287    }
288
289    #[test]
290    fn the_catalog_matches_the_contract() {
291        assert_eq!(
292            catalog().as_array().map(Vec::len),
293            Some(contract::tools().len())
294        );
295    }
296}