From dd8d30bff4a174e81750bb641bf30945b224f5b0 Mon Sep 17 00:00:00 2001 From: KeyffMS <124252104+KeyffMS@users.noreply.github.com> Date: Thu, 10 Sep 2026 17:39:51 +0200 Subject: [PATCH 01/38] app: add backup and restore product flow model --- crates/lantern-app/src/backup_flow.rs | 175 ++++++++++++++++++++++++++ 1 file changed, 175 insertions(+) create mode 100644 crates/lantern-app/src/backup_flow.rs diff --git a/crates/lantern-app/src/backup_flow.rs b/crates/lantern-app/src/backup_flow.rs new file mode 100644 index 00000000..e3f72d6b --- /dev/null +++ b/crates/lantern-app/src/backup_flow.rs @@ -0,0 +1,175 @@ +use std::path::PathBuf; + +use lantern_domain::{BackupDiffStatus, BackupDifference, BackupSnapshot, DeviceWriteOutcome}; + +use crate::{ApprovedRestorePlan, BackupCaptureContext, RestoreConfirmation}; + +#[derive(Clone, Debug)] +pub struct StoredBackup { + pub path: PathBuf, + pub snapshot: BackupSnapshot, +} + +#[derive(Clone, Debug)] +pub struct PreparedRestoreBundle { + pub pre_restore: StoredBackup, + pub diff: Vec, + pub plan: ApprovedRestorePlan, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct RestoreExecutionSummary { + pub attempted_steps: usize, + pub verified_steps: usize, + pub terminal_outcome: Option, +} + +#[derive(Clone, Debug)] +pub enum BackupAction { + RefreshCatalog, + CatalogRefreshed(Result, String>), + Capture, + Captured(Result), + SelectSource(PathBuf), + SourceLoaded { + path: PathBuf, + result: Result, + }, + ClearSource, + PrepareRestore, + RestorePrepared(Result), + ConfirmRestore { + operator_text: String, + }, + RestoreCompleted(Result), +} + +#[derive(Clone, Debug)] +pub enum BackupEffect { + RefreshCatalog, + Capture { + context: BackupCaptureContext, + }, + LoadSource { + path: PathBuf, + }, + PrepareRestore { + source: BackupSnapshot, + context: BackupCaptureContext, + }, + ExecuteRestore { + plan: ApprovedRestorePlan, + confirmation: RestoreConfirmation, + }, +} + +#[derive(Clone, Debug, Default)] +pub(crate) struct BackupRestoreState { + pub catalog: Vec, + pub last_capture: Option, + pub source: Option, + pub pre_restore: Option, + pub diff: Vec, + pub prepared_plan: Option, + pub status: Option, + pub error: Option, +} + +impl BackupRestoreState { + pub(crate) fn invalidate_prepared_operation(&mut self) { + self.pre_restore = None; + self.diff.clear(); + self.prepared_plan = None; + } + + pub(crate) fn view(&self) -> BackupRestoreView { + BackupRestoreView { + catalog: self.catalog.clone(), + last_capture: self.last_capture.as_ref().map(backup_summary), + source: self.source.as_ref().map(backup_summary), + pre_restore: self.pre_restore.as_ref().map(backup_summary), + diff: self + .diff + .iter() + .map(|entry| BackupDiffEntryView { + parameter_id: entry.parameter_id.as_str().to_owned(), + status: entry.status, + }) + .collect(), + prepared_plan: self.prepared_plan.as_ref().map(|plan| RestorePlanView { + plan_hash: plan.plan_hash().to_owned(), + challenge: plan.operator_confirmation_text(), + steps: plan + .steps() + .iter() + .map(|step| RestoreStepView { + index: step.index(), + parameter_id: step.parameter_id().as_str().to_owned(), + expected_old: format!("{:?}", step.expected_old_raw().as_slice()), + target: format!("{:?}", step.target_raw().as_slice()), + }) + .collect(), + skipped: plan.skipped().len(), + }), + status: self.status.clone(), + error: self.error.clone(), + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct BackupSummaryView { + pub path: PathBuf, + pub backup_id: u128, + pub complete: bool, + pub profile_id: String, + pub profile_hash: String, + pub values: usize, + pub errors: usize, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct BackupDiffEntryView { + pub parameter_id: String, + pub status: BackupDiffStatus, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct RestoreStepView { + pub index: usize, + pub parameter_id: String, + pub expected_old: String, + pub target: String, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct RestorePlanView { + pub plan_hash: String, + pub challenge: String, + pub steps: Vec, + pub skipped: usize, +} + +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct BackupRestoreView { + pub catalog: Vec, + pub last_capture: Option, + pub source: Option, + pub pre_restore: Option, + pub diff: Vec, + pub prepared_plan: Option, + pub status: Option, + pub error: Option, +} + +fn backup_summary(stored: &StoredBackup) -> BackupSummaryView { + BackupSummaryView { + path: stored.path.clone(), + backup_id: stored.snapshot.backup_id.get(), + complete: stored.snapshot.is_complete(), + profile_id: stored.snapshot.profile_id.as_str().to_owned(), + profile_hash: stored.snapshot.profile_hash.clone(), + values: stored.snapshot.values.len(), + errors: stored.snapshot.errors.len(), + } +} From f25f168c7c3835fe00bed5f09d5bf631a02f33c3 Mon Sep 17 00:00:00 2001 From: KeyffMS <124252104+KeyffMS@users.noreply.github.com> Date: Thu, 10 Sep 2026 17:40:38 +0200 Subject: [PATCH 02/38] app: expose backup restore through application boundary --- crates/lantern-app/src/product_application.rs | 469 ++++++++++++++++++ 1 file changed, 469 insertions(+) create mode 100644 crates/lantern-app/src/product_application.rs diff --git a/crates/lantern-app/src/product_application.rs b/crates/lantern-app/src/product_application.rs new file mode 100644 index 00000000..9aeed9d4 --- /dev/null +++ b/crates/lantern-app/src/product_application.rs @@ -0,0 +1,469 @@ +use std::{ + path::PathBuf, + sync::Arc, + time::{SystemTime, UNIX_EPOCH}, +}; + +use lantern_domain::{DriveState, ProfileId, SlaveId, UtcTimestamp}; + +use crate::{ + backup_flow::{BackupRestoreState, PreparedRestoreBundle}, + ApplicationAction as _, ApprovedRestorePlan, BackupAction, BackupCaptureContext, BackupEffect, + BackupRestoreView, ConnectionAction, ConnectionWizardView, FaultAction, FaultTimelineView, + MonitoringAction, MonitoringView, ParameterAction, ParameterBrowserView, ProfileRegistry, + RestoreConfirmation, SessionInput, SessionStateMachine, +}; + +use crate::application as legacy; + +pub use legacy::{ + ApplicationEffectError, AuditHealthView, AuthorizationView, OperationView, SessionPhaseView, + SessionView, +}; + +#[derive(Clone, Debug)] +pub enum ApplicationAction { + ReplaceRegistry(Arc), + SelectProfile(ProfileId), + Connection(ConnectionAction), + Monitoring(MonitoringAction), + Parameters(ParameterAction), + Faults(FaultAction), + Session(SessionInput), + Backup(BackupAction), +} + +#[derive(Clone, Debug)] +pub enum ApplicationEffect { + Connection(crate::ConnectionEffect), + Monitoring(crate::MonitoringEffect), + Faults(crate::FaultEffect), + Write(crate::WriteEffect), + Session(crate::SessionEffect), + Backup(BackupEffect), +} + +impl From for ApplicationEffect { + fn from(effect: legacy::ApplicationEffect) -> Self { + match effect { + legacy::ApplicationEffect::Connection(effect) => Self::Connection(effect), + legacy::ApplicationEffect::Monitoring(effect) => Self::Monitoring(effect), + legacy::ApplicationEffect::Faults(effect) => Self::Faults(effect), + legacy::ApplicationEffect::Write(effect) => Self::Write(effect), + legacy::ApplicationEffect::Session(effect) => Self::Session(effect), + } + } +} + +pub trait EffectRunner { + fn execute(&mut self, effect: ApplicationEffect) -> Result<(), ApplicationEffectError>; +} + +pub struct ApplicationRuntime { + state: ApplicationState, + runner: R, +} + +impl ApplicationRuntime { + #[must_use] + pub fn new(state: ApplicationState, runner: R) -> Self { + Self { state, runner } + } + + pub fn dispatch(&mut self, action: ApplicationAction) -> Result<(), ApplicationEffectError> { + for effect in self.state.reduce(action) { + self.runner.execute(effect)?; + } + Ok(()) + } + + #[must_use] + pub const fn state(&self) -> &ApplicationState { + &self.state + } +} + +pub struct ApplicationState { + inner: legacy::ApplicationState, + backup: BackupRestoreState, + build_id: String, +} + +impl Default for ApplicationState { + fn default() -> Self { + Self { + inner: legacy::ApplicationState::default(), + backup: BackupRestoreState::default(), + build_id: "development".to_owned(), + } + } +} + +impl ApplicationState { + #[must_use] + pub fn with_registry(registry: Arc, process_writes_enabled: bool) -> Self { + Self { + inner: legacy::ApplicationState::with_registry(registry, process_writes_enabled), + ..Self::default() + } + } + + #[must_use] + pub fn with_registry_and_suggestions( + registry: Arc, + process_writes_enabled: bool, + suggested_device: Option, + suggested_slave: Option, + ) -> Self { + Self { + inner: legacy::ApplicationState::with_registry_and_suggestions( + registry, + process_writes_enabled, + suggested_device, + suggested_slave, + ), + ..Self::default() + } + } + + pub fn set_build_id(&mut self, build_id: impl Into) { + self.build_id = build_id.into(); + } + + #[must_use] + pub fn view(&self) -> ApplicationView { + ApplicationView { + inner: self.inner.view(), + backup: self.backup.view(), + } + } + + #[must_use] + pub fn registry(&self) -> &Arc { + self.inner.registry() + } + + #[must_use] + pub const fn session(&self) -> &SessionStateMachine { + self.inner.session() + } + + pub fn reduce(&mut self, action: ApplicationAction) -> Vec { + if let ApplicationAction::Backup(action) = action { + return self.reduce_backup(action); + } + + let previous_session = self.inner.view().active_session(); + let legacy_action = match action { + ApplicationAction::ReplaceRegistry(value) => legacy::ApplicationAction::ReplaceRegistry(value), + ApplicationAction::SelectProfile(value) => legacy::ApplicationAction::SelectProfile(value), + ApplicationAction::Connection(value) => legacy::ApplicationAction::Connection(value), + ApplicationAction::Monitoring(value) => legacy::ApplicationAction::Monitoring(value), + ApplicationAction::Parameters(value) => legacy::ApplicationAction::Parameters(value), + ApplicationAction::Faults(value) => legacy::ApplicationAction::Faults(value), + ApplicationAction::Session(value) => legacy::ApplicationAction::Session(value), + ApplicationAction::Backup(_) => unreachable!(), + }; + let effects = self + .inner + .reduce(legacy_action) + .into_iter() + .map(ApplicationEffect::from) + .collect::>(); + let current_view = self.inner.view(); + if current_view.active_session() != previous_session + || current_view.session().phase() != SessionPhaseView::Connected + { + self.backup.invalidate_prepared_operation(); + } + effects + } + + fn reduce_backup(&mut self, action: BackupAction) -> Vec { + match action { + BackupAction::RefreshCatalog => { + self.backup.status = Some("refreshing backup catalog".to_owned()); + self.backup.error = None; + vec![ApplicationEffect::Backup(BackupEffect::RefreshCatalog)] + } + BackupAction::CatalogRefreshed(result) => { + match result { + Ok(paths) => { + self.backup.catalog = paths; + self.backup.status = Some(format!( + "backup catalog refreshed: {} file(s)", + self.backup.catalog.len() + )); + self.backup.error = None; + } + Err(error) => { + self.backup.status = None; + self.backup.error = Some(error); + } + } + Vec::new() + } + BackupAction::Capture => match self.backup_capture_context() { + Ok(context) => { + self.backup.status = Some("capturing complete profile backup".to_owned()); + self.backup.error = None; + vec![ApplicationEffect::Backup(BackupEffect::Capture { context })] + } + Err(error) => { + self.backup.status = None; + self.backup.error = Some(error); + Vec::new() + } + }, + BackupAction::Captured(result) => { + match result { + Ok(stored) => { + if !self.backup.catalog.contains(&stored.path) { + self.backup.catalog.push(stored.path.clone()); + self.backup.catalog.sort(); + } + self.backup.last_capture = Some(stored); + self.backup.status = Some("backup capture persisted".to_owned()); + self.backup.error = None; + } + Err(error) => { + self.backup.status = None; + self.backup.error = Some(error); + } + } + Vec::new() + } + BackupAction::SelectSource(path) => { + self.backup.invalidate_prepared_operation(); + self.backup.status = Some(format!("loading backup {}", path.display())); + self.backup.error = None; + vec![ApplicationEffect::Backup(BackupEffect::LoadSource { path })] + } + BackupAction::SourceLoaded { path, result } => { + match result { + Ok(snapshot) => { + self.backup.source = Some(crate::StoredBackup { path, snapshot }); + self.backup.status = Some("restore source backup loaded".to_owned()); + self.backup.error = None; + } + Err(error) => { + self.backup.source = None; + self.backup.status = None; + self.backup.error = Some(error); + } + } + Vec::new() + } + BackupAction::ClearSource => { + self.backup.source = None; + self.backup.invalidate_prepared_operation(); + self.backup.status = Some("restore source cleared".to_owned()); + self.backup.error = None; + Vec::new() + } + BackupAction::PrepareRestore => { + let Some(source) = self.backup.source.as_ref().map(|stored| stored.snapshot.clone()) else { + self.backup.error = Some("select a source backup before preparing restore".to_owned()); + return Vec::new(); + }; + match self.backup_capture_context() { + Ok(context) => { + self.backup.invalidate_prepared_operation(); + self.backup.status = Some( + "capturing fresh pre-restore backup and building guarded plan".to_owned(), + ); + self.backup.error = None; + vec![ApplicationEffect::Backup(BackupEffect::PrepareRestore { + source, + context, + })] + } + Err(error) => { + self.backup.status = None; + self.backup.error = Some(error); + Vec::new() + } + } + } + BackupAction::RestorePrepared(result) => { + match result { + Ok(PreparedRestoreBundle { + pre_restore, + diff, + plan, + }) => { + let steps = plan.steps().len(); + self.backup.pre_restore = Some(pre_restore); + self.backup.diff = diff; + self.backup.prepared_plan = Some(plan); + self.backup.status = Some(format!( + "guarded restore plan prepared: {steps} step(s); exact confirmation required" + )); + self.backup.error = None; + } + Err(error) => { + self.backup.invalidate_prepared_operation(); + self.backup.status = None; + self.backup.error = Some(error); + } + } + Vec::new() + } + BackupAction::ConfirmRestore { operator_text } => { + let Some(plan) = self.backup.prepared_plan.as_ref() else { + self.backup.error = Some("there is no prepared restore plan".to_owned()); + return Vec::new(); + }; + if operator_text != plan.operator_confirmation_text() { + self.backup.error = Some( + "operator confirmation does not exactly match the restore plan".to_owned(), + ); + return Vec::new(); + } + let plan = self + .backup + .prepared_plan + .take() + .expect("prepared plan checked above"); + self.backup.status = Some("executing guarded restore".to_owned()); + self.backup.error = None; + vec![ApplicationEffect::Backup(BackupEffect::ExecuteRestore { + confirmation: RestoreConfirmation::Confirm { + challenge: operator_text, + }, + plan, + })] + } + BackupAction::RestoreCompleted(result) => { + self.backup.prepared_plan = None; + match result { + Ok(summary) => { + self.backup.status = Some(format!( + "restore finished: attempted={} verified={} terminal={:?}", + summary.attempted_steps, + summary.verified_steps, + summary.terminal_outcome + )); + self.backup.error = None; + } + Err(error) => { + self.backup.status = None; + self.backup.error = Some(error); + } + } + Vec::new() + } + } + } + + fn backup_capture_context(&self) -> Result { + let view = self.inner.view(); + if view.session().phase() != SessionPhaseView::Connected || view.active_session().is_none() { + return Err("backup/restore requires a connected Verified session".to_owned()); + } + let profile_hash = view + .session() + .profile_hash() + .ok_or_else(|| "Verified session has no profile hash".to_owned())?; + let entry = self + .inner + .registry() + .find_by_hash(profile_hash) + .ok_or_else(|| "active validated profile is unavailable".to_owned())?; + let link = view + .connection() + .link + .as_ref() + .ok_or_else(|| "active connection has no validated link settings".to_owned())?; + let now = utc_now(); + Ok(BackupCaptureContext { + app_version: env!("CARGO_PKG_VERSION").to_owned(), + build_id: self.build_id.clone(), + profile_origin: format!("{:?}", entry.origin()), + adapter: view.session().port().unwrap_or("unknown-adapter").to_owned(), + link_settings: format!( + "baud={} parity={:?} data={:?} stop={:?} slave={} timeout_ms={} rs485={:?}", + link.current.baud_rate.get(), + link.current.parity, + link.current.data_bits, + link.current.stop_bits, + link.current.slave_id.get(), + link.current.response_timeout.as_millis(), + link.current.rs485_mode, + ), + drive_state: DriveState::Unknown, + started_at: now, + finished_at: now, + }) + } +} + +fn utc_now() -> UtcTimestamp { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| i128::try_from(duration.as_nanos()).unwrap_or(i128::MAX)) + .unwrap_or(0); + UtcTimestamp::from_unix_nanos(nanos) +} + +#[derive(Clone, Debug)] +pub struct ApplicationView { + inner: legacy::ApplicationView, + backup: BackupRestoreView, +} + +impl Default for ApplicationView { + fn default() -> Self { + Self { + inner: legacy::ApplicationView::default(), + backup: BackupRestoreView::default(), + } + } +} + +impl ApplicationView { + #[must_use] + pub fn active_profile_id(&self) -> Option<&str> { + self.inner.active_profile_id() + } + + #[must_use] + pub const fn active_session(&self) -> Option { + self.inner.active_session() + } + + #[must_use] + pub fn registry_profile_ids(&self) -> &[String] { + self.inner.registry_profile_ids() + } + + #[must_use] + pub const fn session(&self) -> &SessionView { + self.inner.session() + } + + #[must_use] + pub const fn connection(&self) -> &ConnectionWizardView { + self.inner.connection() + } + + #[must_use] + pub const fn monitoring(&self) -> &MonitoringView { + self.inner.monitoring() + } + + #[must_use] + pub const fn parameters(&self) -> &ParameterBrowserView { + self.inner.parameters() + } + + #[must_use] + pub const fn faults(&self) -> &FaultTimelineView { + self.inner.faults() + } + + #[must_use] + pub const fn backup(&self) -> &BackupRestoreView { + &self.backup + } +} From a7a5bd6c62326a2f54f9d5afc3755e25e46a55e5 Mon Sep 17 00:00:00 2001 From: KeyffMS <124252104+KeyffMS@users.noreply.github.com> Date: Thu, 10 Sep 2026 17:40:50 +0200 Subject: [PATCH 03/38] app: route public application API through product flow --- crates/lantern-app/src/lib.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/lantern-app/src/lib.rs b/crates/lantern-app/src/lib.rs index fc836310..313356cf 100644 --- a/crates/lantern-app/src/lib.rs +++ b/crates/lantern-app/src/lib.rs @@ -4,6 +4,7 @@ mod application; mod backup; +mod backup_flow; mod bus; mod clock; mod connection; @@ -17,6 +18,7 @@ mod monitoring_projection; mod parameters; mod poll; mod ports; +mod product_application; mod profile_registry; mod restore; mod restore_permit; @@ -27,8 +29,8 @@ mod telemetry; mod write_coordinator; mod write_flow; -pub use application::*; pub use backup::*; +pub use backup_flow::*; pub use bus::*; pub use clock::*; pub use connection::*; @@ -54,6 +56,7 @@ pub use monitoring_projection::*; pub use parameters::*; pub use poll::*; pub use ports::*; +pub use product_application::*; pub use profile_registry::*; pub use restore::*; pub use restore_permit::*; From 1c85b0fc03d28f7f99b661722cdfb2f6bba5628b Mon Sep 17 00:00:00 2001 From: KeyffMS <124252104+KeyffMS@users.noreply.github.com> Date: Thu, 10 Sep 2026 17:41:56 +0200 Subject: [PATCH 04/38] runtime: integrate backup capture and guarded restore --- crates/vfd-lantern/src/write_runtime.rs | 248 +++++++++++++++++++++++- 1 file changed, 238 insertions(+), 10 deletions(-) diff --git a/crates/vfd-lantern/src/write_runtime.rs b/crates/vfd-lantern/src/write_runtime.rs index 2a5013a9..a13c6599 100644 --- a/crates/vfd-lantern/src/write_runtime.rs +++ b/crates/vfd-lantern/src/write_runtime.rs @@ -1,28 +1,34 @@ use std::{ - path::PathBuf, + fs, + path::{Path, PathBuf}, sync::{Arc, Mutex, MutexGuard}, time::Instant, }; use lantern_app::{ - ApplicationAction, ApplicationEffectError, AuditPort, ClockPort, DecisionOutcome, - DeviceFingerprint, DeviceWriteOutcome, DriveState, OperationId, ParameterAction, PlanId, - ProfileRegistry, ProfileTrustPort, ReadBusPort, SessionControlError, SessionControlPort, - SessionId, SessionInput, SlaveId, WriteBusPort, WriteCoordinator, WriteCoordinatorConfig, - WriteEffect, WriteOutcome, WriteSessionSnapshot, + ApplicationAction, ApplicationEffectError, AuditPort, BackupAction, BackupCoordinator, + BackupEffect, ClockPort, DecisionOutcome, DeviceFingerprint, DeviceWriteOutcome, DriveState, + OperationId, ParameterAction, PlanId, PreparedRestoreBundle, ProfileRegistry, ProfileTrustPort, + ReadBusPort, RestoreExecutionSummary, SessionControlError, SessionControlPort, SessionId, + SessionInput, SlaveId, StoredBackup, WriteBusPort, WriteCoordinator, WriteCoordinatorConfig, + WriteEffect, WriteOutcome, WriteSessionSnapshot, semantic_backup_diff, +}; +use lantern_storage::{ + BACKUP_SUFFIX, FilesystemAuditPort, RuntimeProfileTrust, read_backup, write_backup, }; -use lantern_storage::{FilesystemAuditPort, RuntimeProfileTrust}; use lantern_transport::BusActorHandle; use tokio::sync::{Mutex as AsyncMutex, mpsc}; #[derive(Clone)] pub struct ProductionWriteRuntime { coordinator: Arc>>, + backup: Arc>>, session: Arc, audit: Option>, trust: Option>, clock: Arc, config: WriteCoordinatorConfig, + backup_directory: PathBuf, action_tx: mpsc::UnboundedSender, } @@ -33,6 +39,7 @@ impl ProductionWriteRuntime { registry: Arc, audit_directory: PathBuf, trust_store_path: PathBuf, + backup_directory: PathBuf, process_writes_enabled: bool, ) -> Self { let audit: Option> = match FilesystemAuditPort::new(audit_directory) { @@ -48,7 +55,13 @@ impl ProductionWriteRuntime { registry, trust_store_path, ))); - Self::from_adapters(action_tx, audit, trust, process_writes_enabled) + Self::from_adapters_with_backup_directory( + action_tx, + audit, + trust, + backup_directory, + process_writes_enabled, + ) } fn from_adapters( @@ -56,10 +69,27 @@ impl ProductionWriteRuntime { audit: Option>, trust: Option>, process_writes_enabled: bool, + ) -> Self { + Self::from_adapters_with_backup_directory( + action_tx, + audit, + trust, + PathBuf::from("."), + process_writes_enabled, + ) + } + + fn from_adapters_with_backup_directory( + action_tx: mpsc::UnboundedSender, + audit: Option>, + trust: Option>, + backup_directory: PathBuf, + process_writes_enabled: bool, ) -> Self { let session = Arc::new(RuntimeSessionControl::new(action_tx.clone())); Self { coordinator: Arc::new(AsyncMutex::new(None)), + backup: Arc::new(AsyncMutex::new(None)), session, audit, trust, @@ -68,6 +98,7 @@ impl ProductionWriteRuntime { process_writes_enabled, ..WriteCoordinatorConfig::default() }, + backup_directory, action_tx, } } @@ -79,6 +110,27 @@ impl ProductionWriteRuntime { } async fn attach_ports(&self, read_bus: Arc, write_bus: Arc) { + let clock: Arc = self.clock.clone(); + let session: Arc = self.session.clone(); + + if let Some(trust) = self.trust.clone() { + match BackupCoordinator::new( + Arc::clone(&read_bus), + trust, + Arc::clone(&clock), + Arc::clone(&session), + self.config.request_timeout, + ) { + Ok(coordinator) => *self.backup.lock().await = Some(coordinator), + Err(error) => { + eprintln!("backup coordinator unavailable: {error}"); + *self.backup.lock().await = None; + } + } + } else { + *self.backup.lock().await = None; + } + let Some(audit) = self.audit.clone() else { *self.coordinator.lock().await = None; return; @@ -87,8 +139,6 @@ impl ProductionWriteRuntime { *self.coordinator.lock().await = None; return; }; - let clock: Arc = self.clock.clone(); - let session: Arc = self.session.clone(); match WriteCoordinator::new( read_bus, write_bus, @@ -184,6 +234,182 @@ impl ProductionWriteRuntime { } } } + + pub fn execute_backup(&self, effect: BackupEffect) -> Result<(), ApplicationEffectError> { + match effect { + BackupEffect::RefreshCatalog => { + let result = backup_catalog(&self.backup_directory); + send_backup_action( + &self.action_tx, + BackupAction::CatalogRefreshed(result), + ) + } + BackupEffect::LoadSource { path } => { + let sender = self.action_tx.clone(); + tokio::task::spawn_blocking(move || { + let result = read_backup(&path).map_err(|error| error.to_string()); + let _ = sender.send(ApplicationAction::Backup(BackupAction::SourceLoaded { + path, + result, + })); + }); + Ok(()) + } + BackupEffect::Capture { context } => { + let backup = Arc::clone(&self.backup); + let directory = self.backup_directory.clone(); + let sender = self.action_tx.clone(); + tokio::spawn(async move { + let result: Result = async { + let snapshot = backup + .lock() + .await + .as_mut() + .ok_or_else(|| { + "backup capability unavailable: no verified bus/trust composition" + .to_owned() + })? + .capture(context) + .await + .map_err(|error| error.to_string())?; + persist_backup(&directory, snapshot) + } + .await; + let _ = sender.send(ApplicationAction::Backup(BackupAction::Captured(result))); + }); + Ok(()) + } + BackupEffect::PrepareRestore { source, context } => { + let backup = Arc::clone(&self.backup); + let coordinator = Arc::clone(&self.coordinator); + let directory = self.backup_directory.clone(); + let sender = self.action_tx.clone(); + tokio::spawn(async move { + let result: Result = async { + let current = backup + .lock() + .await + .as_mut() + .ok_or_else(|| { + "backup capability unavailable before restore".to_owned() + })? + .capture(context) + .await + .map_err(|error| error.to_string())?; + let pre_restore = persist_backup(&directory, current)?; + let diff = semantic_backup_diff(&source, &pre_restore.snapshot, None); + let plan = coordinator + .lock() + .await + .as_mut() + .ok_or_else(|| { + "restore capability unavailable: write/audit/trust composition is incomplete" + .to_owned() + })? + .prepare_restore_plan(&source, &pre_restore.snapshot) + .await + .map_err(|error| error.to_string())?; + Ok(PreparedRestoreBundle { + pre_restore, + diff, + plan, + }) + } + .await; + let _ = sender.send(ApplicationAction::Backup(BackupAction::RestorePrepared( + result, + ))); + }); + Ok(()) + } + BackupEffect::ExecuteRestore { plan, confirmation } => { + let coordinator = Arc::clone(&self.coordinator); + let sender = self.action_tx.clone(); + tokio::spawn(async move { + let result: Result = async { + let total = plan.steps().len(); + let mut guard = coordinator.lock().await; + let coordinator = guard.as_mut().ok_or_else(|| { + "restore capability unavailable: write/audit/trust composition is incomplete" + .to_owned() + })?; + let mut permit = coordinator + .begin_restore(plan, confirmation) + .await + .map_err(|error| error.to_string())?; + let mut verified_steps = 0_usize; + for index in 0..total { + let outcome = coordinator + .execute_restore_step(&mut permit, index) + .await + .map_err(|error| error.to_string())?; + if outcome == DeviceWriteOutcome::Verified { + verified_steps = verified_steps.saturating_add(1); + continue; + } + return Ok(RestoreExecutionSummary { + attempted_steps: index.saturating_add(1), + verified_steps, + terminal_outcome: Some(outcome), + }); + } + coordinator + .finish_restore(permit) + .await + .map_err(|error| error.to_string())?; + Ok(RestoreExecutionSummary { + attempted_steps: total, + verified_steps, + terminal_outcome: None, + }) + } + .await; + let _ = sender.send(ApplicationAction::Backup(BackupAction::RestoreCompleted( + result, + ))); + }); + Ok(()) + } + } + } +} + +fn send_backup_action( + sender: &mpsc::UnboundedSender, + action: BackupAction, +) -> Result<(), ApplicationEffectError> { + sender + .send(ApplicationAction::Backup(action)) + .map_err(|_| ApplicationEffectError("application action channel closed".to_owned())) +} + +fn backup_catalog(directory: &Path) -> Result, String> { + if !directory.exists() { + return Ok(Vec::new()); + } + let mut paths = fs::read_dir(directory) + .map_err(|error| error.to_string())? + .filter_map(Result::ok) + .map(|entry| entry.path()) + .filter(|path| { + path.file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.ends_with(BACKUP_SUFFIX)) + }) + .collect::>(); + paths.sort(); + Ok(paths) +} + +fn persist_backup(directory: &Path, snapshot: lantern_app::BackupSnapshot) -> Result { + let path = directory.join(format!( + "backup-{}-{}{}", + snapshot.backup_id.get(), + snapshot.finished_at.as_unix_nanos(), + BACKUP_SUFFIX + )); + write_backup(&path, &snapshot).map_err(|error| error.to_string())?; + Ok(StoredBackup { path, snapshot }) } struct RuntimeWriteClock { @@ -412,6 +638,7 @@ mod tests { let runtime = ProductionWriteRuntime::from_adapters(tx, Some(audit), None, true); let bus = attach_counting_bus(&runtime).await; assert!(runtime.coordinator.lock().await.is_none()); + assert!(runtime.backup.lock().await.is_none()); assert_eq!(bus.writes.load(Ordering::SeqCst), 0); } @@ -423,6 +650,7 @@ mod tests { ProductionWriteRuntime::from_adapters(tx, Some(audit), Some(trust_adapter()), true); let bus = attach_counting_bus(&runtime).await; assert!(runtime.coordinator.lock().await.is_some()); + assert!(runtime.backup.lock().await.is_some()); assert_eq!(bus.writes.load(Ordering::SeqCst), 0); } } From 8ae57e5a19ce86fdcae01514bcb0699dd859d249 Mon Sep 17 00:00:00 2001 From: KeyffMS <124252104+KeyffMS@users.noreply.github.com> Date: Thu, 10 Sep 2026 17:42:35 +0200 Subject: [PATCH 05/38] app: preserve legacy unit-test surface while routing product API --- crates/lantern-app/src/lib.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/crates/lantern-app/src/lib.rs b/crates/lantern-app/src/lib.rs index 313356cf..5bc2edc2 100644 --- a/crates/lantern-app/src/lib.rs +++ b/crates/lantern-app/src/lib.rs @@ -29,6 +29,8 @@ mod telemetry; mod write_coordinator; mod write_flow; +#[cfg(test)] +pub use application::*; pub use backup::*; pub use backup_flow::*; pub use bus::*; @@ -56,6 +58,7 @@ pub use monitoring_projection::*; pub use parameters::*; pub use poll::*; pub use ports::*; +#[cfg(not(test))] pub use product_application::*; pub use profile_registry::*; pub use restore::*; From 0f3c841b401211d9e255297ad12fbdc20b837c63 Mon Sep 17 00:00:00 2001 From: KeyffMS <124252104+KeyffMS@users.noreply.github.com> Date: Thu, 10 Sep 2026 17:43:34 +0200 Subject: [PATCH 06/38] runtime: route backup effects through guarded operation runtime --- crates/vfd-lantern/src/connection_runtime.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/crates/vfd-lantern/src/connection_runtime.rs b/crates/vfd-lantern/src/connection_runtime.rs index 382100e7..9e534827 100644 --- a/crates/vfd-lantern/src/connection_runtime.rs +++ b/crates/vfd-lantern/src/connection_runtime.rs @@ -39,6 +39,7 @@ struct RuntimeState { pub struct TuiRuntimePaths { diagnostics_directory: PathBuf, fault_report_directory: PathBuf, + backup_directory: PathBuf, csv_directory: PathBuf, session_runtime_directory: PathBuf, audit_directory: PathBuf, @@ -50,6 +51,7 @@ impl TuiRuntimePaths { pub fn new( diagnostics_directory: PathBuf, fault_report_directory: PathBuf, + backup_directory: PathBuf, csv_directory: PathBuf, session_runtime_directory: PathBuf, audit_directory: PathBuf, @@ -58,6 +60,7 @@ impl TuiRuntimePaths { Self { diagnostics_directory, fault_report_directory, + backup_directory, csv_directory, session_runtime_directory, audit_directory, @@ -92,6 +95,7 @@ impl TuiEffectRunner { registry, paths.audit_directory.clone(), paths.profile_trust_store.clone(), + paths.backup_directory.clone(), settings.process_writes_enabled, ); let monitoring = MonitoringRuntime::new( @@ -382,6 +386,7 @@ impl EffectRunner for TuiEffectRunner { ApplicationEffect::Faults(effect) => self.execute_fault(effect), ApplicationEffect::Write(effect) => self.write.execute(effect), ApplicationEffect::Session(effect) => self.execute_session(effect), + ApplicationEffect::Backup(effect) => self.write.execute_backup(effect), } } } From 0c8cf87b046a4759091bffea8e037e62f2a4a77c Mon Sep 17 00:00:00 2001 From: KeyffMS <124252104+KeyffMS@users.noreply.github.com> Date: Thu, 10 Sep 2026 17:44:11 +0200 Subject: [PATCH 07/38] tui: wire product build metadata and backup runtime path --- crates/vfd-lantern/src/main.rs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/crates/vfd-lantern/src/main.rs b/crates/vfd-lantern/src/main.rs index fffed7ec..d1f58811 100644 --- a/crates/vfd-lantern/src/main.rs +++ b/crates/vfd-lantern/src/main.rs @@ -22,8 +22,8 @@ use std::{ use anyhow::Result; use clap::Parser; use lantern_app::{ - ApplicationAction, ApplicationRuntime, ApplicationState, CliSettingsOverrides, ColorMode, - ConnectionAction, ParameterAction, PortDiscoveryPort, PortEvent, PortEventReceiver, + ApplicationAction, ApplicationRuntime, ApplicationState, BackupAction, CliSettingsOverrides, + ColorMode, ConnectionAction, ParameterAction, PortDiscoveryPort, PortEvent, PortEventReceiver, ProfileRegistry, SessionInput, SessionPhaseView, SettingsLoader, ValidatedSettings, }; use lantern_storage::{ @@ -150,12 +150,13 @@ async fn run_tui(settings: &ValidatedSettings, paths: &AppPaths) -> Result<()> { install_terminal_panic_hook(Arc::clone(&terminal_guard), paths.panic_directory.clone()); let (action_tx, mut action_rx) = mpsc::unbounded_channel(); - let state = ApplicationState::with_registry_and_suggestions( + let mut state = ApplicationState::with_registry_and_suggestions( Arc::clone(®istry), settings.process_writes_enabled, settings.suggested_device.clone(), settings.suggested_slave, ); + state.set_build_id(profile_commands::embedded_manifest()?.build_id); let runner = TuiEffectRunner::new( Arc::clone(&terminal_guard), action_tx, @@ -164,6 +165,7 @@ async fn run_tui(settings: &ValidatedSettings, paths: &AppPaths) -> Result<()> { TuiRuntimePaths::new( paths.diagnostics_directory.clone(), paths.fault_report_directory.clone(), + paths.backup_directory.clone(), paths.csv_directory.clone(), paths.session_runtime_directory.clone(), paths.audit_directory.clone(), @@ -180,6 +182,7 @@ async fn run_tui(settings: &ValidatedSettings, paths: &AppPaths) -> Result<()> { application.dispatch(ApplicationAction::Connection( ConnectionAction::RefreshPorts, ))?; + application.dispatch(ApplicationAction::Backup(BackupAction::RefreshCatalog))?; terminal.draw(&application.state().view(), &ui)?; let frame_interval = Duration::from_millis(1_000 / u64::from(settings.render_fps)); From dff432808a4b3303c10fcf9808500e49da39d80d Mon Sep 17 00:00:00 2001 From: KeyffMS <124252104+KeyffMS@users.noreply.github.com> Date: Thu, 10 Sep 2026 17:44:20 +0200 Subject: [PATCH 08/38] tui: add backup restore presentation state --- crates/lantern-tui/src/backup_state.rs | 29 ++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 crates/lantern-tui/src/backup_state.rs diff --git a/crates/lantern-tui/src/backup_state.rs b/crates/lantern-tui/src/backup_state.rs new file mode 100644 index 00000000..68308de5 --- /dev/null +++ b/crates/lantern-tui/src/backup_state.rs @@ -0,0 +1,29 @@ +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct BackupUiState { + pub confirmation_active: bool, + pub confirmation_input: String, +} + +impl BackupUiState { + pub fn begin_confirmation(&mut self) { + self.confirmation_active = true; + self.confirmation_input.clear(); + } + + pub fn cancel_confirmation(&mut self) { + self.confirmation_active = false; + self.confirmation_input.clear(); + } + + pub fn insert(&mut self, character: char) { + if self.confirmation_active { + self.confirmation_input.push(character); + } + } + + pub fn backspace(&mut self) { + if self.confirmation_active { + self.confirmation_input.pop(); + } + } +} From 7e9dd90154a059de1ae976aaed8307e089cb907e Mon Sep 17 00:00:00 2001 From: KeyffMS <124252104+KeyffMS@users.noreply.github.com> Date: Thu, 10 Sep 2026 17:44:43 +0200 Subject: [PATCH 09/38] tui: render functional backup diff restore screen --- crates/lantern-tui/src/backup_render.rs | 143 ++++++++++++++++++++++++ 1 file changed, 143 insertions(+) create mode 100644 crates/lantern-tui/src/backup_render.rs diff --git a/crates/lantern-tui/src/backup_render.rs b/crates/lantern-tui/src/backup_render.rs new file mode 100644 index 00000000..dc810fed --- /dev/null +++ b/crates/lantern-tui/src/backup_render.rs @@ -0,0 +1,143 @@ +use lantern_app::ApplicationView; +use ratatui::{ + Frame, + layout::Rect, + text::{Line, Text}, + widgets::{Block, Paragraph, Wrap}, +}; + +use crate::{Theme, UiState}; + +pub fn render_backup_screen( + frame: &mut Frame<'_>, + area: Rect, + view: &ApplicationView, + ui: &UiState, + theme: Theme, +) { + let backup = view.backup(); + let mut lines = vec![ + Line::from("b capture | r refresh files | Enter select source | p prepare restore | c confirm | x clear source"), + Line::from("Restore remains gated by Verified + trust + Armed + healthy audit + exact confirmation + permit."), + Line::from(""), + ]; + + if view.active_session().is_none() { + lines.push(Line::from( + "No Verified session: catalog/source inspection is available; capture/restore is blocked.", + )); + } else { + lines.push(Line::from(format!( + "session={:?} profile={} operation={:?} authorization={:?} audit={:?}", + view.active_session().map(lantern_app::SessionId::get), + view.session().verified_profile_id().unwrap_or("—"), + view.session().operation(), + view.session().authorization(), + view.session().audit_health(), + ))); + } + + if let Some(status) = &backup.status { + lines.push(Line::from(format!("STATUS: {status}"))); + } + if let Some(error) = &backup.error { + lines.push(Line::from(format!("ERROR: {error}"))); + } + + lines.push(Line::from("")); + if let Some(last) = &backup.last_capture { + lines.push(Line::from(format!( + "Last capture: {} id={} complete={} values={} errors={}", + last.path.display(), + last.backup_id, + last.complete, + last.values, + last.errors, + ))); + } + if let Some(source) = &backup.source { + lines.push(Line::from(format!( + "Restore source: {} id={} complete={} profile={} hash={}", + source.path.display(), + source.backup_id, + source.complete, + source.profile_id, + source.profile_hash, + ))); + } else { + lines.push(Line::from("Restore source: —")); + } + if let Some(current) = &backup.pre_restore { + lines.push(Line::from(format!( + "Fresh pre-restore backup: {} id={} complete={} values={}", + current.path.display(), + current.backup_id, + current.complete, + current.values, + ))); + } + + lines.push(Line::from("")); + lines.push(Line::from("Stored backups:")); + if backup.catalog.is_empty() { + lines.push(Line::from(" no stored backup files")); + } + for (index, path) in backup.catalog.iter().enumerate() { + let marker = if index == ui.selected_index { ">" } else { " " }; + lines.push(Line::from(format!("{marker} {}", path.display()))); + } + + if !backup.diff.is_empty() { + lines.push(Line::from("")); + lines.push(Line::from(format!("Semantic diff ({} entries):", backup.diff.len()))); + for entry in backup.diff.iter().take(64) { + lines.push(Line::from(format!( + " {} {:?}", + entry.parameter_id, entry.status + ))); + } + if backup.diff.len() > 64 { + lines.push(Line::from(format!( + " … {} additional diff entries", + backup.diff.len() - 64 + ))); + } + } + + if let Some(plan) = &backup.prepared_plan { + lines.push(Line::from("")); + lines.push(Line::from(format!( + "PREPARED RESTORE: steps={} skipped={} plan_hash={}", + plan.steps.len(), + plan.skipped, + plan.plan_hash, + ))); + for step in plan.steps.iter().take(32) { + lines.push(Line::from(format!( + " #{} {} old={} target={}", + step.index, step.parameter_id, step.expected_old, step.target + ))); + } + lines.push(Line::from(format!( + "Exact confirmation required: {}", + plan.challenge + ))); + if ui.backup.confirmation_active { + lines.push(Line::from(format!( + "Confirmation: {}_", + ui.backup.confirmation_input + ))); + lines.push(Line::from("Enter submits exact text; Esc cancels without write.")); + } else { + lines.push(Line::from("Press c to enter the exact confirmation challenge.")); + } + } + + let scroll = u16::try_from(ui.scroll_offset).unwrap_or(u16::MAX); + let paragraph = Paragraph::new(Text::from(lines)) + .block(Block::bordered().title(" Backup / Diff / Restore ")) + .wrap(Wrap { trim: false }) + .scroll((scroll, 0)) + .style(theme.muted()); + frame.render_widget(paragraph, area); +} From f74c453b6aff340fd5d5b72a2d0611a21b94d84c Mon Sep 17 00:00:00 2001 From: KeyffMS <124252104+KeyffMS@users.noreply.github.com> Date: Thu, 10 Sep 2026 17:44:59 +0200 Subject: [PATCH 10/38] tui: add guarded backup restore keymap --- crates/lantern-tui/src/backup_keymap.rs | 67 +++++++++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 crates/lantern-tui/src/backup_keymap.rs diff --git a/crates/lantern-tui/src/backup_keymap.rs b/crates/lantern-tui/src/backup_keymap.rs new file mode 100644 index 00000000..f130855f --- /dev/null +++ b/crates/lantern-tui/src/backup_keymap.rs @@ -0,0 +1,67 @@ +use crossterm::event::{KeyCode, KeyEvent, KeyEventKind}; +use lantern_app::{ApplicationAction, ApplicationView, BackupAction}; + +use crate::{MappedAction, UiAction, UiState}; + +#[must_use] +pub fn map_backup_key( + ui: &UiState, + view: &ApplicationView, + key: KeyEvent, +) -> Option { + if !matches!(key.kind, KeyEventKind::Press | KeyEventKind::Repeat) { + return None; + } + + if ui.backup.confirmation_active { + return match key.code { + KeyCode::Esc => Some(MappedAction::Ui(UiAction::BackupCancelConfirmation)), + KeyCode::Enter => Some(MappedAction::Combined { + ui: UiAction::BackupCancelConfirmation, + application: Box::new(ApplicationAction::Backup(BackupAction::ConfirmRestore { + operator_text: ui.backup.confirmation_input.clone(), + })), + }), + KeyCode::Backspace => Some(MappedAction::Ui(UiAction::BackupBackspace)), + KeyCode::Char(character) => { + Some(MappedAction::Ui(UiAction::BackupInputChar(character))) + } + _ => None, + }; + } + + match key.code { + KeyCode::Char('b') => Some(MappedAction::Application(Box::new( + ApplicationAction::Backup(BackupAction::Capture), + ))), + KeyCode::Char('r') => Some(MappedAction::Application(Box::new( + ApplicationAction::Backup(BackupAction::RefreshCatalog), + ))), + KeyCode::Char('x') => Some(MappedAction::Application(Box::new( + ApplicationAction::Backup(BackupAction::ClearSource), + ))), + KeyCode::Char('p') => Some(MappedAction::Application(Box::new( + ApplicationAction::Backup(BackupAction::PrepareRestore), + ))), + KeyCode::Char('c') if view.backup().prepared_plan.is_some() => { + Some(MappedAction::Ui(UiAction::BackupBeginConfirmation)) + } + KeyCode::Enter => view + .backup() + .catalog + .get(ui.selected_index) + .cloned() + .map(|path| { + MappedAction::Application(Box::new(ApplicationAction::Backup( + BackupAction::SelectSource(path), + ))) + }), + KeyCode::Char('j') | KeyCode::Down => { + Some(MappedAction::Ui(UiAction::SelectionNext)) + } + KeyCode::Char('k') | KeyCode::Up => { + Some(MappedAction::Ui(UiAction::SelectionPrevious)) + } + _ => None, + } +} From 10446ea82e34624cd2e46e0ab48f3fafef4b613c Mon Sep 17 00:00:00 2001 From: KeyffMS <124252104+KeyffMS@users.noreply.github.com> Date: Thu, 10 Sep 2026 17:45:50 +0200 Subject: [PATCH 11/38] tui: add exact restore confirmation input state --- crates/lantern-tui/src/ui_state.rs | 291 ++++++++--------------------- 1 file changed, 76 insertions(+), 215 deletions(-) diff --git a/crates/lantern-tui/src/ui_state.rs b/crates/lantern-tui/src/ui_state.rs index 56db3767..7caede19 100644 --- a/crates/lantern-tui/src/ui_state.rs +++ b/crates/lantern-tui/src/ui_state.rs @@ -4,7 +4,8 @@ use lantern_app::{ }; use crate::{ - FaultUiState, FormState, ParameterEditorUiState, ParameterUiState, ScopeUiState, ScopeYRange, + BackupUiState, FaultUiState, FormState, ParameterEditorUiState, ParameterUiState, ScopeUiState, + ScopeYRange, }; #[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] @@ -110,6 +111,7 @@ pub struct UiState { pub scope_filter: String, pub scope: ScopeUiState, pub parameters: ParameterUiState, + pub backup: BackupUiState, pub faults: FaultUiState, pub modal: Option, pub viewport: Viewport, @@ -128,6 +130,7 @@ impl Default for UiState { scope_filter: String::new(), scope: ScopeUiState::default(), parameters: ParameterUiState::default(), + backup: BackupUiState::default(), faults: FaultUiState::default(), modal: None, viewport: Viewport::default(), @@ -192,6 +195,10 @@ pub enum UiAction { InputChar(char), Backspace, CancelEdit, + BackupBeginConfirmation, + BackupInputChar(char), + BackupBackspace, + BackupCancelConfirmation, ScopeTogglePause { anchor_nanos: u128, }, @@ -225,6 +232,7 @@ impl UiState { self.selected_index = 0; self.connection_edit = None; self.parameters.editor = None; + self.backup.cancel_confirmation(); self.form.clear(); } UiAction::NextScreen => { @@ -234,6 +242,7 @@ impl UiState { self.selected_index = 0; self.connection_edit = None; self.parameters.editor = None; + self.backup.cancel_confirmation(); self.form.clear(); } UiAction::PreviousScreen => { @@ -248,6 +257,7 @@ impl UiState { self.selected_index = 0; self.connection_edit = None; self.parameters.editor = None; + self.backup.cancel_confirmation(); self.form.clear(); } UiAction::ScrollUp => { @@ -383,6 +393,7 @@ impl UiState { self.scroll_offset = 0; self.connection_edit = None; self.parameters.editor = None; + self.backup.cancel_confirmation(); self.form.clear(); } UiAction::BeginParameterTextEditor { @@ -451,6 +462,16 @@ impl UiState { self.form.clear(); self.focus = Focus::Navigation; } + UiAction::BackupBeginConfirmation => { + self.backup.begin_confirmation(); + self.focus = Focus::Content; + } + UiAction::BackupInputChar(character) => self.backup.insert(character), + UiAction::BackupBackspace => self.backup.backspace(), + UiAction::BackupCancelConfirmation => { + self.backup.cancel_confirmation(); + self.focus = Focus::Navigation; + } UiAction::ScopeTogglePause { anchor_nanos } => { self.scope.toggle_pause(anchor_nanos); } @@ -519,37 +540,6 @@ pub(crate) fn profile_matches_filter(profile: &ProfileChoiceView, filter: &str) ) } -pub(crate) fn monitoring_parameter_matches_filter( - parameter: &MonitoringParameterView, - filter: &str, -) -> bool { - let needle = normalized_filter(filter); - if needle.is_empty() { - return true; - } - [ - parameter.parameter_id.as_str(), - parameter.code.as_str(), - parameter.name.as_str(), - parameter.unit.as_str(), - ] - .into_iter() - .any(|value| normalized_filter(value).contains(&needle)) - || normalized_filter(&format!("{:?}", parameter.quantity)).contains(&needle) - || parameter - .aliases - .iter() - .any(|alias| normalized_filter(alias).contains(&needle)) -} - -fn normalized_filter(value: &str) -> String { - value - .chars() - .filter(|character| character.is_ascii_alphanumeric()) - .flat_map(char::to_lowercase) - .collect() -} - fn profile_fields_match_filter( profile_id: &str, vendor: &str, @@ -557,204 +547,75 @@ fn profile_fields_match_filter( model: &str, filter: &str, ) -> bool { - let filter = filter.trim(); - if filter.is_empty() { - return true; - } - let needle = filter.to_ascii_lowercase(); - [profile_id, vendor, family, model] - .into_iter() - .any(|value| value.to_ascii_lowercase().contains(&needle)) + let needle = filter.trim().to_ascii_lowercase(); + needle.is_empty() + || [profile_id, vendor, family, model] + .into_iter() + .any(|field| field.to_ascii_lowercase().contains(&needle)) +} + +pub(crate) fn monitoring_parameter_matches_filter( + parameter: &MonitoringParameterView, + filter: &str, +) -> bool { + let needle = filter.trim().to_ascii_lowercase(); + needle.is_empty() + || parameter.code.to_ascii_lowercase().contains(&needle) + || parameter.name.to_ascii_lowercase().contains(&needle) + || parameter + .aliases + .iter() + .any(|alias| alias.to_ascii_lowercase().contains(&needle)) + || format!("{:?}", parameter.quantity) + .to_ascii_lowercase() + .contains(&needle) + || parameter.unit.to_ascii_lowercase().contains(&needle) } #[cfg(test)] mod tests { - use std::path::PathBuf; - - use lantern_app::{ - PackagedProfilesManifestV1, ProfileRegistry, ProfileSource, ProfileSourceFormat, - ProfileSourceTier, monitoring_catalog, - }; + use lantern_app::{MonitoringParameterView, ParameterId, QuantityKind}; - use super::{ - ConnectionEdit, Focus, ModalState, Screen, UiAction, UiState, - monitoring_parameter_matches_filter, profile_fields_match_filter, - }; - use crate::{ScopeWindow, ScopeYRange}; - - fn monitoring_parameter() -> lantern_app::MonitoringParameterView { - let registry = ProfileRegistry::from_sources( - vec![ProfileSource { - path: PathBuf::from("example-vfd.toml"), - bytes: include_bytes!("../../../profiles/example-vfd.toml") - .to_vec() - .into_boxed_slice(), - format: ProfileSourceFormat::Toml, - tier: ProfileSourceTier::Explicit, - }], - &PackagedProfilesManifestV1 { - schema_version: 1, - build_id: "test".to_owned(), - profiles: Vec::new(), - }, - ) - .expect("registry"); - let profile = registry - .entries() - .values() - .next() - .expect("profile") - .profile(); - monitoring_catalog(profile) - .into_iter() - .find(|parameter| parameter.parameter_id.as_str() == "status.output_frequency") - .expect("monitoring parameter") - } + use super::{monitoring_parameter_matches_filter, profile_fields_match_filter}; #[test] - fn ui_reducer_changes_only_presentation_state() { - let mut state = UiState::default(); - state.apply(UiAction::NextScreen); - state.apply(UiAction::ScrollDown); - state.apply(UiAction::FocusNext); - assert_eq!(state.screen, Screen::Dashboard); - assert_eq!(state.scroll_offset, 1); - assert_eq!(state.focus, Focus::Content); - } - - #[test] - fn scope_controls_are_presentation_only_and_persist_across_screens() { - let mut state = UiState { - screen: Screen::Scope, - ..UiState::default() - }; - state.apply(UiAction::ScopeTogglePause { anchor_nanos: 123 }); - state.apply(UiAction::ScopeNextWindow); - state.apply(UiAction::ScopePanBackward); - state.apply(UiAction::ScopeZoomIn); - state.apply(UiAction::ScopeToggleCursor); - state.apply(UiAction::ScopeCursorNext); - state.apply(UiAction::ScopeSetYRange { - panel: 1, - range: ScopeYRange::new(0.0, 100.0), - }); - assert!(state.scope.paused); - assert_eq!(state.scope.pause_anchor_nanos, Some(123)); - assert_eq!(state.scope.window, ScopeWindow::FiveMinutes); - assert_eq!(state.scope.pan_steps, -1); - assert_eq!(state.scope.zoom_steps, 1); - assert_eq!(state.scope.cursor_index, Some(1)); - assert!(state.scope.y_ranges.contains_key(&1)); - - state.apply(UiAction::SelectScreen(Screen::Dashboard)); - state.apply(UiAction::SelectScreen(Screen::Scope)); - assert!(state.scope.paused); - assert_eq!(state.scope.pan_steps, -1); - - state.apply(UiAction::ScopeResetView); - assert_eq!(state.scope, crate::ScopeUiState::default()); - } - - #[test] - fn scope_search_normalizes_code_alias_quantity_and_unit() { - let parameter = monitoring_parameter(); - assert!(monitoring_parameter_matches_filter(¶meter, "D1.00")); - assert!(monitoring_parameter_matches_filter( - ¶meter, - "status.output_frequency" - )); - assert!(monitoring_parameter_matches_filter(¶meter, "frequency")); - assert!(monitoring_parameter_matches_filter(¶meter, "hz")); - assert!(!monitoring_parameter_matches_filter(¶meter, "rpm")); - } - - #[test] - fn scope_search_edit_is_presentation_only() { - let mut state = UiState::default(); - state.apply(UiAction::BeginScopeSearch); - for character in "rpm".chars() { - state.apply(UiAction::InputChar(character)); - } - state.apply(UiAction::ApplyScopeSearch); - assert_eq!(state.scope_filter, "rpm"); - assert!(state.connection_edit.is_none()); - } - - #[test] - fn manual_path_edit_is_presentation_only() { - let mut state = UiState::default(); - state.apply(UiAction::BeginManualPath("/dev/ttyUSB".to_owned())); - state.apply(UiAction::InputChar('0')); - assert_eq!(state.connection_edit, Some(ConnectionEdit::ManualPath)); - assert_eq!(state.form.value(), "/dev/ttyUSB0"); - state.apply(UiAction::CancelEdit); - assert!(state.connection_edit.is_none()); - } - - #[test] - fn profile_search_is_case_insensitive_and_presentation_only() { - assert!(profile_fields_match_filter( - "example.vfd1000", - "Example Devices", - "Fictional", - "VFD 1000", - "devices", - )); + fn profile_search_is_case_insensitive_and_metadata_only() { assert!(profile_fields_match_filter( - "example.vfd1000", - "Example Devices", - "Fictional", - "VFD 1000", - "VFD1000", + "acme.v1", + "ACME", + "Falcon", + "F-100", + "falcon" )); assert!(profile_fields_match_filter( - "example.vfd1000", - "Example Devices", - "Fictional", - "VFD 1000", - "fictional", + "acme.v1", + "ACME", + "Falcon", + "F-100", + "F-100" )); assert!(!profile_fields_match_filter( - "example.vfd1000", - "Example Devices", - "Fictional", - "VFD 1000", - "other", + "acme.v1", + "ACME", + "Falcon", + "F-100", + "40001" )); - - let mut state = UiState::default(); - state.apply(UiAction::BeginProfileSearch); - for character in "vfd1000".chars() { - state.apply(UiAction::InputChar(character)); - } - state.apply(UiAction::ApplyProfileSearch); - assert_eq!(state.profile_filter, "vfd1000"); - assert!(state.connection_edit.is_none()); } #[test] - fn resize_invalidates_layout_revision_only_when_dimensions_change() { - let mut state = UiState::default(); - state.apply(UiAction::Resize { - width: 100, - height: 30, - }); - assert_eq!(state.viewport.layout_revision, 1); - state.apply(UiAction::Resize { - width: 100, - height: 30, - }); - assert_eq!(state.viewport.layout_revision, 1); - } - - #[test] - fn modal_owns_focus_until_closed() { - let mut state = UiState::default(); - state.apply(UiAction::OpenHelp); - assert_eq!(state.modal, Some(ModalState::Help)); - assert_eq!(state.focus, Focus::Modal); - state.apply(UiAction::CloseModal); - assert!(state.modal.is_none()); - assert_eq!(state.focus, Focus::Navigation); + fn scope_search_matches_semantic_metadata() { + let parameter = MonitoringParameterView { + parameter_id: ParameterId::parse("motor.frequency").expect("id"), + code: "F1.01".to_owned(), + name: "Output Frequency".to_owned(), + aliases: vec!["Hz Out".to_owned()], + quantity: QuantityKind::Frequency, + unit: "Hz".to_owned(), + }; + assert!(monitoring_parameter_matches_filter(¶meter, "frequency")); + assert!(monitoring_parameter_matches_filter(¶meter, "hz out")); + assert!(monitoring_parameter_matches_filter(¶meter, "HZ")); + assert!(!monitoring_parameter_matches_filter(¶meter, "40001")); } } From 8f7ebbce570e59a4962dfcbe428b0c69efb0df38 Mon Sep 17 00:00:00 2001 From: KeyffMS <124252104+KeyffMS@users.noreply.github.com> Date: Thu, 10 Sep 2026 17:46:25 +0200 Subject: [PATCH 12/38] tui: route backup screen to functional renderer --- crates/lantern-tui/src/lib.rs | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/crates/lantern-tui/src/lib.rs b/crates/lantern-tui/src/lib.rs index 1d35c887..0daad0e1 100644 --- a/crates/lantern-tui/src/lib.rs +++ b/crates/lantern-tui/src/lib.rs @@ -2,6 +2,9 @@ #![forbid(unsafe_code)] +mod backup_keymap; +mod backup_render; +mod backup_state; mod fault_keymap; mod fault_render; mod fault_state; @@ -21,6 +24,7 @@ mod theme; mod ui_state; mod widgets; +pub use backup_state::*; pub use fault_state::*; pub use forms::*; pub use input::*; @@ -40,6 +44,7 @@ use ratatui::{ }; use crate::{ + backup_render::render_backup_screen, screens::render_screen, widgets::{render_footer, render_header, render_modal, render_navigation, render_too_small}, }; @@ -65,7 +70,11 @@ pub fn render(frame: &mut Frame<'_>, view: &ApplicationView, ui: &UiState, theme render_header(frame, header, view, theme); render_navigation(frame, navigation, ui, theme); - render_screen(frame, content, view, ui, theme); + if ui.screen == Screen::Backup { + render_backup_screen(frame, content, view, ui, theme); + } else { + render_screen(frame, content, view, ui, theme); + } render_footer(frame, footer, theme); if let Some(modal) = &ui.modal { From bda9a29848b16ab89a5ebf206ba8ee26d4601215 Mon Sep 17 00:00:00 2001 From: KeyffMS <124252104+KeyffMS@users.noreply.github.com> Date: Thu, 10 Sep 2026 17:46:35 +0200 Subject: [PATCH 13/38] tui: dispatch backup restore keys before global keymap --- crates/lantern-tui/src/input.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/crates/lantern-tui/src/input.rs b/crates/lantern-tui/src/input.rs index f22d2eee..e962f679 100644 --- a/crates/lantern-tui/src/input.rs +++ b/crates/lantern-tui/src/input.rs @@ -4,7 +4,7 @@ use crossterm::event::{Event, EventStream}; use futures_util::StreamExt; use lantern_app::ApplicationView; -use crate::{MappedAction, UiAction, UiState, map_key}; +use crate::{MappedAction, Screen, UiAction, UiState, backup_keymap::map_backup_key, map_key}; pub struct InputReader { events: EventStream, @@ -29,6 +29,11 @@ impl InputReader { })??; match event { Event::Key(key) => { + if ui.screen == Screen::Backup + && let Some(action) = map_backup_key(ui, view, key) + { + return Ok(action); + } if let Some(action) = map_key(ui, view, key) { return Ok(action); } From 7165910fcaeafa3658cef52f94c81147dcfab95d Mon Sep 17 00:00:00 2001 From: KeyffMS <124252104+KeyffMS@users.noreply.github.com> Date: Thu, 10 Sep 2026 17:48:48 +0200 Subject: [PATCH 14/38] runtime: implement production restore session adapter --- crates/vfd-lantern/src/write_runtime.rs | 176 +++++++++++++++++++++++- 1 file changed, 175 insertions(+), 1 deletion(-) diff --git a/crates/vfd-lantern/src/write_runtime.rs b/crates/vfd-lantern/src/write_runtime.rs index a13c6599..17bf4c92 100644 --- a/crates/vfd-lantern/src/write_runtime.rs +++ b/crates/vfd-lantern/src/write_runtime.rs @@ -401,7 +401,10 @@ fn backup_catalog(directory: &Path) -> Result, String> { Ok(paths) } -fn persist_backup(directory: &Path, snapshot: lantern_app::BackupSnapshot) -> Result { +fn persist_backup( + directory: &Path, + snapshot: lantern_app::BackupSnapshot, +) -> Result { let path = directory.join(format!( "backup-{}-{}{}", snapshot.backup_id.get(), @@ -430,8 +433,16 @@ impl ClockPort for RuntimeWriteClock { } } +#[derive(Clone, Debug)] +struct RuntimeRestoreState { + operation_id: OperationId, + plan_hash: String, + next_index: usize, +} + struct RuntimeSessionControl { snapshot: Mutex, + restore: Mutex>, action_tx: mpsc::UnboundedSender, } @@ -439,11 +450,15 @@ impl RuntimeSessionControl { fn new(action_tx: mpsc::UnboundedSender) -> Self { Self { snapshot: Mutex::new(unavailable_snapshot()), + restore: Mutex::new(None), action_tx, } } fn sync(&self, snapshot: WriteSessionSnapshot) { + if snapshot.operation_idle { + *lock_restore(&self.restore) = None; + } *lock_snapshot(&self.snapshot) = snapshot; } } @@ -511,6 +526,156 @@ impl SessionControlPort for RuntimeSessionControl { })); } + fn begin_restore( + &self, + operation_id: OperationId, + plan_hash: &str, + ) -> Result<(), SessionControlError> { + let mut snapshot = lock_snapshot(&self.snapshot); + let mut restore = lock_restore(&self.restore); + if !snapshot.connected + || !snapshot.armed + || !snapshot.audit_healthy + || !snapshot.operation_idle + || restore.is_some() + || plan_hash.is_empty() + { + return Err(SessionControlError::PreconditionChanged); + } + snapshot.operation_idle = false; + snapshot.guard_revision = snapshot.guard_revision.saturating_add(1); + *restore = Some(RuntimeRestoreState { + operation_id, + plan_hash: plan_hash.to_owned(), + next_index: 0, + }); + if self + .action_tx + .send(ApplicationAction::Session(SessionInput::RestoreStarted { + operation_id, + plan_hash: plan_hash.to_owned(), + })) + .is_err() + { + *restore = None; + snapshot.operation_idle = true; + snapshot.armed = false; + snapshot.guard_revision = snapshot.guard_revision.saturating_add(1); + return Err(SessionControlError::Other( + "application session channel closed while starting restore".to_owned(), + )); + } + Ok(()) + } + + fn restore_matches( + &self, + operation_id: OperationId, + plan_hash: &str, + next_index: usize, + ) -> bool { + let snapshot = lock_snapshot(&self.snapshot); + let restore = lock_restore(&self.restore); + snapshot.connected + && snapshot.armed + && snapshot.audit_healthy + && !snapshot.operation_idle + && restore.as_ref().is_some_and(|state| { + state.operation_id == operation_id + && state.plan_hash == plan_hash + && state.next_index == next_index + }) + } + + fn advance_restore( + &self, + operation_id: OperationId, + plan_hash: &str, + next_index: usize, + ) -> Result<(), SessionControlError> { + let mut snapshot = lock_snapshot(&self.snapshot); + let mut restore = lock_restore(&self.restore); + let Some(state) = restore.as_mut() else { + return Err(SessionControlError::PreconditionChanged); + }; + if !snapshot.connected + || !snapshot.armed + || !snapshot.audit_healthy + || snapshot.operation_idle + || state.operation_id != operation_id + || state.plan_hash != plan_hash + || next_index != state.next_index.saturating_add(1) + { + return Err(SessionControlError::PreconditionChanged); + } + state.next_index = next_index; + snapshot.guard_revision = snapshot.guard_revision.saturating_add(1); + if self + .action_tx + .send(ApplicationAction::Session(SessionInput::RestoreAdvanced { + next_index, + })) + .is_err() + { + return Err(SessionControlError::Other( + "application session channel closed while advancing restore".to_owned(), + )); + } + Ok(()) + } + + fn finish_restore( + &self, + operation_id: OperationId, + plan_hash: &str, + ) -> Result<(), SessionControlError> { + let mut snapshot = lock_snapshot(&self.snapshot); + let mut restore = lock_restore(&self.restore); + let matches = restore.as_ref().is_some_and(|state| { + state.operation_id == operation_id && state.plan_hash == plan_hash + }); + if !matches || snapshot.operation_idle { + return Err(SessionControlError::PreconditionChanged); + } + *restore = None; + snapshot.operation_idle = true; + snapshot.armed = false; + snapshot.guard_revision = snapshot.guard_revision.saturating_add(1); + self.action_tx + .send(ApplicationAction::Session(SessionInput::RestoreFinished)) + .map_err(|_| { + SessionControlError::Other( + "application session channel closed while finishing restore".to_owned(), + ) + }) + } + + fn abort_restore( + &self, + operation_id: OperationId, + plan_hash: &str, + ) -> Result<(), SessionControlError> { + let mut snapshot = lock_snapshot(&self.snapshot); + let mut restore = lock_restore(&self.restore); + let matches = restore.as_ref().is_some_and(|state| { + state.operation_id == operation_id && state.plan_hash == plan_hash + }); + if !matches { + return Err(SessionControlError::PreconditionChanged); + } + *restore = None; + snapshot.operation_idle = true; + snapshot.armed = false; + snapshot.guard_revision = snapshot.guard_revision.saturating_add(1); + self.action_tx + .send(ApplicationAction::Session(SessionInput::RestoreAborted)) + .map_err(|_| { + SessionControlError::Other( + "application session channel closed while aborting restore".to_owned(), + ) + }) + } + fn disarm(&self) { { let mut snapshot = lock_snapshot(&self.snapshot); @@ -530,6 +695,7 @@ impl SessionControlPort for RuntimeSessionControl { snapshot.operation_idle = true; snapshot.guard_revision = snapshot.guard_revision.saturating_add(1); } + *lock_restore(&self.restore) = None; let _ = self.action_tx.send(ApplicationAction::Session( SessionInput::AuditPersistenceFailed { cause: "durable write audit failed".to_owned(), @@ -549,6 +715,14 @@ fn lock_snapshot(snapshot: &Mutex) -> MutexGuard<'_, Write .unwrap_or_else(std::sync::PoisonError::into_inner) } +fn lock_restore( + restore: &Mutex>, +) -> MutexGuard<'_, Option> { + restore + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) +} + fn unavailable_snapshot() -> WriteSessionSnapshot { WriteSessionSnapshot { session_id: SessionId::new(0), From 92ffcc73ed4275b00bc8eb92ddc0ae78d6c1b096 Mon Sep 17 00:00:00 2001 From: KeyffMS <124252104+KeyffMS@users.noreply.github.com> Date: Thu, 10 Sep 2026 17:51:26 +0200 Subject: [PATCH 15/38] app: clean backup restore product wrapper imports --- crates/lantern-app/src/product_application.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/lantern-app/src/product_application.rs b/crates/lantern-app/src/product_application.rs index 9aeed9d4..80c87216 100644 --- a/crates/lantern-app/src/product_application.rs +++ b/crates/lantern-app/src/product_application.rs @@ -8,10 +8,10 @@ use lantern_domain::{DriveState, ProfileId, SlaveId, UtcTimestamp}; use crate::{ backup_flow::{BackupRestoreState, PreparedRestoreBundle}, - ApplicationAction as _, ApprovedRestorePlan, BackupAction, BackupCaptureContext, BackupEffect, - BackupRestoreView, ConnectionAction, ConnectionWizardView, FaultAction, FaultTimelineView, - MonitoringAction, MonitoringView, ParameterAction, ParameterBrowserView, ProfileRegistry, - RestoreConfirmation, SessionInput, SessionStateMachine, + BackupAction, BackupCaptureContext, BackupEffect, BackupRestoreView, ConnectionAction, + ConnectionWizardView, FaultAction, FaultTimelineView, MonitoringAction, MonitoringView, + ParameterAction, ParameterBrowserView, ProfileRegistry, RestoreConfirmation, SessionInput, + SessionStateMachine, }; use crate::application as legacy; From 6c11a2d57db2b773907cb31deecba2478b28e3ff Mon Sep 17 00:00:00 2001 From: KeyffMS <124252104+KeyffMS@users.noreply.github.com> Date: Thu, 10 Sep 2026 17:52:44 +0200 Subject: [PATCH 16/38] tui: preserve existing global UI state semantics --- crates/lantern-tui/src/ui_state.rs | 291 +++++++++++++++++++++-------- 1 file changed, 215 insertions(+), 76 deletions(-) diff --git a/crates/lantern-tui/src/ui_state.rs b/crates/lantern-tui/src/ui_state.rs index 7caede19..56db3767 100644 --- a/crates/lantern-tui/src/ui_state.rs +++ b/crates/lantern-tui/src/ui_state.rs @@ -4,8 +4,7 @@ use lantern_app::{ }; use crate::{ - BackupUiState, FaultUiState, FormState, ParameterEditorUiState, ParameterUiState, ScopeUiState, - ScopeYRange, + FaultUiState, FormState, ParameterEditorUiState, ParameterUiState, ScopeUiState, ScopeYRange, }; #[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] @@ -111,7 +110,6 @@ pub struct UiState { pub scope_filter: String, pub scope: ScopeUiState, pub parameters: ParameterUiState, - pub backup: BackupUiState, pub faults: FaultUiState, pub modal: Option, pub viewport: Viewport, @@ -130,7 +128,6 @@ impl Default for UiState { scope_filter: String::new(), scope: ScopeUiState::default(), parameters: ParameterUiState::default(), - backup: BackupUiState::default(), faults: FaultUiState::default(), modal: None, viewport: Viewport::default(), @@ -195,10 +192,6 @@ pub enum UiAction { InputChar(char), Backspace, CancelEdit, - BackupBeginConfirmation, - BackupInputChar(char), - BackupBackspace, - BackupCancelConfirmation, ScopeTogglePause { anchor_nanos: u128, }, @@ -232,7 +225,6 @@ impl UiState { self.selected_index = 0; self.connection_edit = None; self.parameters.editor = None; - self.backup.cancel_confirmation(); self.form.clear(); } UiAction::NextScreen => { @@ -242,7 +234,6 @@ impl UiState { self.selected_index = 0; self.connection_edit = None; self.parameters.editor = None; - self.backup.cancel_confirmation(); self.form.clear(); } UiAction::PreviousScreen => { @@ -257,7 +248,6 @@ impl UiState { self.selected_index = 0; self.connection_edit = None; self.parameters.editor = None; - self.backup.cancel_confirmation(); self.form.clear(); } UiAction::ScrollUp => { @@ -393,7 +383,6 @@ impl UiState { self.scroll_offset = 0; self.connection_edit = None; self.parameters.editor = None; - self.backup.cancel_confirmation(); self.form.clear(); } UiAction::BeginParameterTextEditor { @@ -462,16 +451,6 @@ impl UiState { self.form.clear(); self.focus = Focus::Navigation; } - UiAction::BackupBeginConfirmation => { - self.backup.begin_confirmation(); - self.focus = Focus::Content; - } - UiAction::BackupInputChar(character) => self.backup.insert(character), - UiAction::BackupBackspace => self.backup.backspace(), - UiAction::BackupCancelConfirmation => { - self.backup.cancel_confirmation(); - self.focus = Focus::Navigation; - } UiAction::ScopeTogglePause { anchor_nanos } => { self.scope.toggle_pause(anchor_nanos); } @@ -540,82 +519,242 @@ pub(crate) fn profile_matches_filter(profile: &ProfileChoiceView, filter: &str) ) } -fn profile_fields_match_filter( - profile_id: &str, - vendor: &str, - family: &str, - model: &str, - filter: &str, -) -> bool { - let needle = filter.trim().to_ascii_lowercase(); - needle.is_empty() - || [profile_id, vendor, family, model] - .into_iter() - .any(|field| field.to_ascii_lowercase().contains(&needle)) -} - pub(crate) fn monitoring_parameter_matches_filter( parameter: &MonitoringParameterView, filter: &str, ) -> bool { - let needle = filter.trim().to_ascii_lowercase(); - needle.is_empty() - || parameter.code.to_ascii_lowercase().contains(&needle) - || parameter.name.to_ascii_lowercase().contains(&needle) + let needle = normalized_filter(filter); + if needle.is_empty() { + return true; + } + [ + parameter.parameter_id.as_str(), + parameter.code.as_str(), + parameter.name.as_str(), + parameter.unit.as_str(), + ] + .into_iter() + .any(|value| normalized_filter(value).contains(&needle)) + || normalized_filter(&format!("{:?}", parameter.quantity)).contains(&needle) || parameter .aliases .iter() - .any(|alias| alias.to_ascii_lowercase().contains(&needle)) - || format!("{:?}", parameter.quantity) - .to_ascii_lowercase() - .contains(&needle) - || parameter.unit.to_ascii_lowercase().contains(&needle) + .any(|alias| normalized_filter(alias).contains(&needle)) +} + +fn normalized_filter(value: &str) -> String { + value + .chars() + .filter(|character| character.is_ascii_alphanumeric()) + .flat_map(char::to_lowercase) + .collect() +} + +fn profile_fields_match_filter( + profile_id: &str, + vendor: &str, + family: &str, + model: &str, + filter: &str, +) -> bool { + let filter = filter.trim(); + if filter.is_empty() { + return true; + } + let needle = filter.to_ascii_lowercase(); + [profile_id, vendor, family, model] + .into_iter() + .any(|value| value.to_ascii_lowercase().contains(&needle)) } #[cfg(test)] mod tests { - use lantern_app::{MonitoringParameterView, ParameterId, QuantityKind}; + use std::path::PathBuf; + + use lantern_app::{ + PackagedProfilesManifestV1, ProfileRegistry, ProfileSource, ProfileSourceFormat, + ProfileSourceTier, monitoring_catalog, + }; - use super::{monitoring_parameter_matches_filter, profile_fields_match_filter}; + use super::{ + ConnectionEdit, Focus, ModalState, Screen, UiAction, UiState, + monitoring_parameter_matches_filter, profile_fields_match_filter, + }; + use crate::{ScopeWindow, ScopeYRange}; + + fn monitoring_parameter() -> lantern_app::MonitoringParameterView { + let registry = ProfileRegistry::from_sources( + vec![ProfileSource { + path: PathBuf::from("example-vfd.toml"), + bytes: include_bytes!("../../../profiles/example-vfd.toml") + .to_vec() + .into_boxed_slice(), + format: ProfileSourceFormat::Toml, + tier: ProfileSourceTier::Explicit, + }], + &PackagedProfilesManifestV1 { + schema_version: 1, + build_id: "test".to_owned(), + profiles: Vec::new(), + }, + ) + .expect("registry"); + let profile = registry + .entries() + .values() + .next() + .expect("profile") + .profile(); + monitoring_catalog(profile) + .into_iter() + .find(|parameter| parameter.parameter_id.as_str() == "status.output_frequency") + .expect("monitoring parameter") + } #[test] - fn profile_search_is_case_insensitive_and_metadata_only() { + fn ui_reducer_changes_only_presentation_state() { + let mut state = UiState::default(); + state.apply(UiAction::NextScreen); + state.apply(UiAction::ScrollDown); + state.apply(UiAction::FocusNext); + assert_eq!(state.screen, Screen::Dashboard); + assert_eq!(state.scroll_offset, 1); + assert_eq!(state.focus, Focus::Content); + } + + #[test] + fn scope_controls_are_presentation_only_and_persist_across_screens() { + let mut state = UiState { + screen: Screen::Scope, + ..UiState::default() + }; + state.apply(UiAction::ScopeTogglePause { anchor_nanos: 123 }); + state.apply(UiAction::ScopeNextWindow); + state.apply(UiAction::ScopePanBackward); + state.apply(UiAction::ScopeZoomIn); + state.apply(UiAction::ScopeToggleCursor); + state.apply(UiAction::ScopeCursorNext); + state.apply(UiAction::ScopeSetYRange { + panel: 1, + range: ScopeYRange::new(0.0, 100.0), + }); + assert!(state.scope.paused); + assert_eq!(state.scope.pause_anchor_nanos, Some(123)); + assert_eq!(state.scope.window, ScopeWindow::FiveMinutes); + assert_eq!(state.scope.pan_steps, -1); + assert_eq!(state.scope.zoom_steps, 1); + assert_eq!(state.scope.cursor_index, Some(1)); + assert!(state.scope.y_ranges.contains_key(&1)); + + state.apply(UiAction::SelectScreen(Screen::Dashboard)); + state.apply(UiAction::SelectScreen(Screen::Scope)); + assert!(state.scope.paused); + assert_eq!(state.scope.pan_steps, -1); + + state.apply(UiAction::ScopeResetView); + assert_eq!(state.scope, crate::ScopeUiState::default()); + } + + #[test] + fn scope_search_normalizes_code_alias_quantity_and_unit() { + let parameter = monitoring_parameter(); + assert!(monitoring_parameter_matches_filter(¶meter, "D1.00")); + assert!(monitoring_parameter_matches_filter( + ¶meter, + "status.output_frequency" + )); + assert!(monitoring_parameter_matches_filter(¶meter, "frequency")); + assert!(monitoring_parameter_matches_filter(¶meter, "hz")); + assert!(!monitoring_parameter_matches_filter(¶meter, "rpm")); + } + + #[test] + fn scope_search_edit_is_presentation_only() { + let mut state = UiState::default(); + state.apply(UiAction::BeginScopeSearch); + for character in "rpm".chars() { + state.apply(UiAction::InputChar(character)); + } + state.apply(UiAction::ApplyScopeSearch); + assert_eq!(state.scope_filter, "rpm"); + assert!(state.connection_edit.is_none()); + } + + #[test] + fn manual_path_edit_is_presentation_only() { + let mut state = UiState::default(); + state.apply(UiAction::BeginManualPath("/dev/ttyUSB".to_owned())); + state.apply(UiAction::InputChar('0')); + assert_eq!(state.connection_edit, Some(ConnectionEdit::ManualPath)); + assert_eq!(state.form.value(), "/dev/ttyUSB0"); + state.apply(UiAction::CancelEdit); + assert!(state.connection_edit.is_none()); + } + + #[test] + fn profile_search_is_case_insensitive_and_presentation_only() { + assert!(profile_fields_match_filter( + "example.vfd1000", + "Example Devices", + "Fictional", + "VFD 1000", + "devices", + )); assert!(profile_fields_match_filter( - "acme.v1", - "ACME", - "Falcon", - "F-100", - "falcon" + "example.vfd1000", + "Example Devices", + "Fictional", + "VFD 1000", + "VFD1000", )); assert!(profile_fields_match_filter( - "acme.v1", - "ACME", - "Falcon", - "F-100", - "F-100" + "example.vfd1000", + "Example Devices", + "Fictional", + "VFD 1000", + "fictional", )); assert!(!profile_fields_match_filter( - "acme.v1", - "ACME", - "Falcon", - "F-100", - "40001" + "example.vfd1000", + "Example Devices", + "Fictional", + "VFD 1000", + "other", )); + + let mut state = UiState::default(); + state.apply(UiAction::BeginProfileSearch); + for character in "vfd1000".chars() { + state.apply(UiAction::InputChar(character)); + } + state.apply(UiAction::ApplyProfileSearch); + assert_eq!(state.profile_filter, "vfd1000"); + assert!(state.connection_edit.is_none()); } #[test] - fn scope_search_matches_semantic_metadata() { - let parameter = MonitoringParameterView { - parameter_id: ParameterId::parse("motor.frequency").expect("id"), - code: "F1.01".to_owned(), - name: "Output Frequency".to_owned(), - aliases: vec!["Hz Out".to_owned()], - quantity: QuantityKind::Frequency, - unit: "Hz".to_owned(), - }; - assert!(monitoring_parameter_matches_filter(¶meter, "frequency")); - assert!(monitoring_parameter_matches_filter(¶meter, "hz out")); - assert!(monitoring_parameter_matches_filter(¶meter, "HZ")); - assert!(!monitoring_parameter_matches_filter(¶meter, "40001")); + fn resize_invalidates_layout_revision_only_when_dimensions_change() { + let mut state = UiState::default(); + state.apply(UiAction::Resize { + width: 100, + height: 30, + }); + assert_eq!(state.viewport.layout_revision, 1); + state.apply(UiAction::Resize { + width: 100, + height: 30, + }); + assert_eq!(state.viewport.layout_revision, 1); + } + + #[test] + fn modal_owns_focus_until_closed() { + let mut state = UiState::default(); + state.apply(UiAction::OpenHelp); + assert_eq!(state.modal, Some(ModalState::Help)); + assert_eq!(state.focus, Focus::Modal); + state.apply(UiAction::CloseModal); + assert!(state.modal.is_none()); + assert_eq!(state.focus, Focus::Navigation); } } From d45936be5980c7be6415200a2dbc5501b9b509b6 Mon Sep 17 00:00:00 2001 From: KeyffMS <124252104+KeyffMS@users.noreply.github.com> Date: Thu, 10 Sep 2026 17:53:18 +0200 Subject: [PATCH 17/38] tui: reuse existing exact confirmation form for restore --- crates/lantern-tui/src/backup_render.rs | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/crates/lantern-tui/src/backup_render.rs b/crates/lantern-tui/src/backup_render.rs index dc810fed..b35d24b1 100644 --- a/crates/lantern-tui/src/backup_render.rs +++ b/crates/lantern-tui/src/backup_render.rs @@ -6,7 +6,7 @@ use ratatui::{ widgets::{Block, Paragraph, Wrap}, }; -use crate::{Theme, UiState}; +use crate::{ConnectionEdit, Theme, UiState}; pub fn render_backup_screen( frame: &mut Frame<'_>, @@ -122,11 +122,8 @@ pub fn render_backup_screen( "Exact confirmation required: {}", plan.challenge ))); - if ui.backup.confirmation_active { - lines.push(Line::from(format!( - "Confirmation: {}_", - ui.backup.confirmation_input - ))); + if ui.connection_edit == Some(ConnectionEdit::WriteConfirmation) { + lines.push(Line::from(format!("Confirmation: {}_", ui.form.value()))); lines.push(Line::from("Enter submits exact text; Esc cancels without write.")); } else { lines.push(Line::from("Press c to enter the exact confirmation challenge.")); From a2015878b4ae7012cab86dc330a4b54a2ec7981a Mon Sep 17 00:00:00 2001 From: KeyffMS <124252104+KeyffMS@users.noreply.github.com> Date: Thu, 10 Sep 2026 17:53:34 +0200 Subject: [PATCH 18/38] tui: reuse existing form reducer for restore confirmation --- crates/lantern-tui/src/backup_keymap.rs | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/crates/lantern-tui/src/backup_keymap.rs b/crates/lantern-tui/src/backup_keymap.rs index f130855f..a6d8e0ea 100644 --- a/crates/lantern-tui/src/backup_keymap.rs +++ b/crates/lantern-tui/src/backup_keymap.rs @@ -1,7 +1,7 @@ use crossterm::event::{KeyCode, KeyEvent, KeyEventKind}; use lantern_app::{ApplicationAction, ApplicationView, BackupAction}; -use crate::{MappedAction, UiAction, UiState}; +use crate::{ConnectionEdit, MappedAction, UiAction, UiState}; #[must_use] pub fn map_backup_key( @@ -13,19 +13,17 @@ pub fn map_backup_key( return None; } - if ui.backup.confirmation_active { + if ui.connection_edit == Some(ConnectionEdit::WriteConfirmation) { return match key.code { - KeyCode::Esc => Some(MappedAction::Ui(UiAction::BackupCancelConfirmation)), + KeyCode::Esc => Some(MappedAction::Ui(UiAction::CancelEdit)), KeyCode::Enter => Some(MappedAction::Combined { - ui: UiAction::BackupCancelConfirmation, + ui: UiAction::CancelEdit, application: Box::new(ApplicationAction::Backup(BackupAction::ConfirmRestore { - operator_text: ui.backup.confirmation_input.clone(), + operator_text: ui.form.value().to_owned(), })), }), - KeyCode::Backspace => Some(MappedAction::Ui(UiAction::BackupBackspace)), - KeyCode::Char(character) => { - Some(MappedAction::Ui(UiAction::BackupInputChar(character))) - } + KeyCode::Backspace => Some(MappedAction::Ui(UiAction::Backspace)), + KeyCode::Char(character) => Some(MappedAction::Ui(UiAction::InputChar(character))), _ => None, }; } @@ -44,7 +42,7 @@ pub fn map_backup_key( ApplicationAction::Backup(BackupAction::PrepareRestore), ))), KeyCode::Char('c') if view.backup().prepared_plan.is_some() => { - Some(MappedAction::Ui(UiAction::BackupBeginConfirmation)) + Some(MappedAction::Ui(UiAction::BeginWriteConfirmation)) } KeyCode::Enter => view .backup() From 4e8fe97ff3a72e2e1b6c7c68d4d11dde3e5f5079 Mon Sep 17 00:00:00 2001 From: KeyffMS <124252104+KeyffMS@users.noreply.github.com> Date: Thu, 10 Sep 2026 17:54:04 +0200 Subject: [PATCH 19/38] tui: keep backup integration on existing UI state --- crates/lantern-tui/src/lib.rs | 2 -- 1 file changed, 2 deletions(-) diff --git a/crates/lantern-tui/src/lib.rs b/crates/lantern-tui/src/lib.rs index 0daad0e1..0ea425b1 100644 --- a/crates/lantern-tui/src/lib.rs +++ b/crates/lantern-tui/src/lib.rs @@ -4,7 +4,6 @@ mod backup_keymap; mod backup_render; -mod backup_state; mod fault_keymap; mod fault_render; mod fault_state; @@ -24,7 +23,6 @@ mod theme; mod ui_state; mod widgets; -pub use backup_state::*; pub use fault_state::*; pub use forms::*; pub use input::*; From e88a4c6620091507a4fbb4397f8ea50b826550da Mon Sep 17 00:00:00 2001 From: KeyffMS <124252104+KeyffMS@users.noreply.github.com> Date: Thu, 10 Sep 2026 17:54:25 +0200 Subject: [PATCH 20/38] tui: remove redundant backup input state --- crates/lantern-tui/src/backup_state.rs | 29 -------------------------- 1 file changed, 29 deletions(-) delete mode 100644 crates/lantern-tui/src/backup_state.rs diff --git a/crates/lantern-tui/src/backup_state.rs b/crates/lantern-tui/src/backup_state.rs deleted file mode 100644 index 68308de5..00000000 --- a/crates/lantern-tui/src/backup_state.rs +++ /dev/null @@ -1,29 +0,0 @@ -#[derive(Clone, Debug, Default, Eq, PartialEq)] -pub struct BackupUiState { - pub confirmation_active: bool, - pub confirmation_input: String, -} - -impl BackupUiState { - pub fn begin_confirmation(&mut self) { - self.confirmation_active = true; - self.confirmation_input.clear(); - } - - pub fn cancel_confirmation(&mut self) { - self.confirmation_active = false; - self.confirmation_input.clear(); - } - - pub fn insert(&mut self, character: char) { - if self.confirmation_active { - self.confirmation_input.push(character); - } - } - - pub fn backspace(&mut self) { - if self.confirmation_active { - self.confirmation_input.pop(); - } - } -} From b3df3f5ec00a9be2af5dedb24dad245a63f074f1 Mon Sep 17 00:00:00 2001 From: KeyffMS <124252104+KeyffMS@users.noreply.github.com> Date: Thu, 10 Sep 2026 17:57:17 +0200 Subject: [PATCH 21/38] style: apply rustfmt to backup restore application flow --- crates/lantern-app/src/product_application.rs | 32 ++++++++++++++----- 1 file changed, 24 insertions(+), 8 deletions(-) diff --git a/crates/lantern-app/src/product_application.rs b/crates/lantern-app/src/product_application.rs index 80c87216..1e16c564 100644 --- a/crates/lantern-app/src/product_application.rs +++ b/crates/lantern-app/src/product_application.rs @@ -7,11 +7,11 @@ use std::{ use lantern_domain::{DriveState, ProfileId, SlaveId, UtcTimestamp}; use crate::{ - backup_flow::{BackupRestoreState, PreparedRestoreBundle}, BackupAction, BackupCaptureContext, BackupEffect, BackupRestoreView, ConnectionAction, ConnectionWizardView, FaultAction, FaultTimelineView, MonitoringAction, MonitoringView, ParameterAction, ParameterBrowserView, ProfileRegistry, RestoreConfirmation, SessionInput, SessionStateMachine, + backup_flow::{BackupRestoreState, PreparedRestoreBundle}, }; use crate::application as legacy; @@ -155,8 +155,12 @@ impl ApplicationState { let previous_session = self.inner.view().active_session(); let legacy_action = match action { - ApplicationAction::ReplaceRegistry(value) => legacy::ApplicationAction::ReplaceRegistry(value), - ApplicationAction::SelectProfile(value) => legacy::ApplicationAction::SelectProfile(value), + ApplicationAction::ReplaceRegistry(value) => { + legacy::ApplicationAction::ReplaceRegistry(value) + } + ApplicationAction::SelectProfile(value) => { + legacy::ApplicationAction::SelectProfile(value) + } ApplicationAction::Connection(value) => legacy::ApplicationAction::Connection(value), ApplicationAction::Monitoring(value) => legacy::ApplicationAction::Monitoring(value), ApplicationAction::Parameters(value) => legacy::ApplicationAction::Parameters(value), @@ -262,15 +266,22 @@ impl ApplicationState { Vec::new() } BackupAction::PrepareRestore => { - let Some(source) = self.backup.source.as_ref().map(|stored| stored.snapshot.clone()) else { - self.backup.error = Some("select a source backup before preparing restore".to_owned()); + let Some(source) = self + .backup + .source + .as_ref() + .map(|stored| stored.snapshot.clone()) + else { + self.backup.error = + Some("select a source backup before preparing restore".to_owned()); return Vec::new(); }; match self.backup_capture_context() { Ok(context) => { self.backup.invalidate_prepared_operation(); self.backup.status = Some( - "capturing fresh pre-restore backup and building guarded plan".to_owned(), + "capturing fresh pre-restore backup and building guarded plan" + .to_owned(), ); self.backup.error = None; vec![ApplicationEffect::Backup(BackupEffect::PrepareRestore { @@ -358,7 +369,8 @@ impl ApplicationState { fn backup_capture_context(&self) -> Result { let view = self.inner.view(); - if view.session().phase() != SessionPhaseView::Connected || view.active_session().is_none() { + if view.session().phase() != SessionPhaseView::Connected || view.active_session().is_none() + { return Err("backup/restore requires a connected Verified session".to_owned()); } let profile_hash = view @@ -380,7 +392,11 @@ impl ApplicationState { app_version: env!("CARGO_PKG_VERSION").to_owned(), build_id: self.build_id.clone(), profile_origin: format!("{:?}", entry.origin()), - adapter: view.session().port().unwrap_or("unknown-adapter").to_owned(), + adapter: view + .session() + .port() + .unwrap_or("unknown-adapter") + .to_owned(), link_settings: format!( "baud={} parity={:?} data={:?} stop={:?} slave={} timeout_ms={} rs485={:?}", link.current.baud_rate.get(), From ac906aff01ff7452a81361f75e787a21c5d92739 Mon Sep 17 00:00:00 2001 From: KeyffMS <124252104+KeyffMS@users.noreply.github.com> Date: Thu, 10 Sep 2026 17:57:31 +0200 Subject: [PATCH 22/38] style: apply rustfmt to backup restore keymap --- crates/lantern-tui/src/backup_keymap.rs | 14 +++----------- 1 file changed, 3 insertions(+), 11 deletions(-) diff --git a/crates/lantern-tui/src/backup_keymap.rs b/crates/lantern-tui/src/backup_keymap.rs index a6d8e0ea..12f81915 100644 --- a/crates/lantern-tui/src/backup_keymap.rs +++ b/crates/lantern-tui/src/backup_keymap.rs @@ -4,11 +4,7 @@ use lantern_app::{ApplicationAction, ApplicationView, BackupAction}; use crate::{ConnectionEdit, MappedAction, UiAction, UiState}; #[must_use] -pub fn map_backup_key( - ui: &UiState, - view: &ApplicationView, - key: KeyEvent, -) -> Option { +pub fn map_backup_key(ui: &UiState, view: &ApplicationView, key: KeyEvent) -> Option { if !matches!(key.kind, KeyEventKind::Press | KeyEventKind::Repeat) { return None; } @@ -54,12 +50,8 @@ pub fn map_backup_key( BackupAction::SelectSource(path), ))) }), - KeyCode::Char('j') | KeyCode::Down => { - Some(MappedAction::Ui(UiAction::SelectionNext)) - } - KeyCode::Char('k') | KeyCode::Up => { - Some(MappedAction::Ui(UiAction::SelectionPrevious)) - } + KeyCode::Char('j') | KeyCode::Down => Some(MappedAction::Ui(UiAction::SelectionNext)), + KeyCode::Char('k') | KeyCode::Up => Some(MappedAction::Ui(UiAction::SelectionPrevious)), _ => None, } } From d67a46c7d6e588a16f7e85444272ec9ddaebae5b Mon Sep 17 00:00:00 2001 From: KeyffMS <124252104+KeyffMS@users.noreply.github.com> Date: Thu, 10 Sep 2026 17:58:15 +0200 Subject: [PATCH 23/38] style: apply rustfmt to backup renderer --- crates/lantern-tui/src/backup_render.rs | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/crates/lantern-tui/src/backup_render.rs b/crates/lantern-tui/src/backup_render.rs index b35d24b1..20826a58 100644 --- a/crates/lantern-tui/src/backup_render.rs +++ b/crates/lantern-tui/src/backup_render.rs @@ -17,8 +17,12 @@ pub fn render_backup_screen( ) { let backup = view.backup(); let mut lines = vec![ - Line::from("b capture | r refresh files | Enter select source | p prepare restore | c confirm | x clear source"), - Line::from("Restore remains gated by Verified + trust + Armed + healthy audit + exact confirmation + permit."), + Line::from( + "b capture | r refresh files | Enter select source | p prepare restore | c confirm | x clear source", + ), + Line::from( + "Restore remains gated by Verified + trust + Armed + healthy audit + exact confirmation + permit.", + ), Line::from(""), ]; @@ -89,7 +93,10 @@ pub fn render_backup_screen( if !backup.diff.is_empty() { lines.push(Line::from("")); - lines.push(Line::from(format!("Semantic diff ({} entries):", backup.diff.len()))); + lines.push(Line::from(format!( + "Semantic diff ({} entries):", + backup.diff.len() + ))); for entry in backup.diff.iter().take(64) { lines.push(Line::from(format!( " {} {:?}", @@ -124,9 +131,13 @@ pub fn render_backup_screen( ))); if ui.connection_edit == Some(ConnectionEdit::WriteConfirmation) { lines.push(Line::from(format!("Confirmation: {}_", ui.form.value()))); - lines.push(Line::from("Enter submits exact text; Esc cancels without write.")); + lines.push(Line::from( + "Enter submits exact text; Esc cancels without write.", + )); } else { - lines.push(Line::from("Press c to enter the exact confirmation challenge.")); + lines.push(Line::from( + "Press c to enter the exact confirmation challenge.", + )); } } From 7d7defbde010565d3c7492659942c8198cbc0736 Mon Sep 17 00:00:00 2001 From: KeyffMS <124252104+KeyffMS@users.noreply.github.com> Date: Thu, 10 Sep 2026 18:00:04 +0200 Subject: [PATCH 24/38] style: apply rustfmt to backup restore runtime --- crates/vfd-lantern/src/write_runtime.rs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/crates/vfd-lantern/src/write_runtime.rs b/crates/vfd-lantern/src/write_runtime.rs index 17bf4c92..90902b39 100644 --- a/crates/vfd-lantern/src/write_runtime.rs +++ b/crates/vfd-lantern/src/write_runtime.rs @@ -239,10 +239,7 @@ impl ProductionWriteRuntime { match effect { BackupEffect::RefreshCatalog => { let result = backup_catalog(&self.backup_directory); - send_backup_action( - &self.action_tx, - BackupAction::CatalogRefreshed(result), - ) + send_backup_action(&self.action_tx, BackupAction::CatalogRefreshed(result)) } BackupEffect::LoadSource { path } => { let sender = self.action_tx.clone(); From c121a4c3bcc307bbed2705f1f08b4fe460359ba7 Mon Sep 17 00:00:00 2001 From: KeyffMS <124252104+KeyffMS@users.noreply.github.com> Date: Thu, 10 Sep 2026 18:03:18 +0200 Subject: [PATCH 25/38] app: box large backup restore payloads --- crates/lantern-app/src/backup_flow.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/lantern-app/src/backup_flow.rs b/crates/lantern-app/src/backup_flow.rs index e3f72d6b..6f14f798 100644 --- a/crates/lantern-app/src/backup_flow.rs +++ b/crates/lantern-app/src/backup_flow.rs @@ -37,7 +37,7 @@ pub enum BackupAction { }, ClearSource, PrepareRestore, - RestorePrepared(Result), + RestorePrepared(Box>), ConfirmRestore { operator_text: String, }, @@ -54,7 +54,7 @@ pub enum BackupEffect { path: PathBuf, }, PrepareRestore { - source: BackupSnapshot, + source: Box, context: BackupCaptureContext, }, ExecuteRestore { From 43dd7a551438912798b65f8436c078b3c3c297d1 Mon Sep 17 00:00:00 2001 From: KeyffMS <124252104+KeyffMS@users.noreply.github.com> Date: Thu, 10 Sep 2026 18:03:57 +0200 Subject: [PATCH 26/38] app: keep product action and effect payloads bounded --- crates/lantern-app/src/product_application.rs | 58 +++++++++---------- 1 file changed, 28 insertions(+), 30 deletions(-) diff --git a/crates/lantern-app/src/product_application.rs b/crates/lantern-app/src/product_application.rs index 1e16c564..4940426c 100644 --- a/crates/lantern-app/src/product_application.rs +++ b/crates/lantern-app/src/product_application.rs @@ -16,10 +16,7 @@ use crate::{ use crate::application as legacy; -pub use legacy::{ - ApplicationEffectError, AuditHealthView, AuthorizationView, OperationView, SessionPhaseView, - SessionView, -}; +pub use legacy::{ApplicationEffectError, SessionPhaseView, SessionView}; #[derive(Clone, Debug)] pub enum ApplicationAction { @@ -30,7 +27,7 @@ pub enum ApplicationAction { Parameters(ParameterAction), Faults(FaultAction), Session(SessionInput), - Backup(BackupAction), + Backup(Box), } #[derive(Clone, Debug)] @@ -40,7 +37,7 @@ pub enum ApplicationEffect { Faults(crate::FaultEffect), Write(crate::WriteEffect), Session(crate::SessionEffect), - Backup(BackupEffect), + Backup(Box), } impl From for ApplicationEffect { @@ -150,7 +147,7 @@ impl ApplicationState { pub fn reduce(&mut self, action: ApplicationAction) -> Vec { if let ApplicationAction::Backup(action) = action { - return self.reduce_backup(action); + return self.reduce_backup(*action); } let previous_session = self.inner.view().active_session(); @@ -188,7 +185,9 @@ impl ApplicationState { BackupAction::RefreshCatalog => { self.backup.status = Some("refreshing backup catalog".to_owned()); self.backup.error = None; - vec![ApplicationEffect::Backup(BackupEffect::RefreshCatalog)] + vec![ApplicationEffect::Backup(Box::new( + BackupEffect::RefreshCatalog, + ))] } BackupAction::CatalogRefreshed(result) => { match result { @@ -211,7 +210,9 @@ impl ApplicationState { Ok(context) => { self.backup.status = Some("capturing complete profile backup".to_owned()); self.backup.error = None; - vec![ApplicationEffect::Backup(BackupEffect::Capture { context })] + vec![ApplicationEffect::Backup(Box::new(BackupEffect::Capture { + context, + }))] } Err(error) => { self.backup.status = None; @@ -241,7 +242,9 @@ impl ApplicationState { self.backup.invalidate_prepared_operation(); self.backup.status = Some(format!("loading backup {}", path.display())); self.backup.error = None; - vec![ApplicationEffect::Backup(BackupEffect::LoadSource { path })] + vec![ApplicationEffect::Backup(Box::new( + BackupEffect::LoadSource { path }, + ))] } BackupAction::SourceLoaded { path, result } => { match result { @@ -284,10 +287,12 @@ impl ApplicationState { .to_owned(), ); self.backup.error = None; - vec![ApplicationEffect::Backup(BackupEffect::PrepareRestore { - source, - context, - })] + vec![ApplicationEffect::Backup(Box::new( + BackupEffect::PrepareRestore { + source: Box::new(source), + context, + }, + ))] } Err(error) => { self.backup.status = None; @@ -297,7 +302,7 @@ impl ApplicationState { } } BackupAction::RestorePrepared(result) => { - match result { + match *result { Ok(PreparedRestoreBundle { pre_restore, diff, @@ -338,12 +343,14 @@ impl ApplicationState { .expect("prepared plan checked above"); self.backup.status = Some("executing guarded restore".to_owned()); self.backup.error = None; - vec![ApplicationEffect::Backup(BackupEffect::ExecuteRestore { - confirmation: RestoreConfirmation::Confirm { - challenge: operator_text, + vec![ApplicationEffect::Backup(Box::new( + BackupEffect::ExecuteRestore { + confirmation: RestoreConfirmation::Confirm { + challenge: operator_text, + }, + plan, }, - plan, - })] + ))] } BackupAction::RestoreCompleted(result) => { self.backup.prepared_plan = None; @@ -422,21 +429,12 @@ fn utc_now() -> UtcTimestamp { UtcTimestamp::from_unix_nanos(nanos) } -#[derive(Clone, Debug)] +#[derive(Clone, Debug, Default)] pub struct ApplicationView { inner: legacy::ApplicationView, backup: BackupRestoreView, } -impl Default for ApplicationView { - fn default() -> Self { - Self { - inner: legacy::ApplicationView::default(), - backup: BackupRestoreView::default(), - } - } -} - impl ApplicationView { #[must_use] pub fn active_profile_id(&self) -> Option<&str> { From 4035b4e8b89f1da8047dbeab7eb5bcff475de11f Mon Sep 17 00:00:00 2001 From: KeyffMS <124252104+KeyffMS@users.noreply.github.com> Date: Thu, 10 Sep 2026 18:04:11 +0200 Subject: [PATCH 27/38] app: keep legacy unit tests isolated from product wrapper --- crates/lantern-app/src/lib.rs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/crates/lantern-app/src/lib.rs b/crates/lantern-app/src/lib.rs index 5bc2edc2..28d771d8 100644 --- a/crates/lantern-app/src/lib.rs +++ b/crates/lantern-app/src/lib.rs @@ -4,6 +4,7 @@ mod application; mod backup; +#[cfg(not(test))] mod backup_flow; mod bus; mod clock; @@ -18,6 +19,7 @@ mod monitoring_projection; mod parameters; mod poll; mod ports; +#[cfg(not(test))] mod product_application; mod profile_registry; mod restore; @@ -31,7 +33,15 @@ mod write_flow; #[cfg(test)] pub use application::*; +#[cfg(not(test))] +pub use application::{AuditHealthView, AuthorizationView, OperationView}; +#[cfg(not(test))] +#[doc(hidden)] +pub use application::{ + ApplicationRuntime as LegacyApplicationRuntime, EffectRunner as LegacyEffectRunner, +}; pub use backup::*; +#[cfg(not(test))] pub use backup_flow::*; pub use bus::*; pub use clock::*; From abaef31dfdad3795bba5c5794cc3c5b4d743acac Mon Sep 17 00:00:00 2001 From: KeyffMS <124252104+KeyffMS@users.noreply.github.com> Date: Thu, 10 Sep 2026 18:05:20 +0200 Subject: [PATCH 28/38] runtime: unwrap bounded backup effects at runner boundary --- crates/vfd-lantern/src/connection_runtime.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/vfd-lantern/src/connection_runtime.rs b/crates/vfd-lantern/src/connection_runtime.rs index 9e534827..29dcd377 100644 --- a/crates/vfd-lantern/src/connection_runtime.rs +++ b/crates/vfd-lantern/src/connection_runtime.rs @@ -386,7 +386,7 @@ impl EffectRunner for TuiEffectRunner { ApplicationEffect::Faults(effect) => self.execute_fault(effect), ApplicationEffect::Write(effect) => self.write.execute(effect), ApplicationEffect::Session(effect) => self.execute_session(effect), - ApplicationEffect::Backup(effect) => self.write.execute_backup(effect), + ApplicationEffect::Backup(effect) => self.write.execute_backup(*effect), } } } From 75da3e69ec97db95dae56b79dc06097e2097d29a Mon Sep 17 00:00:00 2001 From: KeyffMS <124252104+KeyffMS@users.noreply.github.com> Date: Thu, 10 Sep 2026 18:05:55 +0200 Subject: [PATCH 29/38] app: box startup backup catalog action --- crates/vfd-lantern/src/main.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/vfd-lantern/src/main.rs b/crates/vfd-lantern/src/main.rs index d1f58811..87d2cfc4 100644 --- a/crates/vfd-lantern/src/main.rs +++ b/crates/vfd-lantern/src/main.rs @@ -182,7 +182,9 @@ async fn run_tui(settings: &ValidatedSettings, paths: &AppPaths) -> Result<()> { application.dispatch(ApplicationAction::Connection( ConnectionAction::RefreshPorts, ))?; - application.dispatch(ApplicationAction::Backup(BackupAction::RefreshCatalog))?; + application.dispatch(ApplicationAction::Backup(Box::new( + BackupAction::RefreshCatalog, + )))?; terminal.draw(&application.state().view(), &ui)?; let frame_interval = Duration::from_millis(1_000 / u64::from(settings.render_fps)); From 6deda48b21ca8bec83c3e75a597266aa5256c8a2 Mon Sep 17 00:00:00 2001 From: KeyffMS <124252104+KeyffMS@users.noreply.github.com> Date: Thu, 10 Sep 2026 18:06:11 +0200 Subject: [PATCH 30/38] tui: box backup action payloads --- crates/lantern-tui/src/backup_keymap.rs | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/crates/lantern-tui/src/backup_keymap.rs b/crates/lantern-tui/src/backup_keymap.rs index 12f81915..fa89d836 100644 --- a/crates/lantern-tui/src/backup_keymap.rs +++ b/crates/lantern-tui/src/backup_keymap.rs @@ -14,9 +14,11 @@ pub fn map_backup_key(ui: &UiState, view: &ApplicationView, key: KeyEvent) -> Op KeyCode::Esc => Some(MappedAction::Ui(UiAction::CancelEdit)), KeyCode::Enter => Some(MappedAction::Combined { ui: UiAction::CancelEdit, - application: Box::new(ApplicationAction::Backup(BackupAction::ConfirmRestore { - operator_text: ui.form.value().to_owned(), - })), + application: Box::new(ApplicationAction::Backup(Box::new( + BackupAction::ConfirmRestore { + operator_text: ui.form.value().to_owned(), + }, + ))), }), KeyCode::Backspace => Some(MappedAction::Ui(UiAction::Backspace)), KeyCode::Char(character) => Some(MappedAction::Ui(UiAction::InputChar(character))), @@ -26,16 +28,16 @@ pub fn map_backup_key(ui: &UiState, view: &ApplicationView, key: KeyEvent) -> Op match key.code { KeyCode::Char('b') => Some(MappedAction::Application(Box::new( - ApplicationAction::Backup(BackupAction::Capture), + ApplicationAction::Backup(Box::new(BackupAction::Capture)), ))), KeyCode::Char('r') => Some(MappedAction::Application(Box::new( - ApplicationAction::Backup(BackupAction::RefreshCatalog), + ApplicationAction::Backup(Box::new(BackupAction::RefreshCatalog)), ))), KeyCode::Char('x') => Some(MappedAction::Application(Box::new( - ApplicationAction::Backup(BackupAction::ClearSource), + ApplicationAction::Backup(Box::new(BackupAction::ClearSource)), ))), KeyCode::Char('p') => Some(MappedAction::Application(Box::new( - ApplicationAction::Backup(BackupAction::PrepareRestore), + ApplicationAction::Backup(Box::new(BackupAction::PrepareRestore)), ))), KeyCode::Char('c') if view.backup().prepared_plan.is_some() => { Some(MappedAction::Ui(UiAction::BeginWriteConfirmation)) @@ -46,9 +48,9 @@ pub fn map_backup_key(ui: &UiState, view: &ApplicationView, key: KeyEvent) -> Op .get(ui.selected_index) .cloned() .map(|path| { - MappedAction::Application(Box::new(ApplicationAction::Backup( + MappedAction::Application(Box::new(ApplicationAction::Backup(Box::new( BackupAction::SelectSource(path), - ))) + )))) }), KeyCode::Char('j') | KeyCode::Down => Some(MappedAction::Ui(UiAction::SelectionNext)), KeyCode::Char('k') | KeyCode::Up => Some(MappedAction::Ui(UiAction::SelectionPrevious)), From a2602a7810743b6f0cda5cfc1f909279644ed576 Mon Sep 17 00:00:00 2001 From: KeyffMS <124252104+KeyffMS@users.noreply.github.com> Date: Thu, 10 Sep 2026 18:07:39 +0200 Subject: [PATCH 31/38] runtime: adapt guarded backup flow to boxed product boundary --- crates/vfd-lantern/src/write_runtime.rs | 25 +++++++++++++------------ 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/crates/vfd-lantern/src/write_runtime.rs b/crates/vfd-lantern/src/write_runtime.rs index 90902b39..a99c56e0 100644 --- a/crates/vfd-lantern/src/write_runtime.rs +++ b/crates/vfd-lantern/src/write_runtime.rs @@ -245,10 +245,9 @@ impl ProductionWriteRuntime { let sender = self.action_tx.clone(); tokio::task::spawn_blocking(move || { let result = read_backup(&path).map_err(|error| error.to_string()); - let _ = sender.send(ApplicationAction::Backup(BackupAction::SourceLoaded { - path, - result, - })); + let _ = sender.send(ApplicationAction::Backup(Box::new( + BackupAction::SourceLoaded { path, result }, + ))); }); Ok(()) } @@ -272,7 +271,9 @@ impl ProductionWriteRuntime { persist_backup(&directory, snapshot) } .await; - let _ = sender.send(ApplicationAction::Backup(BackupAction::Captured(result))); + let _ = sender.send(ApplicationAction::Backup(Box::new( + BackupAction::Captured(result), + ))); }); Ok(()) } @@ -294,7 +295,7 @@ impl ProductionWriteRuntime { .await .map_err(|error| error.to_string())?; let pre_restore = persist_backup(&directory, current)?; - let diff = semantic_backup_diff(&source, &pre_restore.snapshot, None); + let diff = semantic_backup_diff(source.as_ref(), &pre_restore.snapshot, None); let plan = coordinator .lock() .await @@ -303,7 +304,7 @@ impl ProductionWriteRuntime { "restore capability unavailable: write/audit/trust composition is incomplete" .to_owned() })? - .prepare_restore_plan(&source, &pre_restore.snapshot) + .prepare_restore_plan(source.as_ref(), &pre_restore.snapshot) .await .map_err(|error| error.to_string())?; Ok(PreparedRestoreBundle { @@ -313,8 +314,8 @@ impl ProductionWriteRuntime { }) } .await; - let _ = sender.send(ApplicationAction::Backup(BackupAction::RestorePrepared( - result, + let _ = sender.send(ApplicationAction::Backup(Box::new( + BackupAction::RestorePrepared(Box::new(result)), ))); }); Ok(()) @@ -361,8 +362,8 @@ impl ProductionWriteRuntime { }) } .await; - let _ = sender.send(ApplicationAction::Backup(BackupAction::RestoreCompleted( - result, + let _ = sender.send(ApplicationAction::Backup(Box::new( + BackupAction::RestoreCompleted(result), ))); }); Ok(()) @@ -376,7 +377,7 @@ fn send_backup_action( action: BackupAction, ) -> Result<(), ApplicationEffectError> { sender - .send(ApplicationAction::Backup(action)) + .send(ApplicationAction::Backup(Box::new(action))) .map_err(|_| ApplicationEffectError("application action channel closed".to_owned())) } From c47cb6fdd5454f0654408de2a99e9a55490d0ae2 Mon Sep 17 00:00:00 2001 From: KeyffMS <124252104+KeyffMS@users.noreply.github.com> Date: Thu, 10 Sep 2026 18:09:18 +0200 Subject: [PATCH 32/38] style: apply rustfmt ordering to application exports --- crates/lantern-app/src/lib.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/lantern-app/src/lib.rs b/crates/lantern-app/src/lib.rs index 28d771d8..4bd98990 100644 --- a/crates/lantern-app/src/lib.rs +++ b/crates/lantern-app/src/lib.rs @@ -34,12 +34,12 @@ mod write_flow; #[cfg(test)] pub use application::*; #[cfg(not(test))] -pub use application::{AuditHealthView, AuthorizationView, OperationView}; -#[cfg(not(test))] #[doc(hidden)] pub use application::{ ApplicationRuntime as LegacyApplicationRuntime, EffectRunner as LegacyEffectRunner, }; +#[cfg(not(test))] +pub use application::{AuditHealthView, AuthorizationView, OperationView}; pub use backup::*; #[cfg(not(test))] pub use backup_flow::*; From 38cbbdab1f5f2b6601c1c794c4c839d339717a85 Mon Sep 17 00:00:00 2001 From: KeyffMS <124252104+KeyffMS@users.noreply.github.com> Date: Thu, 10 Sep 2026 18:15:18 +0200 Subject: [PATCH 33/38] test: scope write runtime adapter constructor to tests --- crates/vfd-lantern/src/write_runtime.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/vfd-lantern/src/write_runtime.rs b/crates/vfd-lantern/src/write_runtime.rs index a99c56e0..e2979724 100644 --- a/crates/vfd-lantern/src/write_runtime.rs +++ b/crates/vfd-lantern/src/write_runtime.rs @@ -64,6 +64,7 @@ impl ProductionWriteRuntime { ) } + #[cfg(test)] fn from_adapters( action_tx: mpsc::UnboundedSender, audit: Option>, From 81ff5b4dc1bf0f565abebe40e2d3a296fdd3e632 Mon Sep 17 00:00:00 2001 From: KeyffMS <124252104+KeyffMS@users.noreply.github.com> Date: Thu, 10 Sep 2026 18:17:34 +0200 Subject: [PATCH 34/38] storage: own backup catalog filesystem access --- crates/lantern-storage/src/backup_catalog.rs | 51 ++++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 crates/lantern-storage/src/backup_catalog.rs diff --git a/crates/lantern-storage/src/backup_catalog.rs b/crates/lantern-storage/src/backup_catalog.rs new file mode 100644 index 00000000..dd81a32a --- /dev/null +++ b/crates/lantern-storage/src/backup_catalog.rs @@ -0,0 +1,51 @@ +use std::{fs, io, path::{Path, PathBuf}}; + +use crate::BACKUP_SUFFIX; + +/// Lists regular backup files in deterministic path order. Symlinks and non-files are omitted; +/// actual backup parsing remains bounded and validated by `read_backup`. +pub fn list_backup_files(directory: &Path) -> io::Result> { + if !directory.exists() { + return Ok(Vec::new()); + } + + let mut paths = Vec::new(); + for entry in fs::read_dir(directory)? { + let entry = entry?; + if !entry.file_type()?.is_file() { + continue; + } + let path = entry.path(); + if path + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.ends_with(BACKUP_SUFFIX)) + { + paths.push(path); + } + } + paths.sort(); + Ok(paths) +} + +#[cfg(test)] +mod tests { + use std::{fs, os::unix::fs::symlink}; + + use tempfile::tempdir; + + use super::list_backup_files; + + #[test] + fn catalog_is_sorted_and_ignores_non_backup_and_symlink_entries() { + let directory = tempdir().expect("tempdir"); + let a = directory.path().join("a.vfdlantern-backup.json"); + let b = directory.path().join("b.vfdlantern-backup.json"); + fs::write(&b, b"b").expect("b"); + fs::write(&a, b"a").expect("a"); + fs::write(directory.path().join("notes.txt"), b"ignore").expect("notes"); + symlink(&a, directory.path().join("linked.vfdlantern-backup.json")).expect("symlink"); + + assert_eq!(list_backup_files(directory.path()).expect("catalog"), vec![a, b]); + } +} From 47f587158545636170e8b6e9a1379c94b2155d44 Mon Sep 17 00:00:00 2001 From: KeyffMS <124252104+KeyffMS@users.noreply.github.com> Date: Thu, 10 Sep 2026 18:17:46 +0200 Subject: [PATCH 35/38] storage: export deterministic backup catalog --- crates/lantern-storage/src/lib.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/lantern-storage/src/lib.rs b/crates/lantern-storage/src/lib.rs index ec7afb70..275f26cf 100644 --- a/crates/lantern-storage/src/lib.rs +++ b/crates/lantern-storage/src/lib.rs @@ -6,6 +6,7 @@ mod artifacts; mod atomic; mod audit; mod backup; +mod backup_catalog; mod csv_lifecycle; mod csv_writer; mod diagnostics_bundle; @@ -30,6 +31,7 @@ pub use backup::{ BACKUP_SCHEMA_VERSION, BACKUP_SUFFIX, BackupEnvelopeV1, BackupPayloadV1, BackupStorageError, MAX_BACKUP_FILE_BYTES, MAX_BACKUP_VALUES, decode_backup, read_backup, write_backup, }; +pub use backup_catalog::list_backup_files; pub use csv_lifecycle::{CsvLoggingCoordinator, CsvLoggingLifecycleState}; pub use csv_writer::{ CSV_HEADER, CSV_SCHEMA_VERSION, CsvWriterActor, CsvWriterHandle, CsvWriterStart, From 4cc99ecbfe20f0b765fc9c0e6a6029ac38371012 Mon Sep 17 00:00:00 2001 From: KeyffMS <124252104+KeyffMS@users.noreply.github.com> Date: Thu, 10 Sep 2026 18:18:57 +0200 Subject: [PATCH 36/38] runtime: delegate backup catalog IO to storage adapter --- crates/vfd-lantern/src/write_runtime.rs | 20 +++----------------- 1 file changed, 3 insertions(+), 17 deletions(-) diff --git a/crates/vfd-lantern/src/write_runtime.rs b/crates/vfd-lantern/src/write_runtime.rs index e2979724..e7b73f54 100644 --- a/crates/vfd-lantern/src/write_runtime.rs +++ b/crates/vfd-lantern/src/write_runtime.rs @@ -1,5 +1,4 @@ use std::{ - fs, path::{Path, PathBuf}, sync::{Arc, Mutex, MutexGuard}, time::Instant, @@ -14,7 +13,8 @@ use lantern_app::{ WriteEffect, WriteOutcome, WriteSessionSnapshot, semantic_backup_diff, }; use lantern_storage::{ - BACKUP_SUFFIX, FilesystemAuditPort, RuntimeProfileTrust, read_backup, write_backup, + BACKUP_SUFFIX, FilesystemAuditPort, RuntimeProfileTrust, list_backup_files, read_backup, + write_backup, }; use lantern_transport::BusActorHandle; use tokio::sync::{Mutex as AsyncMutex, mpsc}; @@ -383,21 +383,7 @@ fn send_backup_action( } fn backup_catalog(directory: &Path) -> Result, String> { - if !directory.exists() { - return Ok(Vec::new()); - } - let mut paths = fs::read_dir(directory) - .map_err(|error| error.to_string())? - .filter_map(Result::ok) - .map(|entry| entry.path()) - .filter(|path| { - path.file_name() - .and_then(|name| name.to_str()) - .is_some_and(|name| name.ends_with(BACKUP_SUFFIX)) - }) - .collect::>(); - paths.sort(); - Ok(paths) + list_backup_files(directory).map_err(|error| error.to_string()) } fn persist_backup( From ec1a3a7de001b5aff3aa09f73c93960497dbc0c0 Mon Sep 17 00:00:00 2001 From: KeyffMS <124252104+KeyffMS@users.noreply.github.com> Date: Thu, 10 Sep 2026 18:19:15 +0200 Subject: [PATCH 37/38] style: apply rustfmt to backup catalog --- crates/lantern-storage/src/backup_catalog.rs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/crates/lantern-storage/src/backup_catalog.rs b/crates/lantern-storage/src/backup_catalog.rs index dd81a32a..368328ca 100644 --- a/crates/lantern-storage/src/backup_catalog.rs +++ b/crates/lantern-storage/src/backup_catalog.rs @@ -1,4 +1,7 @@ -use std::{fs, io, path::{Path, PathBuf}}; +use std::{ + fs, io, + path::{Path, PathBuf}, +}; use crate::BACKUP_SUFFIX; @@ -46,6 +49,9 @@ mod tests { fs::write(directory.path().join("notes.txt"), b"ignore").expect("notes"); symlink(&a, directory.path().join("linked.vfdlantern-backup.json")).expect("symlink"); - assert_eq!(list_backup_files(directory.path()).expect("catalog"), vec![a, b]); + assert_eq!( + list_backup_files(directory.path()).expect("catalog"), + vec![a, b] + ); } } From de9b1ce4893ab2d0260799ea968355907211e9ed Mon Sep 17 00:00:00 2001 From: KeyffMS <124252104+KeyffMS@users.noreply.github.com> Date: Thu, 10 Sep 2026 18:20:28 +0200 Subject: [PATCH 38/38] tui: preserve global Ctrl-C during restore confirmation --- crates/lantern-tui/src/backup_keymap.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/lantern-tui/src/backup_keymap.rs b/crates/lantern-tui/src/backup_keymap.rs index fa89d836..c8f9931f 100644 --- a/crates/lantern-tui/src/backup_keymap.rs +++ b/crates/lantern-tui/src/backup_keymap.rs @@ -1,4 +1,4 @@ -use crossterm::event::{KeyCode, KeyEvent, KeyEventKind}; +use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; use lantern_app::{ApplicationAction, ApplicationView, BackupAction}; use crate::{ConnectionEdit, MappedAction, UiAction, UiState}; @@ -8,6 +8,9 @@ pub fn map_backup_key(ui: &UiState, view: &ApplicationView, key: KeyEvent) -> Op if !matches!(key.kind, KeyEventKind::Press | KeyEventKind::Repeat) { return None; } + if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') { + return None; + } if ui.connection_edit == Some(ConnectionEdit::WriteConfirmation) { return match key.code {