Skip to main content

ryo_mutations/debugger/
marker.rs

1//! Debug marker for tracking inserted debug logs.
2//!
3//! Markers are embedded as comments in the code to enable:
4//! - Identifying which debug logs were inserted by ryo
5//! - Grouping logs by session (same debugging session)
6//! - Tracking when logs were inserted
7//! - Selective removal of debug logs
8
9use serde::{Deserialize, Serialize};
10use std::time::{SystemTime, UNIX_EPOCH};
11
12/// Prefix used to identify ryo debug markers in comments.
13pub const MARKER_PREFIX: &str = "ryo-debug";
14
15/// A debug marker that tracks metadata about inserted debug logs.
16///
17/// Format in code: `/* ryo-debug:<session_id>:<timestamp>:<description> */`
18///
19/// # Examples
20///
21/// ```
22/// use ryo_mutations::debugger::DebugMarker;
23///
24/// let marker = DebugMarker::new();
25/// let comment = marker.to_comment();
26/// assert!(comment.starts_with("/* ryo-debug:"));
27///
28/// // Parse back from comment
29/// let parsed = DebugMarker::from_comment(&comment).unwrap();
30/// assert_eq!(parsed.session_id, marker.session_id);
31/// ```
32#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
33pub struct DebugMarker {
34    /// Unique session identifier (groups related debug insertions).
35    pub session_id: String,
36    /// Unix timestamp when the marker was created.
37    pub timestamp: u64,
38    /// Optional description of what this debug log is for.
39    pub description: Option<String>,
40}
41
42impl DebugMarker {
43    /// Create a new marker with a random session ID and current timestamp.
44    pub fn new() -> Self {
45        Self {
46            session_id: Self::generate_session_id(),
47            timestamp: Self::current_timestamp(),
48            description: None,
49        }
50    }
51
52    /// Create a marker with a specific session ID.
53    ///
54    /// Use this when adding multiple debug logs in the same session
55    /// so they can be removed together.
56    pub fn with_session(session_id: impl Into<String>) -> Self {
57        Self {
58            session_id: session_id.into(),
59            timestamp: Self::current_timestamp(),
60            description: None,
61        }
62    }
63
64    /// Add a description to the marker.
65    pub fn with_description(mut self, description: impl Into<String>) -> Self {
66        self.description = Some(description.into());
67        self
68    }
69
70    /// Convert to a comment string for embedding in code.
71    ///
72    /// Format: `/* ryo-debug:<session_id>:<timestamp>:<description> */`
73    pub fn to_comment(&self) -> String {
74        let desc = self.description.as_deref().unwrap_or("");
75        format!(
76            "/* {}:{}:{}:{} */",
77            MARKER_PREFIX, self.session_id, self.timestamp, desc
78        )
79    }
80
81    /// Convert to a marker string (without comment delimiters).
82    ///
83    /// Format: `ryo-debug:<session_id>:<timestamp>:<description>`
84    ///
85    /// This is suitable for use as a string literal in code.
86    pub fn to_marker_string(&self) -> String {
87        let desc = self.description.as_deref().unwrap_or("");
88        format!(
89            "{}:{}:{}:{}",
90            MARKER_PREFIX, self.session_id, self.timestamp, desc
91        )
92    }
93
94    /// Parse a marker from a comment string or marker string.
95    ///
96    /// Accepts both formats:
97    /// - Comment: `/* ryo-debug:session:ts:desc */`
98    /// - String: `ryo-debug:session:ts:desc`
99    ///
100    /// Returns `None` if the string is not a valid ryo debug marker.
101    pub fn from_comment(s: &str) -> Option<Self> {
102        let s = s.trim();
103
104        // Try to strip comment delimiters if present
105        let inner = if s.starts_with("/*") && s.ends_with("*/") {
106            s.strip_prefix("/*")?.strip_suffix("*/")?.trim()
107        } else {
108            s
109        };
110
111        // Check prefix
112        let rest = inner.strip_prefix(MARKER_PREFIX)?.strip_prefix(':')?;
113
114        // Parse parts: session_id:timestamp:description
115        let parts: Vec<&str> = rest.splitn(3, ':').collect();
116        if parts.len() < 2 {
117            return None;
118        }
119
120        let session_id = parts[0].to_string();
121        let timestamp = parts[1].parse().ok()?;
122        let description = parts
123            .get(2)
124            .filter(|s| !s.is_empty())
125            .map(|s| s.to_string());
126
127        Some(Self {
128            session_id,
129            timestamp,
130            description,
131        })
132    }
133
134    /// Parse a marker from a string literal (quoted string).
135    ///
136    /// Accepts: `"ryo-debug:session:ts:desc"`
137    pub fn from_string_literal(s: &str) -> Option<Self> {
138        let s = s.trim();
139        let inner = s.strip_prefix('"')?.strip_suffix('"')?;
140        Self::from_comment(inner)
141    }
142
143    /// Check if a string contains a ryo debug marker.
144    ///
145    /// Detects both comment format and string literal format.
146    pub fn contains_marker(s: &str) -> bool {
147        s.contains(&format!("/* {}:", MARKER_PREFIX))
148            || s.contains(&format!("\"{}:", MARKER_PREFIX))
149    }
150
151    /// Extract all markers from a string.
152    pub fn extract_markers(s: &str) -> Vec<Self> {
153        let mut markers = Vec::new();
154        let prefix = format!("/* {}:", MARKER_PREFIX);
155
156        let mut search_start = 0;
157        while let Some(start) = s[search_start..].find(&prefix) {
158            let abs_start = search_start + start;
159            if let Some(end_offset) = s[abs_start..].find("*/") {
160                let end = abs_start + end_offset + 2;
161                if let Some(marker) = Self::from_comment(&s[abs_start..end]) {
162                    markers.push(marker);
163                }
164                search_start = end;
165            } else {
166                break;
167            }
168        }
169
170        markers
171    }
172
173    /// Generate a short random session ID.
174    fn generate_session_id() -> String {
175        use std::collections::hash_map::RandomState;
176        use std::hash::{BuildHasher, Hasher};
177
178        let state = RandomState::new();
179        let mut hasher = state.build_hasher();
180        hasher.write_u64(Self::current_timestamp());
181        hasher.write_usize(std::process::id() as usize);
182
183        format!("{:08x}", hasher.finish() as u32)
184    }
185
186    /// Get current Unix timestamp.
187    fn current_timestamp() -> u64 {
188        SystemTime::now()
189            .duration_since(UNIX_EPOCH)
190            .map(|d| d.as_secs())
191            .unwrap_or(0)
192    }
193}
194
195impl Default for DebugMarker {
196    fn default() -> Self {
197        Self::new()
198    }
199}
200
201/// A session for grouping related debug insertions.
202///
203/// Use this to ensure all debug logs inserted together share the same session ID,
204/// making it easy to remove them all at once.
205#[derive(Debug, Clone)]
206pub struct DebugSession {
207    session_id: String,
208}
209
210impl DebugSession {
211    /// Create a new debug session.
212    pub fn new() -> Self {
213        Self {
214            session_id: DebugMarker::generate_session_id(),
215        }
216    }
217
218    /// Create a session with a specific ID.
219    pub fn with_id(session_id: impl Into<String>) -> Self {
220        Self {
221            session_id: session_id.into(),
222        }
223    }
224
225    /// Get the session ID.
226    pub fn id(&self) -> &str {
227        &self.session_id
228    }
229
230    /// Create a marker for this session.
231    pub fn marker(&self) -> DebugMarker {
232        DebugMarker::with_session(&self.session_id)
233    }
234
235    /// Create a marker with a description for this session.
236    pub fn marker_with_desc(&self, description: impl Into<String>) -> DebugMarker {
237        self.marker().with_description(description)
238    }
239}
240
241impl Default for DebugSession {
242    fn default() -> Self {
243        Self::new()
244    }
245}
246
247#[cfg(test)]
248mod tests {
249    use super::*;
250
251    #[test]
252    fn test_marker_roundtrip() {
253        let marker = DebugMarker::with_session("test123").with_description("after map");
254
255        let comment = marker.to_comment();
256        assert!(comment.contains("ryo-debug:test123:"));
257        assert!(comment.contains(":after map"));
258
259        let parsed = DebugMarker::from_comment(&comment).unwrap();
260        assert_eq!(parsed.session_id, "test123");
261        assert_eq!(parsed.description, Some("after map".to_string()));
262    }
263
264    #[test]
265    fn test_marker_without_description() {
266        let marker = DebugMarker::with_session("abc");
267        let comment = marker.to_comment();
268
269        let parsed = DebugMarker::from_comment(&comment).unwrap();
270        assert_eq!(parsed.session_id, "abc");
271        assert!(parsed.description.is_none());
272    }
273
274    #[test]
275    fn test_contains_marker() {
276        let code = r#"
277            items.iter()
278                .inspect(|x| { /* ryo-debug:abc:123:test */ dbg!(x); })
279                .collect()
280        "#;
281        assert!(DebugMarker::contains_marker(code));
282
283        let code_without = "items.iter().collect()";
284        assert!(!DebugMarker::contains_marker(code_without));
285    }
286
287    #[test]
288    fn test_extract_markers() {
289        let code = r#"
290            items /* ryo-debug:s1:100:first */ .iter()
291                .inspect(|x| { /* ryo-debug:s1:101:second */ dbg!(x); })
292                .collect()
293        "#;
294
295        let markers = DebugMarker::extract_markers(code);
296        assert_eq!(markers.len(), 2);
297        assert_eq!(markers[0].session_id, "s1");
298        assert_eq!(markers[0].description, Some("first".to_string()));
299        assert_eq!(markers[1].session_id, "s1");
300        assert_eq!(markers[1].description, Some("second".to_string()));
301    }
302
303    #[test]
304    fn test_session() {
305        let session = DebugSession::new();
306        let m1 = session.marker_with_desc("first");
307        let m2 = session.marker_with_desc("second");
308
309        assert_eq!(m1.session_id, m2.session_id);
310        assert_eq!(m1.session_id, session.id());
311    }
312}