1use std::path::PathBuf;
4use thiserror::Error;
5
6use crate::model::{ItemId, ItemType, RelationshipType, SourceLocation};
7
8#[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#[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 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 pub fn is_error(&self) -> bool {
115 !matches!(
116 self,
117 Self::UnrecognizedField { .. } | Self::RedundantRelationship { .. }
118 )
119 }
120
121 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#[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#[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#[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#[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#[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 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 pub fn has_suggestions(&self) -> bool {
255 matches!(self, EditError::ItemNotFound { suggestions, .. } if !suggestions.is_empty())
256 }
257}
258
259pub type Result<T> = std::result::Result<T, SaraError>;