Skip to main content

turbovault_core/
utils.rs

1//! Shared utilities for operations across turbovault crates.
2//!
3//! Provides DRY helpers for:
4//! - Serialization with consistent error handling
5//! - Result/report builders
6//! - Path validation
7//! - Transaction tracking
8
9use crate::{Error, Result};
10use std::path::{Path, PathBuf};
11use std::time::Instant;
12
13/// Encode bytes as lowercase hexadecimal.
14pub fn bytes_to_lower_hex(bytes: impl AsRef<[u8]>) -> String {
15    const HEX: &[u8; 16] = b"0123456789abcdef";
16
17    let bytes = bytes.as_ref();
18    let mut out = String::with_capacity(bytes.len() * 2);
19
20    for &byte in bytes {
21        out.push(HEX[(byte >> 4) as usize] as char);
22        out.push(HEX[(byte & 0x0f) as usize] as char);
23    }
24
25    out
26}
27
28/// Generic JSON serialization with consistent error handling
29/// Works with any type that implements Serialize (including slices)
30pub fn to_json_string<T: serde::Serialize + ?Sized>(data: &T, context: &str) -> Result<String> {
31    serde_json::to_string_pretty(data)
32        .map_err(|e| Error::config_error(format!("Failed to serialize {} as JSON: {}", context, e)))
33}
34
35/// Generic CSV serialization builder
36/// Use the CSVBuilder fluent API to construct and export CSV data
37pub struct CSVBuilder {
38    headers: Vec<String>,
39    rows: Vec<Vec<String>>,
40}
41
42impl CSVBuilder {
43    /// Create a new CSV with headers
44    pub fn new(headers: Vec<&str>) -> Self {
45        Self {
46            headers: headers.iter().map(|s| s.to_string()).collect(),
47            rows: Vec::new(),
48        }
49    }
50
51    /// Add a row of data
52    pub fn add_row(mut self, values: Vec<&str>) -> Self {
53        self.rows
54            .push(values.iter().map(|s| s.to_string()).collect());
55        self
56    }
57
58    /// Add a row of data from owned strings
59    pub fn add_row_owned(mut self, values: Vec<String>) -> Self {
60        self.rows.push(values);
61        self
62    }
63
64    /// Build the CSV string
65    pub fn build(self) -> String {
66        let mut csv = self.headers.join(",") + "\n";
67        for row in self.rows {
68            csv.push_str(&row.join(","));
69            csv.push('\n');
70        }
71        csv
72    }
73}
74
75/// Path validation helpers
76pub struct PathValidator;
77
78impl PathValidator {
79    /// Ensure a path is within a vault root (prevents directory traversal)
80    pub fn validate_path_in_vault(vault_root: &Path, path: &Path) -> Result<PathBuf> {
81        let full_path = vault_root.join(path);
82
83        // Canonicalize would require the path to exist. Instead, we check if
84        // the normalized path is still within vault_root by comparing components.
85        let canonical_vault = vault_root
86            .canonicalize()
87            .unwrap_or_else(|_| vault_root.to_path_buf());
88
89        // For non-existent paths, at least check that it doesn't escape via ..
90        // by ensuring normalized form would still be under vault
91        if let Ok(canonical_full) = full_path.canonicalize() {
92            if !canonical_full.starts_with(&canonical_vault) {
93                return Err(Error::path_traversal(full_path));
94            }
95        } else {
96            // Path doesn't exist, check statically using path normalization
97            use std::path::Component;
98            let mut normalized = PathBuf::new();
99            for component in full_path.components() {
100                match component {
101                    Component::ParentDir => {
102                        normalized.pop();
103                    }
104                    Component::Normal(name) => {
105                        normalized.push(name);
106                    }
107                    Component::RootDir => {
108                        normalized.push(component);
109                    }
110                    Component::CurDir => {
111                        // Skip .
112                    }
113                    Component::Prefix(p) => {
114                        normalized.push(p.as_os_str());
115                    }
116                }
117            }
118
119            if !normalized.starts_with(vault_root) {
120                return Err(Error::path_traversal(full_path));
121            }
122        }
123
124        Ok(full_path)
125    }
126
127    /// Ensure a path exists in the vault
128    pub fn validate_path_exists(vault_root: &Path, path: &Path) -> Result<PathBuf> {
129        let full_path = Self::validate_path_in_vault(vault_root, path)?;
130        if !full_path.exists() {
131            return Err(Error::file_not_found(&full_path));
132        }
133        Ok(full_path)
134    }
135
136    /// Get multiple paths and validate them all
137    pub fn validate_multiple(vault_root: &Path, paths: &[&str]) -> Result<Vec<PathBuf>> {
138        paths
139            .iter()
140            .map(|p| Self::validate_path_in_vault(vault_root, Path::new(p)))
141            .collect()
142    }
143}
144
145/// Transaction tracking utilities
146pub struct TransactionBuilder {
147    transaction_id: String,
148    start_time: Instant,
149}
150
151impl TransactionBuilder {
152    /// Create a new transaction tracker
153    pub fn new() -> Self {
154        Self {
155            transaction_id: uuid::Uuid::new_v4().to_string(),
156            start_time: Instant::now(),
157        }
158    }
159
160    /// Get the transaction ID
161    pub fn transaction_id(&self) -> &str {
162        &self.transaction_id
163    }
164
165    /// Get elapsed time in milliseconds
166    pub fn elapsed_ms(&self) -> u64 {
167        self.start_time.elapsed().as_millis() as u64
168    }
169}
170
171impl Default for TransactionBuilder {
172    fn default() -> Self {
173        Self::new()
174    }
175}
176
177#[cfg(test)]
178mod tests {
179    use super::*;
180    use serde::{Deserialize, Serialize};
181
182    #[derive(Serialize, Deserialize)]
183    struct TestData {
184        name: String,
185        value: i32,
186    }
187
188    #[test]
189    fn test_to_json_string() {
190        let data = TestData {
191            name: "test".to_string(),
192            value: 42,
193        };
194        let json = to_json_string(&data, "test_data").unwrap();
195        assert!(json.contains("test"));
196        assert!(json.contains("42"));
197    }
198
199    #[test]
200    fn test_bytes_to_lower_hex() {
201        assert_eq!(bytes_to_lower_hex([0x00, 0x0f, 0xa5, 0xff]), "000fa5ff");
202    }
203
204    #[test]
205    fn test_csv_builder() {
206        let csv = CSVBuilder::new(vec!["name", "age"])
207            .add_row(vec!["Alice", "30"])
208            .add_row(vec!["Bob", "25"])
209            .build();
210
211        assert!(csv.contains("name,age"));
212        assert!(csv.contains("Alice,30"));
213        assert!(csv.contains("Bob,25"));
214    }
215
216    #[test]
217    fn test_path_validator_valid() {
218        let vault_root = PathBuf::from("/vault");
219        let path = Path::new("notes/file.md");
220        let result = PathValidator::validate_path_in_vault(&vault_root, path);
221        assert!(result.is_ok());
222    }
223
224    #[test]
225    fn test_path_validator_traversal() {
226        let vault_root = PathBuf::from("/vault");
227        let path = Path::new("../../../etc/passwd");
228        let result = PathValidator::validate_path_in_vault(&vault_root, path);
229        assert!(result.is_err());
230    }
231
232    #[test]
233    fn test_transaction_builder() {
234        let builder = TransactionBuilder::new();
235        assert!(!builder.transaction_id().is_empty());
236        let elapsed = builder.elapsed_ms();
237        assert!(elapsed < 1000); // Should complete in less than 1 second
238    }
239}