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