ryo-mutations 0.1.0

[experimental] Code transformation primitives for Rust source code
Documentation
//! Debug marker for tracking inserted debug logs.
//!
//! Markers are embedded as comments in the code to enable:
//! - Identifying which debug logs were inserted by ryo
//! - Grouping logs by session (same debugging session)
//! - Tracking when logs were inserted
//! - Selective removal of debug logs

use serde::{Deserialize, Serialize};
use std::time::{SystemTime, UNIX_EPOCH};

/// Prefix used to identify ryo debug markers in comments.
pub const MARKER_PREFIX: &str = "ryo-debug";

/// A debug marker that tracks metadata about inserted debug logs.
///
/// Format in code: `/* ryo-debug:<session_id>:<timestamp>:<description> */`
///
/// # Examples
///
/// ```
/// use ryo_mutations::debugger::DebugMarker;
///
/// let marker = DebugMarker::new();
/// let comment = marker.to_comment();
/// assert!(comment.starts_with("/* ryo-debug:"));
///
/// // Parse back from comment
/// let parsed = DebugMarker::from_comment(&comment).unwrap();
/// assert_eq!(parsed.session_id, marker.session_id);
/// ```
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct DebugMarker {
    /// Unique session identifier (groups related debug insertions).
    pub session_id: String,
    /// Unix timestamp when the marker was created.
    pub timestamp: u64,
    /// Optional description of what this debug log is for.
    pub description: Option<String>,
}

impl DebugMarker {
    /// Create a new marker with a random session ID and current timestamp.
    pub fn new() -> Self {
        Self {
            session_id: Self::generate_session_id(),
            timestamp: Self::current_timestamp(),
            description: None,
        }
    }

    /// Create a marker with a specific session ID.
    ///
    /// Use this when adding multiple debug logs in the same session
    /// so they can be removed together.
    pub fn with_session(session_id: impl Into<String>) -> Self {
        Self {
            session_id: session_id.into(),
            timestamp: Self::current_timestamp(),
            description: None,
        }
    }

    /// Add a description to the marker.
    pub fn with_description(mut self, description: impl Into<String>) -> Self {
        self.description = Some(description.into());
        self
    }

    /// Convert to a comment string for embedding in code.
    ///
    /// Format: `/* ryo-debug:<session_id>:<timestamp>:<description> */`
    pub fn to_comment(&self) -> String {
        let desc = self.description.as_deref().unwrap_or("");
        format!(
            "/* {}:{}:{}:{} */",
            MARKER_PREFIX, self.session_id, self.timestamp, desc
        )
    }

    /// Convert to a marker string (without comment delimiters).
    ///
    /// Format: `ryo-debug:<session_id>:<timestamp>:<description>`
    ///
    /// This is suitable for use as a string literal in code.
    pub fn to_marker_string(&self) -> String {
        let desc = self.description.as_deref().unwrap_or("");
        format!(
            "{}:{}:{}:{}",
            MARKER_PREFIX, self.session_id, self.timestamp, desc
        )
    }

    /// Parse a marker from a comment string or marker string.
    ///
    /// Accepts both formats:
    /// - Comment: `/* ryo-debug:session:ts:desc */`
    /// - String: `ryo-debug:session:ts:desc`
    ///
    /// Returns `None` if the string is not a valid ryo debug marker.
    pub fn from_comment(s: &str) -> Option<Self> {
        let s = s.trim();

        // Try to strip comment delimiters if present
        let inner = if s.starts_with("/*") && s.ends_with("*/") {
            s.strip_prefix("/*")?.strip_suffix("*/")?.trim()
        } else {
            s
        };

        // Check prefix
        let rest = inner.strip_prefix(MARKER_PREFIX)?.strip_prefix(':')?;

        // Parse parts: session_id:timestamp:description
        let parts: Vec<&str> = rest.splitn(3, ':').collect();
        if parts.len() < 2 {
            return None;
        }

        let session_id = parts[0].to_string();
        let timestamp = parts[1].parse().ok()?;
        let description = parts
            .get(2)
            .filter(|s| !s.is_empty())
            .map(|s| s.to_string());

        Some(Self {
            session_id,
            timestamp,
            description,
        })
    }

    /// Parse a marker from a string literal (quoted string).
    ///
    /// Accepts: `"ryo-debug:session:ts:desc"`
    pub fn from_string_literal(s: &str) -> Option<Self> {
        let s = s.trim();
        let inner = s.strip_prefix('"')?.strip_suffix('"')?;
        Self::from_comment(inner)
    }

