use crate::fleet_doctor_schema::{HostDoctorReport, HostProbeStatus, classify_connection_failure};
use serde::{Deserialize, Serialize};
pub const SOURCE_DOCTOR_SCHEMA_VERSION: u32 = 1;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SourceDoctorState {
Reachable,
Unreachable,
Timeout,
AuthDenied,
CassMissing,
OldCass,
SourceRootUnreadable,
RemotePruned,
StaleIndex,
MissingLexicalMetadata,
MirrorAhead,
MirrorBehind,
}
impl SourceDoctorState {
pub fn as_str(self) -> &'static str {
match self {
SourceDoctorState::Reachable => "reachable",
SourceDoctorState::Unreachable => "unreachable",
SourceDoctorState::Timeout => "timeout",
SourceDoctorState::AuthDenied => "auth_denied",
SourceDoctorState::CassMissing => "cass_missing",
SourceDoctorState::OldCass => "old_cass",
SourceDoctorState::SourceRootUnreadable => "source_root_unreadable",
SourceDoctorState::RemotePruned => "remote_pruned",
SourceDoctorState::StaleIndex => "stale_index",
SourceDoctorState::MissingLexicalMetadata => "missing_lexical_metadata",
SourceDoctorState::MirrorAhead => "mirror_ahead",
SourceDoctorState::MirrorBehind => "mirror_behind",
}
}
pub fn host_reached(self) -> bool {
!matches!(
self,
SourceDoctorState::Unreachable
| SourceDoctorState::Timeout
| SourceDoctorState::AuthDenied
)
}
pub fn is_healthy(self) -> bool {
matches!(self, SourceDoctorState::Reachable)
}
}
#[derive(Debug, Clone, Default)]
pub struct SourceDoctorObservation {
pub source_id: String,
pub host: Option<String>,
pub host_reachable: bool,
pub connection_error: Option<String>,
pub cass_present: Option<bool>,
pub cass_current: Option<bool>,
pub source_root_readable: Option<bool>,
pub remote_pruned: bool,
pub index_stale: bool,
pub lexical_metadata_present: Option<bool>,
pub mirror_ahead: bool,
pub mirror_behind: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RemoteBinaryOutcome {
Missing,
Current,
Old,
PresentUnknownVersion,
}
impl RemoteBinaryOutcome {
pub fn from_capability_gap(gap: crate::fleet_version_skew::CapabilityGap) -> Self {
use crate::fleet_version_skew::CapabilityGap;
match gap {
CapabilityGap::BinaryMissing => RemoteBinaryOutcome::Missing,
CapabilityGap::None | CapabilityGap::Minor => RemoteBinaryOutcome::Current,
CapabilityGap::Major => RemoteBinaryOutcome::Old,
CapabilityGap::Unknown => RemoteBinaryOutcome::PresentUnknownVersion,
}
}
}
pub fn apply_remote_binary(obs: &mut SourceDoctorObservation, outcome: RemoteBinaryOutcome) {
match outcome {
RemoteBinaryOutcome::Missing => {
obs.cass_present = Some(false);
}
RemoteBinaryOutcome::Current => {
obs.cass_present = Some(true);
obs.cass_current = Some(true);
}
RemoteBinaryOutcome::Old => {
obs.cass_present = Some(true);
obs.cass_current = Some(false);
}
RemoteBinaryOutcome::PresentUnknownVersion => {
obs.cass_present = Some(true);
}
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct SourceSyncEvidence {
pub remote_path_missing: bool,
pub remote_path_empty: bool,
pub local_mirror_nonempty: bool,
pub has_sync_record: bool,
pub last_sync_incomplete: bool,
pub index_behind_sync: bool,
}
pub fn apply_sync_evidence(obs: &mut SourceDoctorObservation, ev: &SourceSyncEvidence) {
obs.remote_pruned = ev.remote_path_missing && ev.local_mirror_nonempty;
obs.mirror_ahead = ev.remote_path_empty && ev.local_mirror_nonempty && !obs.remote_pruned;
obs.mirror_behind = ev.last_sync_incomplete && !obs.remote_pruned;
obs.index_stale = ev.index_behind_sync;
}
fn reachability_state_from_error(error: &str) -> SourceDoctorState {
match classify_connection_failure(error).0 {
HostProbeStatus::TimedOut => SourceDoctorState::Timeout,
HostProbeStatus::Unreachable => {
let lower = error.to_ascii_lowercase();
if lower.contains("permission denied")
|| lower.contains("publickey")
|| lower.contains("authentication")
{
SourceDoctorState::AuthDenied
} else {
SourceDoctorState::Unreachable
}
}
_ => SourceDoctorState::Unreachable,
}
}
pub fn classify_source_doctor_state(obs: &SourceDoctorObservation) -> SourceDoctorState {
if !obs.host_reachable {
return match obs.connection_error.as_deref() {
Some(error) => reachability_state_from_error(error),
None => SourceDoctorState::Unreachable,
};
}
if obs.cass_present == Some(false) {
return SourceDoctorState::CassMissing;
}
if obs.cass_current == Some(false) {
return SourceDoctorState::OldCass;
}
if obs.remote_pruned {
return SourceDoctorState::RemotePruned;
}
if obs.source_root_readable == Some(false) {
return SourceDoctorState::SourceRootUnreadable;
}
if obs.lexical_metadata_present == Some(false) {
return SourceDoctorState::MissingLexicalMetadata;
}
if obs.index_stale {
return SourceDoctorState::StaleIndex;
}
if obs.mirror_ahead {
return SourceDoctorState::MirrorAhead;
}
if obs.mirror_behind {
return SourceDoctorState::MirrorBehind;
}
SourceDoctorState::Reachable
}
pub fn safe_next_command(state: SourceDoctorState, source_id: &str) -> Option<String> {
let cmd = match state {
SourceDoctorState::Reachable => return None,
SourceDoctorState::Unreachable | SourceDoctorState::Timeout => {
format!(
"cass sources doctor --source {source_id} --json # retry when the host is reachable"
)
}
SourceDoctorState::AuthDenied => {
"ssh-add your key (or fix the identity file), then re-run cass sources doctor --json"
.to_string()
}
SourceDoctorState::CassMissing => {
format!("cass sources setup --source {source_id} # install cass on the remote")
}
SourceDoctorState::OldCass => {
format!(
"cass sources setup --source {source_id} --upgrade # bring the remote binary current"
)
}
SourceDoctorState::SourceRootUnreadable => {
format!(
"verify the configured paths for '{source_id}' (cass sources list --json); fix permissions on the host"
)
}
SourceDoctorState::RemotePruned => {
"preserve the local archive/mirror; do NOT rebuild from the now-missing remote source"
.to_string()
}
SourceDoctorState::StaleIndex => {
"cass index --json # refresh the local index".to_string()
}
SourceDoctorState::MissingLexicalMetadata => {
"cass index --rebuild-lexical --json # restore lexical metadata".to_string()
}
SourceDoctorState::MirrorAhead => {
"local archive is ahead; inspect before any remote-backed rebuild (additive only)"
.to_string()
}
SourceDoctorState::MirrorBehind => {
format!(
"cass sources sync --source {source_id} --json # additive sync to add coverage"
)
}
};
Some(cmd)
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SourceDoctorEntry {
pub source_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub host: Option<String>,
pub state: SourceDoctorState,
pub host_reached: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub connection_error: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub safe_next_command: Option<String>,
}
impl SourceDoctorEntry {
pub fn from_observation(obs: &SourceDoctorObservation) -> Self {
let state = classify_source_doctor_state(obs);
SourceDoctorEntry {
source_id: obs.source_id.clone(),
host: obs.host.clone(),
state,
host_reached: state.host_reached(),
connection_error: obs.connection_error.clone(),
safe_next_command: safe_next_command(state, &obs.source_id),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SourceDoctorHumanSummary {
pub readiness: &'static str,
pub headline: &'static str,
pub host_reached: bool,
pub reason: &'static str,
pub state_codes: String,
pub safe_next_command: Option<String>,
}
impl SourceDoctorHumanSummary {
pub fn render_lines(&self) -> Vec<String> {
let mut lines = vec![
format!("Readiness: {}", self.readiness),
format!("Source state: {}", self.headline),
format!(
"Host reached: {}",
if self.host_reached { "yes" } else { "no" }
),
format!("Why: {}", self.reason),
format!("State codes: {}", self.state_codes),
];
if let Some(command) = &self.safe_next_command {
lines.push(format!("Next safe command: {command}"));
}
lines
}
}
fn source_human_copy(state: SourceDoctorState) -> (&'static str, &'static str) {
match state {
SourceDoctorState::Reachable => (
"no classified source issue",
"the bounded probe found no required repair; optional maintenance and unobserved axes remain unknown",
),
SourceDoctorState::Unreachable => (
"source host unreachable",
"the host could not be contacted; deeper state is unknown",
),
SourceDoctorState::Timeout => (
"source probe timed out",
"the host did not answer within the bounded probe window",
),
SourceDoctorState::AuthDenied => (
"source authentication denied",
"SSH authentication failed before source state could be inspected",
),
SourceDoctorState::CassMissing => (
"remote cass binary missing",
"the host answered but cass was not found on its PATH",
),
SourceDoctorState::OldCass => (
"remote cass binary is too old",
"the reported binary lacks the controller's required contract",
),
SourceDoctorState::SourceRootUnreadable => (
"source root unreadable",
"the configured remote source path could not be read",
),
SourceDoctorState::RemotePruned => (
"remote source pruned",
"the remote path is gone while local mirror evidence remains",
),
SourceDoctorState::StaleIndex => (
"local index trails the source",
"newer sync evidence exists than the local archive index",
),
SourceDoctorState::MissingLexicalMetadata => (
"lexical metadata missing",
"the source is reachable but its derived lexical metadata is absent",
),
SourceDoctorState::MirrorAhead => (
"local mirror ahead",
"local archive evidence exceeds the current remote source",
),
SourceDoctorState::MirrorBehind => (
"local mirror behind",
"the last sync was incomplete and the remote may hold more evidence",
),
}
}
pub fn project_source_human_summary(
entry: &SourceDoctorEntry,
host: &HostDoctorReport,
) -> SourceDoctorHumanSummary {
let (headline, reason) = source_human_copy(entry.state);
SourceDoctorHumanSummary {
readiness: if entry.state.is_healthy() {
"no-action-observed"
} else {
"attention-required"
},
headline,
host_reached: entry.host_reached,
reason,
state_codes: format!(
"source={} host={}",
entry.state.as_str(),
host.status.as_str()
),
safe_next_command: entry.safe_next_command.clone(),
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct SourceDoctorSummary {
pub total: usize,
pub healthy: usize,
pub unhealthy: usize,
pub unreached: usize,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SourceDoctorReport {
pub schema_version: u32,
pub sources: Vec<SourceDoctorEntry>,
pub summary: SourceDoctorSummary,
pub mutation_free: bool,
}
impl SourceDoctorReport {
pub fn build(observations: &[SourceDoctorObservation]) -> Self {
let sources: Vec<SourceDoctorEntry> = observations
.iter()
.map(SourceDoctorEntry::from_observation)
.collect();
let mut summary = SourceDoctorSummary {
total: sources.len(),
..Default::default()
};
for entry in &sources {
if !entry.state.host_reached() {
summary.unreached += 1;
} else if entry.state.is_healthy() {
summary.healthy += 1;
} else {
summary.unhealthy += 1;
}
}
Self {
schema_version: SOURCE_DOCTOR_SCHEMA_VERSION,
sources,
summary,
mutation_free: true,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn reachable_healthy(id: &str) -> SourceDoctorObservation {
SourceDoctorObservation {
source_id: id.to_string(),
host: Some(format!("user@{id}")),
host_reachable: true,
cass_present: Some(true),
cass_current: Some(true),
source_root_readable: Some(true),
lexical_metadata_present: Some(true),
..Default::default()
}
}
#[test]
fn classifies_every_scenario_in_the_taxonomy() {
assert_eq!(
classify_source_doctor_state(&reachable_healthy("ok")),
SourceDoctorState::Reachable
);
let mut o = reachable_healthy("x");
o.host_reachable = false;
o.connection_error = Some("ssh: connect to host x: No route to host".to_string());
assert_eq!(
classify_source_doctor_state(&o),
SourceDoctorState::Unreachable
);
let mut o = reachable_healthy("x");
o.host_reachable = false;
o.connection_error =
Some("ssh: connect to host x port 22: Connection timed out".to_string());
assert_eq!(classify_source_doctor_state(&o), SourceDoctorState::Timeout);
let mut o = reachable_healthy("x");
o.host_reachable = false;
o.connection_error = Some("Permission denied (publickey).".to_string());
assert_eq!(
classify_source_doctor_state(&o),
SourceDoctorState::AuthDenied
);
let mut o = reachable_healthy("x");
o.cass_present = Some(false);
assert_eq!(
classify_source_doctor_state(&o),
SourceDoctorState::CassMissing
);
let mut o = reachable_healthy("x");
o.cass_current = Some(false);
assert_eq!(classify_source_doctor_state(&o), SourceDoctorState::OldCass);
let mut o = reachable_healthy("x");
o.source_root_readable = Some(false);
assert_eq!(
classify_source_doctor_state(&o),
SourceDoctorState::SourceRootUnreadable
);
let mut o = reachable_healthy("x");
o.remote_pruned = true;
assert_eq!(
classify_source_doctor_state(&o),
SourceDoctorState::RemotePruned
);
let mut o = reachable_healthy("x");
o.lexical_metadata_present = Some(false);
assert_eq!(
classify_source_doctor_state(&o),
SourceDoctorState::MissingLexicalMetadata
);
let mut o = reachable_healthy("x");
o.index_stale = true;
assert_eq!(
classify_source_doctor_state(&o),
SourceDoctorState::StaleIndex
);
let mut o = reachable_healthy("x");
o.mirror_ahead = true;
assert_eq!(
classify_source_doctor_state(&o),
SourceDoctorState::MirrorAhead
);
let mut o = reachable_healthy("x");
o.mirror_behind = true;
assert_eq!(
classify_source_doctor_state(&o),
SourceDoctorState::MirrorBehind
);
}
#[test]
fn unreachable_host_severity_wins_over_other_signals() {
let mut o = reachable_healthy("x");
o.host_reachable = false;
o.connection_error = Some("Connection timed out".to_string());
o.index_stale = true;
o.mirror_behind = true;
assert_eq!(classify_source_doctor_state(&o), SourceDoctorState::Timeout);
}
#[test]
fn safe_next_command_is_never_destructive() {
let states = [
SourceDoctorState::Reachable,
SourceDoctorState::Unreachable,
SourceDoctorState::Timeout,
SourceDoctorState::AuthDenied,
SourceDoctorState::CassMissing,
SourceDoctorState::OldCass,
SourceDoctorState::SourceRootUnreadable,
SourceDoctorState::RemotePruned,
SourceDoctorState::StaleIndex,
SourceDoctorState::MissingLexicalMetadata,
SourceDoctorState::MirrorAhead,
SourceDoctorState::MirrorBehind,
];
for state in states {
if let Some(cmd) = safe_next_command(state, "laptop") {
let lower = cmd.to_ascii_lowercase();
for needle in [
"--delete",
"rm -rf",
"rm -r ",
"--remove-source-files",
"prune",
"shred",
] {
assert!(
!lower.contains(needle),
"state {state:?} suggested a destructive command: {cmd:?}"
);
}
}
}
}
#[test]
fn report_counts_unreached_separately_and_is_mutation_free() {
let mut pruned = reachable_healthy("retired");
pruned.remote_pruned = true;
let mut down = reachable_healthy("offline");
down.host_reachable = false;
down.connection_error = Some("No route to host".to_string());
let report = SourceDoctorReport::build(&[reachable_healthy("good"), pruned, down]);
assert!(report.mutation_free);
assert_eq!(report.summary.total, 3);
assert_eq!(report.summary.healthy, 1);
assert_eq!(report.summary.unhealthy, 1); assert_eq!(report.summary.unreached, 1); assert_ne!(report.summary.healthy, report.summary.total);
}
#[test]
fn entry_preserves_identity_for_unreachable_source() {
let mut o = reachable_healthy("mac-mini-old");
o.host_reachable = false;
o.connection_error = Some(
"ssh: Could not resolve hostname mac-mini-old: Name or service not known".to_string(),
);
let entry = SourceDoctorEntry::from_observation(&o);
assert_eq!(entry.source_id, "mac-mini-old");
assert_eq!(entry.host.as_deref(), Some("user@mac-mini-old"));
assert!(!entry.host_reached);
assert!(entry.connection_error.is_some());
assert!(entry.safe_next_command.is_some());
assert_eq!(entry.state, SourceDoctorState::Unreachable);
}
#[test]
fn sources_doctor_human_summary_is_bounded_and_preserves_native_state_codes() {
let mut old = reachable_healthy("legacy-node");
old.cass_current = Some(false);
let entry = SourceDoctorEntry::from_observation(&old);
let host = HostDoctorReport::skeleton(
"legacy-node",
crate::fleet_doctor_schema::Platform::linux_x86_64(),
HostProbeStatus::OldBinarySkew,
12,
);
let summary = project_source_human_summary(&entry, &host);
let lines = summary.render_lines();
assert_eq!(summary.readiness, "attention-required");
assert_eq!(summary.state_codes, "source=old_cass host=old-binary-skew");
assert_eq!(summary.safe_next_command, entry.safe_next_command);
assert_eq!(lines.len(), 6, "human source summary must stay bounded");
assert_eq!(
lines.last().map(String::as_str),
Some(
"Next safe command: cass sources setup --source legacy-node --upgrade # bring the remote binary current"
)
);
assert!(
lines.iter().all(|line| !line.contains("Search usable now")),
"remote source state must not fabricate local search readiness"
);
let mut unreachable = reachable_healthy("offline-node");
unreachable.host_reachable = false;
unreachable.connection_error = Some("No route to host".to_string());
let entry = SourceDoctorEntry::from_observation(&unreachable);
let host = HostDoctorReport::skeleton(
"offline-node",
crate::fleet_doctor_schema::Platform::linux_x86_64(),
HostProbeStatus::Unreachable,
50,
);
let summary = project_source_human_summary(&entry, &host);
assert!(!summary.host_reached);
assert_eq!(summary.state_codes, "source=unreachable host=unreachable");
assert!(summary.render_lines().len() <= 6);
let mut unknown_version = reachable_healthy("unknown-version");
unknown_version.cass_current = None;
let entry = SourceDoctorEntry::from_observation(&unknown_version);
let host = HostDoctorReport::skeleton(
"unknown-version",
crate::fleet_doctor_schema::Platform::linux_x86_64(),
HostProbeStatus::Ok,
5,
);
let summary = project_source_human_summary(&entry, &host);
assert_eq!(summary.readiness, "no-action-observed");
assert_eq!(summary.headline, "no classified source issue");
assert!(summary.reason.contains("no required repair"));
assert!(summary.reason.contains("optional maintenance"));
assert!(summary.reason.contains("unobserved axes remain unknown"));
assert!(summary.safe_next_command.is_none());
assert_eq!(summary.render_lines().len(), 5);
let mut minor_gap = reachable_healthy("minor-gap");
minor_gap.cass_present = None;
minor_gap.cass_current = None;
apply_remote_binary(
&mut minor_gap,
RemoteBinaryOutcome::from_capability_gap(
crate::fleet_version_skew::CapabilityGap::Minor,
),
);
let entry = SourceDoctorEntry::from_observation(&minor_gap);
let host = HostDoctorReport::skeleton(
"minor-gap",
crate::fleet_doctor_schema::Platform::linux_x86_64(),
HostProbeStatus::Ok,
5,
);
let summary = project_source_human_summary(&entry, &host);
assert_eq!(summary.readiness, "no-action-observed");
assert!(summary.reason.contains("optional maintenance"));
assert!(summary.safe_next_command.is_none());
}
#[test]
fn remote_binary_outcome_maps_capability_gap_without_overclaiming() {
use crate::fleet_version_skew::CapabilityGap;
assert_eq!(
RemoteBinaryOutcome::from_capability_gap(CapabilityGap::BinaryMissing),
RemoteBinaryOutcome::Missing
);
assert_eq!(
RemoteBinaryOutcome::from_capability_gap(CapabilityGap::None),
RemoteBinaryOutcome::Current
);
assert_eq!(
RemoteBinaryOutcome::from_capability_gap(CapabilityGap::Minor),
RemoteBinaryOutcome::Current
);
assert_eq!(
RemoteBinaryOutcome::from_capability_gap(CapabilityGap::Major),
RemoteBinaryOutcome::Old
);
assert_eq!(
RemoteBinaryOutcome::from_capability_gap(CapabilityGap::Unknown),
RemoteBinaryOutcome::PresentUnknownVersion
);
}
#[test]
fn apply_remote_binary_drives_cass_missing_and_old_states() {
let mut o = reachable_healthy("h");
o.cass_present = None;
o.cass_current = None;
apply_remote_binary(&mut o, RemoteBinaryOutcome::Missing);
assert_eq!(o.cass_present, Some(false));
assert_eq!(
classify_source_doctor_state(&o),
SourceDoctorState::CassMissing
);
let mut o = reachable_healthy("h");
apply_remote_binary(&mut o, RemoteBinaryOutcome::Old);
assert_eq!(o.cass_present, Some(true));
assert_eq!(o.cass_current, Some(false));
assert_eq!(classify_source_doctor_state(&o), SourceDoctorState::OldCass);
let mut o = reachable_healthy("h");
o.cass_present = None;
o.cass_current = None;
apply_remote_binary(&mut o, RemoteBinaryOutcome::Current);
assert_eq!(o.cass_present, Some(true));
assert_eq!(o.cass_current, Some(true));
assert_eq!(
classify_source_doctor_state(&o),
SourceDoctorState::Reachable
);
let mut o = reachable_healthy("h");
o.cass_present = None;
o.cass_current = None;
apply_remote_binary(&mut o, RemoteBinaryOutcome::PresentUnknownVersion);
assert_eq!(o.cass_present, Some(true));
assert_eq!(o.cass_current, None);
assert_eq!(
classify_source_doctor_state(&o),
SourceDoctorState::Reachable
);
}
#[test]
fn apply_sync_evidence_prefers_preservation_for_pruned_remote() {
let mut o = reachable_healthy("retired");
o.source_root_readable = Some(false);
apply_sync_evidence(
&mut o,
&SourceSyncEvidence {
remote_path_missing: true,
local_mirror_nonempty: true,
last_sync_incomplete: true,
..Default::default()
},
);
assert!(o.remote_pruned);
assert!(!o.mirror_behind, "prune must suppress mirror_behind");
assert!(!o.mirror_ahead);
assert_eq!(
classify_source_doctor_state(&o),
SourceDoctorState::RemotePruned
);
let mut o = reachable_healthy("h");
apply_sync_evidence(
&mut o,
&SourceSyncEvidence {
remote_path_missing: true,
local_mirror_nonempty: false,
..Default::default()
},
);
assert!(!o.remote_pruned);
}
#[test]
fn apply_sync_evidence_drives_mirror_and_index_states() {
let mut o = reachable_healthy("h");
apply_sync_evidence(
&mut o,
&SourceSyncEvidence {
remote_path_empty: true,
local_mirror_nonempty: true,
..Default::default()
},
);
assert!(o.mirror_ahead);
assert_eq!(
classify_source_doctor_state(&o),
SourceDoctorState::MirrorAhead
);
let mut o = reachable_healthy("h");
apply_sync_evidence(
&mut o,
&SourceSyncEvidence {
last_sync_incomplete: true,
has_sync_record: true,
..Default::default()
},
);
assert!(o.mirror_behind);
assert_eq!(
classify_source_doctor_state(&o),
SourceDoctorState::MirrorBehind
);
let mut o = reachable_healthy("h");
apply_sync_evidence(
&mut o,
&SourceSyncEvidence {
index_behind_sync: true,
has_sync_record: true,
..Default::default()
},
);
assert!(o.index_stale);
assert_eq!(
classify_source_doctor_state(&o),
SourceDoctorState::StaleIndex
);
let mut o = reachable_healthy("h");
apply_sync_evidence(&mut o, &SourceSyncEvidence::default());
assert!(!o.mirror_ahead && !o.mirror_behind && !o.index_stale && !o.remote_pruned);
assert_eq!(
classify_source_doctor_state(&o),
SourceDoctorState::Reachable
);
}
#[test]
fn json_contract_is_stable_and_round_trips() {
let report = SourceDoctorReport::build(&[reachable_healthy("good")]);
let value = serde_json::to_value(&report).expect("serialize");
assert_eq!(value["schema_version"], SOURCE_DOCTOR_SCHEMA_VERSION);
assert_eq!(value["mutation_free"], true);
assert_eq!(value["sources"][0]["state"], "reachable");
assert_eq!(value["sources"][0]["host_reached"], true);
let back: SourceDoctorReport = serde_json::from_value(value).expect("deserialize");
assert_eq!(back, report);
}
}