Viewing: record.rs

// SPDX-License-Identifier: MIT

// Copyright (c) 2025. DDN. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.

use super::convert::ClientNid;
use crate::Fid;
use changelog_sys::*;
use serde::{Deserialize, Serialize};

/// Represents the different types of changelog records.
///
/// Each variant corresponds to a specific operation that can be recorded
/// in the Lustre changelog system. Order matches the C constants exactly.
#[derive(Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[repr(u32)]
pub enum RecordType {
    /// None value (`CL_NONE` = -1)
    None,
    /// Mark operation (`CL_MARK` = 0)
    Mark,
    /// File/directory creation (`CL_CREATE` = 1)
    Create,
    /// Directory creation (`CL_MKDIR` = 2)
    Mkdir,
    /// Hard link creation (`CL_HARDLINK` = 3)
    Hardlink,
    /// Soft link creation (`CL_SOFTLINK` = 4)
    Softlink,
    /// Special file creation (`CL_MKNOD` = 5)
    Mknod,
    /// File/directory deletion (`CL_UNLINK` = 6)
    Unlink,
    /// Directory removal (`CL_RMDIR` = 7)
    Rmdir,
    /// File/directory rename (`CL_RENAME` = 8)
    Rename,
    /// Extended operation (`CL_EXT` = 9)
    Ext,
    /// File open (`CL_OPEN` = 10)
    Open,
    /// File close (`CL_CLOSE` = 11)
    Close,
    /// Layout change (`CL_LAYOUT` = 12)
    Layout,
    /// File truncation (`CL_TRUNC` = 13)
    Trunc,
    /// Metadata change (`CL_SETATTR` = 14)
    Setattr,
    /// Extended attribute setting (`CL_SETXATTR` = 15)
    Setxattr,
    /// HSM operation (`CL_HSM` = 16)
    Hsm,
    /// Modify time update (`CL_MTIME` = 17)
    Mtime,
    /// Change time update (`CL_CTIME` = 18)
    Ctime,
    /// Access time update (`CL_ATIME` = 19)
    Atime,
    /// Migration operation (`CL_MIGRATE` = 20)
    Migrate,
    /// File lock read/write (`CL_FLRW` = 21)
    Flrw,
    /// Re-sync operation (`CL_RESYNC` = 22)
    Resync,
    /// Extended attribute retrieval (`CL_GETXATTR` = 23)
    Getxattr,
    /// Directory open (`CL_DN_OPEN` = 24)
    DnOpen,
    /// Last valid record type (`CL_LAST` = 25)
    Last,
    /// Unknown or unsupported record type
    Unknown(u32),
}

impl From<u32> for RecordType {
    fn from(value: u32) -> Self {
        match value {
            x if x == CL_NONE as u32 => RecordType::None,
            x if x == CL_MARK as u32 => RecordType::Mark,
            x if x == CL_CREATE as u32 => RecordType::Create,
            x if x == CL_MKDIR as u32 => RecordType::Mkdir,
            x if x == CL_HARDLINK as u32 => RecordType::Hardlink,
            x if x == CL_SOFTLINK as u32 => RecordType::Softlink,
            x if x == CL_MKNOD as u32 => RecordType::Mknod,
            x if x == CL_UNLINK as u32 => RecordType::Unlink,
            x if x == CL_RMDIR as u32 => RecordType::Rmdir,
            x if x == CL_RENAME as u32 => RecordType::Rename,
            x if x == CL_EXT as u32 => RecordType::Ext,
            x if x == CL_OPEN as u32 => RecordType::Open,
            x if x == CL_CLOSE as u32 => RecordType::Close,
            x if x == CL_LAYOUT as u32 => RecordType::Layout,
            x if x == CL_TRUNC as u32 => RecordType::Trunc,
            x if x == CL_SETATTR as u32 => RecordType::Setattr,
            x if x == CL_SETXATTR as u32 => RecordType::Setxattr,
            x if x == CL_HSM as u32 => RecordType::Hsm,
            x if x == CL_MTIME as u32 => RecordType::Mtime,
            x if x == CL_CTIME as u32 => RecordType::Ctime,
            x if x == CL_ATIME as u32 => RecordType::Atime,
            x if x == CL_MIGRATE as u32 => RecordType::Migrate,
            x if x == CL_FLRW as u32 => RecordType::Flrw,
            x if x == CL_RESYNC as u32 => RecordType::Resync,
            x if x == CL_GETXATTR as u32 => RecordType::Getxattr,
            x if x == CL_DN_OPEN as u32 => RecordType::DnOpen,
            x if x == CL_LAST as u32 => RecordType::Last,
            other => RecordType::Unknown(other),
        }
    }
}

