sara-core 0.5.4

Core library for Sara - Requirements Knowledge Graph CLI
Documentation
//! Error types for the sara-core library.
//!
//! This module defines all error types used throughout the SARA system:
//!
//! - [`ParseError`]: Errors during Markdown/YAML parsing
//! - [`ValidationError`]: Issues detected during graph validation
//! - [`GraphError`]: Problems during graph construction
//! - [`EditError`]: Errors during item editing operations
//!
//! # Error Codes
//!
//! [`ValidationErrorCode`] provides type-safe error identification for programmatic
//! handling without string matching.

use std::path::PathBuf;
use thiserror::Error;

use crate::model::{ItemId, ItemType, RelationshipType};

/// Type-safe error codes for validation errors.
///
/// Provides a programmatic way to identify validation error types
/// without relying on string matching.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize)]
#[serde(rename_all = "snake_case")]
pub enum ValidationErrorCode {
    /// Item ID format is invalid.
    InvalidId,
    /// Required field is missing.
    MissingField,
    /// Reference to non-existent item.
    BrokenReference,
    /// Item has no upstream parent (when required).
    OrphanItem,
    /// Same ID defined in multiple files.
    DuplicateIdentifier,
    /// Cycle detected in the graph.
    CircularReference,
    /// Relationship type not allowed between item types.
    InvalidRelationship,
    /// Metadata validation failed.
    InvalidMetadata,
    /// Unknown field in frontmatter.
    UnrecognizedField,
    /// Both sides of a relationship declare it.
    RedundantRelationship,
}

impl ValidationErrorCode {
    /// Returns the string representation of this error code.
    #[must_use]
    pub const fn as_str(&self) -> &'static str {
        match self {
            Self::InvalidId => "invalid_id",
            Self::MissingField => "missing_field",
            Self::BrokenReference => "broken_reference",
            Self::OrphanItem => "orphan_item",
            Self::DuplicateIdentifier => "duplicate_identifier",
            Self::CircularReference => "circular_reference",
            Self::InvalidRelationship => "invalid_relationship",
            Self::InvalidMetadata => "invalid_metadata",
            Self::UnrecognizedField => "unrecognized_field",
            Self::RedundantRelationship => "redundant_relationship",
        }
    }
}

impl std::fmt::Display for ValidationErrorCode {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "{}", self.as_str())
    }
}

/// Errors that can occur during parsing.
#[derive(Debug, Error)]
pub enum ParseError {
    #[error("Failed to read file {path}: {reason}")]
    FileRead { path: PathBuf, reason: String },

    #[error("Invalid frontmatter in {file}: {reason}")]
    InvalidFrontmatter { file: PathBuf, reason: String },

    #[error("Missing frontmatter in {file}")]
    MissingFrontmatter { file: PathBuf },

    #[error("Invalid YAML in {file}: {reason}")]
    InvalidYaml { file: PathBuf, reason: String },

    #[error("Missing required field '{field}' in {file}")]
    MissingField { file: PathBuf, field: String },

    #[error("Invalid item type '{value}' in {file}")]
    InvalidItemType { file: PathBuf, value: String },
}

/// Errors that can occur during validation.
#[derive(Debug, Error, Clone, serde::Serialize)]
pub enum ValidationError {
    #[error("Invalid item ID '{id}': {reason}")]
    InvalidId { id: String, reason: String },

    #[error("Missing required field '{field}' in {file}")]
    MissingField { field: String, file: String },

    #[error("Broken reference: {from} references non-existent item {to}")]
    BrokenReference { from: ItemId, to: ItemId },

    #[error("Orphan item: {id} has no upstream parent")]
    OrphanItem { id: ItemId, item_type: ItemType },

    #[error("Duplicate identifier: {id} defined in multiple files")]
    DuplicateIdentifier { id: ItemId },

    #[error("Circular reference detected: {cycle}")]
    CircularReference { cycle: String },

    #[error("Invalid relationship: {from_type} cannot {rel_type} {to_type}")]
    InvalidRelationship {
        from_id: ItemId,
        to_id: ItemId,
        from_type: ItemType,
        to_type: ItemType,
        rel_type: RelationshipType,
    },

    #[error("Invalid metadata in {file}: {reason}")]
    InvalidMetadata { file: String, reason: String },

    #[error("Unrecognized field '{field}' in {file}")]
    UnrecognizedField { field: String, file: String },

    #[error(
        "Redundant relationship: {from_id} and {to_id} both declare the relationship (only one is needed)"
    )]
    RedundantRelationship {
        from_id: ItemId,
        to_id: ItemId,
        from_rel: RelationshipType,
        to_rel: RelationshipType,
    },
}

