velesdb_memory/migration/cli.rs
1//! The operator surface for a rebuild (#1815).
2//!
3//! # Why a command and not an MCP tool
4//!
5//! A migration is long, mutates a store, has to show progress and has to be
6//! interruptible and resumable. None of that survives being tied to an MCP
7//! session's lifetime, and #1727 records a client defect that makes a long or
8//! destructive tool call particularly unsuitable. So the first surface is a
9//! subcommand, the engine stays a library, and no mutating MCP tool is added.
10//!
11//! # What this module does and does not do
12//!
13//! Parsing, the dry-run entry point, rendering, and the operator-facing
14//! texts. `--dry-run` reads, decides a regime, and prints what it found and
15//! what would happen — it writes nothing, takes no lock, and may run while
16//! the daemon holds the store: [`diagnose`](crate::migration::diagnose) never
17//! opens the live source, it inspects a verified copy. A non-dry-run runs the
18//! whole migration ([`migrate`](crate::migration::migrate) — rebuild,
19//! validation, switch) and requires the operator to name the destination.
20
21use super::{diagnose, DiagnosisReport, Strategy, TargetContract};
22use std::path::{Path, PathBuf};
23
24/// What the operator asked the command to do.
25#[derive(Debug, Clone, PartialEq, Eq)]
26pub struct MigrateOptions {
27 /// The store to rebuild. `None` means "wherever the daemon would look".
28 pub store: Option<PathBuf>,
29 /// Where the rebuilt store would be written. Inspected, never created.
30 pub destination: Option<PathBuf>,
31 /// Where the diagnosis stages its verified copy. Needs room for the store.
32 pub scratch: Option<PathBuf>,
33 /// The regime, `auto` unless stated.
34 pub strategy: Strategy,
35 /// Whether this run is a dry run. The only supported value today is `true`.
36 pub dry_run: bool,
37}
38
39impl Default for MigrateOptions {
40 fn default() -> Self {
41 Self {
42 store: None,
43 destination: None,
44 scratch: None,
45 strategy: Strategy::Auto,
46 dry_run: false,
47 }
48 }
49}
50
51/// What an operator must be told after a COMPLETED migration.
52///
53/// This text replaced `NOT_YET_SWITCHABLE` when C3 wired validation and the
54/// switch. Two things in it are load-bearing. The daemon holds its store as
55/// an in-memory handle taken at startup and never refreshed, so a daemon that
56/// was running against the old store keeps serving the old data until it is
57/// RESTARTED — a message that failed to say so would let an operator migrate
58/// perfectly and then wonder where the new vectors went. And the journal is
59/// left in place deliberately: it is the evidence of what happened, and its
60/// removal is the operator's call, not this command's.
61const MIGRATION_COMPLETE: &str =
62 "the migration is complete: the rebuilt store now sits at the source's \
63 path, its provenance stamp names the target embedder, and the archived \
64 old store has been freed. RESTART the daemon — it holds its store as a \
65 handle taken at startup, and until it restarts it keeps serving the old \
66 data from memory. The .migration-journal directory beside the (now \
67 empty) destination path is the journal of what happened; it may be \
68 removed once you no longer want the evidence.";
69
70/// Parse `migrate-embeddings`' flags.
71///
72/// Hand-rolled for the same reason as `--version` and `compile-stdin` in the
73/// binary: this crate ships one binary and does not carry `clap` for it.
74///
75/// # Errors
76/// A message naming the offending flag when it is unknown, when a value is
77/// missing, or when `--strategy` is not one of the three arbitrated regimes.
78pub fn parse(args: &[String]) -> Result<MigrateOptions, String> {
79 let mut options = MigrateOptions::default();
80 let mut index = 0;
81 while index < args.len() {
82 let flag = args[index].as_str();
83 if flag == "--dry-run" {
84 options.dry_run = true;
85 index += 1;
86 continue;
87 }
88 let value = value_for(args, index, flag)?;
89 apply_valued_flag(&mut options, flag, value)?;
90 index += 2;
91 }
92 Ok(options)
93}
94
95/// The value following `flag`, or a message naming what is missing.
96fn value_for<'a>(args: &'a [String], index: usize, flag: &str) -> Result<&'a String, String> {
97 args.get(index + 1)
98 .ok_or_else(|| format!("{flag} requires a value"))
99}
100
101fn apply_valued_flag(
102 options: &mut MigrateOptions,
103 flag: &str,
104 value: &String,
105) -> Result<(), String> {
106 match flag {
107 "--store" => options.store = Some(PathBuf::from(value)),
108 "--destination" => options.destination = Some(PathBuf::from(value)),
109 "--scratch" => options.scratch = Some(PathBuf::from(value)),
110 "--strategy" => options.strategy = Strategy::parse(value)?,
111 other => return Err(format!("unknown migrate-embeddings flag {other:?}")),
112 }
113 Ok(())
114}
115
116/// Diagnose the store under `options` against `target`, and render the result.
117///
118/// Reads only. The source is never opened by
119/// [`velesdb_core::Database::open`] — a verified copy under the scratch parent
120/// is — so this runs against a store the daemon still holds.
121///
122/// # Errors
123/// [`crate::MemoryError`] when the store cannot be read, copied or walked, and
124/// a plain message when the invocation is not a dry run.
125pub fn dry_run(
126 store: &Path,
127 scratch_parent: &Path,
128 target: &TargetContract,
129 destination: Option<&Path>,
130) -> Result<DiagnosisReport, crate::MemoryError> {
131 diagnose(store, scratch_parent, target, destination)
132}
133
134/// Require the flag a non-dry-run cannot proceed without.
135///
136/// The rebuild writes somewhere, and "wherever seems sensible" is not an
137/// answer when the somewhere will later be renamed over the live store: the
138/// operator names the destination, or the run does not start.
139///
140/// # Errors
141/// A message naming the missing flag when `--destination` was not given.
142pub fn require_destination(options: &MigrateOptions) -> Result<PathBuf, String> {
143 options.destination.clone().ok_or_else(|| {
144 "a non-dry-run migrate-embeddings rebuilds into a destination you name: \
145 pass --destination <dir> (an empty or not-yet-existing directory on \
146 the store's filesystem), or --dry-run to only diagnose"
147 .to_owned()
148 })
149}
150
151/// Render a report for an operator: what is here, what would happen, and what
152/// still blocks it.
153///
154/// The regime comes FIRST and on its own line. Everything else is context for
155/// it, and burying the one decision in a table of counts is how an operator
156/// ends up acting on the wrong one.
157#[must_use]
158pub fn render(report: &DiagnosisReport) -> String {
159 let guidance = report
160 .resolution
161 .guidance()
162 .map_or_else(String::new, |next| format!("{next}\n\n"));
163 format!(
164 "{}\n\n{guidance}{}{}{}",
165 report.resolution.diagnostic(),
166 render_identity(report),
167 render_inventory(report),
168 render_blockers(report),
169 )
170}
171
172fn render_identity(report: &DiagnosisReport) -> String {
173 let provenance = match &report.source_provenance {
174 super::SourceProvenance::Known { model, dimension } => {
175 format!("{model} ({dimension} dimensions)")
176 }
177 super::SourceProvenance::Unknown { .. } => {
178 "unknown — not inferred from the width".to_owned()
179 }
180 };
181 let source_dimension = report
182 .source_dimension
183 .map_or_else(|| "no shared width".to_owned(), |d| d.to_string());
184 format!(
185 " store: {}\n \
186 source provenance: {provenance}\n \
187 source dimension: {source_dimension}\n \
188 target model: {} ({} dimensions)\n \
189 requested strategy: {:?}\n \
190 report format: v{}\n\n",
191 report.source_path.display(),
192 report.target_model,
193 report.target_dimension,
194 report.requested_strategy,
195 report.format_version,
196 )
197}
198
199fn render_inventory(report: &DiagnosisReport) -> String {
200 format!(
201 " facts: {}\n \
202 edges: {}\n \
203 working contexts: {}\n \
204 facts with a TTL: {}\n \
205 bytes on disk: {}\n\n",
206 report.facts,
207 report.edges,
208 report.working_contexts,
209 report.ttl_summary.with_expiry,
210 report.bytes_on_disk,
211 )
212}
213
214fn render_blockers(report: &DiagnosisReport) -> String {
215 if report.blockers.is_empty() {
216 return "no outstanding blockers.\n".to_owned();
217 }
218 let listed = report
219 .blockers
220 .iter()
221 .fold(String::new(), |mut acc, blocker| {
222 acc.push_str(" - ");
223 acc.push_str(blocker);
224 acc.push('\n');
225 acc
226 });
227 format!(
228 "{} blocker(s) before a rebuild:\n{listed}",
229 report.blockers.len()
230 )
231}
232
233/// Whether this diagnosis leaves the command with nothing it could run.
234///
235/// Separate from rendering so the exit status and the text cannot drift: a
236/// command that printed `REFUSE` and exited 0 would be read as success by every
237/// script that wraps it.
238#[must_use]
239pub fn refuses(report: &DiagnosisReport) -> bool {
240 !report.resolution.runs()
241}
242
243/// Re-exported so the binary can print the completion without duplicating it.
244#[must_use]
245pub fn migration_complete_notice() -> &'static str {
246 MIGRATION_COMPLETE
247}
248
249/// The default scratch parent: the directory the store itself sits in.
250///
251/// This used to be `std::env::temp_dir()`, and Codacy's finding on it
252/// ("`temp_dir` should not be used for security operations") was right for a
253/// reason it did not name. Secrecy is already handled — the copy lands in a
254/// directory created `0o700` with an owner token, one level down — but the
255/// wrong VOLUME is not: the diagnosis copies the whole store, temp
256/// filesystems are routinely small and sometimes RAM-backed, and the doc
257/// comment that used to sit here named that failure mode while the code
258/// shipped it as the default anyway. The store's parent is on the store's
259/// volume by construction, so room for a copy of the store is at least
260/// plausible there — and the staging check still measures it rather than
261/// assuming it.
262///
263/// No silent fallback: a store with no usable parent is an error naming
264/// `--scratch`, never a quiet switch to a different volume.
265///
266/// # Errors
267/// The store path has no non-empty parent to stage beside.
268pub fn default_scratch_parent(store: &Path) -> Result<PathBuf, String> {
269 match store.parent() {
270 Some(parent) if !parent.as_os_str().is_empty() => Ok(parent.to_path_buf()),
271 _ => Err(format!(
272 "cannot derive a scratch parent beside {}: pass --scratch <dir>. The diagnosis \
273 copies the whole store there, so a directory on the store's own volume is best",
274 store.display()
275 )),
276 }
277}