impl From<RecordType> for u32 {
    fn from(value: RecordType) -> Self {
        match value {
            RecordType::None => CL_NONE as u32,
            RecordType::Mark => CL_MARK as u32,
            RecordType::Create => CL_CREATE as u32,
            RecordType::Mkdir => CL_MKDIR as u32,
            RecordType::Hardlink => CL_HARDLINK as u32,
            RecordType::Softlink => CL_SOFTLINK as u32,
            RecordType::Mknod => CL_MKNOD as u32,
            RecordType::Unlink => CL_UNLINK as u32,
            RecordType::Rmdir => CL_RMDIR as u32,
            RecordType::Rename => CL_RENAME as u32,
            RecordType::Ext => CL_EXT as u32,
            RecordType::Open => CL_OPEN as u32,
            RecordType::Close => CL_CLOSE as u32,
            RecordType::Layout => CL_LAYOUT as u32,
            RecordType::Trunc => CL_TRUNC as u32,
            RecordType::Setattr => CL_SETATTR as u32,
            RecordType::Setxattr => CL_SETXATTR as u32,
            RecordType::Hsm => CL_HSM as u32,
            RecordType::Mtime => CL_MTIME as u32,
            RecordType::Ctime => CL_CTIME as u32,
            RecordType::Atime => CL_ATIME as u32,
            RecordType::Migrate => CL_MIGRATE as u32,
            RecordType::Flrw => CL_FLRW as u32,
            RecordType::Resync => CL_RESYNC as u32,
            RecordType::Getxattr => CL_GETXATTR as u32,
            RecordType::DnOpen => CL_DN_OPEN as u32,
            RecordType::Last => CL_LAST as u32,
            RecordType::Unknown(val) => val,
        }
    }
}

impl RecordType {
    /// Returns the string representation of the changelog record type.
    pub fn as_string(&self) -> String {
        match self {
            RecordType::None => "NONE".to_string(),
            RecordType::Mark => "MARK".to_string(),
            RecordType::Create => "CREATE".to_string(),
            RecordType::Mkdir => "MKDIR".to_string(),
            RecordType::Hardlink => "HARDLINK".to_string(),
            RecordType::Softlink => "SOFTLINK".to_string(),
            RecordType::Mknod => "MKNOD".to_string(),
            RecordType::Unlink => "UNLINK".to_string(),
            RecordType::Rmdir => "RMDIR".to_string(),
            RecordType::Rename => "RENAME".to_string(),
            RecordType::Ext => "EXT".to_string(),
            RecordType::Open => "OPEN".to_string(),
            RecordType::Close => "CLOSE".to_string(),
            RecordType::Layout => "LAYOUT".to_string(),
            RecordType::Trunc => "TRUNC".to_string(),
            RecordType::Setattr => "SETATTR".to_string(),
            RecordType::Setxattr => "SETXATTR".to_string(),
            RecordType::Hsm => "HSM".to_string(),
            RecordType::Mtime => "MTIME".to_string(),
            RecordType::Ctime => "CTIME".to_string(),
            RecordType::Atime => "ATIME".to_string(),
            RecordType::Migrate => "MIGRATE".to_string(),
            RecordType::Flrw => "FLRW".to_string(),
            RecordType::Resync => "RESYNC".to_string(),
            RecordType::Getxattr => "GETXATTR".to_string(),
            RecordType::DnOpen => "DN_OPEN".to_string(),
            RecordType::Last => "LAST".to_string(),
            RecordType::Unknown(x) => format!("UNKNOWN({})", x),
        }
    }

    /// Returns the string representation using the C library function.
    pub fn to_c_string(self) -> String {
        let type_val: u32 = self.into();
        unsafe {
            let c_str = changelog_type2str(type_val as i32);
            if c_str.is_null() {
                format!("UNKNOWN({})", type_val)
            } else {
                std::ffi::CStr::from_ptr(c_str)
                    .to_string_lossy()
                    .to_string()
            }
        }
    }
}

