Skip to main content

socket_patch_cli/commands/
remove.rs

1use clap::Args;
2use socket_patch_core::api::client::get_api_client_with_overrides;
3use socket_patch_core::manifest::operations::{read_manifest, write_manifest};
4use socket_patch_core::manifest::schema::PatchManifest;
5use socket_patch_core::utils::cleanup_blobs::{cleanup_unused_blobs, format_cleanup_result};
6use socket_patch_core::utils::telemetry::{track_patch_removed, track_patch_remove_failed};
7use std::path::Path;
8use std::time::Duration;
9
10use super::rollback::rollback_patches;
11use crate::args::{apply_env_toggles, GlobalArgs};
12use crate::commands::lock_cli::{acquire_or_emit, lock_broken_event};
13use crate::json_envelope::{
14    Command, Envelope, EnvelopeError, PatchAction, PatchEvent, Status,
15};
16use crate::output::confirm;
17
18/// Emit a `remove` error envelope and return. Used by the many error
19/// paths in `run` so they all share the same JSON shape.
20fn emit_error_envelope(json: bool, code: &str, message: String) {
21    if json {
22        let mut env = Envelope::new(Command::Remove);
23        env.mark_error(EnvelopeError::new(code, message));
24        println!("{}", env.to_pretty_json());
25    } else {
26        eprintln!("Error: {message}");
27    }
28}
29
30#[derive(Args)]
31pub struct RemoveArgs {
32    /// Package PURL or patch UUID.
33    pub identifier: String,
34
35    #[command(flatten)]
36    pub common: GlobalArgs,
37
38    /// Skip rolling back files before removing (only update manifest).
39    #[arg(long = "skip-rollback", env = "SOCKET_SKIP_ROLLBACK", default_value_t = false)]
40    pub skip_rollback: bool,
41}
42
43pub async fn run(args: RemoveArgs) -> i32 {
44    apply_env_toggles(&args.common);
45    let (telemetry_client, _) =
46        get_api_client_with_overrides(args.common.api_client_overrides()).await;
47    let api_token = telemetry_client.api_token().cloned();
48    let org_slug = telemetry_client.org_slug().cloned();
49
50    let manifest_path = args.common.resolved_manifest_path();
51
52    if tokio::fs::metadata(&manifest_path).await.is_err() {
53        emit_error_envelope(
54            args.common.json,
55            "manifest_not_found",
56            format!("Manifest not found at {}", manifest_path.display()),
57        );
58        return 1;
59    }
60
61    // Serialize against concurrent socket-patch runs targeting the
62    // same `.socket/` directory. Note: `rollback_patches` (which
63    // `remove` calls into) does NOT acquire the lock — that would
64    // self-deadlock — so the outer remove invocation holds it for
65    // both the rollback and the manifest mutation.
66    let socket_dir = manifest_path.parent().unwrap_or(Path::new("."));
67    let acquired = match acquire_or_emit(
68        socket_dir,
69        Command::Remove,
70        args.common.json,
71        false, // remove has no --silent on its own; use false
72        false, // remove has no --dry-run
73        Duration::from_secs(args.common.lock_timeout.unwrap_or(0)),
74        args.common.break_lock,
75    ) {
76        Ok(acquired) => acquired,
77        Err(code) => return code,
78    };
79    let _lock = acquired.guard;
80    let lock_was_broken = acquired.broke_lock;
81
82    // Read manifest to show what will be removed and confirm
83    let manifest = match read_manifest(&manifest_path).await {
84        Ok(Some(m)) => m,
85        Ok(None) => {
86            emit_error_envelope(args.common.json, "manifest_invalid", "Invalid manifest".to_string());
87            return 1;
88        }
89        Err(e) => {
90            emit_error_envelope(args.common.json, "manifest_unreadable", e.to_string());
91            return 1;
92        }
93    };
94
95    // Find matching patches to show what will be removed
96    let matching: Vec<(&String, &socket_patch_core::manifest::schema::PatchRecord)> =
97        if args.identifier.starts_with("pkg:") {
98            manifest
99                .patches
100                .iter()
101                .filter(|(purl, _)| *purl == &args.identifier)
102                .collect()
103        } else {
104            manifest
105                .patches
106                .iter()
107                .filter(|(_, patch)| patch.uuid == args.identifier)
108                .collect()
109        };
110
111    if matching.is_empty() {
112        let msg = format!("No patch found matching identifier: {}", args.identifier);
113        track_patch_remove_failed(&msg, api_token.as_deref(), org_slug.as_deref()).await;
114        if args.common.json {
115            let mut env = Envelope::new(Command::Remove);
116            env.status = Status::NotFound;
117            env.error = Some(EnvelopeError::new("not_found", msg));
118            println!("{}", env.to_pretty_json());
119        } else {
120            eprintln!(
121                "No patch found matching identifier: {}",
122                args.identifier
123            );
124        }
125        return 1;
126    }
127
128    // Show what will be removed and confirm
129    if !args.common.json {
130        eprintln!("The following patch(es) will be removed:");
131        for (purl, patch) in &matching {
132            let file_count = patch.files.len();
133            eprintln!("  - {} (UUID: {}, {} file(s))", purl, &patch.uuid[..8], file_count);
134        }
135        eprintln!();
136    }
137
138    let prompt = format!(
139        "Remove {} patch(es) and rollback files?",
140        matching.len()
141    );
142    if !confirm(&prompt, true, args.common.yes, args.common.json) {
143        if !args.common.json {
144            println!("Removal cancelled.");
145        }
146        return 0;
147    }
148
149    // First, rollback the patch if not skipped
150    let mut rollback_count = 0;
151    if !args.skip_rollback {
152        if !args.common.json {
153            println!("Rolling back patch before removal...");
154        }
155        match rollback_patches(
156            &args.common.cwd,
157            &manifest_path,
158            Some(&args.identifier),
159            false,
160            args.common.json, // silent when JSON
161            false,
162            args.common.global,
163            args.common.global_prefix.clone(),
164            None,
165        )
166        .await
167        {
168            Ok((success, results)) => {
169                if !success {
170                    track_patch_remove_failed(
171                        "Rollback failed during patch removal",
172                        api_token.as_deref(),
173                        org_slug.as_deref(),
174                    )
175                    .await;
176                    emit_error_envelope(
177                        args.common.json,
178                        "rollback_failed",
179                        "Rollback failed during patch removal. Use --skip-rollback to remove from manifest without restoring files.".to_string(),
180                    );
181                    return 1;
182                }
183
184                rollback_count = results
185                    .iter()
186                    .filter(|r| r.success && !r.files_rolled_back.is_empty())
187                    .count();
188                let already_original = results
189                    .iter()
190                    .filter(|r| {
191                        r.success
192                            && r.files_verified.iter().all(|f| {
193                                f.status
194                                    == socket_patch_core::patch::rollback::VerifyRollbackStatus::AlreadyOriginal
195                            })
196                    })
197                    .count();
198
199                if !args.common.json {
200                    if rollback_count > 0 {
201                        println!("Rolled back {rollback_count} package(s)");
202                    }
203                    if already_original > 0 {
204                        println!("{already_original} package(s) already in original state");
205                    }
206                    if results.is_empty() {
207                        println!("No packages found to rollback (not installed)");
208                    }
209                    println!();
210                }
211            }
212            Err(e) => {
213                track_patch_remove_failed(&e, api_token.as_deref(), org_slug.as_deref()).await;
214                emit_error_envelope(
215                    args.common.json,
216                    "rollback_failed",
217                    format!("Error during rollback: {e}. Use --skip-rollback to remove from manifest without restoring files."),
218                );
219                return 1;
220            }
221        }
222    }
223
224    // Now remove from manifest
225    match remove_patch_from_manifest(&args.identifier, &manifest_path).await {
226        Ok((removed, manifest)) => {
227            if removed.is_empty() {
228                let msg = format!("No patch found matching identifier: {}", args.identifier);
229                track_patch_remove_failed(&msg, api_token.as_deref(), org_slug.as_deref()).await;
230                if args.common.json {
231                    let mut env = Envelope::new(Command::Remove);
232                    env.status = Status::NotFound;
233                    env.error = Some(EnvelopeError::new("not_found", msg));
234                    println!("{}", env.to_pretty_json());
235                } else {
236                    eprintln!(
237                        "No patch found matching identifier: {}",
238                        args.identifier
239                    );
240                }
241                return 1;
242            }
243
244            if !args.common.json {
245                println!("Removed {} patch(es) from manifest:", removed.len());
246                for purl in &removed {
247                    println!("  - {purl}");
248                }
249                println!("\nManifest updated at {}", manifest_path.display());
250            }
251
252            // Clean up unused blobs
253            let socket_dir = manifest_path.parent().unwrap();
254            let blobs_path = socket_dir.join("blobs");
255            let mut blobs_removed = 0;
256            if let Ok(cleanup_result) = cleanup_unused_blobs(&manifest, &blobs_path, false).await {
257                blobs_removed = cleanup_result.blobs_removed;
258                if !args.common.json && cleanup_result.blobs_removed > 0 {
259                    println!("\n{}", format_cleanup_result(&cleanup_result, false));
260                }
261            }
262
263            if args.common.json {
264                let mut env = Envelope::new(Command::Remove);
265                if lock_was_broken {
266                    env.record(lock_broken_event(socket_dir));
267                }
268                // One Removed event per purl whose manifest entry was deleted.
269                for purl in &removed {
270                    env.record(PatchEvent::new(PatchAction::Removed, purl.clone()));
271                }
272                // One artifact-level Removed event covering swept blobs.
273                if blobs_removed > 0 {
274                    env.record(
275                        PatchEvent::artifact(PatchAction::Removed).with_details(serde_json::json!({
276                            "blobsRemoved": blobs_removed,
277                            "rolledBack": rollback_count,
278                        })),
279                    );
280                }
281                println!("{}", env.to_pretty_json());
282            }
283
284            track_patch_removed(removed.len(), api_token.as_deref(), org_slug.as_deref()).await;
285            0
286        }
287        Err(e) => {
288            track_patch_remove_failed(&e, api_token.as_deref(), org_slug.as_deref()).await;
289            emit_error_envelope(args.common.json, "remove_failed", e);
290            1
291        }
292    }
293}
294
295async fn remove_patch_from_manifest(
296    identifier: &str,
297    manifest_path: &Path,
298) -> Result<(Vec<String>, PatchManifest), String> {
299    let mut manifest = read_manifest(manifest_path)
300        .await
301        .map_err(|e| e.to_string())?
302        .ok_or_else(|| "Invalid manifest".to_string())?;
303
304    let mut removed = Vec::new();
305
306    if identifier.starts_with("pkg:") {
307        if manifest.patches.remove(identifier).is_some() {
308            removed.push(identifier.to_string());
309        }
310    } else {
311        let purls_to_remove: Vec<String> = manifest
312            .patches
313            .iter()
314            .filter(|(_, patch)| patch.uuid == identifier)
315            .map(|(purl, _)| purl.clone())
316            .collect();
317
318        for purl in purls_to_remove {
319            manifest.patches.remove(&purl);
320            removed.push(purl);
321        }
322    }
323
324    if !removed.is_empty() {
325        write_manifest(manifest_path, &manifest)
326            .await
327            .map_err(|e| e.to_string())?;
328    }
329
330    Ok((removed, manifest))
331}