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/// Translate the core engine's per-package [`ApplyResult`] into a single
74/// patch-level [`PatchEvent`] for the unified envelope.
75///
76/// Action mapping (in priority order):
77///   * `!result.success`                         → `Failed`
78///   * `dry_run` and any file was Ready/Patched → `Verified`
79///   * all `files_verified` are AlreadyPatched   → `Skipped` (already_patched)
80///   * something was actually patched on disk    → `Applied`
81///
82/// `files` enumerates only the files that participated in the action —
83/// for `Applied`, the patched ones with their `applied_via` strategy;
84/// for `Verified`, every file the engine confirmed could be patched.
85pub(crate) fn result_to_event(result: &ApplyResult, dry_run: bool) -> PatchEvent {
86    let purl = result.package_key.clone();
87    if !result.success {
88        return PatchEvent::new(PatchAction::Failed, purl).with_error(
89            "apply_failed",
90            result
91                .error
92                .clone()
93                .unwrap_or_else(|| "unknown error".to_string()),
94        );
95    }
96
97    let all_already_patched = !result.files_verified.is_empty()
98        && result
99            .files_verified
100            .iter()
101            .all(|f| f.status == VerifyStatus::AlreadyPatched);
102
103    if all_already_patched {
104        return PatchEvent::new(PatchAction::Skipped, purl)
105            .with_reason("already_patched", "All files already match afterHash");
106    }
107
108    if dry_run {
109        let files = result
110            .files_verified
111            .iter()
112            .filter(|f| {
113                f.status == VerifyStatus::Ready || f.status == VerifyStatus::AlreadyPatched
114            })
115            .map(|f| PatchEventFile {
116                path: f.file.clone(),
117                verified: true,
118                applied_via: None,
119            })
120            .collect();
121        return PatchEvent::new(PatchAction::Verified, purl).with_files(files);
122    }
123
124    let files = result
125        .files_patched
126        .iter()
127        .map(|f| PatchEventFile {
128            path: f.clone(),
129            verified: true,
130            applied_via: result
131                .applied_via
132                .get(f)
133                .copied()
134                .map(AppliedVia::from_core),
135        })
136        .collect();
137    // Sidecar data is NOT attached here — it's surfaced at the
138    // envelope level under `Envelope.sidecars[]` by the run loop.
139    // See `Envelope::record_sidecar`. Keeping events clean of
140    // sidecar info means each event describes only the apply
141    // action; sidecar reporting is a separate, JOIN-able list.
142    PatchEvent::new(PatchAction::Applied, purl).with_files(files)
143}
144
145pub async fn run(args: ApplyArgs) -> i32 {
146    apply_env_toggles(&args.common);
147    let (telemetry_client, _) =
148        get_api_client_with_overrides(args.common.api_client_overrides()).await;
149    let api_token = telemetry_client.api_token().cloned();
150    let org_slug = telemetry_client.org_slug().cloned();
151
152    let manifest_path = args.common.resolved_manifest_path();
153
154    // Check if manifest exists - exit successfully if no .socket folder is set up
155    if tokio::fs::metadata(&manifest_path).await.is_err() {
156        if args.common.json {
157            let mut env = Envelope::new(Command::Apply);
158            env.status = Status::NoManifest;
159            env.dry_run = args.common.dry_run;
160            println!("{}", env.to_pretty_json());
161        } else if !args.common.silent {
162            println!("No .socket folder found, skipping patch application.");
163        }
164        return 0;
165    }
166
167    // Serialize against concurrent socket-patch runs targeting the same
168    // `.socket/` directory. The guard releases on function return; see
169    // `socket_patch_core::patch::apply_lock`.
170    let socket_dir = manifest_path.parent().unwrap_or(Path::new("."));
171    let acquired = match acquire_or_emit(
172        socket_dir,
173        Command::Apply,
174        args.common.json,
175        args.common.silent,
176        args.common.dry_run,
177        Duration::from_secs(args.common.lock_timeout.unwrap_or(0)),
178        args.common.break_lock,
179    ) {
180        Ok(acquired) => acquired,
181        Err(code) => return code,
182    };
183    let _lock = acquired.guard;
184    let lock_was_broken = acquired.broke_lock;
185
186    // Package-manager layout detection. yarn-berry PnP keeps packages
187    // inside `.yarn/cache/*.zip` and resolves them via `.pnp.cjs` —
188    // the npm crawler can't reach them and rewriting zips is a
189    // different operation entirely. Refuse with a clear pointer to
190    // `yarn patch`. pnpm gets an informational event; the CoW guard
191    // in `apply_file_patch` does the substantive safety work.
192    let pkg_manager = detect_npm_pkg_manager(&args.common.cwd);
193    match pkg_manager {
194        NpmPkgManager::YarnBerryPnP => {
195            if args.common.json {
196                let mut env = Envelope::new(Command::Apply);
197                env.dry_run = args.common.dry_run;
198                env.mark_error(EnvelopeError::new(
199                    "yarn_pnp_unsupported",
200                    "yarn-berry Plug'n'Play layout is not supported by socket-patch (packages live inside .yarn/cache zips). Use `yarn patch <pkg>` instead.",
201                ));
202                println!("{}", env.to_pretty_json());
203            } else if !args.common.silent {
204                eprintln!("Error: yarn-berry Plug'n'Play layout is not supported.");
205                eprintln!(
206                    "  Packages live inside .yarn/cache/*.zip — socket-patch cannot rewrite them in place."
207                );
208                eprintln!("  Use `yarn patch <pkg>` instead.");
209            }
210            return 1;
211        }
212        NpmPkgManager::Pnpm => {
213            if !args.common.json && !args.common.silent {
214                eprintln!(
215                    "Note: pnpm layout detected. Copy-on-write will keep the global store untouched."
216                );
217            }
218            // Non-fatal — CoW handles the safety. JSON consumers see
219            // the layout-detected info in the apply envelope's
220            // existing events (no separate event added here yet).
221        }
222        NpmPkgManager::Bun => {
223            if !args.common.json && !args.common.silent {
224                eprintln!(
225                    "Note: bun layout detected. Copy-on-write will keep ~/.bun/install/cache/ untouched."
226                );
227            }
228            // Same shape as pnpm: bun hard-links from its global
229            // install cache by default. The CoW guard handles the
230            // safety; this is informational only.
231        }
232        _ => {}
233    }
234
235    match apply_patches_inner(&args, &manifest_path).await {
236        Ok((success, results, unmatched)) => {
237            let patched_count = results
238                .iter()
239                .filter(|r| r.success && !r.files_patched.is_empty())
240                .count();
241
242            if args.common.json {
243                let mut env = Envelope::new(Command::Apply);
244                env.dry_run = args.common.dry_run;
245                if lock_was_broken {
246                    env.record(lock_broken_event(socket_dir));
247                }
248                for result in &results {
249                    env.record(result_to_event(result, args.common.dry_run));
250                    // Sidecar records live on the envelope, not on
251                    // individual events. Consumers iterate
252                    // `envelope.sidecars[]` and JOIN against
253                    // `events[]` by `purl` for per-package context.
254                    if let Some(ref sidecar) = result.sidecar {
255                        env.record_sidecar(sidecar.clone());
256                    }
257                }
258                // Manifest entries that targeted in-scope ecosystems but
259                // had no installed package on disk — emit one Skipped
260                // event per purl so downstream consumers can surface them.
261                for purl in &unmatched {
262                    env.record(
263                        PatchEvent::new(PatchAction::Skipped, purl.clone()).with_reason(
264                            "package_not_installed",
265                            "No installed package matches this PURL",
266                        ),
267                    );
268                }
269                if !success {
270                    env.mark_partial_failure();
271                }
272                println!("{}", env.to_pretty_json());
273            } else if !args.common.silent && !results.is_empty() {
274                let patched: Vec<_> = results.iter().filter(|r| r.success).collect();
275                let already_patched: Vec<_> = results
276                    .iter()
277                    .filter(|r| {
278                        r.files_verified
279                            .iter()
280                            .all(|f| f.status == VerifyStatus::AlreadyPatched)
281                    })
282                    .collect();
283
284                if args.common.dry_run {
285                    println!("\nPatch verification complete:");
286                    println!("  {} package(s) can be patched", patched.len());
287                    if !already_patched.is_empty() {
288                        println!("  {} package(s) already patched", already_patched.len());
289                    }
290                } else {
291                    println!("\nPatched packages:");
292                    for result in &patched {
293                        if !result.files_patched.is_empty() {
294                            // Summarize the per-file strategy used by this
295                            // package: if everything came from the same
296                            // source, show just that tag; otherwise list
297                            // distinct sources.
298                            let mut tags: Vec<&'static str> = result
299                                .applied_via
300                                .values()
301                                .map(|v| v.as_tag())
302                                .collect();
303                            tags.sort_unstable();
304                            tags.dedup();
305                            let suffix = if tags.is_empty() {
306                                String::new()
307                            } else {
308                                format!(" (via {})", tags.join("+"))
309                            };
310                            println!("  {}{}", result.package_key, suffix);
311                        } else if result.files_verified.iter().all(|f| {
312                            f.status == VerifyStatus::AlreadyPatched
313                        }) {
314                            println!("  {} (already patched)", result.package_key);
315                        }
316                    }
317                }
318
319                if args.common.verbose {
320                    println!("\nDetailed verification:");
321                    for result in &results {
322                        println!("  {}:", result.package_key);
323                        for f in &result.files_verified {
324                            let status_str = match f.status {
325                                VerifyStatus::Ready => "ready",
326                                VerifyStatus::AlreadyPatched => "already patched",
327                                VerifyStatus::HashMismatch => "hash mismatch",
328                                VerifyStatus::NotFound => "not found",
329                            };
330                            println!("    {} [{}]", f.file, status_str);
331                            if let Some(ref msg) = f.message {
332                                println!("      message: {msg}");
333                            }
334                            if args.common.verbose {
335                                if let Some(ref h) = f.current_hash {
336                                    println!("      current:  {h}");
337                                }
338                                if let Some(ref h) = f.expected_hash {
339                                    println!("      expected: {h}");
340                                }
341                                if let Some(ref h) = f.target_hash {
342                                    println!("      target:   {h}");
343                                }
344                            }
345                        }
346                    }
347                }
348            }
349
350            // Track telemetry
351            if success {
352                track_patch_applied(patched_count, args.common.dry_run, api_token.as_deref(), org_slug.as_deref()).await;
353            } else {
354                track_patch_apply_failed("One or more patches failed to apply", args.common.dry_run, api_token.as_deref(), org_slug.as_deref()).await;
355            }
356
357            if success { 0 } else { 1 }
358        }
359        Err(e) => {
360            track_patch_apply_failed(&e, args.common.dry_run, api_token.as_deref(), org_slug.as_deref()).await;
361            if args.common.json {
362                let mut env = Envelope::new(Command::Apply);
363                env.dry_run = args.common.dry_run;
364                env.mark_error(EnvelopeError::new("apply_failed", e.clone()));
365                println!("{}", env.to_pretty_json());
366            } else if !args.common.silent {
367                eprintln!("Error: {e}");
368            }
369            1
370        }
371    }
372}
373
374async fn apply_patches_inner(
375    args: &ApplyArgs,
376    manifest_path: &Path,
377) -> Result<(bool, Vec<ApplyResult>, Vec<String>), String> {
378    let manifest = read_manifest(manifest_path)
379        .await
380        .map_err(|e| e.to_string())?
381        .ok_or_else(|| "Invalid manifest".to_string())?;
382
383    // The persistent cache directories under `.socket/`. Apply only ever
384    // *reads* from these — writes (downloads, cleanup) happen against a
385    // transient overlay tempdir constructed below when fetching is needed.
386    let socket_dir = manifest_path.parent().unwrap();
387    let socket_blobs_path = socket_dir.join("blobs");
388    let socket_diffs_path = socket_dir.join("diffs");
389    let socket_packages_path = socket_dir.join("packages");
390
391    let download_mode = DownloadMode::parse(&args.common.download_mode).map_err(|e| e.to_string())?;
392
393    // Compute per-patch source availability so both the offline guard
394    // (next block) and the `download_needed` decision below share the
395    // same notion of what's already on disk. These probes are read-only.
396    let missing_blobs = get_missing_blobs(&manifest, &socket_blobs_path).await;
397    let missing_diff_archives = get_missing_archives(&manifest, &socket_diffs_path).await;
398    let missing_package_archives = get_missing_archives(&manifest, &socket_packages_path).await;
399
400    // A patch is "locally applicable" iff at least one of:
401    //   - every `after_hash` blob it references is on disk, OR
402    //   - its diff archive is on disk, OR
403    //   - its package archive is on disk.
404    // The apply pipeline will pick whichever is present per file.
405    let patches_without_source: Vec<&str> = manifest
406        .patches
407        .iter()
408        .filter_map(|(purl, record)| {
409            let all_blobs_present = record
410                .files
411                .values()
412                .all(|f| !missing_blobs.contains(&f.after_hash));
413            let diff_present = !missing_diff_archives.contains(&record.uuid);
414            let pkg_present = !missing_package_archives.contains(&record.uuid);
415            if all_blobs_present || diff_present || pkg_present {
416                None
417            } else {
418                Some(purl.as_str())
419            }
420        })
421        .collect();
422
423    if args.common.offline {
424        // Offline: bail only if some patch has no usable local source.
425        // Note: with `--force`, the apply pipeline can short-circuit
426        // verification on its own; we still surface the no-source
427        // diagnosis so the user runs `repair` before retrying.
428        if !patches_without_source.is_empty() {
429            if !args.common.silent && !args.common.json {
430                eprintln!(
431                    "Error: {} patch(es) have no local source and --offline is set:",
432                    patches_without_source.len()
433                );
434                for purl in patches_without_source.iter().take(5) {
435                    eprintln!("  - {}", purl);
436                }
437                if patches_without_source.len() > 5 {
438                    eprintln!("  ... and {} more", patches_without_source.len() - 5);
439                }
440                eprintln!("Run \"socket-patch repair\" to download missing artifacts.");
441            }
442            return Ok((false, Vec::new(), Vec::new()));
443        }
444    }
445
446    // Decide what (if anything) needs downloading.
447    //
448    // The apply pipeline tries sources in the order package → diff →
449    // blob locally. We honor `--download-mode` for the primary fetch
450    // when there's actually a gap to close. Skip the archive fetch
451    // entirely when all file blobs are already present locally —
452    // apply will succeed via the blob path, and the archive endpoints
453    // would just 404 (current server doesn't serve them yet).
454    let download_needed = !args.common.offline
455        && match download_mode {
456            DownloadMode::File => !missing_blobs.is_empty(),
457            DownloadMode::Diff | DownloadMode::Package if missing_blobs.is_empty() => false,
458            DownloadMode::Diff => !missing_diff_archives.is_empty(),
459            DownloadMode::Package => !missing_package_archives.is_empty(),
460        };
461
462    // Determine where the apply pipeline should read patch sources from.
463    //
464    // - If nothing needs downloading (offline mode, or every required
465    //   artifact is already in `.socket/`), read straight from `.socket/`.
466    //   Apply is purely read-only against the persistent cache.
467    // - Otherwise, stage a transient overlay tempdir that hardlinks every
468    //   existing `.socket/` artifact and receives fresh downloads. Apply
469    //   reads exclusively from the tempdir; `.socket/` is never mutated.
470    //
471    // `_stage_dir` keeps the `TempDir` handle alive for the rest of this
472    // function — on drop the OS removes the directory and any downloaded
473    // bytes go with it.
474    let (blobs_path, diffs_path, packages_path, _stage_dir): (
475        PathBuf,
476        PathBuf,
477        PathBuf,
478        Option<TempDir>,
479    ) = if download_needed {
480        let stage = tempfile::tempdir().map_err(|e| e.to_string())?;
481        let stage_blobs = stage.path().join("blobs");
482        let stage_diffs = stage.path().join("diffs");
483        let stage_packages = stage.path().join("packages");
484        for dir in [&stage_blobs, &stage_diffs, &stage_packages] {
485            tokio::fs::create_dir_all(dir)
486                .await
487                .map_err(|e| e.to_string())?;
488        }
489        overlay_dir(&socket_blobs_path, &stage_blobs).await;
490        overlay_dir(&socket_diffs_path, &stage_diffs).await;
491        overlay_dir(&socket_packages_path, &stage_packages).await;
492
493        if !args.common.silent && !args.common.json {
494            println!(
495                "Downloading missing patch artifacts (mode: {})...",
496                download_mode.as_tag()
497            );
498        }
499
500        let (client, _) =
501            get_api_client_with_overrides(args.common.api_client_overrides()).await;
502        let sources = PatchSources {
503            blobs_path: &stage_blobs,
504            packages_path: Some(&stage_packages),
505            diffs_path: Some(&stage_diffs),
506        };
507        let fetch_result =
508            fetch_missing_sources(&manifest, &sources, download_mode, &client, None).await;
509
510        if !args.common.silent && !args.common.json {
511            println!("{}", format_fetch_result(&fetch_result));
512        }
513
514        // For non-file modes, automatically fetch any still-missing file
515        // blobs as a fallback. Patches that lack the requested mode on
516        // the server will still apply via the legacy blob path.
517        if download_mode != DownloadMode::File {
518            let still_missing_blobs = get_missing_blobs(&manifest, &stage_blobs).await;
519            if !still_missing_blobs.is_empty() {
520                if !args.common.silent && !args.common.json {
521                    println!(
522                        "Falling back to per-file blob downloads for {} blob(s)...",
523                        still_missing_blobs.len()
524                    );
525                }
526                let blob_result =
527                    fetch_missing_blobs(&manifest, &stage_blobs, &client, None).await;
528                if !args.common.silent && !args.common.json {
529                    println!("{}", format_fetch_result(&blob_result));
530                }
531                if blob_result.failed > 0 && fetch_result.failed > 0 {
532                    if !args.common.silent && !args.common.json {
533                        eprintln!("Some artifacts could not be downloaded. Cannot apply patches.");
534                    }
535                    return Ok((false, Vec::new(), Vec::new()));
536                }
537            }
538        } else if fetch_result.failed > 0 {
539            if !args.common.silent && !args.common.json {
540                eprintln!("Some blobs could not be downloaded. Cannot apply patches.");
541            }
542            return Ok((false, Vec::new(), Vec::new()));
543        }
544
545        (stage_blobs, stage_diffs, stage_packages, Some(stage))
546    } else {
547        (
548            socket_blobs_path.clone(),
549            socket_diffs_path.clone(),
550            socket_packages_path.clone(),
551            None,
552        )
553    };
554
555    // Partition manifest PURLs by ecosystem
556    let manifest_purls: Vec<String> = manifest.patches.keys().cloned().collect();
557    let partitioned =
558        partition_purls(&manifest_purls, args.common.ecosystems.as_deref());
559
560    let target_manifest_purls: HashSet<String> = partitioned
561        .values()
562        .flat_map(|purls| purls.iter().cloned())
563        .collect();
564
565    let crawler_options = CrawlerOptions {
566        cwd: args.common.cwd.clone(),
567        global: args.common.global,
568        global_prefix: args.common.global_prefix.clone(),
569        batch_size: 100,
570    };
571
572    let all_packages =
573        find_packages_for_purls(&partitioned, &crawler_options, args.common.silent || args.common.json).await;
574
575    let has_any_purls = !partitioned.is_empty();
576
577    if all_packages.is_empty() && !has_any_purls {
578        if !args.common.silent && !args.common.json {
579            if args.common.global || args.common.global_prefix.is_some() {
580                eprintln!("No global packages found");
581            } else {
582                eprintln!("No package directories found");
583            }
584        }
585        return Ok((false, Vec::new(), Vec::new()));
586    }
587
588    if all_packages.is_empty() {
589        if !args.common.silent && !args.common.json {
590            eprintln!("Warning: No packages found that match available patches");
591            eprintln!(
592                "  {} targeted manifest patch(es) were in scope, but no matching packages were found on disk.",
593                target_manifest_purls.len()
594            );
595            eprintln!("  Check that packages are installed and --cwd points to the right directory.");
596        }
597        let unmatched: Vec<String> = target_manifest_purls.iter().cloned().collect();
598        return Ok((false, Vec::new(), unmatched));
599    }
600
601    // Apply patches
602    let mut results: Vec<ApplyResult> = Vec::new();
603    let mut has_errors = false;
604
605    // Group pypi PURLs by base (for variant matching with qualifiers)
606    let mut pypi_qualified_groups: HashMap<String, Vec<String>> = HashMap::new();
607    if let Some(pypi_purls) = partitioned.get(&Ecosystem::Pypi) {
608        for purl in pypi_purls {
609            let base = strip_purl_qualifiers(purl).to_string();
610            pypi_qualified_groups
611                .entry(base)
612                .or_default()
613                .push(purl.clone());
614        }
615    }
616
617    let mut applied_base_purls: HashSet<String> = HashSet::new();
618    let mut matched_manifest_purls: HashSet<String> = HashSet::new();
619
620    for (purl, pkg_path) in &all_packages {
621        if Ecosystem::from_purl(purl) == Some(Ecosystem::Pypi) {
622            let base_purl = strip_purl_qualifiers(purl).to_string();
623            if applied_base_purls.contains(&base_purl) {
624                continue;
625            }
626
627            let variants = pypi_qualified_groups
628                .get(&base_purl)
629                .cloned()
630                .unwrap_or_else(|| vec![base_purl.clone()]);
631            let mut applied = false;
632
633            for variant_purl in &variants {
634                let patch = match manifest.patches.get(variant_purl) {
635                    Some(p) => p,
636                    None => continue,
637                };
638
639                // Check first file hash match (skip when --force)
640                if !args.force {
641                    if let Some((file_name, file_info)) = patch.files.iter().next() {
642                        let verify = verify_file_patch(pkg_path, file_name, file_info).await;
643                        if verify.status == VerifyStatus::HashMismatch {
644                            continue;
645                        }
646                    }
647                }
648
649                let sources = PatchSources {
650                    blobs_path: &blobs_path,
651                    packages_path: Some(&packages_path),
652                    diffs_path: Some(&diffs_path),
653                };
654                let result = apply_package_patch(
655                    variant_purl,
656                    pkg_path,
657                    &patch.files,
658                    &sources,
659                    Some(&patch.uuid),
660                    args.common.dry_run,
661                    args.force,
662                )
663                .await;
664
665                if result.success {
666                    applied = true;
667                    applied_base_purls.insert(base_purl.clone());
668                    results.push(result);
669                    matched_manifest_purls.insert(variant_purl.clone());
670                    break;
671                } else {
672                    results.push(result);
673                }
674            }
675
676            if !applied {
677                has_errors = true;
678                if !args.common.silent && !args.common.json {
679                    eprintln!("Failed to patch {base_purl}: no matching variant found");
680                }
681            }
682        } else {
683            // npm PURLs: direct lookup
684            let patch = match manifest.patches.get(purl) {
685                Some(p) => p,
686                None => continue,
687            };
688
689            let sources = PatchSources {
690                blobs_path: &blobs_path,
691                packages_path: Some(&packages_path),
692                diffs_path: Some(&diffs_path),
693            };
694            let result = apply_package_patch(
695                purl,
696                pkg_path,
697                &patch.files,
698                &sources,
699                Some(&patch.uuid),
700                args.common.dry_run,
701                args.force,
702            )
703            .await;
704
705            if !result.success {
706                has_errors = true;
707                if !args.common.silent && !args.common.json {
708                    eprintln!(
709                        "Failed to patch {}: {}",
710                        purl,
711                        result.error.as_deref().unwrap_or("unknown error")
712                    );
713                }
714            }
715            results.push(result);
716            matched_manifest_purls.insert(purl.clone());
717        }
718    }
719
720    // Check if targeted manifest entries had no matches
721    let unmatched: Vec<String> = target_manifest_purls
722        .iter()
723        .filter(|p| !matched_manifest_purls.contains(*p))
724        .cloned()
725        .collect();
726
727    if !unmatched.is_empty() && !args.common.silent && !args.common.json {
728        eprintln!("\nWarning: {} manifest patch(es) had no matching installed package:", unmatched.len());
729        for purl in &unmatched {
730            eprintln!("  - {}", purl);
731        }
732    }
733
734    if !target_manifest_purls.is_empty() && matched_manifest_purls.is_empty() && !all_packages.is_empty() {
735        if !args.common.silent && !args.common.json {
736            eprintln!("Warning: None of the targeted manifest patches matched installed packages.");
737        }
738        has_errors = true;
739    }
740
741    // Post-apply summary
742    if !args.common.silent && !args.common.json {
743        let applied_count = results.iter().filter(|r| r.success && !r.files_patched.is_empty()).count();
744        let already_count = results.iter().filter(|r| {
745            r.files_verified.iter().all(|f| f.status == VerifyStatus::AlreadyPatched)
746        }).count();
747        println!(
748            "\nSummary: {}/{} targeted patches applied, {} already patched, {} not found on disk",
749            applied_count,
750            target_manifest_purls.len(),
751            already_count,
752            unmatched.len()
753        );
754    }
755
756    // Note: `apply` deliberately does NOT garbage-collect unused blobs in
757    // `.socket/`. GC is the responsibility of `socket-patch repair` /
758    // `gc` / `scan --prune`. Keeping apply read-only against `.socket/`
759    // means it can run repeatedly (CI dry-runs, deploy hooks) without
760    // mutating patch state.
761
762    Ok((!has_errors, results, unmatched))
763}
764
765#[cfg(test)]
766mod tests {
767    //! Tests for `result_to_event` — the per-package → per-patch event
768    //! translator that feeds apply's unified JSON envelope. Every
769    //! contract value here (action tags, `errorCode` reasons, `files[].path`
770    //! shape) is documented in `CLI_CONTRACT.md`.
771    use super::*;
772    use socket_patch_core::patch::apply::{
773        AppliedVia as CoreAppliedVia, ApplyResult, VerifyResult, VerifyStatus,
774    };
775
776    /// Build a successful `ApplyResult` with one patched file and one
777    /// verified file. Used as the base for action-routing tests.
778    fn sample_applied(status: VerifyStatus) -> ApplyResult {
779        let mut applied_via = HashMap::new();
780        applied_via.insert("package/index.js".to_string(), CoreAppliedVia::Diff);
781        ApplyResult {
782            package_key: "pkg:npm/minimist@1.2.2".to_string(),
783            package_path: "/tmp/node_modules/minimist".to_string(),
784            success: true,
785            files_verified: vec![VerifyResult {
786                file: "package/index.js".to_string(),
787                status,
788                message: None,
789                current_hash: None,
790                expected_hash: None,
791                target_hash: None,
792            }],
793            files_patched: vec!["package/index.js".to_string()],
794            applied_via,
795            error: None,
796            sidecar: None,
797        }
798    }
799
800    #[test]
801    fn failed_result_maps_to_failed_action() {
802        let mut result = sample_applied(VerifyStatus::Ready);
803        result.success = false;
804        result.error = Some("hash mismatch".into());
805
806        let event = result_to_event(&result, false);
807        let v: serde_json::Value =
808            serde_json::from_str(&serde_json::to_string(&event).unwrap()).unwrap();
809        assert_eq!(v["action"], "failed");
810        assert_eq!(v["errorCode"], "apply_failed");
811        assert_eq!(v["error"], "hash mismatch");
812    }
813
814    #[test]
815    fn all_already_patched_maps_to_skipped() {
816        let result = sample_applied(VerifyStatus::AlreadyPatched);
817        let event = result_to_event(&result, false);
818        let v: serde_json::Value =
819            serde_json::from_str(&serde_json::to_string(&event).unwrap()).unwrap();
820        assert_eq!(v["action"], "skipped");
821        assert_eq!(v["errorCode"], "already_patched");
822    }
823
824    #[test]
825    fn dry_run_maps_to_verified() {
826        let result = sample_applied(VerifyStatus::Ready);
827        let event = result_to_event(&result, true);
828        let v: serde_json::Value =
829            serde_json::from_str(&serde_json::to_string(&event).unwrap()).unwrap();
830        assert_eq!(v["action"], "verified");
831        // Dry-run events list verified files but never an `appliedVia`
832        // — nothing was actually written.
833        assert_eq!(v["files"][0]["path"], "package/index.js");
834        assert!(v["files"][0].as_object().unwrap().get("appliedVia").is_none());
835    }
836
837    #[test]
838    fn successful_apply_maps_to_applied_with_files() {
839        let result = sample_applied(VerifyStatus::Ready);
840        let event = result_to_event(&result, false);
841        let v: serde_json::Value =
842            serde_json::from_str(&serde_json::to_string(&event).unwrap()).unwrap();
843        assert_eq!(v["action"], "applied");
844        assert_eq!(v["purl"], "pkg:npm/minimist@1.2.2");
845        let files = v["files"].as_array().unwrap();
846        assert_eq!(files.len(), 1);
847        assert_eq!(files[0]["path"], "package/index.js");
848        assert_eq!(files[0]["verified"], true);
849        // `appliedVia` is camelCase + lowercase tag — contract value.
850        assert_eq!(files[0]["appliedVia"], "diff");
851    }
852
853    #[test]
854    fn applied_event_emits_one_file_entry_per_patched_file() {
855        let mut applied_via = HashMap::new();
856        applied_via.insert("package/a.js".to_string(), CoreAppliedVia::Diff);
857        applied_via.insert("package/b.js".to_string(), CoreAppliedVia::Package);
858        applied_via.insert("package/c.js".to_string(), CoreAppliedVia::Blob);
859        let result = ApplyResult {
860            package_key: "pkg:npm/foo@1.0.0".to_string(),
861            package_path: "/tmp/foo".to_string(),
862            success: true,
863            files_verified: Vec::new(),
864            files_patched: vec![
865                "package/a.js".to_string(),
866                "package/b.js".to_string(),
867                "package/c.js".to_string(),
868            ],
869            applied_via,
870            error: None,
871            sidecar: None,
872        };
873
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        let files = v["files"].as_array().unwrap();
878        assert_eq!(files.len(), 3);
879        let by_path: std::collections::HashMap<String, &serde_json::Value> = files
880            .iter()
881            .map(|f| (f["path"].as_str().unwrap().to_string(), f))
882            .collect();
883        assert_eq!(by_path["package/a.js"]["appliedVia"], "diff");
884        assert_eq!(by_path["package/b.js"]["appliedVia"], "package");
885        assert_eq!(by_path["package/c.js"]["appliedVia"], "blob");
886    }
887}