    /// Check if a string contains a ryo debug marker.
    ///
    /// Detects both comment format and string literal format.
    pub fn contains_marker(s: &str) -> bool {
        s.contains(&format!("/* {}:", MARKER_PREFIX))
            || s.contains(&format!("\"{}:", MARKER_PREFIX))
    }

    /// Extract all markers from a string.
    pub fn extract_markers(s: &str) -> Vec<Self> {
        let mut markers = Vec::new();
        let prefix = format!("/* {}:", MARKER_PREFIX);

        let mut search_start = 0;
        while let Some(start) = s[search_start..].find(&prefix) {
            let abs_start = search_start + start;
            if let Some(end_offset) = s[abs_start..].find("*/") {
                let end = abs_start + end_offset + 2;
                if let Some(marker) = Self::from_comment(&s[abs_start..end]) {
                    markers.push(marker);
                }
                search_start = end;
            } else {
                break;
            }
        }

        markers
    }

    /// Generate a short random session ID.
    fn generate_session_id() -> String {
        use std::collections::hash_map::RandomState;
        use std::hash::{BuildHasher, Hasher};

        let state = RandomState::new();
        let mut hasher = state.build_hasher();
        hasher.write_u64(Self::current_timestamp());
        hasher.write_usize(std::process::id() as usize);

        format!("{:08x}", hasher.finish() as u32)
    }

    /// Get current Unix timestamp.
    fn current_timestamp() -> u64 {
        SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .map(|d| d.as_secs())
            .unwrap_or(0)
    }
}

impl Default for DebugMarker {
    fn default() -> Self {
        Self::new()
    }
}

/// A session for grouping related debug insertions.
///
/// Use this to ensure all debug logs inserted together share the same session ID,
/// making it easy to remove them all at once.
#[derive(Debug, Clone)]
pub struct DebugSession {
    session_id: String,
}

impl DebugSession {
    /// Create a new debug session.
    pub fn new() -> Self {
        Self {
            session_id: DebugMarker::generate_session_id(),
        }
    }

    /// Create a session with a specific ID.
    pub fn with_id(session_id: impl Into<String>) -> Self {
        Self {
            session_id: session_id.into(),
        }
    }

    /// Get the session ID.
    pub fn id(&self) -> &str {
        &self.session_id
    }

    /// Create a marker for this session.
    pub fn marker(&self) -> DebugMarker {
        DebugMarker::with_session(&self.session_id)
    }

    /// Create a marker with a description for this session.
    pub fn marker_with_desc(&self, description: impl Into<String>) -> DebugMarker {
        self.marker().with_description(description)
    }
}

impl Default for DebugSession {
    fn default() -> Self {
        Self::new()
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_marker_roundtrip() {
        let marker = DebugMarker::with_session("test123").with_description("after map");

        let comment = marker.to_comment();
        assert!(comment.contains("ryo-debug:test123:"));
        assert!(comment.contains(":after map"));

        let parsed = DebugMarker::from_comment(&comment).unwrap();
        assert_eq!(parsed.session_id, "test123");
        assert_eq!(parsed.description, Some("after map".to_string()));
    }

    #[test]
    fn test_marker_without_description() {
        let marker = DebugMarker::with_session("abc");
        let comment = marker.to_comment();

        let parsed = DebugMarker::from_comment(&comment).unwrap();
        assert_eq!(parsed.session_id, "abc");
        assert!(parsed.description.is_none());
    }

    #[test]
    fn test_contains_marker() {
        let code = r#"
            items.iter()
                .inspect(|x| { /* ryo-debug:abc:123:test */ dbg!(x); })
                .collect()
        "#;
        assert!(DebugMarker::contains_marker(code));

        let code_without = "items.iter().collect()";
        assert!(!DebugMarker::contains_marker(code_without));
    }

    #[test]
    fn test_extract_markers() {
        let code = r#"
            items /* ryo-debug:s1:100:first */ .iter()
                .inspect(|x| { /* ryo-debug:s1:101:second */ dbg!(x); })
                .collect()
        "#;

        let markers = DebugMarker::extract_markers(code);
        assert_eq!(markers.len(), 2);
        assert_eq!(markers[0].session_id, "s1");
        assert_eq!(markers[0].description, Some("first".to_string()));
        assert_eq!(markers[1].session_id, "s1");
        assert_eq!(markers[1].description, Some("second".to_string()));
    }

    #[test]
    fn test_session() {
        let session = DebugSession::new();
        let m1 = session.marker_with_desc("first");
        let m2 = session.marker_with_desc("second");

        assert_eq!(m1.session_id, m2.session_id);
        assert_eq!(m1.session_id, session.id());
    }
}