use std::path::{Path, PathBuf};
use std::time::Duration;
use serde::{Deserialize, Serialize};
use crate::identity_first::{DowngradeFidelity, HeadCanonicalDowngrade, LocalContinuityStore};
use crate::storage_layout::MobKitStorageLayout;
use crate::storage_migrate::{MigrateMode, MobKitMaintenanceFence};
const FENCE_DRAIN_DEADLINE: Duration = Duration::from_secs(10);
#[non_exhaustive]
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct MobKitDowngradeReport {
#[serde(default)]
pub mode: MigrateMode,
#[serde(default)]
pub state_dir: PathBuf,
#[serde(default)]
pub continuity_db: Option<PathBuf>,
#[serde(default)]
pub fenced_databases: Vec<PathBuf>,
#[serde(default)]
pub downgrade: Option<HeadCanonicalDowngrade>,
#[serde(default)]
pub errors: Vec<String>,
}
impl MobKitDowngradeReport {
fn new(mode: MigrateMode, state_dir: &Path) -> Self {
Self {
mode,
state_dir: state_dir.to_path_buf(),
..Self::default()
}
}
#[must_use]
pub fn has_errors(&self) -> bool {
!self.errors.is_empty()
}
#[must_use]
pub fn lossy_session_count(&self) -> usize {
self.downgrade
.as_ref()
.map(|downgrade| downgrade.lossy_sessions().len())
.unwrap_or(0)
}
#[must_use]
pub fn lockout_lifted(&self) -> bool {
self.downgrade
.as_ref()
.is_some_and(HeadCanonicalDowngrade::lockout_lifted)
}
}
#[must_use]
pub fn downgrade_state_dir(state_dir: &Path, mode: MigrateMode) -> MobKitDowngradeReport {
let mut report = MobKitDowngradeReport::new(mode, state_dir);
if !state_dir.is_dir() {
report.errors.push(format!(
"state directory {} does not exist",
state_dir.display()
));
return report;
}
let apply = mode == MigrateMode::Apply;
let layout = MobKitStorageLayout::with_injected_roots(state_dir.to_path_buf(), None);
let resolved = match layout.continuity_db() {
Ok(resolved) => resolved,
Err(error) => {
report
.errors
.push(format!("continuity locator unresolved: {error}"));
return report;
}
};
if !resolved.path.is_file() {
report.errors.push(format!(
"no continuity database at {} — nothing to downgrade",
resolved.path.display()
));
return report;
}
report.continuity_db = Some(resolved.path.clone());
let fence = if apply {
match MobKitMaintenanceFence::acquire(state_dir, FENCE_DRAIN_DEADLINE) {
Ok(fence) => {
report.fenced_databases = fence.fenced_databases().to_vec();
Some(fence)
}
Err(error) => {
report
.errors
.push(format!("maintenance fence not acquirable: {error}"));
return report;
}
}
} else {
None
};
match LocalContinuityStore::downgrade_head_canonical_at(&resolved.path, apply) {
Ok(downgrade) => report.downgrade = Some(downgrade),
Err(error) => report.errors.push(format!(
"continuity head-canonical downgrade failed: {error}"
)),
}
drop(fence);
report
}
pub fn render_downgrade_report(report: &MobKitDowngradeReport) -> String {
use std::fmt::Write as _;
let mut out = String::new();
let mode = match report.mode {
MigrateMode::Apply => "apply",
_ => "dry-run",
};
let _ = writeln!(
out,
"Continuity head-canonical downgrade ({mode}) over {}:",
report.state_dir.display()
);
if let Some(path) = &report.continuity_db {
let _ = writeln!(out, " continuity database: {}", path.display());
}
match &report.downgrade {
Some(downgrade) => {
let _ = writeln!(
out,
" ledger mobkit-continuity: {:?} -> {:?}",
downgrade.ledger_before, downgrade.ledger_after
);
let _ = writeln!(
out,
" head-canonical channel dropped: {}",
downgrade.channel_dropped
);
let _ = writeln!(
out,
" sessions re-materialized into whole-document snapshots: {}",
downgrade.sessions.len()
);
for session in &downgrade.sessions {
let fidelity = match &session.fidelity {
DowngradeFidelity::Full => "history preserved".to_string(),
DowngradeFidelity::NoHistory => "no history to preserve".to_string(),
DowngradeFidelity::HistoryDropped { reason } => {
format!("HISTORY DROPPED: {reason}")
}
};
let _ = writeln!(
out,
" {} [{}] {} message(s), {} rewrite(s), {} bytes — {fidelity}",
session.session_id,
session.identity,
session.messages,
session.rewrites,
session.bytes
);
}
let lossy = downgrade.lossy_sessions();
if !lossy.is_empty() {
let _ = writeln!(
out,
" WARNING: {} session(s) lost their retained transcript rewrite history. \
Turn content is intact; branch/rewind history for those sessions is not.",
lossy.len()
);
}
if downgrade.lockout_lifted() {
let _ = writeln!(
out,
" previous releases can open this file{}",
if downgrade.applied {
""
} else {
" (after --apply; this run rolled back)"
}
);
}
}
None => {
let _ = writeln!(out, " no downgrade performed");
}
}
for error in &report.errors {
let _ = writeln!(out, " error: {error}");
}
out
}