Skip to main content

socket_patch_cli/commands/
apply.rs

1use clap::Args;
2use socket_patch_core::api::blob_fetcher::{
3    fetch_missing_blobs, fetch_missing_sources, format_fetch_result, get_missing_archives,
4    get_missing_blobs, DownloadMode,
5};
6use socket_patch_core::api::client::get_api_client_with_overrides;
7use socket_patch_core::crawlers::{
8    detect_npm_pkg_manager, CrawlerOptions, Ecosystem, NpmPkgManager,
9};
10use socket_patch_core::manifest::operations::read_manifest;
11use socket_patch_core::patch::apply::{
12    apply_package_patch, verify_file_patch, ApplyResult, PatchSources, VerifyStatus,
13};
14
15use crate::commands::lock_cli::{acquire_or_emit, lock_broken_event};
16use socket_patch_core::utils::purl::strip_purl_qualifiers;
17use socket_patch_core::utils::telemetry::{track_patch_applied, track_patch_apply_failed};
18use std::collections::{HashMap, HashSet};
19use std::path::{Path, PathBuf};
20use std::time::Duration;
21use tempfile::TempDir;
22
23use crate::args::{apply_env_toggles, GlobalArgs};
24use crate::json_envelope::{
25    AppliedVia, Command, Envelope, EnvelopeError, PatchAction, PatchEvent, PatchEventFile, Status,
26};
27
28/// Overlay every regular file from `src` into `dst` via hard link (falling
29/// back to copy if hard linking fails — e.g. cross-filesystem, permission
30/// quirk). Skips files that already exist at `dst`. Silently no-ops if
31/// `src` doesn't exist so fresh projects with no `.socket/` cache work.
32///
33/// Used by `apply` to stage a transient overlay of the persistent
34/// `.socket/` cache inside a tempdir so the apply pipeline can read
35/// pre-cached artifacts and freshly-fetched ones from the same path
36/// without ever mutating `.socket/`.
37async fn overlay_dir(src: &Path, dst: &Path) {
38    let mut entries = match tokio::fs::read_dir(src).await {
39        Ok(e) => e,
40        Err(_) => return,
41    };
42    while let Ok(Some(entry)) = entries.next_entry().await {
43        let file_type = match entry.file_type().await {
44            Ok(t) => t,
45            Err(_) => continue,
46        };
47        if !file_type.is_file() {
48            continue;
49        }
50        let from = entry.path();
51        let to = dst.join(entry.file_name());
52        if tokio::fs::metadata(&to).await.is_ok() {
53            continue;
54        }
55        if tokio::fs::hard_link(&from, &to).await.is_err() {
56            let _ = tokio::fs::copy(&from, &to).await;
57        }
58    }
59}
60
61use crate::ecosystem_dispatch::{find_packages_for_purls, partition_purls};
62
63#[derive(Args)]
64pub struct ApplyArgs {
65    #[command(flatten)]
66    pub common: GlobalArgs,
67
68    /// Skip pre-application hash verification (apply even if package version differs).
69    #[arg(short = 'f', long, env = "SOCKET_FORCE", default_value_t = false)]
70    pub force: bool,
71}
72
73/// True when every file the engine verified for this package is already
74/// at its `afterHash` — i.e. the patch is a complete no-op on disk.
75///
76/// Single source of truth for the `already_patched` classification, shared
77/// by [`result_to_event`] (which feeds the JSON envelope) and the
78/// human-readable summaries so both label packages identically.
79///
80/// The `!is_empty()` guard is essential: `Iterator::all` over an empty
81/// slice is vacuously `true`. Without the guard a result with no verified
82/// files — a zero-file patch, or a freshly-applied package whose
83/// `files_verified` came back empty — would be mislabeled "already
84/// patched" and counted as a no-op even though nothing matched `afterHash`.
85fn all_files_already_patched(result: &ApplyResult) -> bool {
86    !result.files_verified.is_empty()
87        && result
88            .files_verified
89            .iter()
90            .all(|f| f.status == VerifyStatus::AlreadyPatched)
91}
92
93/// Decide whether a release variant describes the distribution that is
94/// actually installed on disk, based on the verification status of its
95/// first patched file.
96///
97/// This is the apply-side mirror of
98/// [`select_installed_variants`](socket_patch_core::patch::apply::select_installed_variants),
99/// which `rollback` and `get` use: a variant matches only when its first
100/// file is [`Ready`](VerifyStatus::Ready) (its `beforeHash` matches the
101/// on-disk bytes) or [`AlreadyPatched`](VerifyStatus::AlreadyPatched)
102/// (its `afterHash` already matches). A variant with no files (`None`)
103/// has nothing to disqualify it and is treated as a match.
104///
105/// Crucially, both [`HashMismatch`](VerifyStatus::HashMismatch) **and**
106/// [`NotFound`](VerifyStatus::NotFound) mean "this variant's
107/// distribution is not the one on disk" and must be skipped. A
108/// `NotFound` arises when a non-installed variant patches a file that
109/// only exists in *its* distribution (e.g. an sdist patching `setup.py`
110/// while a wheel is installed). Skipping it avoids attempting — and
111/// spuriously reporting a `Failed` event for — a variant that was never
112/// installed.
113fn variant_matches_installed(first_file_status: Option<&VerifyStatus>) -> bool {
114    match first_file_status {
115        None => true,
116        Some(status) => {
117            *status == VerifyStatus::Ready || *status == VerifyStatus::AlreadyPatched
118        }
119    }
120}
121
122/// Translate the core engine's per-package [`ApplyResult`] into a single
123/// patch-level [`PatchEvent`] for the unified envelope.
124///
125/// Action mapping (in priority order):
126///   * `!result.success`                         → `Failed`
127///   * `dry_run` and any file was Ready/Patched → `Verified`
128///   * all `files_verified` are AlreadyPatched   → `Skipped` (already_patched)
129///   * something was actually patched on disk    → `Applied`
130///
131/// `files` enumerates only the files that participated in the action —
132/// for `Applied`, the patched ones with their `applied_via` strategy;
133/// for `Verified`, every file the engine confirmed could be patched.
134pub(crate) fn result_to_event(result: &ApplyResult, dry_run: bool) -> PatchEvent {
135    let purl = result.package_key.clone();
136    if !result.success {
137        return PatchEvent::new(PatchAction::Failed, purl).with_error(
138            "apply_failed",
139            result
140                .error
141                .clone()
142                .unwrap_or_else(|| "unknown error".to_string()),
143        );
144    }
145
146    if all_files_already_patched(result) {
147        return PatchEvent::new(PatchAction::Skipped, purl)
148            .with_reason("already_patched", "All files already match afterHash");
149    }
150
151    if dry_run {
152        let files = result
153            .files_verified
154            .iter()
155            .filter(|f| {
156                f.status == VerifyStatus::Ready || f.status == VerifyStatus::AlreadyPatched
157            })
158            .map(|f| PatchEventFile {
159                path: f.file.clone(),
160                verified: true,
161                applied_via: None,
162            })
163            .collect();
164        return PatchEvent::new(PatchAction::Verified, purl).with_files(files);
165    }
166
167    let files = result
168        .files_patched
169        .iter()
170        .map(|f| PatchEventFile {
171            path: f.clone(),
172            verified: true,
173            applied_via: result
174                .applied_via
175                .get(f)
176                .copied()
177                .map(AppliedVia::from_core),
178        })
179        .collect();
180    // Sidecar data is NOT attached here — it's surfaced at the
181    // envelope level under `Envelope.sidecars[]` by the run loop.
182    // See `Envelope::record_sidecar`. Keeping events clean of
183    // sidecar info means each event describes only the apply
184    // action; sidecar reporting is a separate, JOIN-able list.
185    PatchEvent::new(PatchAction::Applied, purl).with_files(files)
186}
187
188pub async fn run(args: ApplyArgs) -> i32 {
189    apply_env_toggles(&args.common);
190    let (telemetry_client, _) =
191        get_api_client_with_overrides(args.common.api_client_overrides()).await;
192    let api_token = telemetry_client.api_token().cloned();
193    let org_slug = telemetry_client.org_slug().cloned();
194
195    let manifest_path = args.common.resolved_manifest_path();
196
197    // Check if manifest exists - exit successfully if no .socket folder is set up
198    if tokio::fs::metadata(&manifest_path).await.is_err() {
199        if args.common.json {
200            let mut env = Envelope::new(Command::Apply);
201            env.status = Status::NoManifest;
202            env.dry_run = args.common.dry_run;
203            println!("{}", env.to_pretty_json());
204        } else if !args.common.silent {
205            println!("No .socket folder found, skipping patch application.");
206        }
207        return 0;
208    }
209
210    // Serialize against concurrent socket-patch runs targeting the same
211    // `.socket/` directory. The guard releases on function return; see
212    // `socket_patch_core::patch::apply_lock`.
213    let socket_dir = manifest_path.parent().unwrap_or(Path::new("."));
214    let acquired = match acquire_or_emit(
215        socket_dir,
216        Command::Apply,
217        args.common.json,
218        args.common.silent,
219        args.common.dry_run,
220        Duration::from_secs(args.common.lock_timeout.unwrap_or(0)),
221        args.common.break_lock,
222    ) {
223        Ok(acquired) => acquired,
224        Err(code) => return code,
225    };
226    let _lock = acquired.guard;
227    let lock_was_broken = acquired.broke_lock;
228
229    // Package-manager layout detection. yarn-berry PnP keeps packages
230    // inside `.yarn/cache/*.zip` and resolves them via `.pnp.cjs` —
231    // the npm crawler can't reach them and rewriting zips is a
232    // different operation entirely. Refuse with a clear pointer to
233    // `yarn patch`. pnpm gets an informational event; the CoW guard
234    // in `apply_file_patch` does the substantive safety work.
235    let pkg_manager = detect_npm_pkg_manager(&args.common.cwd);
236    match pkg_manager {
237        NpmPkgManager::YarnBerryPnP => {
238            if args.common.json {
239                let mut env = Envelope::new(Command::Apply);
240                env.dry_run = args.common.dry_run;
241                env.mark_error(EnvelopeError::new(
242                    "yarn_pnp_unsupported",
243                    "yarn-berry Plug'n'Play layout is not supported by socket-patch (packages live inside .yarn/cache zips). Use `yarn patch <pkg>` instead.",
244                ));
245                println!("{}", env.to_pretty_json());
246            } else if !args.common.silent {
247                eprintln!("Error: yarn-berry Plug'n'Play layout is not supported.");
248                eprintln!(
249                    "  Packages live inside .yarn/cache/*.zip — socket-patch cannot rewrite them in place."
250                );
251                eprintln!("  Use `yarn patch <pkg>` instead.");
252            }
253            return 1;
254        }
255        NpmPkgManager::Pnpm => {
256            if !args.common.json && !args.common.silent {
257                eprintln!(
258                    "Note: pnpm layout detected. Copy-on-write will keep the global store untouched."
259                );
260            }
261            // Non-fatal — CoW handles the safety. JSON consumers see
262            // the layout-detected info in the apply envelope's
263            // existing events (no separate event added here yet).
264        }
265        NpmPkgManager::Bun => {
266            if !args.common.json && !args.common.silent {
267                eprintln!(
268                    "Note: bun layout detected. Copy-on-write will keep ~/.bun/install/cache/ untouched."
269                );
270            }
271            // Same shape as pnpm: bun hard-links from its global
272            // install cache by default. The CoW guard handles the
273            // safety; this is informational only.
274        }
275        _ => {}
276    }
277
278    match apply_patches_inner(&args, &manifest_path).await {
279        Ok((success, results, unmatched)) => {
280            let patched_count = results
281                .iter()
282                .filter(|r| r.success && !r.files_patched.is_empty())
283                .count();
284
285            if args.common.json {
286                let mut env = Envelope::new(Command::Apply);
287                env.dry_run = args.common.dry_run;
288                if lock_was_broken {
289                    env.record(lock_broken_event(socket_dir));
290                }
291                for result in &results {
292                    env.record(result_to_event(result, args.common.dry_run));
293                    // Sidecar records live on the envelope, not on
294                    // individual events. Consumers iterate
295                    // `envelope.sidecars[]` and JOIN against
296                    // `events[]` by `purl` for per-package context.
297                    if let Some(ref sidecar) = result.sidecar {
298                        env.record_sidecar(sidecar.clone());
299                    }
300                }
301                // Manifest entries that targeted in-scope ecosystems but
302                // had no installed package on disk — emit one Skipped
303                // event per purl so downstream consumers can surface them.
304                for purl in &unmatched {
305                    env.record(
306                        PatchEvent::new(PatchAction::Skipped, purl.clone()).with_reason(
307                            "package_not_installed",
308                            "No installed package matches this PURL",
309                        ),
310                    );
311                }
312                if !success {
313                    env.mark_partial_failure();
314                }
315                println!("{}", env.to_pretty_json());
316            } else if !args.common.silent && !results.is_empty() {
317                let patched: Vec<_> = results.iter().filter(|r| r.success).collect();
318                let already_patched: Vec<_> = results
319                    .iter()
320                    .filter(|r| all_files_already_patched(r))
321                    .collect();
322
323                if args.common.dry_run {
324                    // An already-patched package is `Skipped` in the JSON
325                    // envelope, not `Verified`. Mirror that split here so
326                    // "can be patched" excludes the no-ops instead of
327                    // double-counting them against "already patched".
328                    let can_be_patched = patched.len().saturating_sub(already_patched.len());
329                    println!("\nPatch verification complete:");
330                    println!("  {} package(s) can be patched", can_be_patched);
331                    if !already_patched.is_empty() {
332                        println!("  {} package(s) already patched", already_patched.len());
333                    }
334                } else {
335                    println!("\nPatched packages:");
336                    for result in &patched {
337                        if !result.files_patched.is_empty() {
338                            // Summarize the per-file strategy used by this
339                            // package: if everything came from the same
340                            // source, show just that tag; otherwise list
341                            // distinct sources.
342                            let mut tags: Vec<&'static str> = result
343                                .applied_via
344                                .values()
345                                .map(|v| v.as_tag())
346                                .collect();
347                            tags.sort_unstable();
348                            tags.dedup();
349                            let suffix = if tags.is_empty() {
350                                String::new()
351                            } else {
352                                format!(" (via {})", tags.join("+"))
353                            };
354                            println!("  {}{}", result.package_key, suffix);
355                        } else if all_files_already_patched(result) {
356                            println!("  {} (already patched)", result.package_key);
357                        }
358                    }
359                }
360
361                if args.common.verbose {
362                    println!("\nDetailed verification:");
363                    for result in &results {
364                        println!("  {}:", result.package_key);
365                        for f in &result.files_verified {
366                            let status_str = match f.status {
367                                VerifyStatus::Ready => "ready",
368                                VerifyStatus::AlreadyPatched => "already patched",
369                                VerifyStatus::HashMismatch => "hash mismatch",
370                                VerifyStatus::NotFound => "not found",
371                            };
372                            println!("    {} [{}]", f.file, status_str);
373                            if let Some(ref msg) = f.message {
374                                println!("      message: {msg}");
375                            }
376                            if args.common.verbose {
377                                if let Some(ref h) = f.current_hash {
378                                    println!("      current:  {h}");
379                                }
380                                if let Some(ref h) = f.expected_hash {
381                                    println!("      expected: {h}");
382                                }
383                                if let Some(ref h) = f.target_hash {
384                                    println!("      target:   {h}");
385                                }
386                            }
387                        }
388                    }
389                }
390            }
391
392            // Track telemetry
393            if success {
394                track_patch_applied(patched_count, args.common.dry_run, api_token.as_deref(), org_slug.as_deref()).await;
395            } else {
396                track_patch_apply_failed("One or more patches failed to apply", args.common.dry_run, api_token.as_deref(), org_slug.as_deref()).await;
397            }
398
399            if success { 0 } else { 1 }
400        }
401        Err(e) => {
402            track_patch_apply_failed(&e, args.common.dry_run, api_token.as_deref(), org_slug.as_deref()).await;
403            if args.common.json {
404                let mut env = Envelope::new(Command::Apply);
405                env.dry_run = args.common.dry_run;
406                env.mark_error(EnvelopeError::new("apply_failed", e.clone()));
407                println!("{}", env.to_pretty_json());
408            } else if !args.common.silent {
409                eprintln!("Error: {e}");
410            }
411            1
412        }
413    }
414}
415
416async fn apply_patches_inner(
417    args: &ApplyArgs,
418    manifest_path: &Path,
419) -> Result<(bool, Vec<ApplyResult>, Vec<String>), String> {
420    let manifest = read_manifest(manifest_path)
421        .await
422        .map_err(|e| e.to_string())?
423        .ok_or_else(|| "Invalid manifest".to_string())?;
424
425    // The persistent cache directories under `.socket/`. Apply only ever
426    // *reads* from these — writes (downloads, cleanup) happen against a
427    // transient overlay tempdir constructed below when fetching is needed.
428    let socket_dir = manifest_path.parent().unwrap();
429    let socket_blobs_path = socket_dir.join("blobs");
430    let socket_diffs_path = socket_dir.join("diffs");
431    let socket_packages_path = socket_dir.join("packages");
432
433    let download_mode = DownloadMode::parse(&args.common.download_mode).map_err(|e| e.to_string())?;
434
435    // Compute per-patch source availability so both the offline guard
436    // (next block) and the `download_needed` decision below share the
437    // same notion of what's already on disk. These probes are read-only.
438    let missing_blobs = get_missing_blobs(&manifest, &socket_blobs_path).await;
439    let missing_diff_archives = get_missing_archives(&manifest, &socket_diffs_path).await;
440    let missing_package_archives = get_missing_archives(&manifest, &socket_packages_path).await;
441
442    // A patch is "locally applicable" iff at least one of:
443    //   - every `after_hash` blob it references is on disk, OR
444    //   - its diff archive is on disk, OR
445    //   - its package archive is on disk.
446    // The apply pipeline will pick whichever is present per file.
447    let patches_without_source: Vec<&str> = manifest
448        .patches
449        .iter()
450        .filter_map(|(purl, record)| {
451            let all_blobs_present = record
452                .files
453                .values()
454                .all(|f| !missing_blobs.contains(&f.after_hash));
455            let diff_present = !missing_diff_archives.contains(&record.uuid);
456            let pkg_present = !missing_package_archives.contains(&record.uuid);
457            if all_blobs_present || diff_present || pkg_present {
458                None
459            } else {
460                Some(purl.as_str())
461            }
462        })
463        .collect();
464
465    if args.common.offline {
466        // Offline: bail only if some patch has no usable local source.
467        // Note: with `--force`, the apply pipeline can short-circuit
468        // verification on its own; we still surface the no-source
469        // diagnosis so the user runs `repair` before retrying.
470        if !patches_without_source.is_empty() {
471            if !args.common.silent && !args.common.json {
472                eprintln!(
473                    "Error: {} patch(es) have no local source and --offline is set:",
474                    patches_without_source.len()
475                );
476                for purl in patches_without_source.iter().take(5) {
477                    eprintln!("  - {}", purl);
478                }
479                if patches_without_source.len() > 5 {
480                    eprintln!("  ... and {} more", patches_without_source.len() - 5);
481                }
482                eprintln!("Run \"socket-patch repair\" to download missing artifacts.");
483            }
484            return Ok((false, Vec::new(), Vec::new()));
485        }
486    }
487
488    // Decide what (if anything) needs downloading.
489    //
490    // The apply pipeline tries sources in the order package → diff →
491    // blob locally. We honor `--download-mode` for the primary fetch
492    // when there's actually a gap to close. Skip the archive fetch
493    // entirely when all file blobs are already present locally —
494    // apply will succeed via the blob path, and the archive endpoints
495    // would just 404 (current server doesn't serve them yet).
496    let download_needed = !args.common.offline
497        && match download_mode {
498            DownloadMode::File => !missing_blobs.is_empty(),
499            DownloadMode::Diff | DownloadMode::Package if missing_blobs.is_empty() => false,
500            DownloadMode::Diff => !missing_diff_archives.is_empty(),
501            DownloadMode::Package => !missing_package_archives.is_empty(),
502        };
503
504    // Determine where the apply pipeline should read patch sources from.
505    //
506    // - If nothing needs downloading (offline mode, or every required
507    //   artifact is already in `.socket/`), read straight from `.socket/`.
508    //   Apply is purely read-only against the persistent cache.
509    // - Otherwise, stage a transient overlay tempdir that hardlinks every
510    //   existing `.socket/` artifact and receives fresh downloads. Apply
511    //   reads exclusively from the tempdir; `.socket/` is never mutated.
512    //
513    // `_stage_dir` keeps the `TempDir` handle alive for the rest of this
514    // function — on drop the OS removes the directory and any downloaded
515    // bytes go with it.
516    let (blobs_path, diffs_path, packages_path, _stage_dir): (
517        PathBuf,
518        PathBuf,
519        PathBuf,
520        Option<TempDir>,
521    ) = if download_needed {
522        let stage = tempfile::tempdir().map_err(|e| e.to_string())?;
523        let stage_blobs = stage.path().join("blobs");
524        let stage_diffs = stage.path().join("diffs");
525        let stage_packages = stage.path().join("packages");
526        for dir in [&stage_blobs, &stage_diffs, &stage_packages] {
527            tokio::fs::create_dir_all(dir)
528                .await
529                .map_err(|e| e.to_string())?;
530        }
531        overlay_dir(&socket_blobs_path, &stage_blobs).await;
532        overlay_dir(&socket_diffs_path, &stage_diffs).await;
533        overlay_dir(&socket_packages_path, &stage_packages).await;
534
535        if !args.common.silent && !args.common.json {
536            println!(
537                "Downloading missing patch artifacts (mode: {})...",
538                download_mode.as_tag()
539            );
540        }
541
542        let (client, _) =
543            get_api_client_with_overrides(args.common.api_client_overrides()).await;
544        let sources = PatchSources {
545            blobs_path: &stage_blobs,
546            packages_path: Some(&stage_packages),
547            diffs_path: Some(&stage_diffs),
548        };
549        let fetch_result =
550            fetch_missing_sources(&manifest, &sources, download_mode, &client, None).await;
551
552        if !args.common.silent && !args.common.json {
553            println!("{}", format_fetch_result(&fetch_result));
554        }
555
556        // For non-file modes, automatically fetch any still-missing file
557        // blobs as a fallback. Patches that lack the requested mode on
558        // the server will still apply via the legacy blob path.
559        if download_mode != DownloadMode::File {
560            let still_missing_blobs = get_missing_blobs(&manifest, &stage_blobs).await;
561            if !still_missing_blobs.is_empty() {
562                if !args.common.silent && !args.common.json {
563                    println!(
564                        "Falling back to per-file blob downloads for {} blob(s)...",
565                        still_missing_blobs.len()
566                    );
567                }
568                let blob_result =
569                    fetch_missing_blobs(&manifest, &stage_blobs, &client, None).await;
570                if !args.common.silent && !args.common.json {
571                    println!("{}", format_fetch_result(&blob_result));
572                }
573                if blob_result.failed > 0 && fetch_result.failed > 0 {
574                    if !args.common.silent && !args.common.json {
575                        eprintln!("Some artifacts could not be downloaded. Cannot apply patches.");
576                    }
577                    return Ok((false, Vec::new(), Vec::new()));
578                }
579            }
580        } else if fetch_result.failed > 0 {
581            if !args.common.silent && !args.common.json {
582                eprintln!("Some blobs could not be downloaded. Cannot apply patches.");
583            }
584            return Ok((false, Vec::new(), Vec::new()));
585        }
586
587        (stage_blobs, stage_diffs, stage_packages, Some(stage))
588    } else {
589        (
590            socket_blobs_path.clone(),
591            socket_diffs_path.clone(),
592            socket_packages_path.clone(),
593            None,
594        )
595    };
596
597    // Partition manifest PURLs by ecosystem
598    let manifest_purls: Vec<String> = manifest.patches.keys().cloned().collect();
599    let partitioned =
600        partition_purls(&manifest_purls, args.common.ecosystems.as_deref());
601
602    let target_manifest_purls: HashSet<String> = partitioned
603        .values()
604        .flat_map(|purls| purls.iter().cloned())
605        .collect();
606
607    let crawler_options = CrawlerOptions {
608        cwd: args.common.cwd.clone(),
609        global: args.common.global,
610        global_prefix: args.common.global_prefix.clone(),
611        batch_size: 100,
612    };
613
614    let all_packages =
615        find_packages_for_purls(&partitioned, &crawler_options, args.common.silent || args.common.json).await;
616
617    let has_any_purls = !partitioned.is_empty();
618
619    if all_packages.is_empty() && !has_any_purls {
620        if !args.common.silent && !args.common.json {
621            if args.common.global || args.common.global_prefix.is_some() {
622                eprintln!("No global packages found");
623            } else {
624                eprintln!("No package directories found");
625            }
626        }
627        return Ok((false, Vec::new(), Vec::new()));
628    }
629
630    if all_packages.is_empty() {
631        if !args.common.silent && !args.common.json {
632            eprintln!("Warning: No packages found that match available patches");
633            eprintln!(
634                "  {} targeted manifest patch(es) were in scope, but no matching packages were found on disk.",
635                target_manifest_purls.len()
636            );
637            eprintln!("  Check that packages are installed and --cwd points to the right directory.");
638        }
639        let unmatched: Vec<String> = target_manifest_purls.iter().cloned().collect();
640        return Ok((false, Vec::new(), unmatched));
641    }
642
643    // Apply patches
644    let mut results: Vec<ApplyResult> = Vec::new();
645    let mut has_errors = false;
646
647    // Group release-variant PURLs by base. PyPI (`?artifact_id=`),
648    // RubyGems (`?platform=`), and Maven (`?classifier=&ext=`) carry
649    // qualifiers distinguishing releases of one `package@version`; the
650    // crawler emits the base PURL, so we match the manifest's qualified
651    // variants against it here.
652    let mut variant_qualified_groups: HashMap<String, Vec<String>> = HashMap::new();
653    for (eco, purls) in &partitioned {
654        if eco.supports_release_variants() {
655            for purl in purls {
656                variant_qualified_groups
657                    .entry(strip_purl_qualifiers(purl).to_string())
658                    .or_default()
659                    .push(purl.clone());
660            }
661        }
662    }
663
664    let mut applied_base_purls: HashSet<String> = HashSet::new();
665    let mut matched_manifest_purls: HashSet<String> = HashSet::new();
666
667    for (purl, pkg_path) in &all_packages {
668        if Ecosystem::from_purl(purl).is_some_and(|e| e.supports_release_variants()) {
669            let base_purl = strip_purl_qualifiers(purl).to_string();
670            if applied_base_purls.contains(&base_purl) {
671                continue;
672            }
673
674            let variants = variant_qualified_groups
675                .get(&base_purl)
676                .cloned()
677                .unwrap_or_else(|| vec![base_purl.clone()]);
678            let mut applied = false;
679
680            for variant_purl in &variants {
681                let patch = match manifest.patches.get(variant_purl) {
682                    Some(p) => p,
683                    None => continue,
684                };
685
686                // Check the first file's status (skip when --force). A
687                // mismatch *or* a missing file means this variant's
688                // distribution isn't the one on disk, so skip it —
689                // attempting it would only produce a spurious failure.
690                // Mirrors `select_installed_variants`, used by rollback/get.
691                if !args.force {
692                    let first_status = match patch.files.iter().next() {
693                        Some((file_name, file_info)) => {
694                            Some(verify_file_patch(pkg_path, file_name, file_info).await.status)
695                        }
696                        None => None,
697                    };
698                    if !variant_matches_installed(first_status.as_ref()) {
699                        continue;
700                    }
701                }
702
703                let sources = PatchSources {
704                    blobs_path: &blobs_path,
705                    packages_path: Some(&packages_path),
706                    diffs_path: Some(&diffs_path),
707                };
708                let result = apply_package_patch(
709                    variant_purl,
710                    pkg_path,
711                    &patch.files,
712                    &sources,
713                    Some(&patch.uuid),
714                    args.common.dry_run,
715                    args.force,
716                )
717                .await;
718
719                if result.success {
720                    applied = true;
721                    results.push(result);
722                    matched_manifest_purls.insert(variant_purl.clone());
723                    // No `break`: apply *every* matching variant. PyPI/gem
724                    // have exactly one installed distribution (the rest
725                    // hash-mismatch and were skipped above), so this
726                    // applies a single variant for them; Maven's coexisting
727                    // classifier jars each get patched.
728                } else {
729                    results.push(result);
730                }
731            }
732
733            if applied {
734                applied_base_purls.insert(base_purl.clone());
735            } else {
736                has_errors = true;
737                if !args.common.silent && !args.common.json {
738                    eprintln!("Failed to patch {base_purl}: no matching variant found");
739                }
740            }
741        } else {
742            // npm PURLs: direct lookup
743            let patch = match manifest.patches.get(purl) {
744                Some(p) => p,
745                None => continue,
746            };
747
748            let sources = PatchSources {
749                blobs_path: &blobs_path,
750                packages_path: Some(&packages_path),
751                diffs_path: Some(&diffs_path),
752            };
753            let result = apply_package_patch(
754                purl,
755                pkg_path,
756                &patch.files,
757                &sources,
758                Some(&patch.uuid),
759                args.common.dry_run,
760                args.force,
761            )
762            .await;
763
764            if !result.success {
765                has_errors = true;
766                if !args.common.silent && !args.common.json {
767                    eprintln!(
768                        "Failed to patch {}: {}",
769                        purl,
770                        result.error.as_deref().unwrap_or("unknown error")
771                    );
772                }
773            }
774            results.push(result);
775            matched_manifest_purls.insert(purl.clone());
776        }
777    }
778
779    // Check if targeted manifest entries had no matches
780    let unmatched: Vec<String> = target_manifest_purls
781        .iter()
782        .filter(|p| !matched_manifest_purls.contains(*p))
783        .cloned()
784        .collect();
785
786    if !unmatched.is_empty() && !args.common.silent && !args.common.json {
787        eprintln!("\nWarning: {} manifest patch(es) had no matching installed package:", unmatched.len());
788        for purl in &unmatched {
789            eprintln!("  - {}", purl);
790        }
791    }
792
793    if !target_manifest_purls.is_empty() && matched_manifest_purls.is_empty() && !all_packages.is_empty() {
794        if !args.common.silent && !args.common.json {
795            eprintln!("Warning: None of the targeted manifest patches matched installed packages.");
796        }
797        has_errors = true;
798    }
799
800    // Post-apply summary
801    if !args.common.silent && !args.common.json {
802        let applied_count = results.iter().filter(|r| r.success && !r.files_patched.is_empty()).count();
803        let already_count = results.iter().filter(|r| all_files_already_patched(r)).count();
804        println!(
805            "\nSummary: {}/{} targeted patches applied, {} already patched, {} not found on disk",
806            applied_count,
807            target_manifest_purls.len(),
808            already_count,
809            unmatched.len()
810        );
811    }
812
813    // Note: `apply` deliberately does NOT garbage-collect unused blobs in
814    // `.socket/`. GC is the responsibility of `socket-patch repair` /
815    // `gc` / `scan --prune`. Keeping apply read-only against `.socket/`
816    // means it can run repeatedly (CI dry-runs, deploy hooks) without
817    // mutating patch state.
818
819    Ok((!has_errors, results, unmatched))
820}
821
822#[cfg(test)]
823mod tests {
824    //! Tests for `result_to_event` — the per-package → per-patch event
825    //! translator that feeds apply's unified JSON envelope. Every
826    //! contract value here (action tags, `errorCode` reasons, `files[].path`
827    //! shape) is documented in `CLI_CONTRACT.md`.
828    use super::*;
829    use socket_patch_core::patch::apply::{
830        AppliedVia as CoreAppliedVia, ApplyResult, VerifyResult, VerifyStatus,
831    };
832
833    /// Build a successful `ApplyResult` with one patched file and one
834    /// verified file. Used as the base for action-routing tests.
835    fn sample_applied(status: VerifyStatus) -> ApplyResult {
836        let mut applied_via = HashMap::new();
837        applied_via.insert("package/index.js".to_string(), CoreAppliedVia::Diff);
838        ApplyResult {
839            package_key: "pkg:npm/minimist@1.2.2".to_string(),
840            package_path: "/tmp/node_modules/minimist".to_string(),
841            success: true,
842            files_verified: vec![VerifyResult {
843                file: "package/index.js".to_string(),
844                status,
845                message: None,
846                current_hash: None,
847                expected_hash: None,
848                target_hash: None,
849            }],
850            files_patched: vec!["package/index.js".to_string()],
851            applied_via,
852            error: None,
853            sidecar: None,
854        }
855    }
856
857    #[test]
858    fn failed_result_maps_to_failed_action() {
859        let mut result = sample_applied(VerifyStatus::Ready);
860        result.success = false;
861        result.error = Some("hash mismatch".into());
862
863        let event = result_to_event(&result, false);
864        let v: serde_json::Value =
865            serde_json::from_str(&serde_json::to_string(&event).unwrap()).unwrap();
866        assert_eq!(v["action"], "failed");
867        assert_eq!(v["errorCode"], "apply_failed");
868        assert_eq!(v["error"], "hash mismatch");
869    }
870
871    #[test]
872    fn all_already_patched_maps_to_skipped() {
873        let result = sample_applied(VerifyStatus::AlreadyPatched);
874        let event = result_to_event(&result, false);
875        let v: serde_json::Value =
876            serde_json::from_str(&serde_json::to_string(&event).unwrap()).unwrap();
877        assert_eq!(v["action"], "skipped");
878        assert_eq!(v["errorCode"], "already_patched");
879    }
880
881    #[test]
882    fn dry_run_maps_to_verified() {
883        let result = sample_applied(VerifyStatus::Ready);
884        let event = result_to_event(&result, true);
885        let v: serde_json::Value =
886            serde_json::from_str(&serde_json::to_string(&event).unwrap()).unwrap();
887        assert_eq!(v["action"], "verified");
888        // Dry-run events list verified files but never an `appliedVia`
889        // — nothing was actually written.
890        assert_eq!(v["files"][0]["path"], "package/index.js");
891        assert!(v["files"][0].as_object().unwrap().get("appliedVia").is_none());
892    }
893
894    #[test]
895    fn successful_apply_maps_to_applied_with_files() {
896        let result = sample_applied(VerifyStatus::Ready);
897        let event = result_to_event(&result, false);
898        let v: serde_json::Value =
899            serde_json::from_str(&serde_json::to_string(&event).unwrap()).unwrap();
900        assert_eq!(v["action"], "applied");
901        assert_eq!(v["purl"], "pkg:npm/minimist@1.2.2");
902        let files = v["files"].as_array().unwrap();
903        assert_eq!(files.len(), 1);
904        assert_eq!(files[0]["path"], "package/index.js");
905        assert_eq!(files[0]["verified"], true);
906        // `appliedVia` is camelCase + lowercase tag — contract value.
907        assert_eq!(files[0]["appliedVia"], "diff");
908    }
909
910    #[test]
911    fn applied_event_emits_one_file_entry_per_patched_file() {
912        let mut applied_via = HashMap::new();
913        applied_via.insert("package/a.js".to_string(), CoreAppliedVia::Diff);
914        applied_via.insert("package/b.js".to_string(), CoreAppliedVia::Package);
915        applied_via.insert("package/c.js".to_string(), CoreAppliedVia::Blob);
916        let result = ApplyResult {
917            package_key: "pkg:npm/foo@1.0.0".to_string(),
918            package_path: "/tmp/foo".to_string(),
919            success: true,
920            files_verified: Vec::new(),
921            files_patched: vec![
922                "package/a.js".to_string(),
923                "package/b.js".to_string(),
924                "package/c.js".to_string(),
925            ],
926            applied_via,
927            error: None,
928            sidecar: None,
929        };
930
931        let event = result_to_event(&result, false);
932        let v: serde_json::Value =
933            serde_json::from_str(&serde_json::to_string(&event).unwrap()).unwrap();
934        let files = v["files"].as_array().unwrap();
935        assert_eq!(files.len(), 3);
936        let by_path: std::collections::HashMap<String, &serde_json::Value> = files
937            .iter()
938            .map(|f| (f["path"].as_str().unwrap().to_string(), f))
939            .collect();
940        assert_eq!(by_path["package/a.js"]["appliedVia"], "diff");
941        assert_eq!(by_path["package/b.js"]["appliedVia"], "package");
942        assert_eq!(by_path["package/c.js"]["appliedVia"], "blob");
943    }
944
945    /// Build a successful `ApplyResult` whose verified files carry the
946    /// given statuses, with no patched files. Used to exercise the
947    /// `already_patched` classification directly.
948    fn sample_verified(statuses: &[VerifyStatus]) -> ApplyResult {
949        let files_verified = statuses
950            .iter()
951            .enumerate()
952            .map(|(i, status)| VerifyResult {
953                file: format!("package/f{i}.js"),
954                status: status.clone(),
955                message: None,
956                current_hash: None,
957                expected_hash: None,
958                target_hash: None,
959            })
960            .collect();
961        ApplyResult {
962            package_key: "pkg:npm/foo@1.0.0".to_string(),
963            package_path: "/tmp/foo".to_string(),
964            success: true,
965            files_verified,
966            files_patched: Vec::new(),
967            applied_via: HashMap::new(),
968            error: None,
969            sidecar: None,
970        }
971    }
972
973    #[test]
974    fn all_files_already_patched_true_when_every_file_matches() {
975        let result = sample_verified(&[
976            VerifyStatus::AlreadyPatched,
977            VerifyStatus::AlreadyPatched,
978        ]);
979        assert!(all_files_already_patched(&result));
980    }
981
982    #[test]
983    fn all_files_already_patched_false_when_any_file_differs() {
984        let result = sample_verified(&[
985            VerifyStatus::AlreadyPatched,
986            VerifyStatus::Ready,
987        ]);
988        assert!(!all_files_already_patched(&result));
989    }
990
991    /// Regression: `Iterator::all` over an empty slice is vacuously true.
992    /// A result with no verified files must NOT be reported as
993    /// "already patched" — the `!is_empty()` guard enforces this so the
994    /// human summaries and the JSON envelope agree.
995    #[test]
996    fn all_files_already_patched_false_when_no_verified_files() {
997        let mut result = sample_verified(&[]);
998        assert!(result.files_verified.is_empty());
999        assert!(!all_files_already_patched(&result));
1000
1001        // A freshly-applied package (files patched, none left verified)
1002        // is likewise not a no-op.
1003        result.files_patched = vec!["package/a.js".to_string()];
1004        assert!(!all_files_already_patched(&result));
1005    }
1006
1007    /// Regression: a non-installed release variant whose first patched
1008    /// file is `NotFound` (e.g. an sdist patching `setup.py` while only a
1009    /// wheel is on disk) must be treated as NOT installed and skipped —
1010    /// exactly like a `HashMismatch`. Before the fix the loop only skipped
1011    /// `HashMismatch`, so a `NotFound` variant slipped through to
1012    /// `apply_package_patch` and produced a spurious `Failed` event in the
1013    /// JSON envelope. This pins the apply-side decision to the same
1014    /// Ready/AlreadyPatched contract as `select_installed_variants`.
1015    #[test]
1016    fn variant_matches_only_when_first_file_ready_or_already_patched() {
1017        // Installed distribution: first file applies cleanly, or is
1018        // already at afterHash → this variant is the one on disk.
1019        assert!(variant_matches_installed(Some(&VerifyStatus::Ready)));
1020        assert!(variant_matches_installed(Some(&VerifyStatus::AlreadyPatched)));
1021
1022        // Not the installed distribution → must be skipped. The NotFound
1023        // case is the specific regression this guards.
1024        assert!(!variant_matches_installed(Some(&VerifyStatus::HashMismatch)));
1025        assert!(!variant_matches_installed(Some(&VerifyStatus::NotFound)));
1026
1027        // A variant with no files has nothing to disqualify it — match,
1028        // mirroring `select_installed_variants`.
1029        assert!(variant_matches_installed(None));
1030    }
1031
1032    /// Regression: a freshly-applied result with an empty `files_verified`
1033    /// must map to `Applied`, never `Skipped`/`already_patched`. This is
1034    /// the same classification the human-readable summary relies on via
1035    /// `all_files_already_patched`.
1036    #[test]
1037    fn applied_with_empty_verified_is_not_skipped() {
1038        let mut applied_via = HashMap::new();
1039        applied_via.insert("package/a.js".to_string(), CoreAppliedVia::Blob);
1040        let result = ApplyResult {
1041            package_key: "pkg:npm/foo@1.0.0".to_string(),
1042            package_path: "/tmp/foo".to_string(),
1043            success: true,
1044            files_verified: Vec::new(),
1045            files_patched: vec!["package/a.js".to_string()],
1046            applied_via,
1047            error: None,
1048            sidecar: None,
1049        };
1050        let event = result_to_event(&result, false);
1051        let v: serde_json::Value =
1052            serde_json::from_str(&serde_json::to_string(&event).unwrap()).unwrap();
1053        assert_eq!(v["action"], "applied");
1054    }
1055}