hexser 0.6.0

Zero-boilerplate hexagonal architecture with graph-based introspection
Documentation
//! Not found error type for missing resources.
//!
//! Provides NotFoundError struct for resource not found failures.
//! Not found errors occur when requested resources don't exist.
//! Includes resource type and identifier context.
//!
//! Revision History
//! - 2026-07-21T00:00:00Z @AI: Add next_steps/suggestions storage + builders so Hexserror guidance builders no longer silently drop input on this variant.
//! - 2025-10-09T21:51:00Z @AI: Add conditional source location serialization via env_control.
//! - 2025-10-09T21:22:00Z @AI: Add Serde support for rich errors.
//! - 2025-10-06T02:00:00Z @AI: Fix merge conflict duplicates.
//! - 2025-10-06T00:00:00Z @AI: Initial NotFoundError struct for Phase 1.

/// Not found error for missing resources
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct NotFoundError {
  /// Error code from codes::resource module
  pub code: String,
  /// Type of resource that wasn't found
  pub resource: String,
  /// Identifier of missing resource
  pub id: String,
  /// Actionable next steps for resolving the error
  #[cfg_attr(
    feature = "serde",
    serde(default, skip_serializing_if = "Vec::is_empty")
  )]
  pub next_steps: Vec<String>,
  /// Concrete suggestions (e.g. example fixes)
  #[cfg_attr(
    feature = "serde",
    serde(default, skip_serializing_if = "Vec::is_empty")
  )]
  pub suggestions: Vec<String>,
  /// Optional source code location
  #[cfg_attr(
    feature = "serde",
    serde(skip_serializing_if = "crate::error::env_control::should_skip_location")
  )]
  pub location: Option<crate::error::source_location::SourceLocation>,
}

impl NotFoundError {
  /// Create new not found error
  pub fn new(resource: impl Into<String>, id: impl Into<String>) -> Self {
    Self {
      code: String::from(crate::error::codes::resource::NOT_FOUND),
      resource: resource.into(),
      id: id.into(),
      next_steps: Vec::new(),
      suggestions: Vec::new(),
      location: None,
    }
  }

  /// Add an actionable next step (builder pattern)
  pub fn with_next_step(mut self, step: impl Into<String>) -> Self {
    self.next_steps.push(step.into());
    self
  }

  /// Add multiple next steps (builder pattern)
  pub fn with_next_steps(mut self, steps: &[&str]) -> Self {
    self
      .next_steps
      .extend(steps.iter().map(|s| String::from(*s)));
    self
  }

  /// Add a suggestion (builder pattern)
  pub fn with_suggestion(mut self, suggestion: impl Into<String>) -> Self {
    self.suggestions.push(suggestion.into());
    self
  }

  /// Add multiple suggestions (builder pattern)
  pub fn with_suggestions(mut self, suggestions: &[&str]) -> Self {
    self
      .suggestions
      .extend(suggestions.iter().map(|s| String::from(*s)));
    self
  }

  /// Add source location (builder pattern)
  pub fn with_location(mut self, location: crate::error::source_location::SourceLocation) -> Self {
    self.location = Some(location);
    self
  }
}

impl std::fmt::Display for NotFoundError {
  fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
    write!(
      f,
      "Error [{}]: {} not found with id '{}'",
      self.code, self.resource, self.id
    )?;

    if self.next_steps.is_empty() {
      write!(f, "\nNext Step: Verify {} ID and existence", self.resource)?;
    } else {
      for step in &self.next_steps {
        write!(f, "\nNext Step: {step}")?;
      }
    }
    for suggestion in &self.suggestions {
      write!(f, "\nSuggestion: {suggestion}")?;
    }

    if let Some(ref location) = self.location {
      write!(f, "\nSource: {location}")?;
    }

    Ok(())
  }
}

impl std::error::Error for NotFoundError {}

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

  #[test]
  fn test_not_found_error_creation() {
    let err = NotFoundError::new("User", "123");
    assert_eq!(err.resource, "User");
    assert_eq!(err.id, "123");
    assert_eq!(err.code, crate::error::codes::resource::NOT_FOUND);
  }

  #[test]
  fn test_not_found_error_display() {
    let err = NotFoundError::new("Order", "abc-123");
    let display = format!("{err}");
    assert!(display.contains("Order"));
    assert!(display.contains("abc-123"));
    assert!(display.contains(crate::error::codes::resource::NOT_FOUND));
  }

  /// why: next steps and suggestions added via the builders must render in Display and survive
  /// a serde round-trip, so the rich-error guidance reaches both humans and serialized API
  /// consumers instead of being silently dropped.
  #[test]
  #[cfg(feature = "serde")]
  fn test_guidance_renders_and_round_trips() {
    let err = NotFoundError::new("User", "123")
      .with_next_step("Verify the ID")
      .with_suggestion("call users.list() to see valid ids");

    let display = format!("{err}");
    assert!(display.contains("Verify the ID"));
    assert!(display.contains("call users.list()"));

    let json = serde_json::to_string(&err).unwrap();
    let back: NotFoundError = serde_json::from_str(&json).unwrap();
    assert_eq!(back.next_steps, err.next_steps);
    assert_eq!(back.suggestions, err.suggestions);
  }
}