moonlit-pdk 0.2.0

SDK for writing Moonlit release-pipeline plugins as WebAssembly components.
Documentation
//! Ergonomic result builder. Authors return a `MiddlewareResult<Output>`; the
//! `moonlit_plugin!` glue converts it to the WIT `middleware-result` via
//! `into_wit`, serializing the typed output into the ABI's `(key, json-text)`
//! output map. A serialization failure degrades the whole result to a loud
//! `failure(...)`.

/// What a [`Middleware`](crate::Middleware) returns.
///
/// # Examples
///
/// ```
/// use moonlit_pdk::{MiddlewareResult, NoOutput};
///
/// let ok = MiddlewareResult::ok(NoOutput {}).with_warning("`force` is deprecated");
/// assert!(ok.is_success());
/// assert_eq!(ok.warnings(), ["`force` is deprecated"]);
///
/// let failed: MiddlewareResult<NoOutput> = MiddlewareResult::failure("tag already exists");
/// assert!(!failed.is_success());
/// assert_eq!(failed.error_message(), Some("tag already exists"));
/// ```
#[must_use = "a middleware result must be returned from `execute`"]
pub struct MiddlewareResult<T> {
  successful: bool,
  error_message: Option<String>,
  warnings: Vec<String>,
  output: Option<T>,
}

impl<T: serde::Serialize> MiddlewareResult<T> {
  /// A successful outcome carrying the middleware's typed output. Use
  /// `NoOutput` for middlewares that publish nothing.
  pub fn ok(output: T) -> Self {
    Self {
      successful: true,
      error_message: None,
      warnings: Vec::new(),
      output: Some(output),
    }
  }
}

impl<T> MiddlewareResult<T> {
  /// A failed outcome; carries no output.
  pub fn failure(msg: impl Into<String>) -> Self {
    Self {
      successful: false,
      error_message: Some(msg.into()),
      warnings: Vec::new(),
      output: None,
    }
  }

  pub fn with_warning(mut self, msg: impl Into<String>) -> Self {
    self.warnings.push(msg.into());
    self
  }

  /// Whether the middleware succeeded. Useful in `sdk::testing` unit tests.
  #[must_use]
  pub fn is_success(&self) -> bool {
    self.successful
  }

  /// The failure message, if this result is a failure.
  #[must_use]
  pub fn error_message(&self) -> Option<&str> {
    self.error_message.as_deref()
  }

  /// Warnings attached to this result.
  #[must_use]
  pub fn warnings(&self) -> &[String] {
    &self.warnings
  }
}

impl<T: serde::Serialize> MiddlewareResult<T> {
  /// Convert to the WIT record. The typed output is serialized to a JSON
  /// object and spread into the `(key, json-text)` output list (the field
  /// names become the map keys, so `steps.NAME.outputs.<field>` resolves). A
  /// non-object output or a serialization error degrades to a loud failure.
  #[must_use]
  pub fn into_wit(self) -> crate::bindings::MiddlewareResult {
    let mut output: Vec<(String, String)> = Vec::new();
    if let Some(value) = self.output {
      match serde_json::to_value(&value) {
        Ok(serde_json::Value::Object(map)) => {
          for (k, v) in map {
            output.push((k, v.to_string()));
          }
        }
        // `NoOutput`-like empties (`{}`) land here as an object with no
        // entries; `()` serializes to Null. Both mean "no outputs".
        Ok(serde_json::Value::Null) => {}
        Ok(other) => {
          return crate::bindings::MiddlewareResult {
            successful: false,
            error_message: Some(format!("middleware output must serialize to a JSON object, got {other}")),
            warnings: self.warnings,
            output: Vec::new(),
          };
        }
        Err(e) => {
          return crate::bindings::MiddlewareResult {
            successful: false,
            error_message: Some(format!("output serialization failed: {e}")),
            warnings: self.warnings,
            output: Vec::new(),
          };
        }
      }
    }
    crate::bindings::MiddlewareResult {
      successful: self.successful,
      error_message: self.error_message,
      warnings: self.warnings,
      output,
    }
  }
}

#[cfg(test)]
mod tests {
  use super::*;

  #[test]
  fn ok_is_successful_no_warnings() {
    let w = MiddlewareResult::ok(()).into_wit();
    assert!(w.successful);
    assert!(w.error_message.is_none());
    assert!(w.warnings.is_empty());
    assert!(w.output.is_empty());
  }

  #[test]
  fn no_output_serializes_to_empty() {
    let w = MiddlewareResult::ok(crate::NoOutput {}).into_wit();
    assert!(w.successful);
    assert!(w.output.is_empty());
  }

  #[test]
  fn failure_carries_message_and_is_not_successful() {
    let w = MiddlewareResult::<()>::failure("boom").into_wit();
    assert!(!w.successful);
    assert_eq!(w.error_message.as_deref(), Some("boom"));
  }

  #[test]
  fn with_warning_is_chainable() {
    let w = MiddlewareResult::ok(()).with_warning("careful").into_wit();
    assert!(w.successful);
    assert_eq!(w.warnings, vec!["careful".to_string()]);
  }

  #[test]
  fn typed_output_spreads_fields_into_json_values() {
    #[derive(serde::Serialize)]
    struct Out {
      tag: String,
      count: u32,
    }
    let w = MiddlewareResult::ok(Out {
      tag: "v1.2.3".to_string(),
      count: 7,
    })
    .into_wit();
    assert!(w.successful);
    // json-value is JSON text; strings are quoted, numbers bare.
    let map: std::collections::HashMap<_, _> = w.output.into_iter().collect();
    assert_eq!(map["tag"], "\"v1.2.3\"");
    assert_eq!(map["count"], "7");
  }

  #[test]
  fn accessors_expose_outcome() {
    let ok = MiddlewareResult::ok(()).with_warning("w");
    assert!(ok.is_success());
    assert_eq!(ok.error_message(), None);
    assert_eq!(ok.warnings(), &["w".to_string()]);

    let bad = MiddlewareResult::<()>::failure("boom");
    assert!(!bad.is_success());
    assert_eq!(bad.error_message(), Some("boom"));
    assert!(bad.warnings().is_empty());
  }

  #[test]
  fn output_serialize_error_degrades_to_failure() {
    // A field whose value can't be JSON-encoded (non-string map keys) makes
    // `serde_json::to_value` error, which `into_wit` surfaces as a failure.
    #[derive(serde::Serialize)]
    struct Bad {
      bad: std::collections::HashMap<(i32, i32), i32>,
    }
    let mut bad = std::collections::HashMap::new();
    bad.insert((1, 2), 3);
    let w = MiddlewareResult::ok(Bad { bad }).into_wit();
    assert!(!w.successful);
    assert!(
      w.error_message.as_deref().unwrap().contains("serialization"),
      "message should indicate a serialization failure; got {:?}",
      w.error_message
    );
  }

  #[test]
  fn non_object_output_degrades_to_failure() {
    let w = MiddlewareResult::ok(42).with_warning("kept").into_wit();
    assert!(!w.successful);
    assert_eq!(
      w.error_message.as_deref(),
      Some("middleware output must serialize to a JSON object, got 42")
    );
    assert_eq!(w.warnings, vec!["kept".to_string()]);
    assert!(w.output.is_empty());
  }
}