Skip to main content

sara_core/
error.rs

1//! Error types for the sara-core library.
2//!
3//! This module defines a unified error type for all SARA operations.
4//! All errors are consolidated into the single [`SaraError`] enum with
5//! clear variants for each error category.
6//!
7//! # Error Categories
8//!
9//! - **File Operations**: Reading and writing files
10//! - **Parsing**: Markdown and YAML frontmatter parsing
11//! - **Validation**: Graph structure and item validation
12//! - **Configuration**: Loading and validating configuration
13//! - **Queries**: Item lookup and graph traversal
14//! - **Git Operations**: Repository access and version control
15//! - **Editing**: Item modification operations
16//!
17//! # Examples
18//!
19//! ```
20//! use sara_core::error::SaraError;
21//! use std::path::PathBuf;
22//!
23//! # fn example() -> Result<(), SaraError> {
24//! // Validation errors use explicit variants
25//! let err = SaraError::BrokenReference {
26//!     from: sara_core::model::ItemId::new_unchecked("UC-001"),
27//!     to: sara_core::model::ItemId::new_unchecked("SOL-999"),
28//! };
29//!
30//! // File operations with context
31//! let err = SaraError::InvalidFrontmatter {
32//!     file: PathBuf::from("doc.md"),
33//!     reason: "Missing required 'id' field".to_string(),
34//! };
35//! # Ok(())
36//! # }
37//! ```
38
39use std::path::PathBuf;
40
41use thiserror::Error;
42
43use crate::model::{ItemId, ItemType, RelationshipType};
44
45/// Main error type for sara-core operations.
46///
47/// Consolidates all error categories into a single type with clear variants.
48/// Uses `thiserror` for automatic `Display` and `Error` trait implementations.
49///
50/// # Errors
51///
52/// This enum categorizes all possible errors that can occur during SARA operations.
53/// Each variant includes contextual information to help diagnose the issue.
54///
55/// # Examples
56///
57/// ```
58/// use sara_core::error::SaraError;
59/// use std::path::PathBuf;
60///
61/// # fn example() -> Result<(), SaraError> {
62/// // File not found
63/// let err = SaraError::FileNotFound { path: PathBuf::from("missing.md") };
64///
65/// // Parse error with context
66/// let err = SaraError::InvalidFrontmatter {
67///     file: PathBuf::from("doc.md"),
68///     reason: "Missing 'id' field".to_string(),
69/// };
70/// # Ok(())
71/// # }
72/// ```
73#[derive(Debug, Error, serde::Serialize)]
74#[serde(tag = "error_type", content = "details")]
75pub enum SaraError {
76    // ==================== File Operations ====================
77    /// Failed to read a file from the filesystem.
78    #[error("Failed to read file '{path}': {source}")]
79    FileRead {
80        /// Path to the file that couldn't be read.
81        path: PathBuf,
82        /// Underlying I/O error.
83        #[serde(skip)]
84        #[source]
85        source: std::io::Error,
86    },
87
88    /// File was not found at the specified path.
89    #[error("File not found: {path}")]
90    FileNotFound {
91        /// Path to the missing file.
92        path: PathBuf,
93    },
94
95    /// Failed to write a file to the filesystem.
96    #[error("Failed to write file '{path}': {source}")]
97    FileWrite {
98        /// Path to the file that couldn't be written.
99        path: PathBuf,
100        /// Underlying I/O error.
101        #[serde(skip)]
102        #[source]
103        source: std::io::Error,
104    },
105
106    // ==================== Parsing ====================
107    /// Invalid frontmatter in a Markdown file.
108    #[error("Invalid frontmatter in {file}: {reason}")]
109    InvalidFrontmatter {
110        /// Path to the file with invalid frontmatter.
111        file: PathBuf,
112        /// Description of what's wrong with the frontmatter.
113        reason: String,
114    },
115
116    /// File is missing required frontmatter section.
117    #[error("Missing frontmatter in {file}")]
118    MissingFrontmatter {
119        /// Path to the file without frontmatter.
120        file: PathBuf,
121    },
122
123    /// Invalid YAML syntax in frontmatter.
124    #[error("Invalid YAML in {file}: {reason}")]
125    InvalidYaml {
126        /// Path to the file with invalid YAML.
127        file: PathBuf,
128        /// YAML parsing error details.
129        reason: String,
130    },
131
132    /// Invalid item type value in frontmatter.
133    #[error("Invalid item type '{value}' in {file}")]
134    InvalidItemType {
135        /// Path to the file with the invalid type.
136        file: PathBuf,
137        /// The invalid type value encountered.
138        value: String,
139    },
140
141    /// Missing required field in frontmatter.
142    #[error("Missing required field '{field}' in {file}")]
143    MissingField {
144        /// The field that was missing.
145        field: String,
146        /// Path to the file.
147        file: PathBuf,
148    },
149
150    // ==================== Validation ====================
151    /// Invalid item ID format.
152    #[error("Invalid item ID '{id}': {reason}")]
153    InvalidId {
154        /// The invalid ID.
155        id: String,
156        /// Why the ID is invalid.
157        reason: String,
158    },
159
160    /// Broken reference to non-existent item.
161    #[error("Broken reference: {from} references non-existent item {to}")]
162    BrokenReference {
163        /// The item with the broken reference.
164        from: ItemId,
165        /// The non-existent item being referenced.
166        to: ItemId,
167    },
168
169    /// Orphan item with no upstream parent.
170    #[error("Orphan item: {id} ({item_type}) has no upstream parent")]
171    OrphanItem {
172        /// The orphaned item ID.
173        id: ItemId,
174        /// The item type.
175        item_type: ItemType,
176    },
177
178    /// Duplicate identifier found in multiple files.
179    #[error("Duplicate identifier: {id} defined in multiple files")]
180    DuplicateIdentifier {
181        /// The duplicated ID.
182        id: ItemId,
183    },
184
185    /// Circular reference detected in the graph.
186    #[error("Circular reference detected: {cycle}")]
187    CircularReference {
188        /// Description of the cycle.
189        cycle: String,
190    },
191
192    /// Invalid relationship between item types.
193    #[error("Invalid relationship: {from_id} ({from_type}) cannot {rel_type} {to_id} ({to_type})")]
194    InvalidRelationship {
195        /// Source item ID.
196        from_id: ItemId,
197        /// Target item ID.
198        to_id: ItemId,
199        /// Source item type.
200        from_type: ItemType,
201        /// Target item type.
202        to_type: ItemType,
203        /// Relationship type attempted.
204        rel_type: RelationshipType,
205    },
206
207    /// Invalid metadata in item.
208    #[error("Invalid metadata in {file}: {reason}")]
209    InvalidMetadata {
210        /// File containing the invalid metadata.
211        file: String,
212        /// Description of the metadata issue.
213        reason: String,
214    },
215
216    /// Unrecognized field in frontmatter.
217    #[error("Unrecognized field '{field}' in {file}")]
218    UnrecognizedField {
219        /// The unrecognized field name.
220        field: String,
221        /// File containing the field.
222        file: String,
223    },
224
225    /// Redundant relationship declared on both sides.
226    #[error(
227        "Redundant relationship: {from_id} and {to_id} both declare the relationship (only one is needed)"
228    )]
229    RedundantRelationship {
230        /// First item ID.
231        from_id: ItemId,
232        /// Second item ID.
233        to_id: ItemId,
234    },
235
236    // ==================== Configuration ====================
237    /// Configuration file could not be read.
238    #[error("Failed to read config file {path}: {reason}")]
239    ConfigRead {
240        /// Path to the config file.
241        path: PathBuf,
242        /// Reason for the failure.
243        reason: String,
244    },
245
246    /// Configuration file has invalid content.
247    #[error("Invalid config file {path}: {reason}")]
248    InvalidConfig {
249        /// Path to the config file.
250        path: PathBuf,
251        /// Description of the configuration error.
252        reason: String,
253    },
254
255    /// Repository path does not exist or is not a directory.
256    #[error("Repository not found: {path}")]
257    RepositoryNotFound {
258        /// Path that was expected to be a repository.
259        path: PathBuf,
260    },
261
262    // ==================== Queries ====================
263    /// No parent items exist for the given item type.
264    #[error(
265        "Cannot create {item_type}: no {parent_type} items exist. Create a {parent_type} first."
266    )]
267    MissingParent {
268        /// The item type that requires a parent.
269        item_type: String,
270        /// The parent type that is missing.
271        parent_type: String,
272    },
273
274    /// Item was not found in the knowledge graph.
275    #[error("Item not found: {id}")]
276    ItemNotFound {
277        /// The item ID that wasn't found.
278        id: String,
279        /// Suggested similar item IDs (fuzzy matches).
280        suggestions: Vec<String>,
281    },
282
283    /// Query syntax or parameters are invalid.
284    #[error("Invalid query: {reason}")]
285    InvalidQuery {
286        /// Description of what's wrong with the query.
287        reason: String,
288    },
289
290    // ==================== Git Operations ====================
291    /// Failed to open a Git repository.
292    #[error("Failed to open repository {path}: {reason}")]
293    GitOpenRepository {
294        /// Path to the repository.
295        path: PathBuf,
296        /// Error from git2.
297        reason: String,
298    },
299
300    /// Git reference (branch, tag, commit) is invalid.
301    #[error("Invalid Git reference: {reference}")]
302    InvalidGitReference {
303        /// The invalid reference string.
304        reference: String,
305    },
306
307    /// Failed to read a file from a Git reference.
308    #[error("Failed to read file {path} at {reference}: {reason}")]
309    GitReadFile {
310        /// Path to the file in the repository.
311        path: PathBuf,
312        /// Git reference (commit, branch, tag).
313        reference: String,
314        /// Error details.
315        reason: String,
316    },
317
318    /// Generic Git operation error.
319    #[error("Git operation failed: {0}")]
320    Git(String),
321
322    // ==================== Edit Operations ====================
323    /// Interactive terminal required but not available.
324    #[error(
325        "Interactive mode requires a terminal. Use modification flags (--name, --description, etc.) to edit non-interactively."
326    )]
327    NonInteractiveTerminal,
328
329    /// User cancelled the operation.
330    #[error("User cancelled")]
331    Cancelled,
332
333    /// Traceability link points to non-existent item.
334    #[error("Invalid traceability link: {id} does not exist")]
335    InvalidLink {
336        /// The invalid item ID.
337        id: String,
338    },
339
340    /// Edit operation failed with custom error message.
341    #[error("Edit failed: {0}")]
342    EditFailed(String),
343
344    // ==================== Wrapped Errors ====================
345    /// Standard I/O error.
346    #[error("I/O error: {0}")]
347    Io(
348        #[serde(skip)]
349        #[from]
350        std::io::Error,
351    ),
352
353    /// Git2 library error.
354    #[error("Git error: {0}")]
355    Git2(
356        #[serde(skip)]
357        #[from]
358        git2::Error,
359    ),
360}
361
362impl SaraError {
363    /// Formats suggestions as a user-friendly message.
364    ///
365    /// Returns `None` if this is not an `ItemNotFound` error or if there are no suggestions.
366    pub fn format_suggestions(&self) -> Option<String> {
367        match self {
368            Self::ItemNotFound { suggestions, .. } if !suggestions.is_empty() => {
369                Some(format!("Did you mean: {}?", suggestions.join(", ")))
370            }
371            _ => None,
372        }
373    }
374}
375
376/// Result type for sara-core operations.
377///
378/// This is a convenience alias for `Result<T, SaraError>`.
379///
380/// # Examples
381///
382/// ```
383/// use sara_core::error::Result;
384///
385/// fn parse_file() -> Result<String> {
386///     Ok("parsed content".to_string())
387/// }
388/// ```
389pub type Result<T> = std::result::Result<T, SaraError>;
390
391// Rust guideline compliant 2026-02-06