impl ValidationError {
    /// Returns true if this is an error (blocks validation).
    #[must_use]
    pub const fn is_error(&self) -> bool {
        !matches!(
            self,
            Self::UnrecognizedField { .. } | Self::RedundantRelationship { .. }
        )
    }

    /// Returns the error code for this validation error.
    #[must_use]
    pub const fn code(&self) -> ValidationErrorCode {
        match self {
            Self::InvalidId { .. } => ValidationErrorCode::InvalidId,
            Self::MissingField { .. } => ValidationErrorCode::MissingField,
            Self::BrokenReference { .. } => ValidationErrorCode::BrokenReference,
            Self::OrphanItem { .. } => ValidationErrorCode::OrphanItem,
            Self::DuplicateIdentifier { .. } => ValidationErrorCode::DuplicateIdentifier,
            Self::CircularReference { .. } => ValidationErrorCode::CircularReference,
            Self::InvalidRelationship { .. } => ValidationErrorCode::InvalidRelationship,
            Self::InvalidMetadata { .. } => ValidationErrorCode::InvalidMetadata,
            Self::UnrecognizedField { .. } => ValidationErrorCode::UnrecognizedField,
            Self::RedundantRelationship { .. } => ValidationErrorCode::RedundantRelationship,
        }
    }
}

/// Errors that can occur with configuration.
#[derive(Debug, Error)]
pub enum ConfigError {
    #[error("Failed to read config file {path}: {reason}")]
    FileRead { path: PathBuf, reason: String },

    #[error("Invalid config file {path}: {reason}")]
    InvalidConfig { path: PathBuf, reason: String },

    #[error("Repository not found: {path}")]
    RepositoryNotFound { path: PathBuf },

    #[error("Invalid glob pattern '{pattern}': {reason}")]
    InvalidGlobPattern { pattern: String, reason: String },
}

/// Errors that can occur during queries.
#[derive(Debug, Error)]
pub enum QueryError {
    #[error("Item not found: {id}")]
    ItemNotFound {
        id: String,
        suggestions: Vec<String>,
    },

    #[error("Invalid query: {reason}")]
    InvalidQuery { reason: String },
}

/// Errors that can occur with Git operations.
#[derive(Debug, Error)]
pub enum GitError {
    #[error("Failed to open repository {path}: {reason}")]
    OpenRepository { path: PathBuf, reason: String },

    #[error("Invalid Git reference: {reference}")]
    InvalidReference { reference: String },

    #[error("Failed to read file {path} at {reference}: {reason}")]
    ReadFile {
        path: PathBuf,
        reference: String,
        reason: String,
    },
}

/// Main error type for sara-core.
#[derive(Debug, Error)]
pub enum SaraError {
    #[error(transparent)]
    Parse(#[from] ParseError),

    #[error(transparent)]
    Validation(Box<ValidationError>),

    #[error(transparent)]
    Config(#[from] ConfigError),

    #[error(transparent)]
    Query(#[from] QueryError),

    #[error(transparent)]
    Git(#[from] GitError),

    #[error("IO error: {0}")]
    Io(#[from] std::io::Error),

    #[error("Git operation failed: {0}")]
    GitError(String),
}

impl From<ValidationError> for SaraError {
    fn from(err: ValidationError) -> Self {
        SaraError::Validation(Box::new(err))
    }
}

/// Errors that can occur during edit operations (FR-054 through FR-066).
#[derive(Debug, Error)]
pub enum EditError {
    #[error("Item not found: {id}")]
    ItemNotFound {
        id: String,
        suggestions: Vec<String>,
    },

    #[error(
        "Interactive mode requires a terminal. Use modification flags (--name, --description, etc.) to edit non-interactively."
    )]
    NonInteractiveTerminal,

    #[error("User cancelled")]
    Cancelled,

    #[error("Invalid traceability link: {id} does not exist")]
    InvalidLink { id: String },

    #[error("Failed to read file: {0}")]
    IoError(String),

    #[error("Failed to parse graph: {0}")]
    GraphError(String),
}

impl EditError {
    /// Format suggestions for "not found" error (FR-061).
    pub fn format_suggestions(&self) -> Option<String> {
        if let EditError::ItemNotFound { suggestions, .. } = self
            && !suggestions.is_empty()
        {
            return Some(format!("Did you mean: {}?", suggestions.join(", ")));
        }
        None
    }

    /// Returns true if this error has suggestions.
    pub fn has_suggestions(&self) -> bool {
        matches!(self, EditError::ItemNotFound { suggestions, .. } if !suggestions.is_empty())
    }
}

/// Result type for sara-core operations.
pub type Result<T> = std::result::Result<T, SaraError>;