Skip to main content

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}