homeboy 0.74.0

CLI for multi-component deployment and development workflow automation
Documentation
//! Input validation primitives.
//!
//! Provides ergonomic helpers for common validation patterns:
//! - Unwrapping Option values with descriptive errors
//! - Validating non-empty strings and collections
//!
//! These replace verbose ok_or_else + Error::validation_invalid_argument chains.

use crate::error::{Error, Result};

/// Require an Option to contain a value.
///
/// Replaces the common pattern:
/// ```ignore
/// value.ok_or_else(|| Error::validation_invalid_argument("field", "msg", None, None))?
/// ```
///
/// With:
/// ```ignore
/// validation::require(value, "field", "msg")?
/// ```
pub fn require<T>(opt: Option<T>, field: &str, message: &str) -> Result<T> {
    opt.ok_or_else(|| Error::validation_invalid_argument(field, message, None, None))
}

/// Require an Option to contain a value, with hints for resolution.
pub fn require_with_hints<T>(
    opt: Option<T>,
    field: &str,
    message: &str,
    hints: Vec<String>,
) -> Result<T> {
    opt.ok_or_else(|| Error::validation_invalid_argument(field, message, None, Some(hints)))
}

/// Collects validation errors for aggregated reporting.
///
/// Use when a command has multiple independent validations that should
/// all be checked before returning, rather than failing on the first error.
///
/// # Example
/// ```ignore
/// let mut v = ValidationCollector::new();
///
/// // Capture errors from Result-returning functions
/// let version = v.capture(read_version(id), "version");
/// let changelog = v.capture(validate_changelog(&comp), "changelog");
///
/// // Add custom errors
/// if uncommitted_files.len() > 0 {
///     v.push("working_tree", "Uncommitted changes detected", Some(json!({"files": files})));
/// }
///
/// // Return all errors at once (or Ok if none)
/// v.finish()?;
/// ```
pub struct ValidationCollector {
    errors: Vec<crate::error::ValidationErrorItem>,
}

impl ValidationCollector {
    pub fn new() -> Self {
        Self { errors: Vec::new() }
    }

    /// Capture a Result, storing the error if Err and returning the Ok value.
    pub fn capture<T>(&mut self, result: Result<T>, field: &str) -> Option<T> {
        match result {
            Ok(value) => Some(value),
            Err(err) => {
                // Extract specific problem from details if available, otherwise use generic message
                let problem = err
                    .details
                    .get("problem")
                    .and_then(|v| v.as_str())
                    .map(|s| s.to_string())
                    .unwrap_or_else(|| err.message.clone());

                self.errors.push(crate::error::ValidationErrorItem {
                    field: field.to_string(),
                    problem,
                    context: if err.details.as_object().is_some_and(|o| !o.is_empty()) {
                        Some(err.details)
                    } else {
                        None
                    },
                });
                None
            }
        }
    }

    /// Add an error directly.
    pub fn push(&mut self, field: &str, problem: &str, context: Option<serde_json::Value>) {
        self.errors.push(crate::error::ValidationErrorItem {
            field: field.to_string(),
            problem: problem.to_string(),
            context,
        });
    }

    /// Check if any errors have been collected.
    pub fn has_errors(&self) -> bool {
        !self.errors.is_empty()
    }

    /// Consume the collector and return Result.
    /// - No errors: Ok(())
    /// - Single error: Err(ValidationInvalidArgument) for backward compat
    /// - Multiple errors: Err(ValidationMultipleErrors)
    pub fn finish(self) -> Result<()> {
        match self.errors.len() {
            0 => Ok(()),
            1 => {
                let err = &self.errors[0];
                Err(Error::validation_invalid_argument(
                    &err.field,
                    &err.problem,
                    None,
                    None,
                ))
            }
            _ => Err(Error::validation_multiple_errors(self.errors)),
        }
    }
}

impl Default for ValidationCollector {
    fn default() -> Self {
        Self::new()
    }
}

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

    #[test]
    fn require_returns_value_when_some() {
        let result = require(Some("value"), "field", "msg");
        assert_eq!(result.unwrap(), "value");
    }

    #[test]
    fn require_returns_error_when_none() {
        let result: Result<&str> = require(None, "field", "Missing field");
        assert!(result.is_err());
    }

    #[test]
    fn collector_finish_returns_ok_when_no_errors() {
        let v = ValidationCollector::new();
        assert!(v.finish().is_ok());
    }

    #[test]
    fn collector_finish_returns_single_error() {
        use crate::error::ErrorCode;

        let mut v = ValidationCollector::new();
        v.push("field1", "Problem 1", None);
        let err = v.finish().unwrap_err();
        assert_eq!(err.code, ErrorCode::ValidationInvalidArgument);
    }

    #[test]
    fn collector_finish_returns_multiple_errors() {
        use crate::error::ErrorCode;

        let mut v = ValidationCollector::new();
        v.push("field1", "Problem 1", None);
        v.push("field2", "Problem 2", None);
        let err = v.finish().unwrap_err();
        assert_eq!(err.code, ErrorCode::ValidationMultipleErrors);
        assert!(err.message.contains("2 validation issue"));
    }

    #[test]
    fn collector_capture_stores_error_and_returns_none() {
        let mut v = ValidationCollector::new();
        let result: Option<i32> = v.capture(
            Err(Error::validation_invalid_argument(
                "test", "msg", None, None,
            )),
            "test",
        );
        assert!(result.is_none());
        assert!(v.has_errors());
    }

    #[test]
    fn collector_capture_returns_value_on_success() {
        let mut v = ValidationCollector::new();
        let result: Option<i32> = v.capture(Ok(42), "test");
        assert_eq!(result, Some(42));
        assert!(!v.has_errors());
    }

    #[test]
    fn collector_capture_extracts_problem_from_details() {
        let mut v = ValidationCollector::new();

        // Create an error with a specific problem in details (like changelog validation does)
        let err = Error::validation_invalid_argument(
            "changelog",
            "Changelog has no finalized versions",
            None,
            Some(vec![
                "Add at least one finalized version section like '## [0.1.0] - YYYY-MM-DD'"
                    .to_string(),
            ]),
        );

        let result: Option<i32> = v.capture(Err(err), "changelog_sync");
        assert!(result.is_none());
        assert!(v.has_errors());

        // Finish and verify the error contains the specific problem, not the generic message
        let final_err = v.finish().unwrap_err();
        assert!(final_err
            .details
            .get("problem")
            .and_then(|v| v.as_str())
            .unwrap()
            .contains("Changelog has no finalized versions"));
    }
}