sara_core/
error.rs

1//! Error types for the sara-core library.
2
3use std::path::PathBuf;
4use thiserror::Error;
5
6use crate::model::{ItemId, ItemType, RelationshipType, SourceLocation};
7
8/// Errors that can occur during parsing.
9#[derive(Debug, Error)]
10pub enum ParseError {
11    #[error("Failed to read file {path}: {reason}")]
12    FileRead { path: PathBuf, reason: String },
13
14    #[error("Invalid frontmatter in {file}: {reason}")]
15    InvalidFrontmatter { file: PathBuf, reason: String },
16
17    #[error("Missing frontmatter in {file}")]
18    MissingFrontmatter { file: PathBuf },
19
20    #[error("Invalid YAML in {file}: {reason}")]
21    InvalidYaml { file: PathBuf, reason: String },
22
23    #[error("Missing required field '{field}' in {file}")]
24    MissingField { file: PathBuf, field: String },
25
26    #[error("Invalid item type '{value}' in {file}")]
27    InvalidItemType { file: PathBuf, value: String },
28}
29
30/// Errors that can occur during validation.
31#[derive(Debug, Error, Clone, serde::Serialize)]
32pub enum ValidationError {
33    #[error("Invalid item ID '{id}': {reason}")]
34    InvalidId { id: String, reason: String },
35
36    #[error("Missing required field '{field}' in {file}")]
37    MissingField { field: String, file: String },
38
39    #[error("Broken reference: {from} references non-existent item {to}")]
40    BrokenReference {
41        from: ItemId,
42        to: ItemId,
43        location: Option<SourceLocation>,
44    },
45
46    #[error("Orphan item: {id} has no upstream parent")]
47    OrphanItem {
48        id: ItemId,
49        item_type: ItemType,
50        location: Option<SourceLocation>,
51    },
52
53    #[error("Duplicate identifier: {id} defined in multiple files")]
54    DuplicateIdentifier {
55        id: ItemId,
56        locations: Vec<SourceLocation>,
57    },
58
59    #[error("Circular reference detected: {cycle}")]
60    CircularReference {
61        cycle: String,
62        location: Option<SourceLocation>,
63    },
64
65    #[error("Invalid relationship: {from_type} cannot {rel_type} {to_type}")]
66    InvalidRelationship {
67        from_id: ItemId,
68        to_id: ItemId,
69        from_type: ItemType,
70        to_type: ItemType,
71        rel_type: RelationshipType,
72        location: Option<SourceLocation>,
73    },
74
75    #[error("Invalid metadata in {file}: {reason}")]
76    InvalidMetadata { file: String, reason: String },
77
78    #[error("Unrecognized field '{field}' in {file}")]
79    UnrecognizedField {
80        field: String,
81        file: String,
82        location: Option<SourceLocation>,
83    },
84
85    #[error(
86        "Redundant relationship: {from_id} and {to_id} both declare the relationship (only one is needed)"
87    )]
88    RedundantRelationship {
89        from_id: ItemId,
90        to_id: ItemId,
91        from_rel: RelationshipType,
92        to_rel: RelationshipType,
93        from_location: Option<SourceLocation>,
94        to_location: Option<SourceLocation>,
95    },
96}
97
98impl ValidationError {
99    /// Returns the source location if available.
100    pub fn location(&self) -> Option<&SourceLocation> {
101        match self {
102            Self::BrokenReference { location, .. } => location.as_ref(),
103            Self::OrphanItem { location, .. } => location.as_ref(),
104            Self::DuplicateIdentifier { locations, .. } => locations.first(),
105            Self::CircularReference { location, .. } => location.as_ref(),
106            Self::InvalidRelationship { location, .. } => location.as_ref(),
107            Self::UnrecognizedField { location, .. } => location.as_ref(),
108            Self::RedundantRelationship { from_location, .. } => from_location.as_ref(),
109            _ => None,
110        }
111    }
112
113    /// Returns true if this is an error (blocks validation).
114    pub fn is_error(&self) -> bool {
115        !matches!(
116            self,
117            Self::UnrecognizedField { .. } | Self::RedundantRelationship { .. }
118        )
119    }
120
121    /// Returns the error code for this validation error.
122    pub fn code(&self) -> &'static str {
123        match self {
124            Self::InvalidId { .. } => "invalid_id",
125            Self::MissingField { .. } => "missing_field",
126            Self::BrokenReference { .. } => "broken_reference",
127            Self::OrphanItem { .. } => "orphan_item",
128            Self::DuplicateIdentifier { .. } => "duplicate_identifier",
129            Self::CircularReference { .. } => "circular_reference",
130            Self::InvalidRelationship { .. } => "invalid_relationship",
131            Self::InvalidMetadata { .. } => "invalid_metadata",
132            Self::UnrecognizedField { .. } => "unrecognized_field",
133            Self::RedundantRelationship { .. } => "redundant_relationship",
134        }
135    }
136}
137
138/// Errors that can occur with configuration.
139#[derive(Debug, Error)]
140pub enum ConfigError {
141    #[error("Failed to read config file {path}: {reason}")]
142    FileRead { path: PathBuf, reason: String },
143
144    #[error("Invalid config file {path}: {reason}")]
145    InvalidConfig { path: PathBuf, reason: String },
146
147    #[error("Repository not found: {path}")]
148    RepositoryNotFound { path: PathBuf },
149
150    #[error("Invalid glob pattern '{pattern}': {reason}")]
151    InvalidGlobPattern { pattern: String, reason: String },
152}
153
154/// Errors that can occur during queries.
155#[derive(Debug, Error)]
156pub enum QueryError {
157    #[error("Item not found: {id}")]
158    ItemNotFound {
159        id: String,
160        suggestions: Vec<String>,
161    },
162
163    #[error("Invalid query: {reason}")]
164    InvalidQuery { reason: String },
165}
166
167/// Errors that can occur with Git operations.
168#[derive(Debug, Error)]
169pub enum GitError {
170    #[error("Failed to open repository {path}: {reason}")]
171    OpenRepository { path: PathBuf, reason: String },
172
173    #[error("Invalid Git reference: {reference}")]
174    InvalidReference { reference: String },
175
176    #[error("Failed to read file {path} at {reference}: {reason}")]
177    ReadFile {
178        path: PathBuf,
179        reference: String,
180        reason: String,
181    },
182}
183
184/// Main error type for sara-core.
185#[derive(Debug, Error)]
186pub enum SaraError {
187    #[error(transparent)]
188    Parse(#[from] ParseError),
189
190    #[error(transparent)]
191    Validation(Box<ValidationError>),
192
193    #[error(transparent)]
194    Config(#[from] ConfigError),
195
196    #[error(transparent)]
197    Query(#[from] QueryError),
198
199    #[error(transparent)]
200    Git(#[from] GitError),
201
202    #[error("IO error: {0}")]
203    Io(#[from] std::io::Error),
204
205    #[error("Git operation failed: {0}")]
206    GitError(String),
207}
208
209impl From<ValidationError> for SaraError {
210    fn from(err: ValidationError) -> Self {
211        SaraError::Validation(Box::new(err))
212    }
213}
214
215/// Errors that can occur during edit operations (FR-054 through FR-066).
216#[derive(Debug, Error)]
217pub enum EditError {
218    #[error("Item not found: {id}")]
219    ItemNotFound {
220        id: String,
221        suggestions: Vec<String>,
222    },
223
224    #[error(
225        "Interactive mode requires a terminal. Use modification flags (--name, --description, etc.) to edit non-interactively."
226    )]
227    NonInteractiveTerminal,
228
229    #[error("User cancelled")]
230    Cancelled,
231
232    #[error("Invalid traceability link: {id} does not exist")]
233    InvalidLink { id: String },
234
235    #[error("Failed to read file: {0}")]
236    IoError(String),
237
238    #[error("Failed to parse graph: {0}")]
239    GraphError(String),
240}
241
242impl EditError {
243    /// Format suggestions for "not found" error (FR-061).
244    pub fn format_suggestions(&self) -> Option<String> {
245        if let EditError::ItemNotFound { suggestions, .. } = self
246            && !suggestions.is_empty()
247        {
248            return Some(format!("Did you mean: {}?", suggestions.join(", ")));
249        }
250        None
251    }
252
253    /// Returns true if this error has suggestions.
254    pub fn has_suggestions(&self) -> bool {
255        matches!(self, EditError::ItemNotFound { suggestions, .. } if !suggestions.is_empty())
256    }
257}
258
259/// Result type for sara-core operations.
260pub type Result<T> = std::result::Result<T, SaraError>;