impl std::fmt::Display for RecordType {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "{}", self.as_string())
    }
}

/// Represents a changelog record with unified fields for all operation types.
///
/// This structure contains all fields that may be present in any changelog record type.
/// Fields that are not applicable to a specific record type will be `None`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Record {
    /// The type of changelog operation
    pub record_type: RecordType,
    /// Record index in the changelog
    pub index: u64,
    /// Previous record index
    pub prev: u64,
    /// Timestamp of the operation
    pub time: String,

    // Core FID fields
    /// Target file/directory FID (not present in `CL_MARK` records)
    #[serde(skip_serializing_if = "Option::is_none")]
    pub target_fid: Option<Fid>,
    /// Parent directory FID (None if empty or not applicable)
    #[serde(skip_serializing_if = "Option::is_none")]
    pub parent_fid: Option<Fid>,
    /// Resolved parent directory path (when available)
    #[serde(skip_serializing_if = "Option::is_none")]
    pub parent_dir: Option<String>,
    /// File name associated with the operation
    #[serde(skip_serializing_if = "Option::is_none")]
    pub filename: Option<String>,

    // Extension fields (available in most record types)
    /// User ID from `UIDGID` extension
    pub uid: Option<u64>,
    /// Group ID from `UIDGID` extension  
    pub gid: Option<u64>,
    /// Client NID from NID extension
    #[serde(skip_serializing_if = "Option::is_none")]
    pub client_nid: Option<ClientNid>,
    /// Job ID from JOBID extension
    #[serde(skip_serializing_if = "Option::is_none")]
    pub job_id: Option<String>,
    /// Open flags from OPEN extension
    #[serde(skip_serializing_if = "Option::is_none")]
    pub open_flags: Option<u32>,
    /// Extra flags
    #[serde(skip_serializing_if = "Option::is_none")]
    pub extra_flags: Option<u64>,

    // Type-specific fields
    /// Marker flags (`CL_MARK` records only)
    #[serde(skip_serializing_if = "Option::is_none")]
    pub marker_flags: Option<u32>,
    /// Source file name for rename operations (`CL_RENAME` only)
    #[serde(skip_serializing_if = "Option::is_none")]
    pub source_name: Option<String>,
    /// Source FID for rename operations (`CL_RENAME` only)
    #[serde(skip_serializing_if = "Option::is_none")]
    pub source_fid: Option<Fid>,
    /// Source parent FID for rename operations (`CL_RENAME` only)
    #[serde(skip_serializing_if = "Option::is_none")]
    pub source_parent_fid: Option<Fid>,
    /// Resolved source parent directory path (`CL_RENAME` only)
    #[serde(skip_serializing_if = "Option::is_none")]
    pub source_path: Option<String>,
    /// Extended attribute name (`CL_SETXATTR`/`CL_GETXATTR` only)
    #[serde(skip_serializing_if = "Option::is_none")]
    pub xattr_name: Option<String>,
}

#[cfg(test)]
mod tests {
    use super::*;
    use lnetconfig::{SimpleNid, lnet_nid_t};

    #[test]
    fn test_changelog_record_type_conversions() {
        let create_type = RecordType::Create;
        let create_val: u32 = create_type.into();
        let back_to_type = RecordType::from(create_val);
        assert_eq!(create_type, back_to_type);
    }

    #[test]
    fn test_changelog_record_type_display() {
        assert_eq!(RecordType::Create.to_string(), "CREATE");
        assert_eq!(RecordType::Unlink.to_string(), "UNLINK");
        assert_eq!(RecordType::Unknown(999).to_string(), "UNKNOWN(999)");
    }

    #[test]
    fn test_changelog_record_type_c_string() {
        let create_type = RecordType::Create;
        let c_string = create_type.to_c_string();
        assert!(!c_string.is_empty());
    }

