beads_rs/daemon/
ops.rs

1//! Operations and patches for bead mutations.
2//!
3//! Provides:
4//! - `Patch<T>` - Three-way patch enum (Keep, Clear, Set)
5//! - `BeadPatch` - Partial update for bead fields
6//! - `BeadOp` - All mutation operations
7//! - `OpError` - Operation errors
8//! - `OpResult` - Operation results
9
10use 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// =============================================================================
21// Patch<T> - Three-way field update
22// =============================================================================
23
24/// Three-way patch for updating a field.
25///
26/// This is the clean solution to the "Option<Option<T>>" problem for nullable fields:
27/// - `Keep` - Don't change the field
28/// - `Clear` - Set the field to None
29/// - `Set(T)` - Set the field to Some(T)
30#[derive(Debug, Clone, PartialEq, Eq, Default)]
31pub enum Patch<T> {
32    /// Don't change the field.
33    #[default]
34    Keep,
35    /// Clear the field (set to None).
36    Clear,
37    /// Set the field to a new value.
38    Set(T),
39}
40
41impl<T> Patch<T> {
42    /// Check if this patch would change the value.
43    pub fn is_keep(&self) -> bool {
44        matches!(self, Patch::Keep)
45    }
46
47    /// Apply the patch to a current value.
48    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
57// Custom serde for Patch: absent = Keep, null = Clear, value = Set
58impl<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(), // Won't actually be serialized if skip_serializing_if
65            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        // If present and null -> Clear
77        // If present and value -> Set
78        // If absent -> Keep (handled by #[serde(default)])
79        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// =============================================================================
88// BeadPatch - Partial update for bead fields
89// =============================================================================
90
91/// Partial update for bead fields.
92///
93/// All fields default to `Keep`, meaning no change.
94/// Use `Patch::Set(value)` to update a field.
95/// Use `Patch::Clear` to clear a nullable field.
96#[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    /// Validate the patch, returning error if invalid.
134    ///
135    /// Rules:
136    /// - Cannot clear required fields (title, description)
137    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    /// Check if this patch has any changes.
163    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// =============================================================================
179// BeadOp - All mutation operations
180// =============================================================================
181
182/// Mutation operations on beads.
183#[derive(Debug, Clone, Serialize, Deserialize)]
184#[serde(tag = "op", rename_all = "snake_case")]
185pub enum BeadOp {
186    /// Create a new bead.
187    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 an existing bead.
216    Update {
217        repo: PathBuf,
218        id: BeadId,
219        patch: BeadPatch,
220        /// Optional CAS check - if provided, operation fails if content hash doesn't match.
221        #[serde(default)]
222        cas: Option<String>,
223    },
224
225    /// Close a bead.
226    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 a closed bead.
236    Reopen { repo: PathBuf, id: BeadId },
237
238    /// Delete a bead (soft delete via tombstone).
239    Delete {
240        repo: PathBuf,
241        id: BeadId,
242        #[serde(default)]
243        reason: Option<String>,
244    },
245
246    /// Add a dependency.
247    AddDep {
248        repo: PathBuf,
249        from: BeadId,
250        to: BeadId,
251        kind: DepKind,
252    },
253
254    /// Remove a dependency (soft delete).
255    RemoveDep {
256        repo: PathBuf,
257        from: BeadId,
258        to: BeadId,
259        kind: DepKind,
260    },
261
262    /// Add a note to a bead.
263    AddNote {
264        repo: PathBuf,
265        id: BeadId,
266        content: String,
267    },
268
269    /// Claim a bead for the current actor.
270    Claim {
271        repo: PathBuf,
272        id: BeadId,
273        /// Lease duration in seconds.
274        lease_secs: u64,
275    },
276
277    /// Release a claim on a bead.
278    Unclaim { repo: PathBuf, id: BeadId },
279
280    /// Extend an existing claim.
281    ExtendClaim {
282        repo: PathBuf,
283        id: BeadId,
284        /// New lease duration in seconds.
285        lease_secs: u64,
286    },
287}
288
289impl BeadOp {
290    /// Get the repo path for this operation.
291    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    /// Get the bead ID if this operation targets a specific bead.
308    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// =============================================================================
326// OpError - Operation errors
327// =============================================================================
328
329/// Errors that can occur during operations.
330#[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    /// Get the error code for IPC responses.
387    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    /// Whether retrying this operation may succeed.
409    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    /// What we know about side effects when this error is returned.
434    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    /// Convert a LiveLookupError to OpError with the given bead ID.
445    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
453/// Extension trait for mapping LiveLookupError to OpError.
454pub trait MapLiveError<T> {
455    /// Map a LiveLookupError to OpError using the given bead ID.
456    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// =============================================================================
466// OpResult - Operation results
467// =============================================================================
468
469/// Result of a successful operation.
470#[derive(Debug, Clone, Serialize, Deserialize)]
471#[serde(tag = "result", rename_all = "snake_case")]
472pub enum OpResult {
473    /// Bead was created.
474    Created { id: BeadId },
475
476    /// Bead was updated.
477    Updated { id: BeadId },
478
479    /// Bead was closed.
480    Closed { id: BeadId },
481
482    /// Bead was reopened.
483    Reopened { id: BeadId },
484
485    /// Bead was deleted.
486    Deleted { id: BeadId },
487
488    /// Dependency was added.
489    DepAdded { from: BeadId, to: BeadId },
490
491    /// Dependency was removed.
492    DepRemoved { from: BeadId, to: BeadId },
493
494    /// Note was added.
495    NoteAdded { bead_id: BeadId, note_id: String },
496
497    /// Bead was claimed.
498    Claimed { id: BeadId, expires: WallClock },
499
500    /// Claim was released.
501    Unclaimed { id: BeadId },
502
503    /// Claim was extended.
504    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}