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