readstat/
rs_path.rs

1//! Path validation for SAS file input.
2//!
3//! [`ReadStatPath`] validates the input `.sas7bdat` file path and converts it
4//! to a C-compatible string for the FFI layer.
5
6use std::{
7    ffi::CString,
8    path::{Path, PathBuf},
9};
10
11use crate::err::ReadStatError;
12
13const IN_EXTENSIONS: &[&str] = &["sas7bdat", "sas7bcat"];
14
15/// Validated file path for SAS file input.
16///
17/// Encapsulates the input `.sas7bdat` path (validated to exist with correct extension).
18/// The input path is also converted to a [`CString`] for passing to the `ReadStat` C library.
19#[derive(Debug, Clone)]
20pub struct ReadStatPath {
21    /// Absolute path to the input `.sas7bdat` file.
22    pub path: PathBuf,
23    /// File extension of the input file (e.g. `"sas7bdat"`).
24    pub extension: String,
25    /// Input path as a C-compatible string for FFI.
26    pub cstring_path: CString,
27}
28
29impl ReadStatPath {
30    /// Creates a new `ReadStatPath` after validating the input path.
31    ///
32    /// # Errors
33    ///
34    /// Returns [`ReadStatError`] if the path does not exist or has an invalid extension.
35    #[allow(clippy::needless_pass_by_value)]
36    pub fn new(path: PathBuf) -> Result<Self, ReadStatError> {
37        let p = Self::validate_path(&path)?;
38        let ext = Self::validate_in_extension(&p)?;
39        let csp = Self::path_to_cstring(&p)?;
40
41        Ok(Self {
42            path: p,
43            extension: ext,
44            cstring_path: csp,
45        })
46    }
47
48    /// Converts a file path to a [`CString`] for FFI. Uses raw bytes on Unix.
49    #[cfg(unix)]
50    pub(crate) fn path_to_cstring(path: &Path) -> Result<CString, ReadStatError> {
51        use std::os::unix::ffi::OsStrExt;
52        let bytes = path.as_os_str().as_bytes();
53        Ok(CString::new(bytes)?)
54    }
55
56    /// Converts a file path to a [`CString`] for FFI. Uses UTF-8 on non-Unix platforms.
57    #[cfg(not(unix))]
58    pub(crate) fn path_to_cstring(path: &Path) -> Result<CString, ReadStatError> {
59        let rust_str = path
60            .as_os_str()
61            .to_str()
62            .ok_or_else(|| ReadStatError::Other("Invalid path".to_string()))?;
63        Ok(CString::new(rust_str)?)
64    }
65
66    fn validate_in_extension(path: &Path) -> Result<String, ReadStatError> {
67        path.extension()
68            .and_then(|e| e.to_str())
69            .map(std::borrow::ToOwned::to_owned)
70            .map_or(
71                Err(ReadStatError::Other(format!(
72                    "File {} does not have an extension!",
73                    path.to_string_lossy()
74                ))),
75                |e|
76                    if IN_EXTENSIONS.iter().any(|&ext| ext == e) {
77                        Ok(e)
78                    } else {
79                        Err(ReadStatError::Other(format!(
80                            "Expecting extension sas7bdat or sas7bcat.\nFile {} does not have expected extension!",
81                            path.to_string_lossy()
82                        )))
83                    }
84            )
85    }
86
87    fn validate_path(path: &Path) -> Result<PathBuf, ReadStatError> {
88        let abs_path = std::path::absolute(path)
89            .map_err(|e| ReadStatError::Other(format!("Failed to resolve path: {e}")))?;
90
91        if abs_path.exists() {
92            Ok(abs_path)
93        } else {
94            Err(ReadStatError::Other(format!(
95                "File {} does not exist!",
96                abs_path.to_string_lossy()
97            )))
98        }
99    }
100}
101
102#[cfg(test)]
103mod tests {
104    use super::*;
105
106    // --- validate_in_extension ---
107
108    #[test]
109    fn valid_sas7bdat_extension() {
110        let path = Path::new("/some/file.sas7bdat");
111        assert_eq!(
112            ReadStatPath::validate_in_extension(path).unwrap(),
113            "sas7bdat"
114        );
115    }
116
117    #[test]
118    fn valid_sas7bcat_extension() {
119        let path = Path::new("/some/file.sas7bcat");
120        assert_eq!(
121            ReadStatPath::validate_in_extension(path).unwrap(),
122            "sas7bcat"
123        );
124    }
125
126    #[test]
127    fn invalid_extension() {
128        let path = Path::new("/some/file.csv");
129        assert!(ReadStatPath::validate_in_extension(path).is_err());
130    }
131
132    #[test]
133    fn no_extension() {
134        let path = Path::new("/some/file");
135        assert!(ReadStatPath::validate_in_extension(path).is_err());
136    }
137
138    // --- path_to_cstring ---
139
140    #[test]
141    fn path_to_cstring_normal() {
142        let path = PathBuf::from("/tmp/test.sas7bdat");
143        let cs = ReadStatPath::path_to_cstring(&path).unwrap();
144        assert_eq!(cs.to_str().unwrap(), "/tmp/test.sas7bdat");
145    }
146}