1use std::path::PathBuf;
9
10use anyhow::Result;
11use serde::Serialize;
12
13#[derive(Debug, Serialize)]
16pub struct NukePlan {
17 pub paths: Vec<PathBuf>,
19 pub mcp_files: Vec<PathBuf>,
22 pub purge_binary: bool,
24}
25
26impl NukePlan {
27 pub fn compute(purge: bool) -> Result<Self> {
29 let mut paths = Vec::new();
30 for p in [
32 crate::config::config_dir().ok(),
33 crate::config::state_dir().ok(),
34 crate::session::sessions_root().ok(),
35 dirs::cache_dir().map(|c| c.join("wire")),
36 ]
37 .into_iter()
38 .flatten()
39 {
40 if p.exists() && !paths.contains(&p) {
41 paths.push(p);
42 }
43 }
44 let mut mcp_files = Vec::new();
46 for adapter in crate::adapters::harness::HARNESS_ADAPTERS {
47 for path in (adapter.paths_fn)() {
48 if path.exists() && !mcp_files.contains(&path) {
49 mcp_files.push(path);
50 }
51 }
52 }
53 Ok(NukePlan {
54 paths,
55 mcp_files,
56 purge_binary: purge,
57 })
58 }
59
60 pub fn execute(&self) -> Result<NukeReport> {
65 self.execute_with(|kind| crate::service::uninstall_kind(kind).map(|rep| rep.platform))
66 }
67
68 fn execute_with<U>(&self, uninstall_unit: U) -> Result<NukeReport>
77 where
78 U: Fn(crate::service::ServiceKind) -> Result<String>,
79 {
80 let mut r = NukeReport::default();
81
82 for kind in [
84 crate::service::ServiceKind::Daemon,
85 crate::service::ServiceKind::LocalRelay,
86 ] {
87 match uninstall_unit(kind) {
88 Ok(platform) => r.removed_units.push(format!("{kind:?}: {platform}")),
89 Err(e) => r.warnings.push(format!("uninstall {kind:?}: {e:#}")),
90 }
91 }
92
93 'files: for path in &self.mcp_files {
98 for adapter in crate::adapters::harness::HARNESS_ADAPTERS {
99 match (adapter.remove_fn)(path, "wire") {
100 Ok(true) => {
101 r.removed_mcp_entries.push(path.clone());
102 continue 'files;
103 }
104 Ok(false) => {}
105 Err(e) => {
106 r.warnings
107 .push(format!("mcp de-register {}: {e:#}", path.display()));
108 continue 'files;
109 }
110 }
111 }
112 }
113
114 for p in &self.paths {
116 match std::fs::remove_dir_all(p) {
117 Ok(()) => r.removed_paths.push(p.clone()),
118 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
119 Err(e) => r.warnings.push(format!("rm {}: {e:#}", p.display())),
120 }
121 }
122
123 Ok(r)
124 }
125}
126
127pub fn should_proceed(force: bool, is_tty: bool, read_line: impl FnOnce() -> String) -> bool {
131 if force {
132 return true;
133 }
134 if !is_tty {
135 return false;
136 }
137 read_line().trim() == "nuke"
138}
139
140pub fn parse_registry_bindings(bytes: &[u8]) -> Vec<(String, String)> {
144 let Ok(v) = serde_json::from_slice::<serde_json::Value>(bytes) else {
145 return Vec::new();
146 };
147 let Some(by_cwd) = v.get("by_cwd").and_then(|m| m.as_object()) else {
148 return Vec::new();
149 };
150 by_cwd
151 .iter()
152 .filter_map(|(cwd, name)| name.as_str().map(|n| (cwd.clone(), n.to_string())))
153 .collect()
154}
155
156pub fn default_registry_bindings() -> Vec<(String, String)> {
161 let Ok(root) = crate::session::default_sessions_root() else {
162 return Vec::new();
163 };
164 match std::fs::read(root.join("registry.json")) {
165 Ok(bytes) => parse_registry_bindings(&bytes),
166 Err(_) => Vec::new(),
167 }
168}
169
170pub fn host_guard_refusal(bound: &[(String, String)], really: bool) -> Option<String> {
179 if really || bound.is_empty() {
180 return None;
181 }
182 let mut msg = format!(
183 "refusing to nuke: this machine has a live wire install ({} registry-bound session(s)):\n",
184 bound.len()
185 );
186 for (cwd, name) in bound {
187 msg.push_str(&format!(" {name} ← {cwd}\n"));
188 }
189 msg.push_str(
190 "nuke removes launchd/systemd units, MCP registrations, and kills every wire daemon \
191 machine-wide — even when WIRE_HOME points elsewhere.\n\
192 If you really mean this machine, re-run with --really-this-machine.",
193 );
194 Some(msg)
195}
196
197#[derive(Debug, Default, Serialize)]
199pub struct NukeReport {
200 pub removed_paths: Vec<PathBuf>,
201 pub removed_mcp_entries: Vec<PathBuf>,
202 pub removed_units: Vec<String>,
203 pub killed_pids: Vec<u32>,
204 pub binary_removed: bool,
205 pub warnings: Vec<String>,
207}
208
209#[cfg(test)]
210mod tests {
211 use super::*;
212
213 #[test]
214 fn plan_lists_existing_wire_dirs_only() {
215 crate::config::test_support::with_temp_home(|| {
216 crate::config::ensure_dirs().unwrap();
218 let plan = NukePlan::compute(false).unwrap();
219 assert!(
222 plan.paths.iter().any(|p| p.ends_with("wire")),
223 "expected a wire dir in {:?}",
224 plan.paths
225 );
226 assert!(
227 !plan.purge_binary,
228 "default plan does not remove the binary"
229 );
230 });
231 }
232
233 #[test]
234 fn purge_plan_sets_binary_removal() {
235 crate::config::test_support::with_temp_home(|| {
236 let plan = NukePlan::compute(true).unwrap();
237 assert!(plan.purge_binary);
238 });
239 }
240
241 #[test]
242 fn confirm_logic() {
243 assert!(should_proceed(
245 true,
246 false,
247 || unreachable!()
248 ));
249 assert!(!should_proceed(false, false, String::new));
251 assert!(should_proceed(false, true, || "nuke".to_string()));
253 assert!(!should_proceed(false, true, || "no".to_string()));
254 assert!(!should_proceed(false, true, || "NUKE".to_string()));
255 }
256
257 #[test]
258 fn execute_removes_dirs_and_mcp_entry() {
259 crate::config::test_support::with_temp_home(|| {
260 crate::config::ensure_dirs().unwrap();
261 let state = crate::config::state_dir().unwrap();
262 assert!(state.exists());
263 let mcp =
265 std::path::PathBuf::from(std::env::var("WIRE_HOME").unwrap()).join("mcp.json");
266 std::fs::write(&mcp, r#"{"mcpServers":{"wire":{"command":"wire"}}}"#).unwrap();
267 let plan = NukePlan {
268 paths: vec![state.clone()],
269 mcp_files: vec![mcp.clone()],
270 purge_binary: false,
271 };
272 let report = plan.execute_with(|_kind| Ok("stub".to_string())).unwrap();
276 assert_eq!(report.removed_units.len(), 2, "both unit kinds attempted");
277 assert!(!state.exists(), "state dir deleted");
278 let v: serde_json::Value =
279 serde_json::from_slice(&std::fs::read(&mcp).unwrap()).unwrap();
280 assert!(v["mcpServers"].get("wire").is_none(), "wire de-registered");
281 assert!(report.removed_paths.contains(&state));
282 assert!(report.removed_mcp_entries.contains(&mcp));
283 });
284 }
285
286 #[test]
289 fn host_guard_silent_with_no_bindings() {
290 assert_eq!(host_guard_refusal(&[], false), None);
292 assert_eq!(host_guard_refusal(&[], true), None);
293 }
294
295 #[test]
296 fn host_guard_refuses_bound_machine_without_flag() {
297 let bound = vec![(
298 "/Users/op/Source/wire".to_string(),
299 "slancha-wire".to_string(),
300 )];
301 let msg = host_guard_refusal(&bound, false).expect("guard must refuse");
302 assert!(msg.contains("slancha-wire"));
304 assert!(msg.contains("/Users/op/Source/wire"));
305 assert!(msg.contains("--really-this-machine"));
306 }
307
308 #[test]
309 fn host_guard_passes_with_explicit_flag() {
310 let bound = vec![("/x".to_string(), "s".to_string())];
311 assert_eq!(host_guard_refusal(&bound, true), None);
312 }
313
314 #[test]
315 fn registry_bindings_parse_shapes() {
316 let bytes = br#"{"by_cwd":{"/a":"one","/b":"two"}}"#;
318 let mut got = parse_registry_bindings(bytes);
319 got.sort();
320 assert_eq!(
321 got,
322 vec![
323 ("/a".to_string(), "one".to_string()),
324 ("/b".to_string(), "two".to_string())
325 ]
326 );
327 assert!(parse_registry_bindings(br#"{"by_cwd":{}}"#).is_empty());
329 assert!(parse_registry_bindings(br"{}").is_empty());
330 assert!(parse_registry_bindings(br#"{"by_cwd":42}"#).is_empty());
331 assert!(parse_registry_bindings(b"not json").is_empty());
332 assert_eq!(
334 parse_registry_bindings(br#"{"by_cwd":{"/a":1,"/b":"two"}}"#),
335 vec![("/b".to_string(), "two".to_string())]
336 );
337 }
338}