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