Skip to main content

ucp_agent/
cursor.rs

1//! Traversal cursor for tracking position in the graph.
2
3use chrono::{DateTime, Utc};
4use serde::{Deserialize, Serialize};
5use std::collections::VecDeque;
6use ucm_core::{BlockId, EdgeType};
7
8/// Represents an agent's current position and view in the graph.
9#[derive(Debug, Clone)]
10pub struct TraversalCursor {
11    /// Current focus block.
12    pub position: BlockId,
13    /// Visible neighborhood (cached expansion).
14    pub neighborhood: CursorNeighborhood,
15    /// Breadcrumb trail for navigation.
16    pub breadcrumbs: VecDeque<BlockId>,
17    /// Current view mode.
18    pub view_mode: ViewMode,
19    /// Maximum breadcrumbs to retain.
20    max_breadcrumbs: usize,
21}
22
23impl TraversalCursor {
24    pub fn new(position: BlockId, max_breadcrumbs: usize) -> Self {
25        Self {
26            position,
27            neighborhood: CursorNeighborhood::default(),
28            breadcrumbs: VecDeque::new(),
29            view_mode: ViewMode::default(),
30            max_breadcrumbs,
31        }
32    }
33
34    /// Move cursor to a new position.
35    pub fn move_to(&mut self, new_position: BlockId) {
36        // Add current position to breadcrumbs
37        self.breadcrumbs.push_back(self.position);
38        if self.breadcrumbs.len() > self.max_breadcrumbs {
39            self.breadcrumbs.pop_front();
40        }
41
42        // Update position and mark neighborhood as stale
43        self.position = new_position;
44        self.neighborhood.stale = true;
45    }
46
47    /// Go back in navigation history.
48    pub fn go_back(&mut self, steps: usize) -> Option<BlockId> {
49        for _ in 0..steps {
50            if let Some(prev) = self.breadcrumbs.pop_back() {
51                self.position = prev;
52                self.neighborhood.stale = true;
53            } else {
54                return None;
55            }
56        }
57        Some(self.position)
58    }
59
60    /// Check if we can go back.
61    pub fn can_go_back(&self) -> bool {
62        !self.breadcrumbs.is_empty()
63    }
64
65    /// Get the number of steps we can go back.
66    pub fn history_depth(&self) -> usize {
67        self.breadcrumbs.len()
68    }
69
70    /// Clear navigation history.
71    pub fn clear_history(&mut self) {
72        self.breadcrumbs.clear();
73    }
74
75    /// Update the neighborhood cache.
76    pub fn update_neighborhood(&mut self, neighborhood: CursorNeighborhood) {
77        self.neighborhood = neighborhood;
78        self.neighborhood.stale = false;
79        self.neighborhood.computed_at = Utc::now();
80    }
81
82    /// Check if neighborhood needs refresh.
83    pub fn needs_refresh(&self) -> bool {
84        self.neighborhood.stale
85    }
86}
87
88/// The cached visible neighborhood around cursor position.
89#[derive(Debug, Clone)]
90pub struct CursorNeighborhood {
91    /// Parent chain to root (limited depth).
92    pub ancestors: Vec<BlockId>,
93    /// Immediate children.
94    pub children: Vec<BlockId>,
95    /// Siblings at same level.
96    pub siblings: Vec<BlockId>,
97    /// Semantic connections (via edges).
98    pub connections: Vec<(BlockId, EdgeType)>,
99    /// Timestamp when neighborhood was computed.
100    pub computed_at: DateTime<Utc>,
101    /// Whether neighborhood needs refresh.
102    pub stale: bool,
103}
104
105impl Default for CursorNeighborhood {
106    fn default() -> Self {
107        Self::new()
108    }
109}
110
111impl CursorNeighborhood {
112    pub fn new() -> Self {
113        Self {
114            ancestors: Vec::new(),
115            children: Vec::new(),
116            siblings: Vec::new(),
117            connections: Vec::new(),
118            computed_at: Utc::now(),
119            stale: true,
120        }
121    }
122
123    /// Total blocks in neighborhood.
124    pub fn total_blocks(&self) -> usize {
125        self.ancestors.len() + self.children.len() + self.siblings.len() + self.connections.len()
126    }
127}
128
129/// How much detail to show in traversal results.
130#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
131#[serde(rename_all = "snake_case")]
132#[derive(Default)]
133pub enum ViewMode {
134    /// Just block IDs and structure.
135    IdsOnly,
136    /// Structure with short previews (first N characters).
137    Preview { length: usize },
138    /// Full content for blocks in view.
139    #[default]
140    Full,
141    /// Metadata only (semantic role, tags, edge counts).
142    Metadata,
143    /// Adaptive - auto-select based on relevance.
144    Adaptive { interest_threshold: f32 },
145}
146
147impl ViewMode {
148    pub fn preview(length: usize) -> Self {
149        Self::Preview { length }
150    }
151
152    pub fn adaptive(threshold: f32) -> Self {
153        Self::Adaptive {
154            interest_threshold: threshold,
155        }
156    }
157}
158
159/// Convert from UCL AST ViewMode to our ViewMode.
160impl From<ucl_parser::ast::ViewMode> for ViewMode {
161    fn from(mode: ucl_parser::ast::ViewMode) -> Self {
162        match mode {
163            ucl_parser::ast::ViewMode::Full => ViewMode::Full,
164            ucl_parser::ast::ViewMode::Preview { length } => ViewMode::Preview { length },
165            ucl_parser::ast::ViewMode::Metadata => ViewMode::Metadata,
166            ucl_parser::ast::ViewMode::IdsOnly => ViewMode::IdsOnly,
167        }
168    }
169}
170
171#[cfg(test)]
172mod tests {
173    use super::*;
174
175    fn block_id(s: &str) -> BlockId {
176        s.parse().unwrap_or_else(|_| {
177            // Create a deterministic ID from the input string for testing
178            let mut bytes = [0u8; 12];
179            let s_bytes = s.as_bytes();
180            for (i, b) in s_bytes.iter().enumerate() {
181                bytes[i % 12] ^= *b;
182            }
183            BlockId::from_bytes(bytes)
184        })
185    }
186
187    #[test]
188    fn test_cursor_movement() {
189        let mut cursor = TraversalCursor::new(block_id("blk_000000000001"), 10);
190
191        cursor.move_to(block_id("blk_000000000002"));
192        assert_eq!(cursor.position, block_id("blk_000000000002"));
193        assert_eq!(cursor.breadcrumbs.len(), 1);
194
195        cursor.move_to(block_id("blk_000000000003"));
196        assert_eq!(cursor.position, block_id("blk_000000000003"));
197        assert_eq!(cursor.breadcrumbs.len(), 2);
198
199        // Go back
200        cursor.go_back(1);
201        assert_eq!(cursor.position, block_id("blk_000000000002"));
202        assert_eq!(cursor.breadcrumbs.len(), 1);
203    }
204
205    #[test]
206    fn test_cursor_history_limit() {
207        let mut cursor = TraversalCursor::new(block_id("blk_000000000001"), 3);
208
209        // Move more times than the limit
210        for i in 2..=5 {
211            cursor.move_to(block_id(&format!("blk_00000000000{}", i)));
212        }
213
214        // Should only have 3 breadcrumbs
215        assert_eq!(cursor.breadcrumbs.len(), 3);
216    }
217
218    #[test]
219    fn test_neighborhood_staleness() {
220        let mut cursor = TraversalCursor::new(block_id("blk_000000000001"), 10);
221
222        // Initially stale
223        assert!(cursor.needs_refresh());
224
225        // Update neighborhood
226        cursor.update_neighborhood(CursorNeighborhood::new());
227        assert!(!cursor.needs_refresh());
228
229        // Move makes it stale again
230        cursor.move_to(block_id("blk_000000000002"));
231        assert!(cursor.needs_refresh());
232    }
233}