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        }])
75    }
76}
77
78#[cfg(test)]
79mod tests {
80    use super::*;
81
82    #[test]
83    fn matches_identity_extension() {
84        let pp = IdentityPreprocessor::new();
85        assert!(pp.matches_extension("config.toml.identity"));
86        assert!(pp.matches_extension("aliases.sh.identity"));
87        assert!(!pp.matches_extension("config.toml"));
88        assert!(!pp.matches_extension("identity"));
89        assert!(!pp.matches_extension("config.identity.bak"));
90    }
91
92    #[test]
93    fn stripped_name_removes_extension() {
94        let pp = IdentityPreprocessor::new();
95        assert_eq!(pp.stripped_name("config.toml.identity"), "config.toml");
96        assert_eq!(pp.stripped_name("aliases.sh.identity"), "aliases.sh");
97        assert_eq!(pp.stripped_name("simple.identity"), "simple");
98    }
99
100    #[test]
101    fn custom_extension() {
102        let pp = IdentityPreprocessor::with_extension("test");
103        assert!(pp.matches_extension("config.toml.test"));
104        assert!(!pp.matches_extension("config.toml.identity"));
105        assert_eq!(pp.stripped_name("config.toml.test"), "config.toml");
106    }
107
108    #[test]
109    fn expand_returns_content_unchanged() {
110        let env = crate::testing::TempEnvironment::builder()
111            .pack("app")
112            .file("config.toml.identity", "host = localhost\nport = 5432")
113            .done()
114            .build();
115
116        let pp = IdentityPreprocessor::new();
117        let source = env.dotfiles_root.join("app/config.toml.identity");
118        let result = pp.expand(&source, env.fs.as_ref()).unwrap();
119
120        assert_eq!(result.len(), 1);
121        assert_eq!(result[0].relative_path, PathBuf::from("config.toml"));
122        assert_eq!(
123            String::from_utf8_lossy(&result[0].content),
124            "host = localhost\nport = 5432"
125        );
126        assert!(!result[0].is_dir);
127    }
128
129    #[test]
130    fn trait_properties() {
131        let pp = IdentityPreprocessor::new();
132        assert_eq!(pp.name(), "identity");
133        assert_eq!(pp.transform_type(), TransformType::Generative);
134    }
135
136    #[test]
137    fn expand_empty_file() {
138        let env = crate::testing::TempEnvironment::builder()
139            .pack("app")
140            .file("empty.conf.identity", "")
141            .done()
142            .build();
143
144        let pp = IdentityPreprocessor::new();
145        let source = env.dotfiles_root.join("app/empty.conf.identity");
146        let result = pp.expand(&source, env.fs.as_ref()).unwrap();
147
148        assert_eq!(result.len(), 1);
149        assert_eq!(result[0].relative_path, PathBuf::from("empty.conf"));
150        assert!(result[0].content.is_empty());
151    }
152
153    #[test]
154    fn expand_binary_content() {
155        let env = crate::testing::TempEnvironment::builder()
156            .pack("app")
157            .file("data.bin.identity", "")
158            .done()
159            .build();
160
161        // Write binary content directly
162        let source = env.dotfiles_root.join("app/data.bin.identity");
163        let binary = vec![0u8, 1, 2, 255, 128, 64];
164        env.fs.write_file(&source, &binary).unwrap();
165
166        let pp = IdentityPreprocessor::new();
167        let result = pp.expand(&source, env.fs.as_ref()).unwrap();
168
169        assert_eq!(result.len(), 1);
170        assert_eq!(result[0].content, binary);
171    }
172
173    #[test]
174    fn expand_missing_file_returns_error() {
175        let env = crate::testing::TempEnvironment::builder().build();
176
177        let pp = IdentityPreprocessor::new();
178        let source = env.dotfiles_root.join("nonexistent.identity");
179        let err = pp.expand(&source, env.fs.as_ref());
180
181        assert!(err.is_err(), "expanding a missing file should fail");
182    }
183
184    #[test]
185    fn double_extension_only_strips_last() {
186        let pp = IdentityPreprocessor::new();
187        // Only the outermost .identity is stripped
188        assert_eq!(pp.stripped_name("file.identity.identity"), "file.identity");
189        assert!(pp.matches_extension("file.identity.identity"));
190    }
191
192    #[test]
193    fn expand_is_idempotent() {
194        let env = crate::testing::TempEnvironment::builder()
195            .pack("app")
196            .file("config.toml.identity", "data")
197            .done()
198            .build();
199
200        let pp = IdentityPreprocessor::new();
201        let source = env.dotfiles_root.join("app/config.toml.identity");
202
203        let result1 = pp.expand(&source, env.fs.as_ref()).unwrap();
204        let result2 = pp.expand(&source, env.fs.as_ref()).unwrap();
205
206        assert_eq!(result1.len(), result2.len());
207        assert_eq!(result1[0].relative_path, result2[0].relative_path);
208        assert_eq!(result1[0].content, result2[0].content);
209    }
210}