use super::diagnostic_copy::DiagnosticCopy;
use super::strategy::{assess, resolve, Resolution, Strategy};
use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};
mod capabilities;
mod inventory;
mod report;
pub(super) fn switch_filesystem_capability(same_filesystem: Option<bool>) -> Capability {
capabilities::switch_filesystem_capability(same_filesystem)
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(tag = "verdict", rename_all = "snake_case")]
pub enum Capability {
Proven {
evidence: String,
},
Missing {
blocker: String,
},
}
impl Capability {
#[must_use]
pub fn is_proven(&self) -> bool {
matches!(self, Self::Proven { .. })
}
}
pub const DIAGNOSIS_FORMAT_VERSION: u32 = 6;
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum SourceProvenance {
Known {
model: String,
dimension: usize,
},
Unknown {
reason: String,
},
}
#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct TtlSummary {
pub with_expiry: u64,
pub earliest: Option<u64>,
pub latest: Option<u64>,
}
impl TtlSummary {
fn observe(&mut self, expires_at: u64) {
self.with_expiry += 1;
self.earliest = Some(self.earliest.map_or(expires_at, |e| e.min(expires_at)));
self.latest = Some(self.latest.map_or(expires_at, |e| e.max(expires_at)));
}
fn merge(&mut self, other: &Self) {
self.with_expiry += other.with_expiry;
if let Some(e) = other.earliest {
self.earliest = Some(self.earliest.map_or(e, |cur| cur.min(e)));
}
if let Some(l) = other.latest {
self.latest = Some(self.latest.map_or(l, |cur| cur.max(l)));
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct CollectionInventory {
pub name: String,
pub present: bool,
pub dimension: Option<usize>,
pub facts: u64,
pub edges: Option<u64>,
pub working_contexts: u64,
pub reserved_metadata: BTreeSet<String>,
pub ttl: TtlSummary,
}
impl CollectionInventory {
fn absent(name: &str) -> Self {
Self {
name: name.to_owned(),
present: false,
dimension: None,
facts: 0,
edges: None,
working_contexts: 0,
reserved_metadata: BTreeSet::new(),
ttl: TtlSummary::default(),
}
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.facts == 0
}
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
pub struct DiagnosisReport {
pub format_version: u32,
pub source_path: PathBuf,
pub source_fingerprint: String,
pub source_dimension: Option<usize>,
pub source_provenance: SourceProvenance,
pub target_model: String,
pub target_dimension: usize,
pub requested_strategy: Strategy,
pub resolution: Resolution,
pub collections: Vec<CollectionInventory>,
pub facts: u64,
pub edges: u64,
pub working_contexts: u64,
pub reserved_metadata: BTreeSet<String>,
pub ttl_summary: TtlSummary,
pub bytes_on_disk: u64,
pub diagnostic_staging_required: u64,
pub diagnostic_staging_available: u64,
pub disk_headroom: Option<u64>,
pub same_filesystem: Option<bool>,
pub capabilities: BTreeMap<String, Capability>,
pub blockers: Vec<String>,
}
#[must_use]
pub fn same_filesystem(a: &Path, b: &Path) -> Option<bool> {
#[cfg(unix)]
{
use std::os::unix::fs::MetadataExt;
let (a, b) = (existing_ancestor(a)?, existing_ancestor(b)?);
Some(a.dev() == b.dev())
}
#[cfg(not(unix))]
{
let _ = (a, b);
None
}
}
#[cfg(unix)]
fn existing_ancestor(path: &Path) -> Option<std::fs::Metadata> {
path.ancestors().find_map(|p| std::fs::metadata(p).ok())
}
pub fn diagnose(
source: &Path,
scratch_parent: &Path,
target: &TargetContract,
destination: Option<&Path>,
) -> Result<DiagnosisReport, crate::MemoryError> {
let source = canonical_source(source)?;
let copy = DiagnosticCopy::capture(&source, scratch_parent)?;
let result = diagnose_copy(&source, target, destination, ©);
copy.finish(result)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TargetContract {
pub model: String,
pub dimension: usize,
pub strategy: Strategy,
}
impl TargetContract {
#[must_use]
pub fn automatic(model: impl Into<String>, dimension: usize) -> Self {
Self {
model: model.into(),
dimension,
strategy: Strategy::Auto,
}
}
}
fn canonical_source(source: &Path) -> Result<PathBuf, crate::MemoryError> {
let metadata = std::fs::symlink_metadata(source).map_err(|err| {
velesdb_core::Error::Query(format!(
"cannot inspect migration source {}: {err}",
source.display()
))
})?;
if metadata.file_type().is_symlink() {
return Err(velesdb_core::Error::Query(format!(
"migration source {} is a symlink; diagnose the canonical store directory directly",
source.display()
))
.into());
}
let canonical = std::fs::canonicalize(source).map_err(|err| {
velesdb_core::Error::Query(format!(
"cannot canonicalize migration source {}: {err}",
source.display()
))
})?;
if !canonical.is_absolute() {
return Err(velesdb_core::Error::Query(format!(
"canonical migration source is not absolute: {}",
canonical.display()
))
.into());
}
Ok(canonical)
}
pub(super) fn diagnose_copy(
source: &Path,
target: &TargetContract,
destination: Option<&Path>,
copy: &DiagnosticCopy,
) -> Result<DiagnosisReport, crate::MemoryError> {
let inventory = inventory::inspect(copy.store_path())?;
copy.verify_source_unchanged(source)?;
let same_filesystem = destination.and_then(|dest| same_filesystem(source, dest));
Ok(report_from_inventory(
source,
target,
same_filesystem,
copy,
inventory,
))
}
fn resolved_strategy(
target: &TargetContract,
inventory: &inventory::StoreInventory,
) -> crate::migration::strategy::Resolution {
resolve(
target.strategy,
assess(
&inventory.source_provenance,
inventory.source_dimension,
&target.model,
target.dimension,
),
)
}
fn report_from_inventory(
source: &Path,
target: &TargetContract,
same_filesystem: Option<bool>,
copy: &DiagnosticCopy,
inventory: inventory::StoreInventory,
) -> DiagnosisReport {
let resolution = resolved_strategy(target, &inventory);
let capabilities = capabilities::capability_map(
&inventory.source_provenance,
inventory.source_dimension,
&target.model,
target.dimension,
inventory.edge_counts,
same_filesystem,
copy,
);
let blockers = capabilities::blockers_for(&capabilities, &inventory.collections);
DiagnosisReport {
format_version: DIAGNOSIS_FORMAT_VERSION,
source_path: source.to_path_buf(),
source_fingerprint: copy.source_fingerprint().to_owned(),
source_dimension: inventory.source_dimension,
source_provenance: inventory.source_provenance,
target_model: target.model.clone(),
target_dimension: target.dimension,
requested_strategy: target.strategy,
resolution,
collections: inventory.collections,
facts: inventory.facts,
edges: inventory.edges,
working_contexts: inventory.working_contexts,
reserved_metadata: inventory.reserved_metadata,
ttl_summary: inventory.ttl,
bytes_on_disk: copy.source_bytes(),
diagnostic_staging_required: copy.staging_required(),
diagnostic_staging_available: copy.staging_available(),
disk_headroom: None,
same_filesystem,
capabilities,
blockers,
}
}