1use std::{
7 ffi::CString,
8 path::{Path, PathBuf},
9};
10
11use crate::err::ReadStatError;
12
13const IN_EXTENSIONS: &[&str] = &["sas7bdat"];
16
17#[derive(Debug, Clone)]
22pub struct ReadStatPath {
23 pub path: PathBuf,
25 pub(crate) cstring_path: CString,
27}
28
29impl ReadStatPath {
30 pub fn new<P: AsRef<Path>>(path: P) -> Result<Self, ReadStatError> {
39 let p = Self::validate_path(path.as_ref())?;
40 Self::validate_in_extension(&p)?;
41 let csp = Self::path_to_cstring(&p)?;
42
43 Ok(Self {
44 path: p,
45 cstring_path: csp,
46 })
47 }
48
49 #[cfg(unix)]
51 pub(crate) fn path_to_cstring(path: &Path) -> Result<CString, ReadStatError> {
52 use std::os::unix::ffi::OsStrExt;
53 let bytes = path.as_os_str().as_bytes();
54 Ok(CString::new(bytes)?)
55 }
56
57 #[cfg(not(unix))]
59 pub(crate) fn path_to_cstring(path: &Path) -> Result<CString, ReadStatError> {
60 let rust_str = path
61 .as_os_str()
62 .to_str()
63 .ok_or_else(|| ReadStatError::Other("Invalid path".to_string()))?;
64 Ok(CString::new(rust_str)?)
65 }
66
67 fn validate_in_extension(path: &Path) -> Result<String, ReadStatError> {
68 match path.extension().and_then(|e| e.to_str()) {
69 Some(e) if IN_EXTENSIONS.contains(&e) => Ok(e.to_owned()),
70 _ => Err(ReadStatError::UnsupportedInputExtension(path.to_owned())),
71 }
72 }
73
74 fn validate_path(path: &Path) -> Result<PathBuf, ReadStatError> {
75 let abs_path = std::path::absolute(path)
76 .map_err(|e| ReadStatError::Other(format!("Failed to resolve path: {e}")))?;
77
78 if abs_path.exists() {
79 Ok(abs_path)
80 } else {
81 Err(ReadStatError::FileNotFound(abs_path))
82 }
83 }
84}
85
86#[cfg(test)]
87mod tests {
88 use super::*;
89
90 #[test]
93 fn valid_sas7bdat_extension() {
94 let path = Path::new("/some/file.sas7bdat");
95 assert_eq!(
96 ReadStatPath::validate_in_extension(path).unwrap(),
97 "sas7bdat"
98 );
99 }
100
101 #[test]
102 fn sas7bcat_extension_rejected() {
103 let path = Path::new("/some/file.sas7bcat");
105 assert!(ReadStatPath::validate_in_extension(path).is_err());
106 }
107
108 #[test]
109 fn invalid_extension() {
110 let path = Path::new("/some/file.csv");
111 assert!(ReadStatPath::validate_in_extension(path).is_err());
112 }
113
114 #[test]
115 fn no_extension() {
116 let path = Path::new("/some/file");
117 assert!(ReadStatPath::validate_in_extension(path).is_err());
118 }
119
120 #[test]
123 fn path_to_cstring_normal() {
124 let path = PathBuf::from("/tmp/test.sas7bdat");
125 let cs = ReadStatPath::path_to_cstring(&path).unwrap();
126 assert_eq!(cs.to_str().unwrap(), "/tmp/test.sas7bdat");
127 }
128
129 mod property_tests {
132 use super::*;
133 use proptest::prelude::*;
134
135 proptest! {
136 #[test]
138 fn sas7bdat_extension_always_valid(name in "[a-zA-Z0-9_]{1,50}") {
139 let path = PathBuf::from(format!("/tmp/{name}.sas7bdat"));
140 let result = ReadStatPath::validate_in_extension(&path);
141 prop_assert!(result.is_ok(), "Expected Ok for {:?}", path);
142 prop_assert_eq!(result.unwrap(), "sas7bdat");
143 }
144
145 #[test]
148 fn sas7bcat_extension_always_invalid(name in "[a-zA-Z0-9_]{1,50}") {
149 let path = PathBuf::from(format!("/tmp/{name}.sas7bcat"));
150 let result = ReadStatPath::validate_in_extension(&path);
151 prop_assert!(result.is_err(), "Expected Err for {:?}", path);
152 }
153
154 #[test]
156 fn non_sas_extensions_always_invalid(
157 name in "[a-zA-Z0-9_]{1,50}",
158 ext in "[a-z]{1,10}".prop_filter("not sas", |e| e != "sas7bdat" && e != "sas7bcat")
159 ) {
160 let path = PathBuf::from(format!("/tmp/{name}.{ext}"));
161 let result = ReadStatPath::validate_in_extension(&path);
162 prop_assert!(result.is_err(), "Expected Err for {:?}", path);
163 }
164
165 #[test]
167 fn no_extension_always_invalid(name in "[a-zA-Z0-9_]{1,50}") {
168 let path = PathBuf::from(format!("/tmp/{name}"));
169 let result = ReadStatPath::validate_in_extension(&path);
170 prop_assert!(result.is_err(), "Expected Err for {:?}", path);
171 }
172 }
173 }
174}