Skip to main content

dodot_lib/preprocessing/
baseline.rs

1//! Per-file baseline cache for the preprocessing pipeline.
2//!
3//! Every successful expansion writes a JSON record at
4//! `<cache_dir>/preprocessor/<pack>/<handler>/<filename>.json` capturing
5//! enough state to (a) detect drift on the deployed file, (b) decide
6//! whether the source has changed, and (c) drive cache-backed
7//! reverse-merge without re-rendering the template.
8//!
9//! See `docs/proposals/preprocessing-pipeline.lex` §5.2 for the
10//! field-level contract and `docs/proposals/magic.lex` §"Cache That
11//! Makes It Cheap" for why the `tracked_render` field exists.
12//!
13//! # Lifecycle
14//!
15//! - **Write**: `preprocess_pack` calls [`Baseline::write`] after every
16//!   successful expansion. Re-running `dodot up` overwrites the file in
17//!   place.
18//! - **Read**: `dodot transform check` and the clean filter call
19//!   [`Baseline::load`] to drive divergence detection.
20//! - **Cleanup**: `dodot down` deletes the per-pack subdirectory; the
21//!   cache survives `dodot up` failures so partial deployments don't
22//!   strand baseline data for files that did succeed.
23//!
24//! # Schema versioning
25//!
26//! Records carry a `version` field. The current schema is `1`. Future
27//! changes that add fields can stay at `v1` (serde-default fills in the
28//! missing value); breaking changes bump the version, and load returns
29//! a clean error so the user can clear the cache and re-baseline.
30
31use std::path::{Path, PathBuf};
32use std::time::{SystemTime, UNIX_EPOCH};
33
34use serde::{Deserialize, Serialize};
35use sha2::{Digest, Sha256};
36
37use crate::fs::Fs;
38use crate::paths::Pather;
39use crate::{DodotError, Result};
40
41/// Current baseline-cache schema version. Bump on incompatible changes.
42pub const SCHEMA_VERSION: u32 = 1;
43
44/// One baseline record — the cached state of a single processed file.
45#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
46pub struct Baseline {
47    /// Schema version — see [`SCHEMA_VERSION`].
48    pub version: u32,
49    /// Absolute path of the source file at expansion time. Captured so
50    /// `dodot transform check` can re-find the template to patch
51    /// without re-walking the pack tree, and so cache-only diagnostics
52    /// can name the source even after pack reorganisation.
53    ///
54    /// `#[serde(default)]` for forward compatibility with any v1
55    /// baseline written before this field existed (treated as empty;
56    /// transform check will skip such entries until they're rewritten
57    /// by the next `dodot up`).
58    #[serde(default)]
59    pub source_path: PathBuf,
60    /// SHA-256 of the rendered (visible, marker-free) output, hex-encoded.
61    pub rendered_hash: String,
62    /// The full rendered output verbatim. Stored so reverse-merge can
63    /// diff the deployed file against the baseline byte-for-byte
64    /// without re-rendering the template.
65    pub rendered_content: String,
66    /// SHA-256 of the source file's bytes at the moment of expansion,
67    /// hex-encoded. Used to distinguish "user edited the source" from
68    /// "user edited the deployed file" (the 4-state matrix in the
69    /// pipeline spec §6.1).
70    pub source_hash: String,
71    /// SHA-256 of the rendering context (variables, dodot.* values),
72    /// hex-encoded. Provided by the preprocessor; for templates this is
73    /// the deterministic projection computed by
74    /// [`compute_context_hash`](crate::preprocessing::template). May be
75    /// empty if the preprocessor has no meaningful context concept.
76    #[serde(default)]
77    pub context_hash: String,
78    /// Marker-annotated rendered output (burgertocow's "tracked"
79    /// stream). Empty when the preprocessor doesn't produce one.
80    /// Persisted so the clean filter can rehydrate a `TrackedRender`
81    /// via [`burgertocow::TrackedRender::from_tracked_string`] and
82    /// drive the reverse-diff without re-rendering — re-rendering at
83    /// clean-filter time would re-trigger any secret-provider auth
84    /// prompts on every `git status`.
85    #[serde(default)]
86    pub tracked_render: String,
87    /// Wall-clock unix timestamp (seconds) of when the baseline was
88    /// written. Used by `dodot transform status` to show "deployed
89    /// since …". Not load-bearing for divergence detection.
90    pub timestamp: u64,
91}
92
93impl Baseline {
94    /// Build a baseline from raw inputs. Hashes are computed here so
95    /// callers don't repeat the SHA setup; the optional `tracked_render`
96    /// and `context_hash` come straight off the preprocessor's
97    /// `ExpandedFile`.
98    ///
99    /// `source_path` is the absolute path of the source file inside
100    /// the pack — recorded so reverse-merge knows where to write the
101    /// patched template back to.
102    pub fn build(
103        source_path: &Path,
104        rendered_content: &[u8],
105        source_bytes: &[u8],
106        tracked_render: Option<&str>,
107        context_hash: Option<&[u8; 32]>,
108    ) -> Self {
109        Self {
110            version: SCHEMA_VERSION,
111            source_path: source_path.to_path_buf(),
112            rendered_hash: hex_sha256(rendered_content),
113            rendered_content: String::from_utf8_lossy(rendered_content).into_owned(),
114            source_hash: hex_sha256(source_bytes),
115            context_hash: context_hash.map(hex_encode_32).unwrap_or_default(),
116            tracked_render: tracked_render.unwrap_or("").to_string(),
117            timestamp: now_secs_unix(),
118        }
119    }
120
121    /// Persist this baseline to its JSON path under the cache dir.
122    /// Creates parent directories as needed. Overwrites any existing
123    /// file at the target path.
124    pub fn write(
125        &self,
126        fs: &dyn Fs,
127        paths: &dyn Pather,
128        pack: &str,
129        handler: &str,
130        filename: &str,
131    ) -> Result<PathBuf> {
132        let path = paths.preprocessor_baseline_path(pack, handler, filename);
133        if let Some(parent) = path.parent() {
134            fs.mkdir_all(parent)?;
135        }
136        let body = serde_json::to_string_pretty(self).map_err(|e| {
137            DodotError::Other(format!(
138                "failed to serialise baseline for {pack}/{handler}/{filename}: {e}"
139            ))
140        })?;
141        fs.write_file(&path, body.as_bytes())?;
142        Ok(path)
143    }
144
145    /// Load a baseline from its JSON path. Returns `Ok(None)` if the
146    /// file does not exist (a file with no baseline is a normal state
147    /// for a brand-new pack); returns an error for parse failures or
148    /// unsupported schema versions so the caller can suggest a manual
149    /// clear.
150    pub fn load(
151        fs: &dyn Fs,
152        paths: &dyn Pather,
153        pack: &str,
154        handler: &str,
155        filename: &str,
156    ) -> Result<Option<Self>> {
157        let path = paths.preprocessor_baseline_path(pack, handler, filename);
158        if !fs.exists(&path) {
159            return Ok(None);
160        }
161        let raw = fs.read_to_string(&path)?;
162        let baseline: Self = serde_json::from_str(&raw).map_err(|e| {
163            DodotError::Other(format!(
164                "failed to parse baseline at {}: {e}\n  \
165                 Try `dodot up --force` to re-baseline.",
166                path.display()
167            ))
168        })?;
169        if baseline.version != SCHEMA_VERSION {
170            return Err(DodotError::Other(format!(
171                "baseline at {} has unsupported schema version {} (expected {}). \
172                 Clear the file and run `dodot up` to rebuild.",
173                path.display(),
174                baseline.version,
175                SCHEMA_VERSION
176            )));
177        }
178        Ok(Some(baseline))
179    }
180}
181
182/// Per-file secrets sidecar — the on-disk shape of `secret_line_ranges`.
183///
184/// Schema is intentionally minimal: a version field and the slice of
185/// `SecretLineRange` entries the preprocessor emitted on the last
186/// successful render. Stored next to the baseline as
187/// `<filename>.secret.json`. See secrets.lex §3.3.
188///
189/// **No baseline migration**: this file is purely additive. Pre-secrets
190/// renders simply have no sidecar, which the load path treats as
191/// `secret_line_ranges = []` (empty mask, byte-equivalent to legacy
192/// reverse-merge behaviour).
193#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
194pub struct SecretsSidecar {
195    /// Schema version. Bumps independently of the baseline schema —
196    /// they're separate files with separate evolution paths.
197    pub version: u32,
198    /// Line ranges produced on the last successful render. Empty when
199    /// the file's template renders without secrets (an explicit
200    /// empty-array sidecar is fine; absence of the file is also
201    /// fine and means the same thing).
202    pub secret_line_ranges: Vec<crate::preprocessing::SecretLineRange>,
203}
204
205/// Current sidecar schema version.
206pub const SECRETS_SIDECAR_VERSION: u32 = 1;
207
208impl SecretsSidecar {
209    /// Build a sidecar from a slice of line ranges.
210    pub fn new(ranges: Vec<crate::preprocessing::SecretLineRange>) -> Self {
211        Self {
212            version: SECRETS_SIDECAR_VERSION,
213            secret_line_ranges: ranges,
214        }
215    }
216
217    /// Persist the sidecar next to its baseline. Path layout matches
218    /// `Pather::preprocessor_secrets_sidecar_path`. Creates parent
219    /// directories as needed (in practice the baseline write that
220    /// runs first has already created them, but write is robust to
221    /// being called in either order). Overwrites any existing file.
222    ///
223    /// When `self.secret_line_ranges` is empty, this is a no-op:
224    /// callers don't need to special-case "no secrets" — they always
225    /// call `write` with whatever the renderer emitted, and the
226    /// no-secrets case skips the disk write rather than dropping a
227    /// `{ "secret_line_ranges": [] }` file. Removes any existing
228    /// sidecar in that case so a previous render's secrets don't
229    /// linger after the user removes them from the template.
230    pub fn write(
231        &self,
232        fs: &dyn Fs,
233        paths: &dyn Pather,
234        pack: &str,
235        handler: &str,
236        filename: &str,
237    ) -> Result<Option<PathBuf>> {
238        let path = paths.preprocessor_secrets_sidecar_path(pack, handler, filename);
239        if self.secret_line_ranges.is_empty() {
240            // No secrets in this render. Remove a stale sidecar from
241            // a prior render if one exists; otherwise no-op.
242            if fs.exists(&path) {
243                fs.remove_file(&path)?;
244            }
245            return Ok(None);
246        }
247        if let Some(parent) = path.parent() {
248            fs.mkdir_all(parent)?;
249        }
250        let body = serde_json::to_string_pretty(self).map_err(|e| {
251            DodotError::Other(format!(
252                "failed to serialise secrets sidecar for {pack}/{handler}/{filename}: {e}"
253            ))
254        })?;
255        fs.write_file(&path, body.as_bytes())?;
256        Ok(Some(path))
257    }
258
259    /// Load the sidecar for a file. Returns `Ok(None)` when no
260    /// sidecar exists — the documented "no secrets" state per §3.3.
261    /// Errors on parse failure or unsupported version so the caller
262    /// can suggest a `dodot up --force` re-render.
263    pub fn load(
264        fs: &dyn Fs,
265        paths: &dyn Pather,
266        pack: &str,
267        handler: &str,
268        filename: &str,
269    ) -> Result<Option<Self>> {
270        let path = paths.preprocessor_secrets_sidecar_path(pack, handler, filename);
271        if !fs.exists(&path) {
272            return Ok(None);
273        }
274        let raw = fs.read_to_string(&path)?;
275        let sidecar: Self = serde_json::from_str(&raw).map_err(|e| {
276            DodotError::Other(format!(
277                "failed to parse secrets sidecar at {}: {e}\n  \
278                 Run `dodot up --force` to re-render and rewrite the sidecar.",
279                path.display()
280            ))
281        })?;
282        if sidecar.version != SECRETS_SIDECAR_VERSION {
283            return Err(DodotError::Other(format!(
284                "secrets sidecar at {} has unsupported schema version {} (expected {}). \
285                 Clear the file and run `dodot up --force` to rebuild.",
286                path.display(),
287                sidecar.version,
288                SECRETS_SIDECAR_VERSION
289            )));
290        }
291        Ok(Some(sidecar))
292    }
293}
294
295/// SHA-256 → 64-char lowercase hex. Used by the baseline cache for
296/// rendered/source content hashing and by the divergence walker for
297/// the same purpose against current on-disk state. `pub(crate)` so
298/// the divergence module reuses it instead of cloning a parallel
299/// implementation.
300pub(crate) fn hex_sha256(bytes: &[u8]) -> String {
301    let mut hasher = Sha256::new();
302    hasher.update(bytes);
303    hex_encode_32(&hasher.finalize().into())
304}
305
306fn hex_encode_32(bytes: &[u8; 32]) -> String {
307    let mut out = String::with_capacity(64);
308    for b in bytes {
309        out.push(hex_nibble(b >> 4));
310        out.push(hex_nibble(b & 0x0f));
311    }
312    out
313}
314
315fn hex_nibble(n: u8) -> char {
316    match n {
317        0..=9 => (b'0' + n) as char,
318        10..=15 => (b'a' + n - 10) as char,
319        _ => unreachable!(),
320    }
321}
322
323fn now_secs_unix() -> u64 {
324    SystemTime::now()
325        .duration_since(UNIX_EPOCH)
326        .map(|d| d.as_secs())
327        .unwrap_or(0)
328}
329
330/// Canonical filename for a baseline given a logical (stripped) pack
331/// path. Strips parent directories and uses the bare basename, which
332/// matches the cache-path convention specified in the pipeline doc.
333///
334/// Subdirectory-bearing virtual entries (e.g. `subdir/config.toml`) get
335/// flattened to `config.toml` here. The pipeline disambiguates on its
336/// side via the per-pack-and-handler directory tree, but the cache
337/// layout intentionally mirrors a single per-file slot. Two files with
338/// the same basename in different subdirectories of the same pack would
339/// share a cache slot — uncommon for the dotfile-sized payloads
340/// preprocessors produce, but if it surfaces we can extend the
341/// filename encoding without touching callers.
342pub fn cache_filename_for(virtual_relative: &Path) -> String {
343    virtual_relative
344        .file_name()
345        .map(|n| n.to_string_lossy().into_owned())
346        .unwrap_or_else(|| virtual_relative.to_string_lossy().into_owned())
347}
348
349#[cfg(test)]
350mod tests {
351    use super::*;
352    use crate::testing::TempEnvironment;
353
354    #[test]
355    fn build_then_write_then_load_round_trips() {
356        let env = TempEnvironment::builder().build();
357        let baseline = Baseline::build(
358            Path::new("/tmp/config.toml.tmpl"),
359            b"name = Alice\n",
360            b"name = {{ name }}\n",
361            Some("name = \u{1e}Alice\u{1f}\n"),
362            Some(&[0x42; 32]),
363        );
364        let path = baseline
365            .write(
366                env.fs.as_ref(),
367                env.paths.as_ref(),
368                "app",
369                "preprocessed",
370                "config.toml",
371            )
372            .unwrap();
373        assert!(env.fs.exists(&path));
374
375        let loaded = Baseline::load(
376            env.fs.as_ref(),
377            env.paths.as_ref(),
378            "app",
379            "preprocessed",
380            "config.toml",
381        )
382        .unwrap()
383        .expect("baseline must exist after write");
384        assert_eq!(loaded, baseline);
385    }
386
387    #[test]
388    fn load_returns_none_for_missing_file() {
389        let env = TempEnvironment::builder().build();
390        let result = Baseline::load(
391            env.fs.as_ref(),
392            env.paths.as_ref(),
393            "app",
394            "preprocessed",
395            "nope.toml",
396        )
397        .unwrap();
398        assert!(result.is_none());
399    }
400
401    #[test]
402    fn load_rejects_unsupported_schema_version() {
403        let env = TempEnvironment::builder().build();
404        let path = env
405            .paths
406            .preprocessor_baseline_path("app", "preprocessed", "config.toml");
407        env.fs.mkdir_all(path.parent().unwrap()).unwrap();
408        env.fs
409            .write_file(
410                &path,
411                br#"{"version": 999, "rendered_hash": "x", "rendered_content": "x", "source_hash": "x", "timestamp": 0}"#,
412            )
413            .unwrap();
414
415        let err = Baseline::load(
416            env.fs.as_ref(),
417            env.paths.as_ref(),
418            "app",
419            "preprocessed",
420            "config.toml",
421        )
422        .unwrap_err();
423        assert!(
424            format!("{err}").contains("unsupported schema version"),
425            "got: {err}"
426        );
427    }
428
429    #[test]
430    fn load_rejects_corrupted_json() {
431        let env = TempEnvironment::builder().build();
432        let path = env
433            .paths
434            .preprocessor_baseline_path("app", "preprocessed", "config.toml");
435        env.fs.mkdir_all(path.parent().unwrap()).unwrap();
436        env.fs.write_file(&path, b"{not json").unwrap();
437
438        let err = Baseline::load(
439            env.fs.as_ref(),
440            env.paths.as_ref(),
441            "app",
442            "preprocessed",
443            "config.toml",
444        )
445        .unwrap_err();
446        let msg = format!("{err}");
447        assert!(msg.contains("failed to parse"), "got: {msg}");
448        // Hint to clear the cache should be in the error so users have
449        // a recovery path.
450        assert!(
451            msg.contains("--force"),
452            "expected recovery hint, got: {msg}"
453        );
454    }
455
456    #[test]
457    fn build_records_hashes_and_optional_fields() {
458        // Empty optionals → empty strings (serde default), not Null.
459        let p = Path::new("/dummy/source");
460        let b = Baseline::build(p, b"hello", b"hello", None, None);
461        assert_eq!(b.version, SCHEMA_VERSION);
462        assert_eq!(b.source_path, p);
463        assert_eq!(b.rendered_hash.len(), 64); // SHA-256 hex
464        assert_eq!(b.source_hash, b.rendered_hash); // same bytes
465        assert!(b.context_hash.is_empty());
466        assert!(b.tracked_render.is_empty());
467
468        // Provided optionals → encoded.
469        let b2 = Baseline::build(p, b"x", b"y", Some("tracked"), Some(&[0xff; 32]));
470        assert_eq!(b2.context_hash.len(), 64);
471        assert!(b2.context_hash.chars().all(|c| c == 'f'));
472        assert_eq!(b2.tracked_render, "tracked");
473    }
474
475    #[test]
476    fn rendered_content_preserves_lossy_utf8() {
477        // The cache holds rendered_content as UTF-8 (templates are
478        // text); this test pins the loss behaviour for non-UTF-8 bytes
479        // so a future change is a deliberate decision.
480        let b = Baseline::build(
481            Path::new("/dummy"),
482            &[0x66, 0x6f, 0xff, 0x6f],
483            b"src",
484            None,
485            None,
486        );
487        // Replacement character for the invalid 0xff.
488        assert_eq!(b.rendered_content, "fo\u{fffd}o");
489    }
490
491    #[test]
492    fn write_creates_nested_directories() {
493        // Pack-and-handler directories may not exist on first write;
494        // confirm we mkdir_all rather than expecting them to be there.
495        let env = TempEnvironment::builder().build();
496        let baseline = Baseline::build(Path::new("/dummy"), b"x", b"y", None, None);
497        let path = baseline
498            .write(
499                env.fs.as_ref(),
500                env.paths.as_ref(),
501                "deep",
502                "preprocessed",
503                "x",
504            )
505            .unwrap();
506        assert!(env.fs.exists(&path));
507        assert!(env.fs.is_dir(path.parent().unwrap()));
508    }
509
510    #[test]
511    fn write_overwrites_existing_baseline() {
512        // A second write at the same logical path replaces the first.
513        let env = TempEnvironment::builder().build();
514        let first = Baseline::build(Path::new("/dummy"), b"first", b"src", None, None);
515        first
516            .write(
517                env.fs.as_ref(),
518                env.paths.as_ref(),
519                "app",
520                "preprocessed",
521                "f",
522            )
523            .unwrap();
524        let second = Baseline::build(Path::new("/dummy"), b"second", b"src", None, None);
525        second
526            .write(
527                env.fs.as_ref(),
528                env.paths.as_ref(),
529                "app",
530                "preprocessed",
531                "f",
532            )
533            .unwrap();
534
535        let loaded = Baseline::load(
536            env.fs.as_ref(),
537            env.paths.as_ref(),
538            "app",
539            "preprocessed",
540            "f",
541        )
542        .unwrap()
543        .unwrap();
544        assert_eq!(loaded.rendered_content, "second");
545    }
546
547    #[test]
548    fn cache_filename_for_drops_parent_directories() {
549        assert_eq!(cache_filename_for(Path::new("config.toml")), "config.toml");
550        assert_eq!(
551            cache_filename_for(Path::new("subdir/config.toml")),
552            "config.toml"
553        );
554        assert_eq!(cache_filename_for(Path::new("a/b/c/leaf.txt")), "leaf.txt");
555    }
556
557    #[test]
558    fn hex_encoding_is_lowercase_and_padded() {
559        assert_eq!(hex_encode_32(&[0; 32]).len(), 64);
560        assert!(hex_encode_32(&[0; 32]).chars().all(|c| c == '0'));
561        assert_eq!(hex_encode_32(&[0xab; 32]).len(), 64);
562        // Lowercase by convention.
563        assert!(hex_encode_32(&[0xab; 32])
564            .chars()
565            .all(|c| c == 'a' || c == 'b'));
566    }
567
568    // ── secrets sidecar (Phase S1) ───────────────────────────
569
570    fn range(start: usize, reference: &str) -> crate::preprocessing::SecretLineRange {
571        crate::preprocessing::SecretLineRange {
572            start,
573            end: start + 1,
574            reference: reference.into(),
575        }
576    }
577
578    #[test]
579    fn sidecar_round_trips_through_write_and_load() {
580        let env = TempEnvironment::builder().build();
581        let sidecar = SecretsSidecar::new(vec![
582            range(2, "op://Vault/db/password"),
583            range(5, "pass:api/token"),
584        ]);
585
586        let written = sidecar
587            .write(
588                env.fs.as_ref(),
589                env.paths.as_ref(),
590                "app",
591                "preprocessed",
592                "config.toml",
593            )
594            .unwrap()
595            .expect("non-empty sidecar should write");
596        assert!(env.fs.exists(&written));
597
598        let loaded = SecretsSidecar::load(
599            env.fs.as_ref(),
600            env.paths.as_ref(),
601            "app",
602            "preprocessed",
603            "config.toml",
604        )
605        .unwrap()
606        .expect("written sidecar should load");
607
608        assert_eq!(loaded, sidecar);
609        assert_eq!(loaded.version, SECRETS_SIDECAR_VERSION);
610        assert_eq!(loaded.secret_line_ranges.len(), 2);
611        assert_eq!(
612            loaded.secret_line_ranges[0].reference,
613            "op://Vault/db/password"
614        );
615    }
616
617    #[test]
618    fn sidecar_load_returns_none_when_absent() {
619        let env = TempEnvironment::builder().build();
620        let loaded = SecretsSidecar::load(
621            env.fs.as_ref(),
622            env.paths.as_ref(),
623            "app",
624            "preprocessed",
625            "config.toml",
626        )
627        .unwrap();
628        assert!(
629            loaded.is_none(),
630            "absent sidecar = None (no secrets to mask)"
631        );
632    }
633
634    #[test]
635    fn sidecar_write_with_empty_ranges_does_not_create_file() {
636        // Templates without any `secret(...)` calls should leave NO
637        // sidecar on disk — not even an empty `[]` one. Keeps the
638        // file system clean for the common case (most templates
639        // have no secrets).
640        let env = TempEnvironment::builder().build();
641        let sidecar = SecretsSidecar::new(Vec::new());
642        let written = sidecar
643            .write(
644                env.fs.as_ref(),
645                env.paths.as_ref(),
646                "app",
647                "preprocessed",
648                "c.toml",
649            )
650            .unwrap();
651        assert!(written.is_none(), "empty sidecar should not write");
652        let path = env
653            .paths
654            .preprocessor_secrets_sidecar_path("app", "preprocessed", "c.toml");
655        assert!(!env.fs.exists(&path));
656    }
657
658    #[test]
659    fn sidecar_write_with_empty_ranges_removes_stale_file() {
660        // Previous render had secrets → sidecar on disk. New render
661        // has none → the writer must clean up the stale file so
662        // burgertocow's mask doesn't keep masking lines that
663        // legitimately surface as drift now.
664        let env = TempEnvironment::builder().build();
665        let original = SecretsSidecar::new(vec![range(1, "pass:k")]);
666        original
667            .write(
668                env.fs.as_ref(),
669                env.paths.as_ref(),
670                "app",
671                "preprocessed",
672                "c.toml",
673            )
674            .unwrap()
675            .expect("first write");
676
677        let path = env
678            .paths
679            .preprocessor_secrets_sidecar_path("app", "preprocessed", "c.toml");
680        assert!(env.fs.exists(&path));
681
682        let empty = SecretsSidecar::new(Vec::new());
683        empty
684            .write(
685                env.fs.as_ref(),
686                env.paths.as_ref(),
687                "app",
688                "preprocessed",
689                "c.toml",
690            )
691            .unwrap();
692        assert!(
693            !env.fs.exists(&path),
694            "stale sidecar must be removed when the new render has no secrets"
695        );
696    }
697
698    #[test]
699    fn sidecar_load_rejects_unsupported_version_with_actionable_message() {
700        let env = TempEnvironment::builder().build();
701        let path = env
702            .paths
703            .preprocessor_secrets_sidecar_path("app", "preprocessed", "c.toml");
704        env.fs.mkdir_all(path.parent().unwrap()).unwrap();
705        env.fs
706            .write_file(&path, br#"{"version":99,"secret_line_ranges":[]}"#)
707            .unwrap();
708
709        let err = SecretsSidecar::load(
710            env.fs.as_ref(),
711            env.paths.as_ref(),
712            "app",
713            "preprocessed",
714            "c.toml",
715        )
716        .unwrap_err()
717        .to_string();
718        assert!(err.contains("unsupported schema version 99"));
719        assert!(err.contains("dodot up --force"));
720    }
721
722    #[test]
723    fn sidecar_load_rejects_corrupt_json_with_actionable_message() {
724        let env = TempEnvironment::builder().build();
725        let path = env
726            .paths
727            .preprocessor_secrets_sidecar_path("app", "preprocessed", "c.toml");
728        env.fs.mkdir_all(path.parent().unwrap()).unwrap();
729        env.fs.write_file(&path, b"{not valid json").unwrap();
730
731        let err = SecretsSidecar::load(
732            env.fs.as_ref(),
733            env.paths.as_ref(),
734            "app",
735            "preprocessed",
736            "c.toml",
737        )
738        .unwrap_err()
739        .to_string();
740        assert!(err.contains("failed to parse"));
741        assert!(err.contains("dodot up --force"));
742    }
743}