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