Add explicit vocabulary and named operation contexts

This commit is contained in:
2026-10-29 22:41:38 +02:00
parent ede45d224a
commit 8413b382b1
14 changed files with 344 additions and 241 deletions
+107 -44
View File
@@ -287,17 +287,30 @@ pub trait OperationExecutor: Send + 'static {
}
}
enum DeliveryMode {
FirstIssue,
Reissue,
}
struct PreparedDestinationContext {
identity: PublicKey,
include_qr: bool,
mode: DeliveryMode,
attempt: FirstIssueAttempt,
generation: u64,
destination: PreparedDestination,
}
struct RestorePreviewContext {
_archive_name: String,
generation: u64,
preview: RestorePreview,
}
#[derive(Default)]
pub struct ApplicationExecutor {
prepared_destination: Option<(
PublicKey,
bool,
bool,
FirstIssueAttempt,
u64,
PreparedDestination,
)>,
restore_preview: Option<(String, u64, RestorePreview)>,
prepared_destination: Option<PreparedDestinationContext>,
restore_preview: Option<RestorePreviewContext>,
}
impl OperationExecutor for ApplicationExecutor {
@@ -379,14 +392,18 @@ impl OperationExecutor for ApplicationExecutor {
attempt,
prepared_generation: generation,
};
self.prepared_destination = Some((
self.prepared_destination = Some(PreparedDestinationContext {
identity,
include_qr,
reissue,
mode: if reissue {
DeliveryMode::Reissue
} else {
DeliveryMode::FirstIssue
},
attempt,
generation,
destination,
));
});
CommandResult::DeliveryDestination(summary)
}),
UiCommand::DeliverFirstIssue {
@@ -394,23 +411,16 @@ impl OperationExecutor for ApplicationExecutor {
overwrite_confirmed,
prepared_generation,
} => {
let (
identity,
include_qr,
reissue,
prepared_attempt,
destination_generation,
mut destination,
) = self
let prepared = self
.prepared_destination
.take()
.ok_or_else(|| OperationError {
message: "No prepared output destination is available.".to_owned(),
})?;
if reissue
|| prepared_attempt != attempt
if !matches!(prepared.mode, DeliveryMode::FirstIssue)
|| prepared.attempt != attempt
|| !destination_generation_matches(
destination_generation,
prepared.generation,
prepared_generation,
generation,
)
@@ -420,6 +430,9 @@ impl OperationExecutor for ApplicationExecutor {
.to_owned(),
});
}
let identity = prepared.identity;
let include_qr = prepared.include_qr;
let mut destination = prepared.destination;
if destination.overwrite_confirmation_required() {
if !overwrite_confirmed {
return Err(OperationError {
@@ -519,7 +532,11 @@ impl OperationExecutor for ApplicationExecutor {
changes,
preview_generation: generation,
};
self.restore_preview = Some((file_name, generation, preview));
self.restore_preview = Some(RestorePreviewContext {
_archive_name: file_name,
generation,
preview,
});
CommandResult::RestorePreview(summary)
})
}
@@ -532,14 +549,12 @@ impl OperationExecutor for ApplicationExecutor {
Ok(CommandResult::Cancelled)
}
UiCommand::ConfirmRestore { preview_generation } => {
let (_, stored_generation, preview) =
self.restore_preview.take().ok_or_else(|| OperationError {
message:
"Restore preview is no longer available; select the archive again."
.to_owned(),
})?;
let context = self.restore_preview.take().ok_or_else(|| OperationError {
message: "Restore preview is no longer available; select the archive again."
.to_owned(),
})?;
if !restore_preview_generation_matches(
stored_generation,
context.generation,
preview_generation,
generation,
) {
@@ -547,7 +562,8 @@ impl OperationExecutor for ApplicationExecutor {
message: "Restore preview is stale; select the archive again.".to_owned(),
});
}
app.confirm_restore(preview).map(CommandResult::Restored)
app.confirm_restore(context.preview)
.map(CommandResult::Restored)
}
};
result.map_err(|error| OperationError {
@@ -573,28 +589,23 @@ impl OperationExecutor for ApplicationExecutor {
overwrite_confirmed,
prepared_generation,
} => {
let (
prepared_identity,
_include_qr,
reissue,
_attempt,
destination_generation,
mut destination,
) = self
let prepared = self
.prepared_destination
.take()
.ok_or_else(|| OperationError {
message: "No prepared output destination is available.".to_owned(),
})?;
if prepared_identity != identity
|| !reissue
|| destination_generation != prepared_generation
if prepared.identity != identity
|| !matches!(prepared.mode, DeliveryMode::Reissue)
|| prepared.generation != prepared_generation
{
return Err(OperationError {
message: "Prepared output destination is stale; select it again."
.to_owned(),
});
}
let include_qr = prepared.include_qr;
let mut destination = prepared.destination;
if destination.overwrite_confirmation_required() {
if !overwrite_confirmed {
return Err(OperationError {
@@ -604,7 +615,7 @@ impl OperationExecutor for ApplicationExecutor {
destination = destination.confirm_overwrite().map_err(operation_error)?;
}
let delivery = app
.reissue_client_file(identity, password.expose(), destination, _include_qr)
.reissue_client_file(identity, password.expose(), destination, include_qr)
.map_err(|error| OperationError {
message: error.to_string(),
})?;
@@ -1088,6 +1099,58 @@ mod tests {
assert!(!restore_preview_generation_matches(8, 8, 8));
}
#[test]
fn prepared_delivery_is_cleared_by_cancel_and_unrelated_commands() {
let (_root, mut app) = application();
let identity = PublicKey::from_bytes([44; 32]);
let mut executor = ApplicationExecutor::default();
let path = _root.path().join("prepared.conf");
let prepare = || UiCommand::PrepareDelivery {
identity,
path: path.clone(),
include_qr: false,
reissue: false,
attempt: FirstIssueAttempt::Initial,
};
let deliver = || UiCommand::DeliverFirstIssue {
attempt: FirstIssueAttempt::Initial,
overwrite_confirmed: false,
prepared_generation: 7,
};
assert!(matches!(
executor.execute_at(&mut app, prepare(), 7),
Ok(CommandResult::DeliveryDestination(_))
));
assert!(matches!(
executor.execute_at(&mut app, UiCommand::CancelPreparedDelivery, 7),
Ok(CommandResult::Cancelled)
));
assert_eq!(
executor
.execute_at(&mut app, deliver(), 7)
.unwrap_err()
.message,
"No prepared output destination is available."
);
assert!(matches!(
executor.execute_at(&mut app, prepare(), 7),
Ok(CommandResult::DeliveryDestination(_))
));
assert!(matches!(
executor.execute_at(&mut app, UiCommand::ListBackups, 7),
Ok(CommandResult::Backups(_))
));
assert_eq!(
executor
.execute_at(&mut app, deliver(), 7)
.unwrap_err()
.message,
"No prepared output destination is available."
);
}
struct FakeStatistics {
started: Option<mpsc::SyncSender<()>>,
release: Option<Receiver<()>>,
+20 -20
View File
@@ -654,9 +654,9 @@ fn qr_state_summary(state: &UiState, width: usize) -> Option<String> {
.filter(|row| row.saved.is_some())
.count(),
match view.applied {
super::state::AppliedState::Verified => "verified",
super::state::AppliedState::Unknown => "not verified",
super::state::AppliedState::Unavailable => "observation unavailable",
crate::domain::ApplyStatus::Verified => "verified",
crate::domain::ApplyStatus::Unknown => "not verified",
crate::domain::ApplyStatus::Unavailable => "observation unavailable",
}
))
}
@@ -1210,9 +1210,9 @@ fn persistent_state_line(view: &SessionView) -> String {
.filter(|row| row.saved.is_some())
.count();
let applied = match view.applied {
super::state::AppliedState::Verified => "verified",
super::state::AppliedState::Unknown => "not verified",
super::state::AppliedState::Unavailable => "unavailable",
crate::domain::ApplyStatus::Verified => "verified",
crate::domain::ApplyStatus::Unknown => "not verified",
crate::domain::ApplyStatus::Unavailable => "unavailable",
};
format!(
"Draft: {draft}; Saved peers: {saved_peers} / managed {saved_managed}; Applied(saved): {applied}"
@@ -1797,16 +1797,16 @@ fn saved_status(view: &SessionView) -> String {
fn applied_status(view: &SessionView) -> String {
match view.applied {
super::state::AppliedState::Verified if view.has_draft => {
crate::domain::ApplyStatus::Verified if view.has_draft => {
"Applied: verified for saved configuration (draft excluded)".to_owned()
}
super::state::AppliedState::Verified => {
crate::domain::ApplyStatus::Verified => {
"Applied: verified for saved configuration".to_owned()
}
super::state::AppliedState::Unknown => {
crate::domain::ApplyStatus::Unknown => {
"Applied: not verified for saved configuration".to_owned()
}
super::state::AppliedState::Unavailable => "Applied: observation unavailable".to_owned(),
crate::domain::ApplyStatus::Unavailable => "Applied: observation unavailable".to_owned(),
}
}
@@ -2329,7 +2329,7 @@ mod tests {
client_states: BTreeMap::new(),
saved_peer_count: Some(0),
document_issues: Vec::new(),
applied: super::super::state::AppliedState::Unknown,
applied: crate::domain::ApplyStatus::Unknown,
reserved_addresses: Default::default(),
statistics: RuntimeObservation::unknown(),
has_draft: true,
@@ -2545,7 +2545,7 @@ mod tests {
)]),
saved_peer_count: Some(1),
document_issues: Vec::new(),
applied: super::super::state::AppliedState::Unknown,
applied: crate::domain::ApplyStatus::Unknown,
reserved_addresses: Default::default(),
statistics: RuntimeObservation::unknown(),
has_draft: false,
@@ -2558,13 +2558,13 @@ mod tests {
let identity = PublicKey::from_bytes([61; 32]);
let mut view = saved_client_view(identity);
view.has_draft = false;
view.applied = super::super::state::AppliedState::Verified;
view.applied = crate::domain::ApplyStatus::Verified;
let summary = state_summary(&view);
assert!(summary.contains("Draft: no changes"));
assert!(summary.contains("Saved: 1 peer sections; 1 managed clients"));
assert!(summary.contains("Applied: verified for saved configuration"));
view.applied = super::super::state::AppliedState::Unknown;
view.applied = crate::domain::ApplyStatus::Unknown;
assert_eq!(
applied_status(&view),
"Applied: not verified for saved configuration"
@@ -2572,7 +2572,7 @@ mod tests {
assert!(!state_summary(&view).contains("restart pending"));
view.has_draft = true;
view.applied = super::super::state::AppliedState::Verified;
view.applied = crate::domain::ApplyStatus::Verified;
assert!(
draft_status(&view)
.contains("Draft: pending; draft peers: 1; managed-client changes: 0")
@@ -2619,13 +2619,13 @@ mod tests {
);
assert!(draft_status(&view).contains("managed-client changes: 2"));
view.applied = super::super::state::AppliedState::Unknown;
view.applied = crate::domain::ApplyStatus::Unknown;
assert_eq!(
applied_status(&view),
"Applied: not verified for saved configuration"
);
assert!(!state_summary(&view).contains("restart pending"));
view.applied = super::super::state::AppliedState::Unavailable;
view.applied = crate::domain::ApplyStatus::Unavailable;
assert_eq!(applied_status(&view), "Applied: observation unavailable");
let mut state = UiState {
@@ -2637,7 +2637,7 @@ mod tests {
assert_eq!(state_summary(state.view.as_ref().unwrap()), before);
assert_eq!(
state.view.as_ref().unwrap().applied,
super::super::state::AppliedState::Unavailable
crate::domain::ApplyStatus::Unavailable
);
}
@@ -3932,7 +3932,7 @@ mod tests {
)]),
saved_peer_count: Some(1),
document_issues: Vec::new(),
applied: super::super::state::AppliedState::Unknown,
applied: crate::domain::ApplyStatus::Unknown,
reserved_addresses: Default::default(),
statistics: RuntimeObservation::unknown(),
has_draft: false,
@@ -4055,7 +4055,7 @@ mod tests {
]),
saved_peer_count: Some(2),
document_issues: vec![],
applied: super::super::state::AppliedState::Unknown,
applied: crate::domain::ApplyStatus::Unknown,
reserved_addresses: Default::default(),
statistics: RuntimeObservation::unknown(),
has_draft: true,
+12 -24
View File
@@ -4,8 +4,8 @@ use std::collections::BTreeSet;
use std::net::IpAddr;
use crate::app::{Application, ClientDisplay};
use crate::domain::{Client, PublicKey, RuntimeObservation};
use crate::runtime::RuntimeObservation as Statistics;
use crate::domain::{ApplyStatus, Client, PublicKey};
use crate::runtime::RuntimeObservation;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum Screen {
@@ -34,13 +34,6 @@ pub enum DialogKind {
Exit,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum AppliedState {
Unknown,
Unavailable,
Verified,
}
/// Public client values for both states. No configuration bytes or secrets are
/// retained in a view snapshot.
#[derive(Clone, Debug, Eq, PartialEq)]
@@ -58,9 +51,9 @@ pub struct SessionView {
/// saved bytes can be projected. This includes nonmanaged peers.
pub saved_peer_count: Option<usize>,
pub document_issues: Vec<crate::wireguard::DocumentIssue>,
pub applied: AppliedState,
pub applied: ApplyStatus,
pub reserved_addresses: BTreeSet<IpAddr>,
pub statistics: Statistics,
pub statistics: RuntimeObservation,
pub has_draft: bool,
pub vault_initialized: bool,
}
@@ -83,11 +76,6 @@ impl SessionView {
)
})
.collect();
let applied = match states.applied() {
RuntimeObservation::Unknown => AppliedState::Unknown,
RuntimeObservation::Unavailable => AppliedState::Unavailable,
RuntimeObservation::Verified => AppliedState::Verified,
};
let projection = app.display_projection();
let saved_peer_count =
crate::wireguard::ConfigDocument::parse(&app.saved_bytes().server_config)
@@ -99,9 +87,9 @@ impl SessionView {
client_states,
saved_peer_count,
document_issues: projection.document_issues,
applied,
applied: *states.applied(),
reserved_addresses: states.reserved_addresses().clone(),
statistics: Statistics::unknown(),
statistics: RuntimeObservation::unknown(),
has_draft: app.has_draft_changes(),
vault_initialized: app.vault_initialized(),
}
@@ -318,7 +306,7 @@ impl UiState {
/// Apply only statistics sampled from the still-current saved/draft view.
/// This never changes the applied-state marker.
pub fn apply_statistics(&mut self, generation: u64, statistics: Statistics) -> bool {
pub fn apply_statistics(&mut self, generation: u64, statistics: RuntimeObservation) -> bool {
if generation != self.generation {
return false;
}
@@ -521,9 +509,9 @@ mod tests {
client_states: Default::default(),
saved_peer_count: None,
document_issues: Vec::new(),
applied: AppliedState::Unknown,
applied: ApplyStatus::Unknown,
reserved_addresses: BTreeSet::new(),
statistics: Statistics::unknown(),
statistics: RuntimeObservation::unknown(),
has_draft: false,
vault_initialized: true,
}
@@ -669,9 +657,9 @@ mod tests {
let key = PublicKey::from_bytes([4; 32]);
let mut state = UiState::default();
assert!(state.apply_view(view(0, vec![row(key, "client", true)])));
assert!(state.apply_statistics(0, Statistics::unknown()));
assert_eq!(state.view.as_ref().unwrap().applied, AppliedState::Unknown);
assert!(state.apply_statistics(0, RuntimeObservation::unknown()));
assert_eq!(state.view.as_ref().unwrap().applied, ApplyStatus::Unknown);
state.generation = 1;
assert!(!state.apply_statistics(0, Statistics::unknown()));
assert!(!state.apply_statistics(0, RuntimeObservation::unknown()));
}
}
+8 -7
View File
@@ -960,9 +960,10 @@ mod tests {
use ratatui::backend::TestBackend;
use crate::app::ClientDisplay;
use crate::domain::ApplyStatus;
use crate::runtime::RuntimeObservation;
use crate::tui::events::OperationError;
use crate::tui::state::{AppliedState, OperationKind, SessionView};
use crate::tui::state::{OperationKind, SessionView};
use crate::wireguard::PeerIssue;
struct FakeWorker {
@@ -1032,7 +1033,7 @@ mod tests {
client_states: Default::default(),
saved_peer_count: Some(0),
document_issues: Vec::new(),
applied: AppliedState::Unknown,
applied: ApplyStatus::Unknown,
reserved_addresses: Default::default(),
statistics: RuntimeObservation::unknown(),
has_draft: false,
@@ -1163,7 +1164,7 @@ mod tests {
let identity = crate::domain::PublicKey::from_bytes([1; 32]);
let mut view = navigation_view();
view.has_draft = true;
view.applied = AppliedState::Verified;
view.applied = ApplyStatus::Verified;
let mut state = UiState {
screen: Screen::Help,
selected_identity: Some(identity),
@@ -1192,7 +1193,7 @@ mod tests {
assert_eq!(state.selected_peer_index, selected_peer_index);
assert_eq!(state.view, view_before);
assert!(state.view.as_ref().unwrap().has_draft);
assert_eq!(state.view.as_ref().unwrap().applied, AppliedState::Verified);
assert_eq!(state.view.as_ref().unwrap().applied, ApplyStatus::Verified);
// The scrolling helper accepts no worker or command sink, so Help input
// cannot submit work or mutate saved/applied application state.
assert!(!handle_help_navigation_key(
@@ -1313,7 +1314,7 @@ mod tests {
document_issues: vec![crate::wireguard::DocumentIssue::MalformedSectionHeader {
line: 1,
}],
applied: AppliedState::Unknown,
applied: ApplyStatus::Unknown,
reserved_addresses: Default::default(),
statistics: RuntimeObservation::unknown(),
has_draft: false,
@@ -1344,7 +1345,7 @@ mod tests {
client_states: Default::default(),
saved_peer_count: Some(2),
document_issues: Vec::new(),
applied: AppliedState::Unknown,
applied: ApplyStatus::Unknown,
reserved_addresses: Default::default(),
statistics: RuntimeObservation::unknown(),
has_draft: false,
@@ -1578,7 +1579,7 @@ mod tests {
client_states: Default::default(),
saved_peer_count: Some(0),
document_issues: Vec::new(),
applied: AppliedState::Unknown,
applied: ApplyStatus::Unknown,
reserved_addresses: Default::default(),
statistics: RuntimeObservation::unknown(),
has_draft: false,