#![allow(dead_code)]
use serde::{Deserialize, Serialize};
use crate::search::readiness::{DerivedAssetTruthTable, SafeNextAction};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub(crate) enum MutationScope {
None,
DerivedAssets,
Archive,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub(crate) enum CostClass {
None,
Seconds,
Minutes,
Hours,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub(crate) struct RecommendedCommand {
pub action: SafeNextAction,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub command: Option<String>,
pub mutation_scope: MutationScope,
pub cost_class: CostClass,
pub why: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub(crate) struct UnsafeCommand {
pub action: SafeNextAction,
pub command: String,
pub why_blocked: String,
pub unblock_precondition: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub(crate) struct NextCommandEnvelope {
pub recommended_commands: Vec<RecommendedCommand>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub unsafe_commands: Vec<UnsafeCommand>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub preconditions: Vec<String>,
}
fn mutation_scope(action: SafeNextAction) -> MutationScope {
use SafeNextAction as A;
match action {
A::IndexFull | A::RepairLexical | A::RefreshLexical | A::RebuildForCurrentBinary => {
MutationScope::DerivedAssets
}
_ => MutationScope::None,
}
}
fn cost_class(action: SafeNextAction) -> CostClass {
use SafeNextAction as A;
match action {
A::None | A::WaitForMaintenance | A::WaitForSemantic | A::HostUnreachable => {
CostClass::None
}
A::InspectCanonicalDb | A::InspectQuarantine | A::ConfigureSources | A::ReconnectSource => {
CostClass::Seconds
}
A::RefreshLexical
| A::RepairLexical
| A::RebuildForCurrentBinary
| A::InstallSemanticModel
| A::UpgradeBinary
| A::BackupThenRepair => CostClass::Minutes,
A::IndexFull => CostClass::Hours,
}
}
impl DerivedAssetTruthTable {
pub(crate) fn next_command_envelope(&self, data_dir: Option<&str>) -> NextCommandEnvelope {
let safe = self.safe_next_command();
let archive = self.archive_safety_envelope(data_dir);
let recommended = RecommendedCommand {
action: safe.action,
command: safe.command.clone(),
mutation_scope: mutation_scope(safe.action),
cost_class: cost_class(safe.action),
why: safe.reason.clone(),
};
let unsafe_commands: Vec<UnsafeCommand> = archive
.unsafe_until_backup
.iter()
.map(|&action| UnsafeCommand {
action,
command: command_for(action),
why_blocked:
"high archive risk: this would touch the only copy before a backup exists"
.to_string(),
unblock_precondition:
"create a verified backup or a fingerprinted recovery plan first".to_string(),
})
.collect();
let mut preconditions = Vec::new();
if archive.backup_recommended {
let where_ = data_dir.unwrap_or("the canonical data dir");
preconditions.push(format!("back up {where_} before any mutating repair"));
}
NextCommandEnvelope {
recommended_commands: vec![recommended],
unsafe_commands,
preconditions,
}
}
}
fn command_for(action: SafeNextAction) -> String {
use SafeNextAction as A;
match action {
A::IndexFull | A::RebuildForCurrentBinary => "cass index --full",
A::RefreshLexical => "cass index",
A::RepairLexical => "cass index --full",
_ => "cass status --json",
}
.to_string()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::search::readiness::{
ArchiveRiskLevel, CanonicalDbAvailability, LexicalMetadata, LexicalReadinessState,
MaintenanceActivity, QuarantineSummary, ReadinessSnapshot, SemanticReadinessState,
SourceCoverageState, fleet_fixtures,
};
fn fixture(name: &str) -> DerivedAssetTruthTable {
fleet_fixtures()
.into_iter()
.find(|(n, _)| *n == name)
.unwrap_or_else(|| panic!("missing fixture {name}"))
.1
}
fn assert_no_bare_or_destructive(env: &NextCommandEnvelope) {
let all = env
.recommended_commands
.iter()
.filter_map(|c| c.command.clone())
.chain(env.unsafe_commands.iter().map(|c| c.command.clone()));
for cmd in all {
assert_ne!(cmd.trim(), "cass", "bare cass");
assert_ne!(cmd.trim(), "bv", "bare bv");
assert!(
cmd.starts_with("cass "),
"must be a concrete cass command: {cmd}"
);
for bad in ["rm ", "rm -", "--force-clean", "DROP ", "delete "] {
assert!(!cmd.contains(bad), "destructive: {cmd}");
}
}
}
#[test]
fn enums_serialize_snake_case() {
assert_eq!(
serde_json::to_string(&MutationScope::DerivedAssets).unwrap(),
"\"derived_assets\""
);
assert_eq!(
serde_json::to_string(&CostClass::Hours).unwrap(),
"\"hours\""
);
}
#[test]
fn stale_searchable_recommends_refresh_minutes_derived_assets() {
let env = fixture("css_stale_existing_index").next_command_envelope(Some("/data/cass"));
let rec = &env.recommended_commands[0];
assert_eq!(rec.action, SafeNextAction::RefreshLexical);
assert_eq!(rec.mutation_scope, MutationScope::DerivedAssets);
assert_eq!(rec.cost_class, CostClass::Minutes);
assert!(env.unsafe_commands.is_empty());
assert_no_bare_or_destructive(&env);
}
#[test]
fn missing_lexical_metadata_recommends_repair() {
let env = fixture("csd_missing_lexical_metadata").next_command_envelope(Some("/data/cass"));
let rec = &env.recommended_commands[0];
assert_eq!(rec.action, SafeNextAction::RepairLexical);
assert_eq!(rec.mutation_scope, MutationScope::DerivedAssets);
assert_no_bare_or_destructive(&env);
}
#[test]
fn high_archive_risk_gates_mutating_commands_with_backup_precondition() {
let env = fixture("ts1_high_archive_risk").next_command_envelope(Some("/data/cass"));
let rec = &env.recommended_commands[0];
assert_eq!(rec.action, SafeNextAction::BackupThenRepair);
assert_eq!(
rec.mutation_scope,
MutationScope::None,
"backup-first is non-mutating"
);
assert!(!env.unsafe_commands.is_empty());
for u in &env.unsafe_commands {
assert!(mutation_scope(u.action) == MutationScope::DerivedAssets);
assert!(!u.unblock_precondition.is_empty());
assert!(u.why_blocked.contains("archive risk"));
}
assert!(env.preconditions.iter().any(|p| p.contains("/data/cass")));
assert_no_bare_or_destructive(&env);
}
#[test]
fn semantic_absent_recommends_install_no_unsafe() {
let t = DerivedAssetTruthTable {
db: CanonicalDbAvailability::Available,
source_coverage: SourceCoverageState::Complete,
scan_watermark_ms: Some(1),
last_projection_ms: Some(1),
lexical_metadata: LexicalMetadata::default(),
readiness: ReadinessSnapshot::new(
LexicalReadinessState::Ready,
SemanticReadinessState::Absent,
),
quarantine: QuarantineSummary::default(),
maintenance: MaintenanceActivity::Idle,
archive_risk: ArchiveRiskLevel::Low,
binary: crate::search::readiness::BinaryCompatibility::Current,
};
let env = t.next_command_envelope(Some("/data/cass"));
assert_eq!(
env.recommended_commands[0].action,
SafeNextAction::InstallSemanticModel
);
assert!(env.unsafe_commands.is_empty());
assert!(
env.preconditions.is_empty(),
"low risk: no backup precondition"
);
assert_no_bare_or_destructive(&env);
}
#[test]
fn slow_status_semantic_backfill_waits_boundedly() {
let env = fixture("ts2_fast_health_slow_status").next_command_envelope(Some("/data/cass"));
let rec = &env.recommended_commands[0];
assert_eq!(rec.action, SafeNextAction::WaitForSemantic);
assert_eq!(rec.cost_class, CostClass::None);
assert_eq!(rec.mutation_scope, MutationScope::None);
assert!(rec.command.is_none(), "a wait has no command");
}
#[test]
fn source_path_mismatch_recommends_reconnect() {
let t = DerivedAssetTruthTable {
db: CanonicalDbAvailability::Available,
source_coverage: SourceCoverageState::Unavailable,
scan_watermark_ms: Some(1),
last_projection_ms: Some(1),
lexical_metadata: LexicalMetadata::default(),
readiness: ReadinessSnapshot::new(
LexicalReadinessState::Ready,
SemanticReadinessState::HybridReady,
),
quarantine: QuarantineSummary::default(),
maintenance: MaintenanceActivity::Idle,
archive_risk: ArchiveRiskLevel::Low,
binary: crate::search::readiness::BinaryCompatibility::Current,
};
let env = t.next_command_envelope(Some("/data/cass"));
let rec = &env.recommended_commands[0];
assert_eq!(rec.action, SafeNextAction::ReconnectSource);
assert_eq!(rec.mutation_scope, MutationScope::None);
assert_eq!(rec.cost_class, CostClass::Seconds);
assert_no_bare_or_destructive(&env);
}
#[test]
fn envelope_round_trips_through_json() {
let env = fixture("ts1_high_archive_risk").next_command_envelope(Some("/data/cass"));
let json = serde_json::to_string(&env).unwrap();
assert!(json.contains("\"recommended_commands\""));
assert!(json.contains("\"unsafe_commands\""));
assert!(json.contains("\"unblock_precondition\""));
let parsed: NextCommandEnvelope = serde_json::from_str(&json).unwrap();
assert_eq!(parsed, env);
}
}