Skip to main content

oximedia_edit/
track_lock.rs

1//! Track locking and protection system.
2//!
3//! Provides mechanisms to lock tracks and clips against accidental
4//! modifications during editing. Supports full lock, partial lock
5//! (position-only, content-only), and clip-level pinning.
6
7#![allow(dead_code)]
8#![allow(clippy::cast_precision_loss)]
9#![allow(clippy::too_many_arguments)]
10
11use std::collections::{HashMap, HashSet};
12
13/// Level of protection applied to a track or clip.
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
15pub enum LockLevel {
16    /// Fully unlocked; all operations allowed.
17    Unlocked,
18    /// Position is locked; clip cannot be moved or trimmed but effects/volume can change.
19    PositionLocked,
20    /// Content is locked; effects/volume cannot change but clip can be moved.
21    ContentLocked,
22    /// Fully locked; no modifications allowed.
23    FullyLocked,
24}
25
26impl LockLevel {
27    /// Whether position-changing operations are blocked.
28    #[must_use]
29    pub fn blocks_position(&self) -> bool {
30        matches!(self, Self::PositionLocked | Self::FullyLocked)
31    }
32
33    /// Whether content-changing operations are blocked.
34    #[must_use]
35    pub fn blocks_content(&self) -> bool {
36        matches!(self, Self::ContentLocked | Self::FullyLocked)
37    }
38
39    /// Whether any operations are blocked.
40    #[must_use]
41    pub fn is_locked(&self) -> bool {
42        !matches!(self, Self::Unlocked)
43    }
44}
45
46/// A lock applied to a specific track.
47#[derive(Debug, Clone)]
48pub struct TrackLock {
49    /// Track index.
50    pub track_index: u32,
51    /// Lock level.
52    pub level: LockLevel,
53    /// Who set this lock (user ID, system, etc.).
54    pub locked_by: String,
55    /// Reason / note.
56    pub reason: String,
57}
58
59impl TrackLock {
60    /// Create a new track lock.
61    #[must_use]
62    pub fn new(track_index: u32, level: LockLevel, locked_by: &str) -> Self {
63        Self {
64            track_index,
65            level,
66            locked_by: locked_by.to_string(),
67            reason: String::new(),
68        }
69    }
70
71    /// Attach a reason.
72    #[must_use]
73    pub fn with_reason(mut self, reason: &str) -> Self {
74        self.reason = reason.to_string();
75        self
76    }
77}
78
79/// A lock applied to a specific clip.
80#[derive(Debug, Clone)]
81pub struct ClipLock {
82    /// Clip identifier.
83    pub clip_id: u64,
84    /// Lock level.
85    pub level: LockLevel,
86    /// Who set this lock.
87    pub locked_by: String,
88}
89
90impl ClipLock {
91    /// Create a new clip lock.
92    #[must_use]
93    pub fn new(clip_id: u64, level: LockLevel, locked_by: &str) -> Self {
94        Self {
95            clip_id,
96            level,
97            locked_by: locked_by.to_string(),
98        }
99    }
100}
101
102/// Result of checking whether an operation is permitted.
103#[derive(Debug, Clone, PartialEq, Eq)]
104pub enum LockCheckResult {
105    /// Operation is allowed.
106    Allowed,
107    /// Operation is blocked by a track lock.
108    BlockedByTrack {
109        /// Track index.
110        track: u32,
111        /// Lock level.
112        level: LockLevel,
113    },
114    /// Operation is blocked by a clip lock.
115    BlockedByClip {
116        /// Clip ID.
117        clip_id: u64,
118        /// Lock level.
119        level: LockLevel,
120    },
121}
122
123impl LockCheckResult {
124    /// Whether the operation is allowed.
125    #[must_use]
126    pub fn is_allowed(&self) -> bool {
127        matches!(self, Self::Allowed)
128    }
129}
130
131/// Kind of editing operation being checked.
132#[derive(Debug, Clone, Copy, PartialEq, Eq)]
133pub enum OperationKind {
134    /// Moving or trimming a clip (position-changing).
135    Move,
136    /// Changing effects, volume, or content properties.
137    ContentEdit,
138    /// Deleting a clip.
139    Delete,
140    /// Adding a new clip to the track.
141    Add,
142}
143
144impl OperationKind {
145    /// Whether this operation changes position.
146    #[must_use]
147    pub fn is_position_change(&self) -> bool {
148        matches!(self, Self::Move | Self::Delete)
149    }
150
151    /// Whether this operation changes content.
152    #[must_use]
153    pub fn is_content_change(&self) -> bool {
154        matches!(self, Self::ContentEdit | Self::Delete)
155    }
156}
157
158/// Manager for track and clip locks.
159#[derive(Debug, Clone, Default)]
160pub struct LockManager {
161    /// Track locks keyed by track index.
162    track_locks: HashMap<u32, TrackLock>,
163    /// Clip locks keyed by clip ID.
164    clip_locks: HashMap<u64, ClipLock>,
165    /// Pinned clips (cannot be moved by ripple operations).
166    pinned_clips: HashSet<u64>,
167}
168
169impl LockManager {
170    /// Create a new lock manager.
171    #[must_use]
172    pub fn new() -> Self {
173        Self::default()
174    }
175
176    /// Lock a track.
177    pub fn lock_track(&mut self, lock: TrackLock) {
178        self.track_locks.insert(lock.track_index, lock);
179    }
180
181    /// Unlock a track.
182    pub fn unlock_track(&mut self, track_index: u32) -> bool {
183        self.track_locks.remove(&track_index).is_some()
184    }
185
186    /// Get the lock for a track.
187    #[must_use]
188    pub fn get_track_lock(&self, track_index: u32) -> Option<&TrackLock> {
189        self.track_locks.get(&track_index)
190    }
191
192    /// Lock a clip.
193    pub fn lock_clip(&mut self, lock: ClipLock) {
194        self.clip_locks.insert(lock.clip_id, lock);
195    }
196
197    /// Unlock a clip.
198    pub fn unlock_clip(&mut self, clip_id: u64) -> bool {
199        self.clip_locks.remove(&clip_id).is_some()
200    }
201
202    /// Get the lock for a clip.
203    #[must_use]
204    pub fn get_clip_lock(&self, clip_id: u64) -> Option<&ClipLock> {
205        self.clip_locks.get(&clip_id)
206    }
207
208    /// Pin a clip (prevent ripple operations from moving it).
209    pub fn pin_clip(&mut self, clip_id: u64) {
210        self.pinned_clips.insert(clip_id);
211    }
212
213    /// Unpin a clip.
214    pub fn unpin_clip(&mut self, clip_id: u64) -> bool {
215        self.pinned_clips.remove(&clip_id)
216    }
217
218    /// Check if a clip is pinned.
219    #[must_use]
220    pub fn is_pinned(&self, clip_id: u64) -> bool {
221        self.pinned_clips.contains(&clip_id)
222    }
223
224    /// Check whether an operation is permitted on a clip on a given track.
225    #[must_use]
226    pub fn check(&self, track_index: u32, clip_id: u64, op: OperationKind) -> LockCheckResult {
227        // Check track lock first.
228        if let Some(tl) = self.track_locks.get(&track_index) {
229            if Self::does_lock_block(tl.level, op) {
230                return LockCheckResult::BlockedByTrack {
231                    track: track_index,
232                    level: tl.level,
233                };
234            }
235        }
236        // Check clip lock.
237        if let Some(cl) = self.clip_locks.get(&clip_id) {
238            if Self::does_lock_block(cl.level, op) {
239                return LockCheckResult::BlockedByClip {
240                    clip_id,
241                    level: cl.level,
242                };
243            }
244        }
245        LockCheckResult::Allowed
246    }
247
248    /// Determine if a lock level blocks a given operation kind.
249    fn does_lock_block(level: LockLevel, op: OperationKind) -> bool {
250        match level {
251            LockLevel::Unlocked => false,
252            LockLevel::FullyLocked => true,
253            LockLevel::PositionLocked => op.is_position_change(),
254            LockLevel::ContentLocked => op.is_content_change(),
255        }
256    }
257
258    /// Count of locked tracks.
259    #[must_use]
260    pub fn locked_track_count(&self) -> usize {
261        self.track_locks.len()
262    }
263
264    /// Count of locked clips.
265    #[must_use]
266    pub fn locked_clip_count(&self) -> usize {
267        self.clip_locks.len()
268    }
269
270    /// Count of pinned clips.
271    #[must_use]
272    pub fn pinned_clip_count(&self) -> usize {
273        self.pinned_clips.len()
274    }
275
276    /// Clear all locks and pins.
277    pub fn clear_all(&mut self) {
278        self.track_locks.clear();
279        self.clip_locks.clear();
280        self.pinned_clips.clear();
281    }
282
283    /// List all locked track indices.
284    #[must_use]
285    pub fn locked_tracks(&self) -> Vec<u32> {
286        self.track_locks.keys().copied().collect()
287    }
288
289    /// List all locked clip IDs.
290    #[must_use]
291    pub fn locked_clips(&self) -> Vec<u64> {
292        self.clip_locks.keys().copied().collect()
293    }
294}
295
296#[cfg(test)]
297mod tests {
298    use super::*;
299
300    #[test]
301    fn test_lock_level_blocks_position() {
302        assert!(!LockLevel::Unlocked.blocks_position());
303        assert!(LockLevel::PositionLocked.blocks_position());
304        assert!(!LockLevel::ContentLocked.blocks_position());
305        assert!(LockLevel::FullyLocked.blocks_position());
306    }
307
308    #[test]
309    fn test_lock_level_blocks_content() {
310        assert!(!LockLevel::Unlocked.blocks_content());
311        assert!(!LockLevel::PositionLocked.blocks_content());
312        assert!(LockLevel::ContentLocked.blocks_content());
313        assert!(LockLevel::FullyLocked.blocks_content());
314    }
315
316    #[test]
317    fn test_lock_level_is_locked() {
318        assert!(!LockLevel::Unlocked.is_locked());
319        assert!(LockLevel::PositionLocked.is_locked());
320        assert!(LockLevel::ContentLocked.is_locked());
321        assert!(LockLevel::FullyLocked.is_locked());
322    }
323
324    #[test]
325    fn test_track_lock_new() {
326        let tl = TrackLock::new(0, LockLevel::FullyLocked, "alice").with_reason("final mix");
327        assert_eq!(tl.track_index, 0);
328        assert_eq!(tl.locked_by, "alice");
329        assert_eq!(tl.reason, "final mix");
330    }
331
332    #[test]
333    fn test_clip_lock_new() {
334        let cl = ClipLock::new(42, LockLevel::ContentLocked, "bob");
335        assert_eq!(cl.clip_id, 42);
336        assert_eq!(cl.level, LockLevel::ContentLocked);
337    }
338
339    #[test]
340    fn test_manager_lock_unlock_track() {
341        let mut mgr = LockManager::new();
342        mgr.lock_track(TrackLock::new(0, LockLevel::FullyLocked, "u"));
343        assert_eq!(mgr.locked_track_count(), 1);
344        assert!(mgr.get_track_lock(0).is_some());
345        assert!(mgr.unlock_track(0));
346        assert_eq!(mgr.locked_track_count(), 0);
347    }
348
349    #[test]
350    fn test_manager_lock_unlock_clip() {
351        let mut mgr = LockManager::new();
352        mgr.lock_clip(ClipLock::new(10, LockLevel::PositionLocked, "u"));
353        assert_eq!(mgr.locked_clip_count(), 1);
354        assert!(mgr.get_clip_lock(10).is_some());
355        assert!(mgr.unlock_clip(10));
356        assert_eq!(mgr.locked_clip_count(), 0);
357    }
358
359    #[test]
360    fn test_manager_pin_unpin() {
361        let mut mgr = LockManager::new();
362        mgr.pin_clip(5);
363        assert!(mgr.is_pinned(5));
364        assert_eq!(mgr.pinned_clip_count(), 1);
365        assert!(mgr.unpin_clip(5));
366        assert!(!mgr.is_pinned(5));
367    }
368
369    #[test]
370    fn test_check_fully_locked_track() {
371        let mut mgr = LockManager::new();
372        mgr.lock_track(TrackLock::new(0, LockLevel::FullyLocked, "u"));
373        let r = mgr.check(0, 1, OperationKind::Move);
374        assert!(!r.is_allowed());
375        assert!(matches!(
376            r,
377            LockCheckResult::BlockedByTrack { track: 0, .. }
378        ));
379    }
380
381    #[test]
382    fn test_check_position_locked_allows_content_edit() {
383        let mut mgr = LockManager::new();
384        mgr.lock_track(TrackLock::new(0, LockLevel::PositionLocked, "u"));
385        // Content edit should be allowed.
386        assert!(mgr.check(0, 1, OperationKind::ContentEdit).is_allowed());
387        // Move should be blocked.
388        assert!(!mgr.check(0, 1, OperationKind::Move).is_allowed());
389    }
390
391    #[test]
392    fn test_check_content_locked_allows_move() {
393        let mut mgr = LockManager::new();
394        mgr.lock_track(TrackLock::new(0, LockLevel::ContentLocked, "u"));
395        assert!(mgr.check(0, 1, OperationKind::Move).is_allowed());
396        assert!(!mgr.check(0, 1, OperationKind::ContentEdit).is_allowed());
397    }
398
399    #[test]
400    fn test_check_clip_lock_overrides() {
401        let mut mgr = LockManager::new();
402        // Track unlocked, but clip is fully locked.
403        mgr.lock_clip(ClipLock::new(42, LockLevel::FullyLocked, "u"));
404        let r = mgr.check(0, 42, OperationKind::Add);
405        assert!(!r.is_allowed());
406        assert!(matches!(
407            r,
408            LockCheckResult::BlockedByClip { clip_id: 42, .. }
409        ));
410    }
411
412    #[test]
413    fn test_check_unlocked() {
414        let mgr = LockManager::new();
415        assert!(mgr.check(0, 1, OperationKind::Move).is_allowed());
416        assert!(mgr.check(0, 1, OperationKind::Delete).is_allowed());
417    }
418
419    #[test]
420    fn test_clear_all() {
421        let mut mgr = LockManager::new();
422        mgr.lock_track(TrackLock::new(0, LockLevel::FullyLocked, "u"));
423        mgr.lock_clip(ClipLock::new(1, LockLevel::FullyLocked, "u"));
424        mgr.pin_clip(2);
425        mgr.clear_all();
426        assert_eq!(mgr.locked_track_count(), 0);
427        assert_eq!(mgr.locked_clip_count(), 0);
428        assert_eq!(mgr.pinned_clip_count(), 0);
429    }
430
431    #[test]
432    fn test_operation_kind_classification() {
433        assert!(OperationKind::Move.is_position_change());
434        assert!(OperationKind::Delete.is_position_change());
435        assert!(!OperationKind::ContentEdit.is_position_change());
436        assert!(!OperationKind::Add.is_position_change());
437
438        assert!(OperationKind::ContentEdit.is_content_change());
439        assert!(OperationKind::Delete.is_content_change());
440        assert!(!OperationKind::Move.is_content_change());
441    }
442}