1use serde_json::{Value, json};
20
21use crate::config::{Registry, Settings};
22use crate::constants;
23use crate::engine::{PruneResult, PruneStatus, RepoStatusEntry, SkipReason};
24use crate::output::clean_path;
25
26pub const SCHEMA_VERSION: u32 = 1;
28
29fn status_tag(status: &PruneStatus) -> &'static str {
34 match status {
35 PruneStatus::Pruned => "pruned",
36 PruneStatus::SkippedActive => "skipped_active",
37 PruneStatus::SkippedDryRun => "skipped_dry_run",
38 PruneStatus::LockfileError(_) => "lockfile_error",
39 PruneStatus::ActivityCheckError(_) => "activity_check_error",
40 PruneStatus::PathMissing => "path_missing",
41 PruneStatus::NoBloat => "no_bloat",
42 PruneStatus::Disabled => "disabled",
43 PruneStatus::SkippedIgnored => "ignored",
44 PruneStatus::DeleteError(_) => "delete_error",
45 PruneStatus::ConfigError(_) => "config_error",
46 PruneStatus::SkippedSymlink(_) => "skipped_symlink",
47 }
48}
49
50fn status_message(status: &PruneStatus) -> Option<&str> {
52 match status {
53 PruneStatus::LockfileError(e)
54 | PruneStatus::ActivityCheckError(e)
55 | PruneStatus::DeleteError(e)
56 | PruneStatus::ConfigError(e)
57 | PruneStatus::SkippedSymlink(e) => Some(e.trim()),
58 _ => None,
59 }
60}
61
62pub fn lockfile_fix_command(adapter: &str) -> Option<&'static str> {
73 Some(match adapter {
74 "npm" => "npm install --package-lock-only --ignore-scripts",
75 "pnpm" => "pnpm install --lockfile-only",
76 "yarn" => "yarn install --mode update-lockfile",
77 "bun" => "bun install",
80 "uv" => "uv lock",
81 "poetry" => "poetry lock",
82 "pdm" => "pdm lock",
83 "pipenv" => "pipenv lock",
84 "cargo" => "cargo generate-lockfile",
85 "go" => "go mod tidy",
86 "composer" => "composer update --no-install",
87 "bundler" => "bundle lock",
88 "cocoapods" => "pod install",
89 "mix" | "mix_build" => "mix deps.get",
91 "terraform" => "terraform providers lock",
94 "dart" => "dart pub get",
97 _ => return None,
103 })
104}
105
106fn result_value(result: &PruneResult) -> Value {
107 let mut obj = json!({
108 "repository": clean_path(&result.repo_path),
109 "adapter": result.adapter_name,
110 "directory": result.bloat_dir,
111 "status": status_tag(&result.status),
112 "bytes": result.size_freed,
113 "shared_bytes": result.shared_bytes,
114 });
115
116 if let Some(message) = status_message(&result.status) {
117 obj["message"] = json!(message);
118 }
119 if matches!(result.status, PruneStatus::LockfileError(_))
120 && let Some(fix) = lockfile_fix_command(&result.adapter_name)
121 {
122 obj["fix_command"] = json!(fix);
123 }
124 obj
125}
126
127pub fn run_document(results: &[PruneResult], dry_run: bool) -> Value {
132 let bytes_freed: u64 = results
133 .iter()
134 .filter(|r| matches!(r.status, PruneStatus::Pruned))
135 .map(|r| r.size_freed)
136 .sum();
137 let directories_pruned = results
138 .iter()
139 .filter(|r| matches!(r.status, PruneStatus::Pruned))
140 .count();
141 let bytes_reclaimable: u64 = results
142 .iter()
143 .filter(|r| matches!(r.status, PruneStatus::SkippedDryRun))
144 .map(|r| r.size_freed)
145 .sum();
146 let errors = results
147 .iter()
148 .filter(|r| {
149 matches!(
150 r.status,
151 PruneStatus::LockfileError(_)
152 | PruneStatus::ActivityCheckError(_)
153 | PruneStatus::DeleteError(_)
154 | PruneStatus::ConfigError(_)
155 )
156 })
157 .count();
158
159 json!({
160 "schema": SCHEMA_VERSION,
161 "version": constants::VERSION,
162 "command": "run",
163 "dry_run": dry_run,
164 "results": results.iter().map(result_value).collect::<Vec<_>>(),
165 "summary": {
166 "bytes_freed": bytes_freed,
167 "bytes_reclaimable": bytes_reclaimable,
168 "directories_pruned": directories_pruned,
169 "errors": errors,
170 },
171 })
172}
173
174fn reason_tag(reason: &SkipReason) -> &'static str {
176 match reason {
177 SkipReason::Candidate => "candidate",
178 SkipReason::Active => "active",
179 SkipReason::Ignored => "ignored",
180 SkipReason::NoBloat => "no_bloat",
181 SkipReason::PathMissing => "path_missing",
182 SkipReason::ConfigError(_) => "config_error",
183 }
184}
185
186fn settings_value(settings: &Settings) -> Value {
187 json!({
188 "idle_days": settings.idle_days,
189 "check_interval_days": settings.check_interval_days,
190 "auto_setup": settings.auto_setup,
191 "auto_hooks": settings.auto_hooks,
192 "auto_daemon": settings.auto_daemon,
193 "require_confirmation": settings.require_confirmation,
194 "command_timeout_secs": settings.command_timeout_secs,
195 "min_size_mb": settings.min_size_mb,
196 "update_check": settings.update_check,
197 })
198}
199
200fn repo_value(registry: &Registry, entry: &RepoStatusEntry) -> Value {
201 let mut obj = json!({
202 "path": clean_path(&entry.path),
203 "state": reason_tag(&entry.reason),
204 "enabled": entry.entry.enabled,
205 "idle_days": entry.idle_days,
206 "last_activity": entry.last_activity.map(|t| t.to_rfc3339()),
207 "last_pruned_at": entry.entry.last_pruned_at.map(|t| t.to_rfc3339()),
208 "bytes_freed": entry.entry.total_freed_bytes,
209 "added_at": entry.entry.added_at.to_rfc3339(),
210 "adapters": entry.adapters,
211 "reclaimable_bytes": entry.reclaimable_bytes,
212 "directories": entry.bloat_dirs.iter().map(|b| json!({
213 "name": b.name,
214 "path": clean_path(&b.path),
215 "bytes": b.size_bytes,
216 "shared_bytes": b.shared_bytes,
217 })).collect::<Vec<_>>(),
218 "restore_estimate_secs": registry
221 .estimate_restore(&entry.reclaimable_by_adapter)
222 .map(|(secs, _)| secs.round() as u64),
223 });
224
225 if let SkipReason::ConfigError(e) = &entry.reason {
230 obj["error"] = json!(e);
231 }
232 obj
233}
234
235pub fn status_document(
245 registry: &Registry,
246 repos: &[RepoStatusEntry],
247 daemon: &str,
248 hooks: &str,
249 top: Option<usize>,
250) -> Value {
251 let reclaimable: u64 = repos.iter().map(|r| r.reclaimable_bytes).sum();
252 let candidates = repos
253 .iter()
254 .filter(|r| matches!(r.reason, SkipReason::Candidate))
255 .count();
256 let listed = crate::engine::take_top(repos, top);
257
258 let mut by_adapter: std::collections::BTreeMap<String, u64> = std::collections::BTreeMap::new();
260 for repo in repos {
261 for (adapter, bytes) in &repo.reclaimable_by_adapter {
262 *by_adapter.entry(adapter.clone()).or_default() += bytes;
263 }
264 }
265 let estimate = registry.estimate_restore(&by_adapter.into_iter().collect::<Vec<_>>());
266
267 let mut doc = json!({
268 "schema": SCHEMA_VERSION,
269 "version": constants::VERSION,
270 "command": "status",
271 "config_path": Registry::registry_path().map(|p| clean_path(&p)).ok(),
272 "integrations": { "daemon": daemon, "git_hooks": hooks },
273 "settings": settings_value(®istry.settings),
274 "totals": {
275 "repositories": registry.repo_count(),
276 "candidates": candidates,
277 "reclaimable_bytes": reclaimable,
278 "historical_bytes_freed": registry.total_freed_bytes,
279 "prune_passes": registry.total_pruned_count,
280 "restore_estimate": estimate.map(|(secs, covered)| json!({
285 "seconds": secs.round() as u64,
286 "covered_bytes": covered,
287 "samples": registry.restore_rates.values().map(|r| r.samples as u64).sum::<u64>(),
288 })),
289 },
290 "repositories": listed.iter().map(|r| repo_value(registry, r)).collect::<Vec<_>>(),
291 });
292
293 if let Some(n) = top {
296 doc["top"] = json!(n);
297 }
298 doc
299}
300
301pub fn stats_document(registry: &Registry) -> Value {
309 let mut repos: Vec<(&std::path::PathBuf, &crate::config::RepoEntry)> =
310 registry.repositories.iter().collect();
311 repos.sort_by(|a, b| {
312 b.1.total_freed_bytes
313 .cmp(&a.1.total_freed_bytes)
314 .then_with(|| a.0.cmp(b.0))
315 });
316
317 json!({
318 "schema": SCHEMA_VERSION,
319 "version": constants::VERSION,
320 "command": "stats",
321 "history_starts_at": constants::HISTORY_STARTS_AT,
322 "lifetime": {
323 "bytes_freed": registry.total_freed_bytes,
324 "prune_passes": registry.total_pruned_count,
327 "repositories": registry.repo_count(),
328 },
329 "last_prune": registry.last_prune.as_ref().map(|p| json!({
330 "at": p.at.to_rfc3339(),
331 "bytes_freed": p.dirs.iter().map(|d| d.size_freed).sum::<u64>(),
332 "directories": p.dirs.len(),
333 })),
334 "recent_passes": registry.prune_history.iter().rev().map(|p| json!({
335 "at": p.at.to_rfc3339(),
336 "bytes_freed": p.bytes_freed,
337 "directories": p.dirs_removed,
338 "repositories": p.repos_touched,
339 })).collect::<Vec<_>>(),
340 "repositories": repos.iter().map(|(path, entry)| json!({
341 "path": clean_path(path),
342 "bytes_freed": entry.total_freed_bytes,
343 "last_pruned_at": entry.last_pruned_at.map(|t| t.to_rfc3339()),
344 })).collect::<Vec<_>>(),
345 })
346}
347
348pub fn caches_document(
359 reports: &[crate::commands::caches::CacheReport],
360 registered_repositories: Option<usize>,
361) -> Value {
362 let total: u64 = reports.iter().map(|r| r.bytes).sum();
363
364 let caches: Vec<Value> = reports
365 .iter()
366 .map(|r| {
367 let mut obj = json!({
368 "manager": r.manager,
369 "kind": r.kind,
370 "path": clean_path(&r.path),
371 "bytes": r.bytes,
372 "clear_command": &r.clear_command,
373 });
374 if let Some(note) = r.note {
375 obj["note"] = json!(note);
376 }
377 if let Some(gb) = r.cap_gb {
380 obj["cap_gb"] = json!(gb);
381 obj["over_cap"] = json!(r.over_cap);
382 }
383 if let Some(n) = r.dependents {
387 obj["dependents"] = json!(n);
388 }
389 obj
390 })
391 .collect();
392
393 let mut summary = json!({
394 "total_bytes": total,
395 "count": reports.len(),
396 });
397 if let Some(n) = registered_repositories {
398 summary["registered_repositories"] = json!(n);
399 }
400
401 json!({
402 "schema": SCHEMA_VERSION,
403 "version": constants::VERSION,
404 "command": "caches",
405 "caches": caches,
406 "summary": summary,
407 })
408}
409fn kept_caches(kept: &[crate::commands::caches::CacheReport]) -> Vec<Value> {
414 use crate::commands::caches::Clear;
415 kept.iter()
416 .filter_map(|r| {
417 let Clear::Manual { why } = r.clear else {
418 return None;
419 };
420 Some(json!({
421 "manager": r.manager,
422 "kind": r.kind,
423 "path": clean_path(&r.path),
424 "bytes": r.bytes,
425 "clear_command": &r.clear_command,
426 "reason": why,
427 }))
428 })
429 .collect()
430}
431
432pub fn caches_clear_plan_document(
434 reports: &[crate::commands::caches::CacheReport],
435 kept: &[crate::commands::caches::CacheReport],
436) -> Value {
437 let total: u64 = reports.iter().map(|r| r.bytes).sum();
438
439 let caches: Vec<Value> = reports
440 .iter()
441 .map(|r| {
442 let mut obj = json!({
443 "manager": r.manager,
444 "kind": r.kind,
445 "path": clean_path(&r.path),
446 "bytes": r.bytes,
447 "clear_command": &r.clear_command,
448 });
449 if let Some(n) = r.dependents {
450 obj["dependents"] = json!(n);
451 }
452 obj
453 })
454 .collect();
455
456 json!({
457 "schema": SCHEMA_VERSION,
458 "version": constants::VERSION,
459 "command": "caches clear",
460 "dry_run": true,
461 "caches": caches,
462 "kept": kept_caches(kept),
463 "summary": {
464 "total_bytes": total,
465 "count": reports.len(),
466 },
467 })
468}
469
470pub fn caches_clear_document(
475 outcomes: &[crate::commands::caches::ClearOutcome],
476 kept: &[crate::commands::caches::CacheReport],
477) -> Value {
478 let freed: u64 = outcomes.iter().map(|o| o.freed()).sum();
479 let failed = outcomes.iter().filter(|o| o.problem.is_some()).count();
480
481 let caches: Vec<Value> = outcomes
482 .iter()
483 .map(|o| {
484 let mut obj = json!({
485 "manager": o.manager,
486 "kind": o.kind,
487 "path": clean_path(&o.path),
488 "bytes_before": o.before,
489 "bytes_after": o.after,
490 "freed_bytes": o.freed(),
491 "cleared": o.problem.is_none(),
492 });
493 if let Some(problem) = &o.problem {
494 obj["error"] = json!(problem);
495 }
496 obj
497 })
498 .collect();
499
500 json!({
501 "schema": SCHEMA_VERSION,
502 "version": constants::VERSION,
503 "command": "caches clear",
504 "dry_run": false,
505 "caches": caches,
506 "kept": kept_caches(kept),
507 "summary": {
508 "freed_bytes": freed,
509 "count": outcomes.len(),
510 "failed": failed,
511 },
512 })
513}
514pub fn trust_document(report: &crate::commands::trust::TrustReport) -> Value {
520 let rows = |rows: &[crate::commands::trust::TrustRow]| -> Vec<Value> {
521 rows.iter()
522 .map(|r| {
523 json!({
524 "key": r.key,
525 "subject": r.subject,
526 "state": r.state,
527 "verdict": r.verdict_key(),
528 })
529 })
530 .collect()
531 };
532
533 let widened = report.widened();
534
535 json!({
536 "schema": SCHEMA_VERSION,
537 "version": constants::VERSION,
538 "command": "trust",
539 "guarantees": rows(&report.guarantees),
540 "machine": rows(&report.machine),
541 "summary": {
542 "widened": widened,
543 "widened_count": widened.len(),
544 },
545 })
546}
547
548pub fn drift_document(findings: &[crate::commands::status::ProjectDrift]) -> Value {
555 let unrecorded_total: usize = findings.iter().map(|f| f.report.unrecorded.len()).sum();
556
557 json!({
558 "schema": SCHEMA_VERSION,
559 "version": constants::VERSION,
560 "command": "status --drift",
561 "drift": findings.iter().map(|f| json!({
562 "repository": clean_path(&f.repository),
563 "project": f.project,
564 "adapter": f.adapter,
565 "directory": f.report.directory,
566 "unrecorded": f.report.unrecorded,
567 "record_command": f.report.record_command,
568 })).collect::<Vec<_>>(),
569 "summary": {
570 "projects_with_drift": findings.len(),
571 "unrecorded_packages": unrecorded_total,
572 },
573 })
574}
575
576pub fn emit(document: &Value) -> anyhow::Result<()> {
587 use std::io::IsTerminal;
588 let text = serde_json::to_string_pretty(document)?;
589 println!("{text}");
590 if std::io::stdout().is_terminal() && copy_to_clipboard(&text) {
591 use colored::Colorize;
592 eprintln!("{}", "(also copied to your clipboard)".dimmed());
593 }
594 Ok(())
595}
596
597fn copy_to_clipboard(text: &str) -> bool {
604 let bytes: Vec<u8> = if cfg!(windows) {
608 let mut utf16 = vec![0xFF, 0xFE];
609 for unit in text.encode_utf16() {
610 utf16.extend_from_slice(&unit.to_le_bytes());
611 }
612 utf16
613 } else {
614 text.as_bytes().to_vec()
615 };
616
617 let windows_clip = std::env::var("SystemRoot")
623 .map(|root| format!("{root}\\System32\\clip.exe"))
624 .unwrap_or_else(|_| String::from("C:\\Windows\\System32\\clip.exe"));
625 let tools: Vec<Vec<&str>> = if cfg!(windows) {
626 vec![vec![windows_clip.as_str()]]
627 } else if cfg!(target_os = "macos") {
628 vec![vec!["pbcopy"]]
629 } else {
630 vec![
631 vec!["wl-copy"],
632 vec!["xclip", "-selection", "clipboard"],
633 vec!["xsel", "--clipboard", "--input"],
634 ]
635 };
636 tools.iter().any(|tool| pipe_into(tool, &bytes))
637}
638
639fn pipe_into(command: &[&str], bytes: &[u8]) -> bool {
641 use std::io::Write;
642 use std::process::Stdio;
643 let Ok(mut child) = crate::spawn::command(command[0])
644 .args(&command[1..])
645 .stdin(Stdio::piped())
646 .stdout(Stdio::null())
647 .stderr(Stdio::null())
648 .spawn()
649 else {
650 return false;
651 };
652 let wrote = child
653 .stdin
654 .take()
655 .is_some_and(|mut stdin| stdin.write_all(bytes).is_ok());
656 let exited_cleanly = child.wait().map(|status| status.success()).unwrap_or(false);
657 wrote && exited_cleanly
658}
659
660#[cfg(test)]
661mod tests {
662 use super::*;
663 use std::path::PathBuf;
664
665 fn result(status: PruneStatus, bytes: u64) -> PruneResult {
666 PruneResult {
667 repo_path: PathBuf::from("/tmp/repo"),
668 adapter_name: "pnpm".to_string(),
669 bloat_dir: "node_modules".to_string(),
670 size_freed: bytes,
671 shared_bytes: 0,
672 runtime: None,
673 status,
674 }
675 }
676
677 #[test]
678 fn every_status_has_a_distinct_stable_tag() {
679 let all = [
680 PruneStatus::Pruned,
681 PruneStatus::SkippedActive,
682 PruneStatus::SkippedDryRun,
683 PruneStatus::LockfileError("x".into()),
684 PruneStatus::ActivityCheckError("x".into()),
685 PruneStatus::PathMissing,
686 PruneStatus::NoBloat,
687 PruneStatus::Disabled,
688 PruneStatus::SkippedIgnored,
689 PruneStatus::DeleteError("x".into()),
690 PruneStatus::ConfigError("x".into()),
691 PruneStatus::SkippedSymlink("x".into()),
692 ];
693 let mut tags: Vec<&str> = all.iter().map(status_tag).collect();
694 let count = tags.len();
695 tags.sort_unstable();
696 tags.dedup();
697 assert_eq!(tags.len(), count, "two statuses share a JSON tag");
698 }
699
700 #[test]
701 fn every_repository_state_has_a_distinct_stable_tag() {
702 let all = [
703 SkipReason::Candidate,
704 SkipReason::Active,
705 SkipReason::Ignored,
706 SkipReason::NoBloat,
707 SkipReason::PathMissing,
708 SkipReason::ConfigError("x".into()),
709 ];
710 let mut tags: Vec<&str> = all.iter().map(reason_tag).collect();
711 let count = tags.len();
712 tags.sort_unstable();
713 tags.dedup();
714 assert_eq!(tags.len(), count, "two repository states share a JSON tag");
715 }
716
717 #[test]
718 fn only_an_unreadable_config_carries_an_error_field() {
719 let entry = |reason| RepoStatusEntry {
720 path: PathBuf::from("/tmp/repo"),
721 entry: crate::config::RepoEntry::new(),
722 reason,
723 adapters: Vec::new(),
724 bloat_dirs: Vec::new(),
725 reclaimable_bytes: 0,
726 reclaimable_by_adapter: Vec::new(),
727 last_activity: None,
728 idle_days: 15,
729 };
730
731 let registry = Registry::default();
732 let broken = repo_value(
733 ®istry,
734 &entry(SkipReason::ConfigError("bad json".into())),
735 );
736 assert_eq!(broken["state"], "config_error");
737 assert_eq!(broken["error"], "bad json");
738
739 let healthy = repo_value(®istry, &entry(SkipReason::Candidate));
741 assert!(healthy.get("error").is_none());
742 }
743
744 #[test]
745 fn run_summary_counts_only_real_deletions() {
746 let doc = run_document(
747 &[
748 result(PruneStatus::Pruned, 100),
749 result(PruneStatus::Pruned, 50),
750 result(PruneStatus::SkippedActive, 0),
751 result(PruneStatus::LockfileError("nope".into()), 0),
752 ],
753 false,
754 );
755 assert_eq!(doc["summary"]["bytes_freed"], 150);
756 assert_eq!(doc["summary"]["directories_pruned"], 2);
757 assert_eq!(doc["summary"]["errors"], 1);
758 }
759
760 #[test]
761 fn dry_run_bytes_land_in_reclaimable_not_freed() {
762 let doc = run_document(&[result(PruneStatus::SkippedDryRun, 4096)], true);
765 assert_eq!(doc["summary"]["bytes_freed"], 0);
766 assert_eq!(doc["summary"]["bytes_reclaimable"], 4096);
767 assert_eq!(doc["dry_run"], true);
768 }
769
770 #[test]
771 fn lockfile_errors_carry_the_fix_command() {
772 let doc = run_document(
773 &[result(PruneStatus::LockfileError("boom".into()), 0)],
774 false,
775 );
776 assert_eq!(doc["results"][0]["message"], "boom");
777 assert_eq!(
778 doc["results"][0]["fix_command"],
779 "pnpm install --lockfile-only"
780 );
781 }
782
783 #[test]
784 fn a_successful_result_carries_no_message_or_fix() {
785 let doc = run_document(&[result(PruneStatus::Pruned, 1)], false);
786 assert!(doc["results"][0].get("message").is_none());
787 assert!(doc["results"][0].get("fix_command").is_none());
788 }
789
790 #[test]
791 fn venv_has_no_mechanical_lockfile_fix() {
792 assert!(lockfile_fix_command("venv").is_none());
795 assert!(lockfile_fix_command("nonsense").is_none());
796 }
797
798 #[test]
799 fn the_cache_report_totals_what_it_lists() {
800 use crate::commands::caches::{CacheReport, Clear};
801
802 let doc = caches_document(
803 &[
804 CacheReport {
805 manager: "go",
806 kind: "module cache",
807 path: PathBuf::from("/home/dev/go/pkg/mod"),
808 bytes: 4_000,
809 clear_command: "go clean -modcache".to_string(),
810 clear: Clear::Command("go", &["clean", "-modcache"]),
811 note: None,
812 cap_gb: None,
813 over_cap: false,
814 dependents: None,
815 extra_args: Vec::new(),
816 },
817 CacheReport {
818 manager: "pnpm",
819 kind: "store",
820 path: PathBuf::from("/home/dev/.pnpm-store"),
821 bytes: 1_000,
822 clear_command: "pnpm store prune".to_string(),
823 clear: Clear::Command("pnpm", &["store", "prune"]),
824 note: Some("hardlinked"),
825 cap_gb: None,
826 over_cap: false,
827 dependents: None,
828 extra_args: Vec::new(),
829 },
830 ],
831 Some(3),
832 );
833
834 assert_eq!(doc["command"], "caches");
835 assert_eq!(doc["summary"]["total_bytes"], 5_000);
836 assert_eq!(doc["summary"]["count"], 2);
837 assert!(doc["caches"][0].get("note").is_none());
840 assert_eq!(doc["caches"][1]["note"], "hardlinked");
841 assert_eq!(doc["caches"][0]["clear_command"], "go clean -modcache");
842 }
843
844 #[test]
845 fn an_empty_cache_report_is_still_a_document() {
846 let doc = caches_document(&[], None);
849 assert_eq!(doc["summary"]["total_bytes"], 0);
850 assert_eq!(doc["caches"].as_array().unwrap().len(), 0);
851 }
852
853 #[test]
854 fn every_adapter_with_a_lockfile_has_a_fix_command() {
855 for adapter in crate::adapters::get_all_adapters() {
856 if matches!(
860 adapter.name(),
861 "venv" | "gradle" | "maven" | "swift" | "vcpkg" | "cmake_build"
862 ) {
863 continue;
864 }
865 assert!(
866 lockfile_fix_command(adapter.name()).is_some(),
867 "{} has no fix command",
868 adapter.name()
869 );
870 }
871 }
872}