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} 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#[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 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 pub fn is_error(&self) -> bool {
119 !matches!(
120 self,
121 Self::UnrecognizedField { .. } | Self::RedundantRelationship { .. }
122 )
123 }
124
125 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#[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#[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#[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#[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#[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 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 pub fn has_suggestions(&self) -> bool {
259 matches!(self, EditError::ItemNotFound { suggestions, .. } if !suggestions.is_empty())
260 }
261}
262
263pub type Result<T> = std::result::Result<T, SaraError>;