1use std::path::PathBuf;
11
12use serde::{Deserialize, Serialize};
13use thiserror::Error;
14
15use crate::core::{ActorId, BeadId, BeadType, CoreError, DepKind, Priority, WallClock};
16use crate::daemon::wal::WalError;
17use crate::error::{Effect, Transience};
18use crate::git::SyncError;
19
20#[derive(Debug, Clone, PartialEq, Eq, Default)]
31pub enum Patch<T> {
32 #[default]
34 Keep,
35 Clear,
37 Set(T),
39}
40
41impl<T> Patch<T> {
42 pub fn is_keep(&self) -> bool {
44 matches!(self, Patch::Keep)
45 }
46
47 pub fn apply(self, current: Option<T>) -> Option<T> {
49 match self {
50 Patch::Keep => current,
51 Patch::Clear => None,
52 Patch::Set(v) => Some(v),
53 }
54 }
55}
56
57impl<T: Serialize> Serialize for Patch<T> {
59 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
60 where
61 S: serde::Serializer,
62 {
63 match self {
64 Patch::Keep => serializer.serialize_none(), Patch::Clear => serializer.serialize_none(),
66 Patch::Set(v) => v.serialize(serializer),
67 }
68 }
69}
70
71impl<'de, T: Deserialize<'de>> Deserialize<'de> for Patch<T> {
72 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
73 where
74 D: serde::Deserializer<'de>,
75 {
76 let opt: Option<T> = Option::deserialize(deserializer)?;
80 match opt {
81 None => Ok(Patch::Clear),
82 Some(v) => Ok(Patch::Set(v)),
83 }
84 }
85}
86
87#[derive(Debug, Clone, Default, Serialize, Deserialize)]
97pub struct BeadPatch {
98 #[serde(default, skip_serializing_if = "Patch::is_keep")]
99 pub title: Patch<String>,
100
101 #[serde(default, skip_serializing_if = "Patch::is_keep")]
102 pub description: Patch<String>,
103
104 #[serde(default, skip_serializing_if = "Patch::is_keep")]
105 pub design: Patch<String>,
106
107 #[serde(default, skip_serializing_if = "Patch::is_keep")]
108 pub acceptance_criteria: Patch<String>,
109
110 #[serde(default, skip_serializing_if = "Patch::is_keep")]
111 pub priority: Patch<Priority>,
112
113 #[serde(default, skip_serializing_if = "Patch::is_keep")]
114 pub bead_type: Patch<BeadType>,
115
116 #[serde(default, skip_serializing_if = "Patch::is_keep")]
117 pub labels: Patch<Vec<String>>,
118
119 #[serde(default, skip_serializing_if = "Patch::is_keep")]
120 pub external_ref: Patch<String>,
121
122 #[serde(default, skip_serializing_if = "Patch::is_keep")]
123 pub source_repo: Patch<String>,
124
125 #[serde(default, skip_serializing_if = "Patch::is_keep")]
126 pub estimated_minutes: Patch<u32>,
127
128 #[serde(default, skip_serializing_if = "Patch::is_keep")]
129 pub status: Patch<String>,
130}
131
132impl BeadPatch {
133 pub fn validate(&self) -> Result<(), OpError> {
138 if matches!(self.title, Patch::Clear) {
139 return Err(OpError::ValidationFailed {
140 field: "title".into(),
141 reason: "cannot clear required field".into(),
142 });
143 }
144 if matches!(self.description, Patch::Clear) {
145 return Err(OpError::ValidationFailed {
146 field: "description".into(),
147 reason: "cannot clear required field".into(),
148 });
149 }
150
151 if let Patch::Set(labels) = &self.labels {
152 for raw in labels {
153 crate::core::Label::parse(raw.clone()).map_err(|e| OpError::ValidationFailed {
154 field: "labels".into(),
155 reason: e.to_string(),
156 })?;
157 }
158 }
159 Ok(())
160 }
161
162 pub fn is_empty(&self) -> bool {
164 self.title.is_keep()
165 && self.description.is_keep()
166 && self.design.is_keep()
167 && self.acceptance_criteria.is_keep()
168 && self.priority.is_keep()
169 && self.bead_type.is_keep()
170 && self.labels.is_keep()
171 && self.external_ref.is_keep()
172 && self.source_repo.is_keep()
173 && self.estimated_minutes.is_keep()
174 && self.status.is_keep()
175 }
176}
177
178#[derive(Debug, Clone, Serialize, Deserialize)]
184#[serde(tag = "op", rename_all = "snake_case")]
185pub enum BeadOp {
186 Create {
188 repo: PathBuf,
189 #[serde(default)]
190 id: Option<String>,
191 #[serde(default)]
192 parent: Option<String>,
193 title: String,
194 #[serde(rename = "type")]
195 bead_type: BeadType,
196 priority: Priority,
197 #[serde(default)]
198 description: Option<String>,
199 #[serde(default)]
200 design: Option<String>,
201 #[serde(default)]
202 acceptance_criteria: Option<String>,
203 #[serde(default)]
204 assignee: Option<String>,
205 #[serde(default)]
206 external_ref: Option<String>,
207 #[serde(default)]
208 estimated_minutes: Option<u32>,
209 #[serde(default)]
210 labels: Vec<String>,
211 #[serde(default)]
212 dependencies: Vec<String>,
213 },
214
215 Update {
217 repo: PathBuf,
218 id: BeadId,
219 patch: BeadPatch,
220 #[serde(default)]
222 cas: Option<String>,
223 },
224
225 Close {
227 repo: PathBuf,
228 id: BeadId,
229 #[serde(default)]
230 reason: Option<String>,
231 #[serde(default)]
232 on_branch: Option<String>,
233 },
234
235 Reopen { repo: PathBuf, id: BeadId },
237
238 Delete {
240 repo: PathBuf,
241 id: BeadId,
242 #[serde(default)]
243 reason: Option<String>,
244 },
245
246 AddDep {
248 repo: PathBuf,
249 from: BeadId,
250 to: BeadId,
251 kind: DepKind,
252 },
253
254 RemoveDep {
256 repo: PathBuf,
257 from: BeadId,
258 to: BeadId,
259 kind: DepKind,
260 },
261
262 AddNote {
264 repo: PathBuf,
265 id: BeadId,
266 content: String,
267 },
268
269 Claim {
271 repo: PathBuf,
272 id: BeadId,
273 lease_secs: u64,
275 },
276
277 Unclaim { repo: PathBuf, id: BeadId },
279
280 ExtendClaim {
282 repo: PathBuf,
283 id: BeadId,
284 lease_secs: u64,
286 },
287}
288
289impl BeadOp {
290 pub fn repo(&self) -> &PathBuf {
292 match self {
293 BeadOp::Create { repo, .. } => repo,
294 BeadOp::Update { repo, .. } => repo,
295 BeadOp::Close { repo, .. } => repo,
296 BeadOp::Reopen { repo, .. } => repo,
297 BeadOp::Delete { repo, .. } => repo,
298 BeadOp::AddDep { repo, .. } => repo,
299 BeadOp::RemoveDep { repo, .. } => repo,
300 BeadOp::AddNote { repo, .. } => repo,
301 BeadOp::Claim { repo, .. } => repo,
302 BeadOp::Unclaim { repo, .. } => repo,
303 BeadOp::ExtendClaim { repo, .. } => repo,
304 }
305 }
306
307 pub fn bead_id(&self) -> Option<&BeadId> {
309 match self {
310 BeadOp::Create { .. } => None,
311 BeadOp::Update { id, .. } => Some(id),
312 BeadOp::Close { id, .. } => Some(id),
313 BeadOp::Reopen { id, .. } => Some(id),
314 BeadOp::Delete { id, .. } => Some(id),
315 BeadOp::AddDep { from, .. } => Some(from),
316 BeadOp::RemoveDep { from, .. } => Some(from),
317 BeadOp::AddNote { id, .. } => Some(id),
318 BeadOp::Claim { id, .. } => Some(id),
319 BeadOp::Unclaim { id, .. } => Some(id),
320 BeadOp::ExtendClaim { id, .. } => Some(id),
321 }
322 }
323}
324
325#[derive(Error, Debug)]
331#[non_exhaustive]
332pub enum OpError {
333 #[error("bead not found: {0}")]
334 NotFound(BeadId),
335
336 #[error("bead already exists: {0}")]
337 AlreadyExists(BeadId),
338
339 #[error("bead already claimed by {by}, expires at {expires:?}")]
340 AlreadyClaimed {
341 by: ActorId,
342 expires: Option<WallClock>,
343 },
344
345 #[error("CAS mismatch: expected {expected}, got {actual}")]
346 CasMismatch { expected: String, actual: String },
347
348 #[error("invalid transition from {from} to {to}")]
349 InvalidTransition { from: String, to: String },
350
351 #[error("validation failed for field {field}: {reason}")]
352 ValidationFailed { field: String, reason: String },
353
354 #[error("not a git repo: {0}")]
355 NotAGitRepo(PathBuf),
356
357 #[error("no origin remote configured for repo: {0}")]
358 NoRemote(PathBuf),
359
360 #[error("repo not initialized: {0}")]
361 RepoNotInitialized(PathBuf),
362
363 #[error(transparent)]
364 Sync(#[from] SyncError),
365
366 #[error("bead is deleted: {0}")]
367 BeadDeleted(BeadId),
368
369 #[error(transparent)]
370 Wal(#[from] WalError),
371
372 #[error("wal merge conflict: {errors:?}")]
373 WalMerge { errors: Vec<CoreError> },
374
375 #[error("cannot unclaim - not claimed by you")]
376 NotClaimedByYou,
377
378 #[error("dependency not found")]
379 DepNotFound,
380
381 #[error("daemon internal error: {0}")]
382 Internal(&'static str),
383}
384
385impl OpError {
386 pub fn code(&self) -> &'static str {
388 match self {
389 OpError::NotFound(_) => "not_found",
390 OpError::AlreadyExists(_) => "already_exists",
391 OpError::AlreadyClaimed { .. } => "already_claimed",
392 OpError::CasMismatch { .. } => "cas_mismatch",
393 OpError::InvalidTransition { .. } => "invalid_transition",
394 OpError::ValidationFailed { .. } => "validation_failed",
395 OpError::NotAGitRepo(_) => "not_a_git_repo",
396 OpError::NoRemote(_) => "no_remote",
397 OpError::RepoNotInitialized(_) => "repo_not_initialized",
398 OpError::Sync(_) => "sync_failed",
399 OpError::BeadDeleted(_) => "bead_deleted",
400 OpError::Wal(_) => "wal_error",
401 OpError::WalMerge { .. } => "wal_merge_conflict",
402 OpError::NotClaimedByYou => "not_claimed_by_you",
403 OpError::DepNotFound => "dep_not_found",
404 OpError::Internal(_) => "internal",
405 }
406 }
407
408 pub fn transience(&self) -> Transience {
410 match self {
411 OpError::Sync(e) => e.transience(),
412 OpError::Wal(e) => match e {
413 WalError::Io(_) => Transience::Retryable,
414 WalError::Json(_) | WalError::VersionMismatch { .. } => Transience::Permanent,
415 },
416 OpError::WalMerge { .. } => Transience::Permanent,
417 OpError::AlreadyClaimed { .. } => Transience::Retryable,
418 OpError::NotFound(_)
419 | OpError::AlreadyExists(_)
420 | OpError::CasMismatch { .. }
421 | OpError::InvalidTransition { .. }
422 | OpError::ValidationFailed { .. }
423 | OpError::NotAGitRepo(_)
424 | OpError::NoRemote(_)
425 | OpError::RepoNotInitialized(_)
426 | OpError::BeadDeleted(_)
427 | OpError::NotClaimedByYou
428 | OpError::DepNotFound => Transience::Permanent,
429 OpError::Internal(_) => Transience::Retryable,
430 }
431 }
432
433 pub fn effect(&self) -> Effect {
435 match self {
436 OpError::Sync(e) => e.effect(),
437 OpError::Wal(_) | OpError::WalMerge { .. } => Effect::None,
438 _ => Effect::None,
439 }
440 }
441}
442
443impl OpError {
444 pub fn from_live_lookup(err: crate::core::LiveLookupError, id: BeadId) -> Self {
446 match err {
447 crate::core::LiveLookupError::NotFound => OpError::NotFound(id),
448 crate::core::LiveLookupError::Deleted => OpError::BeadDeleted(id),
449 }
450 }
451}
452
453pub trait MapLiveError<T> {
455 fn map_live_err(self, id: &BeadId) -> Result<T, OpError>;
457}
458
459impl<T> MapLiveError<T> for Result<T, crate::core::LiveLookupError> {
460 fn map_live_err(self, id: &BeadId) -> Result<T, OpError> {
461 self.map_err(|e| OpError::from_live_lookup(e, id.clone()))
462 }
463}
464
465#[derive(Debug, Clone, Serialize, Deserialize)]
471#[serde(tag = "result", rename_all = "snake_case")]
472pub enum OpResult {
473 Created { id: BeadId },
475
476 Updated { id: BeadId },
478
479 Closed { id: BeadId },
481
482 Reopened { id: BeadId },
484
485 Deleted { id: BeadId },
487
488 DepAdded { from: BeadId, to: BeadId },
490
491 DepRemoved { from: BeadId, to: BeadId },
493
494 NoteAdded { bead_id: BeadId, note_id: String },
496
497 Claimed { id: BeadId, expires: WallClock },
499
500 Unclaimed { id: BeadId },
502
503 ClaimExtended { id: BeadId, expires: WallClock },
505}
506
507#[cfg(test)]
508mod tests {
509 use super::*;
510
511 #[test]
512 fn patch_default_is_keep() {
513 let patch: Patch<String> = Patch::default();
514 assert!(patch.is_keep());
515 }
516
517 #[test]
518 fn patch_apply() {
519 let current = Some("old".to_string());
520
521 assert_eq!(Patch::Keep.apply(current.clone()), Some("old".to_string()));
522 assert_eq!(Patch::<String>::Clear.apply(current.clone()), None);
523 assert_eq!(
524 Patch::Set("new".to_string()).apply(current),
525 Some("new".to_string())
526 );
527 }
528
529 #[test]
530 fn bead_patch_validation() {
531 let mut patch = BeadPatch::default();
532 assert!(patch.validate().is_ok());
533
534 patch.title = Patch::Clear;
535 assert!(patch.validate().is_err());
536 }
537
538 #[test]
539 fn bead_op_repo() {
540 let op = BeadOp::Create {
541 repo: PathBuf::from("/test"),
542 id: None,
543 parent: None,
544 title: "test".into(),
545 bead_type: BeadType::Task,
546 priority: Priority::default(),
547 description: None,
548 design: None,
549 acceptance_criteria: None,
550 assignee: None,
551 external_ref: None,
552 estimated_minutes: None,
553 labels: Vec::new(),
554 dependencies: Vec::new(),
555 };
556 assert_eq!(op.repo(), &PathBuf::from("/test"));
557 }
558}