Skip to main content

dodot_lib/preprocessing/
identity.rs

1//! Identity preprocessor — passes content through unchanged.
2//!
3//! This preprocessor exists for testing the preprocessing pipeline.
4//! It matches files with the `.identity` extension and returns their
5//! content unchanged.
6
7use std::path::{Path, PathBuf};
8
9use crate::fs::Fs;
10use crate::preprocessing::{ExpandedFile, Preprocessor, TransformType};
11use crate::Result;
12
13/// A preprocessor that passes content through unchanged.
14///
15/// Useful for testing the preprocessing pipeline without depending
16/// on any transformation engine.
17pub struct IdentityPreprocessor {
18    extension: String,
19}
20
21impl IdentityPreprocessor {
22    /// Create a new identity preprocessor with the default extension `.identity`.
23    pub fn new() -> Self {
24        Self {
25            extension: "identity".to_string(),
26        }
27    }
28
29    /// Create an identity preprocessor with a custom extension.
30    pub fn with_extension(ext: &str) -> Self {
31        Self {
32            extension: ext.to_string(),
33        }
34    }
35}
36
37impl Default for IdentityPreprocessor {
38    fn default() -> Self {
39        Self::new()
40    }
41}
42
43impl Preprocessor for IdentityPreprocessor {
44    fn name(&self) -> &str {
45        "identity"
46    }
47
48    fn transform_type(&self) -> TransformType {
49        TransformType::Generative
50    }
51
52    fn matches_extension(&self, filename: &str) -> bool {
53        let suffix = format!(".{}", self.extension);
54        filename.ends_with(&suffix)
55    }
56
57    fn stripped_name(&self, filename: &str) -> String {
58        let suffix = format!(".{}", self.extension);
59        filename
60            .strip_suffix(&suffix)
61            .unwrap_or(filename)
62            .to_string()
63    }
64
65    fn expand(&self, source: &Path, fs: &dyn Fs) -> Result<Vec<ExpandedFile>> {
66        let content = fs.read_file(source)?;
67        let stripped =
68            self.stripped_name(&source.file_name().unwrap_or_default().to_string_lossy());
69
70        Ok(vec![ExpandedFile {
71            relative_path: PathBuf::from(stripped),
72            content,
73            is_dir: false,
74            tracked_render: None,
75            context_hash: None,
76        }])
77    }
78}
79
80#[cfg(test)]
81mod tests {
82    use super::*;
83
84    #[test]
85    fn matches_identity_extension() {
86        let pp = IdentityPreprocessor::new();
87        assert!(pp.matches_extension("config.toml.identity"));
88        assert!(pp.matches_extension("aliases.sh.identity"));
89        assert!(!pp.matches_extension("config.toml"));
90        assert!(!pp.matches_extension("identity"));
91        assert!(!pp.matches_extension("config.identity.bak"));
92    }
93
94    #[test]
95    fn stripped_name_removes_extension() {
96        let pp = IdentityPreprocessor::new();
97        assert_eq!(pp.stripped_name("config.toml.identity"), "config.toml");
98        assert_eq!(pp.stripped_name("aliases.sh.identity"), "aliases.sh");
99        assert_eq!(pp.stripped_name("simple.identity"), "simple");
100    }
101
102    #[test]
103    fn custom_extension() {
104        let pp = IdentityPreprocessor::with_extension("test");
105        assert!(pp.matches_extension("config.toml.test"));
106        assert!(!pp.matches_extension("config.toml.identity"));
107        assert_eq!(pp.stripped_name("config.toml.test"), "config.toml");
108    }
109
110    #[test]
111    fn expand_returns_content_unchanged() {
112        let env = crate::testing::TempEnvironment::builder()
113            .pack("app")
114            .file("config.toml.identity", "host = localhost\nport = 5432")
115            .done()
116            .build();
117
118        let pp = IdentityPreprocessor::new();
119        let source = env.dotfiles_root.join("app/config.toml.identity");
120        let result = pp.expand(&source, env.fs.as_ref()).unwrap();
121
122        assert_eq!(result.len(), 1);
123        assert_eq!(result[0].relative_path, PathBuf::from("config.toml"));
124        assert_eq!(
125            String::from_utf8_lossy(&result[0].content),
126            "host = localhost\nport = 5432"
127        );
128        assert!(!result[0].is_dir);
129    }
130
131    #[test]
132    fn trait_properties() {
133        let pp = IdentityPreprocessor::new();
134        assert_eq!(pp.name(), "identity");
135        assert_eq!(pp.transform_type(), TransformType::Generative);
136    }
137
138    #[test]
139    fn expand_empty_file() {
140        let env = crate::testing::TempEnvironment::builder()
141            .pack("app")
142            .file("empty.conf.identity", "")
143            .done()
144            .build();
145
146        let pp = IdentityPreprocessor::new();
147        let source = env.dotfiles_root.join("app/empty.conf.identity");
148        let result = pp.expand(&source, env.fs.as_ref()).unwrap();
149
150        assert_eq!(result.len(), 1);
151        assert_eq!(result[0].relative_path, PathBuf::from("empty.conf"));
152        assert!(result[0].content.is_empty());
153    }
154
155    #[test]
156    fn expand_binary_content() {
157        let env = crate::testing::TempEnvironment::builder()
158            .pack("app")
159            .file("data.bin.identity", "")
160            .done()
161            .build();
162
163        // Write binary content directly
164        let source = env.dotfiles_root.join("app/data.bin.identity");
165        let binary = vec![0u8, 1, 2, 255, 128, 64];
166        env.fs.write_file(&source, &binary).unwrap();
167
168        let pp = IdentityPreprocessor::new();
169        let result = pp.expand(&source, env.fs.as_ref()).unwrap();
170
171        assert_eq!(result.len(), 1);
172        assert_eq!(result[0].content, binary);
173    }
174
175    #[test]
176    fn expand_missing_file_returns_error() {
177        let env = crate::testing::TempEnvironment::builder().build();
178
179        let pp = IdentityPreprocessor::new();
180        let source = env.dotfiles_root.join("nonexistent.identity");
181        let err = pp.expand(&source, env.fs.as_ref());
182
183        assert!(err.is_err(), "expanding a missing file should fail");
184    }
185
186    #[test]
187    fn double_extension_only_strips_last() {
188        let pp = IdentityPreprocessor::new();
189        // Only the outermost .identity is stripped
190        assert_eq!(pp.stripped_name("file.identity.identity"), "file.identity");
191        assert!(pp.matches_extension("file.identity.identity"));
192    }
193
194    #[test]
195    fn expand_is_idempotent() {
196        let env = crate::testing::TempEnvironment::builder()
197            .pack("app")
198            .file("config.toml.identity", "data")
199            .done()
200            .build();
201
202        let pp = IdentityPreprocessor::new();
203        let source = env.dotfiles_root.join("app/config.toml.identity");
204
205        let result1 = pp.expand(&source, env.fs.as_ref()).unwrap();
206        let result2 = pp.expand(&source, env.fs.as_ref()).unwrap();
207
208        assert_eq!(result1.len(), result2.len());
209        assert_eq!(result1[0].relative_path, result2[0].relative_path);
210        assert_eq!(result1[0].content, result2[0].content);
211    }
212}