    #[test]
    fn test_record_create() {
        let parent_fid = Fid::new(0x100, 0x1, 0x0);
        let target_fid = Some(Fid::new(0x200, 0x2, 0x0));

        let record = Record {
            record_type: RecordType::Create,
            index: 123,
            prev: 122,
            time: "1640995200".to_string(),
            parent_fid: Some(parent_fid),
            parent_dir: None,
            target_fid,
            filename: Some("test.txt".to_string()),
            uid: Some(1000),
            gid: Some(1000),
            client_nid: Some(ClientNid::Simple(SimpleNid::new(
                2533274790395904 as lnet_nid_t,
            ))),
            job_id: Some("job123".to_string()),
            open_flags: None,
            extra_flags: None,
            marker_flags: None,
            source_name: None,
            source_fid: None,
            source_parent_fid: None,
            source_path: None,
            xattr_name: None,
        };

        assert_eq!(record.record_type, RecordType::Create);
        assert_eq!(record.index, 123);
        assert_eq!(record.filename.as_deref(), Some("test.txt"));
        assert_eq!(record.uid, Some(1000));
        assert_eq!(record.gid, Some(1000));
        assert_eq!(record.job_id.as_deref(), Some("job123"));
    }

    #[test]
    fn test_record_mkdir() {
        let parent_fid = Fid::new(0x100, 0x1, 0x0);
        let target_fid = Some(Fid::new(0x300, 0x3, 0x0));

        let record = Record {
            record_type: RecordType::Mkdir,
            index: 124,
            prev: 123,
            time: "1640995300".to_string(),
            parent_fid: Some(parent_fid),
            parent_dir: None,
            target_fid,
            filename: Some("testdir".to_string()),
            uid: None,
            gid: None,
            client_nid: None,
            job_id: None,
            open_flags: None,
            extra_flags: None,
            marker_flags: None,
            source_name: None,
            source_fid: None,
            source_parent_fid: None,
            source_path: None,
            xattr_name: None,
        };

        assert_eq!(record.record_type, RecordType::Mkdir);
        assert_eq!(record.index, 124);
        assert_eq!(record.filename.as_deref(), Some("testdir"));
    }

    #[test]
    fn test_record_serialization() {
        let parent_fid = Fid::new(0x100, 0x1, 0x0);
        let target_fid = Some(Fid::new(0x200, 0x2, 0x0));

        let record = Record {
            record_type: RecordType::Unlink,
            index: 125,
            prev: 124,
            time: "1640995400".to_string(),
            parent_fid: Some(parent_fid),
            parent_dir: None,
            target_fid,
            filename: Some("deleted.txt".to_string()),
            uid: None,
            gid: None,
            client_nid: None,
            job_id: None,
            open_flags: None,
            extra_flags: None,
            marker_flags: None,
            source_name: None,
            source_fid: None,
            source_parent_fid: None,
            source_path: None,
            xattr_name: None,
        };

        // Test basic record properties
        assert_eq!(record.record_type, RecordType::Unlink);
        assert_eq!(record.index, 125);
        assert_eq!(record.prev, 124);
        assert_eq!(record.time, "1640995400");
        assert_eq!(record.filename.as_deref(), Some("deleted.txt"));
    }

    #[test]
    fn test_record_with_extensions() {
        let parent_fid = Fid::new(0x100, 0x1, 0x0);
        let target_fid = Some(Fid::new(0x200, 0x2, 0x0));

        // Test record with all extension fields populated
        let record = Record {
            record_type: RecordType::Create,
            index: 126,
            prev: 125,
            time: "1640995500".to_string(),
            parent_fid: Some(parent_fid),
            parent_dir: None,
            target_fid,
            filename: Some("extended.txt".to_string()),
            uid: Some(1001),
            gid: Some(1001),
            client_nid: Some(ClientNid::Simple(SimpleNid::new(
                2533274790395904 as lnet_nid_t,
            ))),
            job_id: Some("job456".to_string()),
            open_flags: Some(0o644),
            extra_flags: Some(0x1000),
            marker_flags: None,
            source_name: None,
            source_fid: None,
            source_parent_fid: None,
            source_path: None,
            xattr_name: None,
        };

        assert_eq!(record.uid, Some(1001));
        assert_eq!(record.gid, Some(1001));
        assert_eq!(
            record.client_nid,
            Some(ClientNid::Simple(SimpleNid::new(
                2533274790395904 as lnet_nid_t
            )))
        );
        assert_eq!(record.job_id.as_deref(), Some("job456"));
        assert_eq!(record.open_flags, Some(0o644));
        assert_eq!(record.extra_flags, Some(0x1000));
    }
}