#![cfg_attr(not(feature = "web-monitoring"), allow(dead_code))]
use serde::{Deserialize, Serialize};
pub const SCHEMA_VERSION: u32 = 1;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Operation {
Status,
Wait,
ControlMark,
ControlUnmark,
ControlStart,
ControlStop,
ControlForceStop,
ControlForceStopChange,
SubscribeSet,
SubscribeGet,
SubscribeClear,
}
impl Operation {
pub fn as_str(self) -> &'static str {
match self {
Self::Status => "status",
Self::Wait => "wait",
Self::ControlMark => "control_mark",
Self::ControlUnmark => "control_unmark",
Self::ControlStart => "control_start",
Self::ControlStop => "control_stop",
Self::ControlForceStop => "control_force_stop",
Self::ControlForceStopChange => "control_force_stop_change",
Self::SubscribeSet => "subscribe_set",
Self::SubscribeGet => "subscribe_get",
Self::SubscribeClear => "subscribe_clear",
}
}
}
#[allow(dead_code)] pub const ALL_OPERATIONS: [Operation; 11] = [
Operation::Status,
Operation::Wait,
Operation::ControlMark,
Operation::ControlUnmark,
Operation::ControlStart,
Operation::ControlStop,
Operation::ControlForceStop,
Operation::ControlForceStopChange,
Operation::SubscribeSet,
Operation::SubscribeGet,
Operation::SubscribeClear,
];
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Outcome {
Observed,
Completed,
Subscribed,
Cleared,
Marked,
Unmarked,
Unchanged,
Stopped,
Accepted,
NotInRepository,
OwnerNotRunning,
AuthenticationFailed,
IncompatibleOwner,
OwnerNotCommandCapable,
OwnerRestarted,
ChangeNotFound,
TargetIneligible,
RevisionConflict,
ObservationConflict,
CommandFailed,
PartialIntent,
UnsupportedTerminalMode,
EvidenceError,
ChangeRejected,
ChangeRequiresAction,
ProcessFailed,
Timeout,
FeatureUnavailable,
TransportError,
UsageError,
TransportNotPermitted,
UnsupportedOwner,
}
impl Outcome {
pub fn as_str(self) -> &'static str {
match self {
Self::Observed => "observed",
Self::Completed => "completed",
Self::Subscribed => "subscribed",
Self::Cleared => "cleared",
Self::Marked => "marked",
Self::Unmarked => "unmarked",
Self::Unchanged => "unchanged",
Self::Stopped => "stopped",
Self::Accepted => "accepted",
Self::NotInRepository => "not_in_repository",
Self::OwnerNotRunning => "owner_not_running",
Self::AuthenticationFailed => "authentication_failed",
Self::IncompatibleOwner => "incompatible_owner",
Self::OwnerNotCommandCapable => "owner_not_command_capable",
Self::OwnerRestarted => "owner_restarted",
Self::ChangeNotFound => "change_not_found",
Self::TargetIneligible => "target_ineligible",
Self::RevisionConflict => "revision_conflict",
Self::ObservationConflict => "observation_conflict",
Self::CommandFailed => "command_failed",
Self::PartialIntent => "partial_intent",
Self::UnsupportedTerminalMode => "unsupported_terminal_mode",
Self::EvidenceError => "evidence_error",
Self::ChangeRejected => "change_rejected",
Self::ChangeRequiresAction => "change_requires_action",
Self::ProcessFailed => "process_failed",
Self::Timeout => "timeout",
Self::FeatureUnavailable => "feature_unavailable",
Self::TransportError => "transport_error",
Self::UsageError => "usage_error",
Self::TransportNotPermitted => "transport_not_permitted",
Self::UnsupportedOwner => "unsupported_owner",
}
}
pub fn is_success(self) -> bool {
matches!(
self,
Self::Observed
| Self::Completed
| Self::Subscribed
| Self::Cleared
| Self::Marked
| Self::Unmarked
| Self::Unchanged
| Self::Stopped
| Self::Accepted
)
}
pub fn exit_code(self) -> i32 {
match self {
Self::Observed
| Self::Completed
| Self::Subscribed
| Self::Cleared
| Self::Marked
| Self::Unmarked
| Self::Unchanged
| Self::Stopped
| Self::Accepted => 0,
Self::UsageError => 2,
Self::NotInRepository => 3,
Self::OwnerNotRunning => 4,
Self::AuthenticationFailed => 5,
Self::IncompatibleOwner => 6,
Self::OwnerNotCommandCapable => 7,
Self::OwnerRestarted => 8,
Self::ChangeNotFound => 9,
Self::TargetIneligible => 10,
Self::RevisionConflict => 12,
Self::ObservationConflict => 13,
Self::CommandFailed => 14,
Self::PartialIntent => 15,
Self::UnsupportedTerminalMode => 16,
Self::EvidenceError => 22,
Self::ChangeRejected => 17,
Self::ProcessFailed => 18,
Self::Timeout => 19,
Self::FeatureUnavailable => 20,
Self::TransportError => 21,
Self::TransportNotPermitted => 25,
Self::UnsupportedOwner => 26,
Self::ChangeRequiresAction => 27,
}
}
}
#[allow(dead_code)] pub const ALL_OUTCOMES: [Outcome; 32] = [
Outcome::Observed,
Outcome::Completed,
Outcome::Subscribed,
Outcome::Cleared,
Outcome::Marked,
Outcome::Unmarked,
Outcome::Unchanged,
Outcome::Stopped,
Outcome::Accepted,
Outcome::NotInRepository,
Outcome::OwnerNotRunning,
Outcome::AuthenticationFailed,
Outcome::IncompatibleOwner,
Outcome::OwnerNotCommandCapable,
Outcome::OwnerRestarted,
Outcome::ChangeNotFound,
Outcome::TargetIneligible,
Outcome::RevisionConflict,
Outcome::ObservationConflict,
Outcome::CommandFailed,
Outcome::PartialIntent,
Outcome::UnsupportedTerminalMode,
Outcome::EvidenceError,
Outcome::ChangeRejected,
Outcome::ChangeRequiresAction,
Outcome::ProcessFailed,
Outcome::Timeout,
Outcome::FeatureUnavailable,
Outcome::TransportError,
Outcome::UsageError,
Outcome::TransportNotPermitted,
Outcome::UnsupportedOwner,
];
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ResultEnvelope {
pub schema_version: u32,
pub ok: bool,
pub operation: Operation,
pub outcome: Outcome,
#[serde(skip_serializing_if = "Option::is_none")]
pub instance_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub execution_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub change_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub message: Option<String>,
pub detail: serde_json::Value,
}
impl ResultEnvelope {
pub fn new(operation: Operation, outcome: Outcome) -> Self {
Self {
schema_version: SCHEMA_VERSION,
ok: outcome.is_success(),
operation,
outcome,
instance_id: None,
execution_id: None,
change_id: None,
message: None,
detail: serde_json::Value::Object(serde_json::Map::new()),
}
}
pub fn with_instance(mut self, instance_id: Option<String>) -> Self {
self.instance_id = instance_id;
self
}
pub fn with_execution(mut self, execution_id: Option<String>) -> Self {
self.execution_id = execution_id;
self
}
pub fn with_change(mut self, change_id: impl Into<String>) -> Self {
self.change_id = Some(change_id.into());
self
}
pub fn with_message(mut self, message: impl Into<String>) -> Self {
self.message = Some(message.into());
self
}
pub fn with_detail(mut self, detail: serde_json::Value) -> Self {
self.detail = detail;
self
}
pub fn exit_code(&self) -> i32 {
self.outcome.exit_code()
}
pub fn to_json_line(&self) -> String {
serde_json::to_string(self).unwrap_or_else(|_| {
format!(
r#"{{"schema_version":{SCHEMA_VERSION},"ok":false,"operation":"{}","outcome":"transport_error","detail":{{}}}}"#,
self.operation.as_str()
)
})
}
pub fn to_human_line(&self) -> String {
let mut line = String::new();
if let Some(change_id) = &self.change_id {
line.push_str(&format!(
"{}: {} ({change_id})",
self.operation.as_str(),
self.outcome.as_str()
));
} else {
line.push_str(&format!(
"{}: {}",
self.operation.as_str(),
self.outcome.as_str()
));
}
if let Some(message) = &self.message {
line.push_str(&format!(" — {message}"));
}
line
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::BTreeSet;
#[test]
fn every_outcome_has_a_distinct_stable_exit_code() {
let mut codes = BTreeSet::new();
for outcome in ALL_OUTCOMES {
if outcome.is_success() {
assert_eq!(outcome.exit_code(), 0, "{}", outcome.as_str());
continue;
}
assert!(
codes.insert(outcome.exit_code()),
"{} reuses exit code {}",
outcome.as_str(),
outcome.exit_code()
);
}
let successes = ALL_OUTCOMES
.iter()
.filter(|outcome| outcome.is_success())
.count();
assert_eq!(codes.len(), ALL_OUTCOMES.len() - successes);
}
#[test]
fn every_outcome_token_is_unique_and_snake_case() {
let mut tokens = BTreeSet::new();
for outcome in ALL_OUTCOMES {
let token = outcome.as_str();
assert!(tokens.insert(token), "{token} appears twice");
assert!(
token.bytes().all(|b| b.is_ascii_lowercase() || b == b'_'),
"{token} is not snake_case"
);
let serialized = serde_json::to_string(&outcome).unwrap();
assert_eq!(serialized, format!("\"{token}\""));
}
assert_eq!(tokens.len(), ALL_OUTCOMES.len());
}
#[test]
fn success_is_limited_to_reached_goals() {
let successes: Vec<&str> = ALL_OUTCOMES
.iter()
.filter(|outcome| outcome.is_success())
.map(|outcome| outcome.as_str())
.collect();
assert_eq!(
successes,
vec![
"observed",
"completed",
"subscribed",
"cleared",
"marked",
"unmarked",
"unchanged",
"stopped",
"accepted",
]
);
}
#[test]
fn a_manual_action_release_is_an_unsuccessful_outcome_with_its_own_status() {
assert!(!Outcome::ChangeRequiresAction.is_success());
assert_eq!(Outcome::ChangeRequiresAction.exit_code(), 27);
assert_eq!(
Outcome::ChangeRequiresAction.as_str(),
"change_requires_action"
);
assert_ne!(
Outcome::ChangeRequiresAction.exit_code(),
Outcome::ChangeRejected.exit_code()
);
assert_ne!(
Outcome::ChangeRequiresAction.exit_code(),
Outcome::Timeout.exit_code()
);
}
#[test]
fn a_json_envelope_is_one_object_with_a_stable_shape() {
let envelope = ResultEnvelope::new(Operation::ControlMark, Outcome::Marked)
.with_instance(Some("abc".to_string()))
.with_change("alpha");
let line = envelope.to_json_line();
assert!(!line.contains('\n'), "the envelope must be one line");
let parsed: serde_json::Value = serde_json::from_str(&line).expect("parses");
assert_eq!(parsed["schema_version"], SCHEMA_VERSION);
assert_eq!(parsed["ok"], true);
assert_eq!(parsed["operation"], "control_mark");
assert_eq!(parsed["outcome"], "marked");
assert_eq!(parsed["instance_id"], "abc");
assert_eq!(parsed["change_id"], "alpha");
assert!(!parsed.as_object().unwrap().contains_key("execution_id"));
assert!(parsed["detail"].is_object());
}
#[test]
fn absent_optional_fields_are_omitted_rather_than_nulled() {
let envelope = ResultEnvelope::new(Operation::Status, Outcome::OwnerNotRunning);
let parsed: serde_json::Value = serde_json::from_str(&envelope.to_json_line()).unwrap();
let object = parsed.as_object().unwrap();
assert!(!object.contains_key("instance_id"));
assert!(!object.contains_key("execution_id"));
assert!(!object.contains_key("change_id"));
assert!(!object.contains_key("message"));
assert_eq!(parsed["ok"], false);
assert_eq!(envelope.exit_code(), 4);
}
#[test]
fn execution_identity_is_an_additive_optional_field() {
let envelope = ResultEnvelope::new(Operation::SubscribeGet, Outcome::Subscribed)
.with_instance(Some("i-1".to_string()))
.with_change("alpha")
.with_execution(Some("e-1".to_string()));
let parsed: serde_json::Value = serde_json::from_str(&envelope.to_json_line()).unwrap();
assert_eq!(parsed["execution_id"], "e-1");
assert_eq!(parsed["ok"], true);
assert_eq!(parsed["outcome"], "subscribed");
assert_eq!(envelope.exit_code(), 0);
let without = ResultEnvelope::new(Operation::ControlMark, Outcome::Marked)
.with_change("alpha")
.with_execution(None);
let parsed: serde_json::Value = serde_json::from_str(&without.to_json_line()).unwrap();
assert!(!parsed.as_object().unwrap().contains_key("execution_id"));
}
#[test]
fn subscribe_operations_report_their_own_names_and_success_tokens() {
for operation in [Operation::SubscribeSet, Operation::SubscribeGet] {
let envelope = ResultEnvelope::new(operation, Outcome::Subscribed);
assert!(envelope.ok);
assert_eq!(envelope.exit_code(), 0);
assert_eq!(envelope.outcome.as_str(), "subscribed");
}
let cleared = ResultEnvelope::new(Operation::SubscribeClear, Outcome::Cleared);
assert!(cleared.ok);
assert_eq!(cleared.outcome.as_str(), "cleared");
let names: Vec<&str> = ALL_OPERATIONS.iter().map(|op| op.as_str()).collect();
assert_eq!(
names,
vec![
"status",
"wait",
"control_mark",
"control_unmark",
"control_start",
"control_stop",
"control_force_stop",
"control_force_stop_change",
"subscribe_set",
"subscribe_get",
"subscribe_clear",
]
);
for retired in ["enqueue", "notify_set", "notify_get", "notify_clear"] {
assert!(!names.contains(&retired), "{retired} is retired");
}
}
#[test]
fn the_human_line_names_the_operation_the_outcome_and_the_change() {
let line = ResultEnvelope::new(Operation::Wait, Outcome::Timeout)
.with_change("alpha")
.with_message("deadline reached")
.to_human_line();
assert_eq!(line, "wait: timeout (alpha) — deadline reached");
}
}