Skip to main content

dodot_lib/commands/
transform.rs

1//! `dodot transform check` — propagate deployed-file edits back to
2//! template sources via the cached baseline + reverse-merge pipeline.
3//!
4//! Reads every per-file baseline under `<cache_dir>/preprocessor/`,
5//! classifies each entry against the 4-state matrix from
6//! `docs/proposals/preprocessing-pipeline.lex` §6.1, and acts on each
7//! state:
8//!
9//! | state            | action                                              |
10//! |------------------|-----------------------------------------------------|
11//! | `Synced`         | nothing (no divergence)                             |
12//! | `InputChanged`   | nothing (next `dodot up` re-renders)                |
13//! | `OutputChanged`  | reverse-merge into source; clean diff → write back  |
14//! | `BothChanged`    | reverse-merge into source; conflict → report       |
15//! | `MissingSource`  | report only (cache stale; next `up` will refresh)   |
16//! | `MissingDeployed`| report only (deployed file gone; manual recovery)   |
17//!
18//! For `OutputChanged` and `BothChanged`, the call into burgertocow
19//! returns either a clean unified diff (which is applied to the source
20//! file via `diffy`) or a conflict block (which is *not* written —
21//! instead surfaced in the report so the user resolves it manually).
22//! The intent: `transform check` only mutates source files when the
23//! reverse-merge is unambiguous, and surfaces every other case for
24//! human review.
25//!
26//! # Strict mode
27//!
28//! `check(ctx, strict=true)` is the form used by the pre-commit hook
29//! (R4). On top of the matrix work above, it scans every source file
30//! for unresolved [`crate::preprocessing::conflict`] markers — if any
31//! are found, the result reports them and the command exits non-zero
32//! so a commit is blocked until the user resolves them.
33
34use serde::Serialize;
35
36use crate::packs::orchestration::ExecutionContext;
37use crate::preprocessing::conflict::find_unresolved_marker_lines;
38use crate::preprocessing::divergence::{
39    classify_one, collect_baselines, DivergenceReport, DivergenceState,
40};
41use crate::preprocessing::no_reverse::is_no_reverse;
42use crate::preprocessing::reverse_merge::{reverse_merge, ReverseMergeOutcome};
43use crate::Result;
44
45/// What `transform check` did to a single processed file.
46#[derive(Debug, Clone, Serialize)]
47#[serde(rename_all = "snake_case")]
48pub enum TransformAction {
49    /// Source and deployed match the baseline — no action.
50    Synced,
51    /// Source has been edited; next `dodot up` will re-render.
52    InputChanged,
53    /// The reverse-merge produced a clean unified diff and the source
54    /// file was patched in place.
55    Patched,
56    /// The reverse-merge surfaced a conflict block; the source file is
57    /// left untouched. The user resolves it manually.
58    Conflict,
59    /// Reverse-merge declined to act (e.g. cached `tracked_render` was
60    /// empty — typically a v1 baseline written before this field
61    /// existed). Re-run `dodot up` to refresh the baseline.
62    NeedsRebaseline,
63    /// The cached source path no longer exists on disk.
64    MissingSource,
65    /// The deployed file is gone from the datastore.
66    MissingDeployed,
67}
68
69/// One row in the transform-check report.
70#[derive(Debug, Clone, Serialize)]
71pub struct TransformCheckEntry {
72    pub pack: String,
73    pub handler: String,
74    pub filename: String,
75    pub source_path: String,
76    pub deployed_path: String,
77    pub action: TransformAction,
78    /// For `Conflict`: the burgertocow-emitted block, ready for the
79    /// CLI layer to print. Empty for other actions.
80    #[serde(default, skip_serializing_if = "String::is_empty")]
81    pub conflict_block: String,
82}
83
84/// One unresolved-marker hit found in `--strict` mode. Path-and-line
85/// granularity, identical in shape to what the pipeline gate reports.
86#[derive(Debug, Clone, Serialize)]
87pub struct UnresolvedMarkerEntry {
88    pub source_path: String,
89    pub line_numbers: Vec<usize>,
90}
91
92/// Aggregate outcome of a `transform check` invocation.
93#[derive(Debug, Clone, Serialize)]
94pub struct TransformCheckResult {
95    pub entries: Vec<TransformCheckEntry>,
96    /// Populated only when `strict = true` and at least one source
97    /// carries unresolved dodot-conflict markers.
98    pub unresolved_markers: Vec<UnresolvedMarkerEntry>,
99    /// True iff at least one entry has a non-clean state that should
100    /// make the command exit non-zero (Conflict, NeedsRebaseline,
101    /// MissingSource, MissingDeployed) or `--strict` found unresolved
102    /// markers. CLI uses this to decide the process exit code.
103    ///
104    /// `Patched` does *not* set this — an unambiguous reverse-merge is
105    /// the auto-merge happy path: burgertocow + diffy produced a clean
106    /// unified patch with no markers, the source has been rewritten
107    /// to match, and there's nothing for the user to review. The
108    /// pre-commit hook lets the original `git commit` proceed; the
109    /// patched source surfaces as modified on the next `git status`,
110    /// at which point the user `git add`s and commits a follow-up
111    /// (or amends) if they want a clean history. Issue #113 walks
112    /// through the rationale.
113    pub has_findings: bool,
114    pub strict: bool,
115}
116
117impl TransformCheckResult {
118    /// Process exit code per the spec: 0 if everything is clean, 1
119    /// otherwise. Strict-mode unresolved markers also flip this to 1.
120    pub fn exit_code(&self) -> i32 {
121        if self.has_findings {
122            1
123        } else {
124            0
125        }
126    }
127}
128
129/// One row in `dodot transform status`'s passive report.
130///
131/// Mirrors `TransformCheckEntry` but without any of the action /
132/// conflict-block fields — `status` is a read-only inspection;
133/// `check` is the action layer.
134#[derive(Debug, Clone, Serialize)]
135pub struct TransformStatusEntry {
136    pub pack: String,
137    pub handler: String,
138    pub filename: String,
139    pub source_path: String,
140    pub deployed_path: String,
141    /// Mirror of `DivergenceState`, serialised as snake_case so the
142    /// template branches and JSON consumers see the same shape they
143    /// see in `transform check`.
144    #[serde(rename = "state")]
145    pub state: String,
146}
147
148/// Aggregate result of `dodot transform status` — one row per
149/// cached baseline, plus a few rollup counters for the renderer.
150#[derive(Debug, Clone, Serialize)]
151pub struct TransformStatusResult {
152    pub entries: Vec<TransformStatusEntry>,
153    pub synced_count: usize,
154    pub diverged_count: usize,
155    pub missing_count: usize,
156}
157
158/// Run `dodot transform status` — read-only view of the baseline
159/// cache. Walks every cached entry and reports its state without
160/// running the reverse-merge engine, writing source files, or doing
161/// anything else that mutates state. Useful as a "what's currently
162/// out of sync?" check before deciding whether to run `dodot transform
163/// check`. Always exits 0 — even a fully-diverged repo isn't a
164/// failure here, just information.
165pub fn status(ctx: &ExecutionContext) -> Result<TransformStatusResult> {
166    use crate::preprocessing::divergence::{collect_divergences, DivergenceState};
167    let reports = collect_divergences(ctx.fs.as_ref(), ctx.paths.as_ref())?;
168    let mut synced_count = 0usize;
169    let mut diverged_count = 0usize;
170    let mut missing_count = 0usize;
171    let entries: Vec<TransformStatusEntry> = reports
172        .into_iter()
173        .map(|r| {
174            let state_str = match r.state {
175                DivergenceState::Synced => {
176                    synced_count += 1;
177                    "synced"
178                }
179                DivergenceState::InputChanged => {
180                    diverged_count += 1;
181                    "input_changed"
182                }
183                DivergenceState::OutputChanged => {
184                    diverged_count += 1;
185                    "output_changed"
186                }
187                DivergenceState::BothChanged => {
188                    diverged_count += 1;
189                    "both_changed"
190                }
191                DivergenceState::MissingSource => {
192                    missing_count += 1;
193                    "missing_source"
194                }
195                DivergenceState::MissingDeployed => {
196                    missing_count += 1;
197                    "missing_deployed"
198                }
199            };
200            TransformStatusEntry {
201                pack: r.pack,
202                handler: r.handler,
203                filename: r.filename,
204                source_path: render_path(&r.source_path, ctx.paths.home_dir()),
205                deployed_path: render_path(&r.deployed_path, ctx.paths.home_dir()),
206                state: state_str.to_string(),
207            }
208        })
209        .collect();
210    Ok(TransformStatusResult {
211        entries,
212        synced_count,
213        diverged_count,
214        missing_count,
215    })
216}
217
218/// Run `dodot transform check`. See module docs for the matrix.
219pub fn check(ctx: &ExecutionContext, strict: bool) -> Result<TransformCheckResult> {
220    let baselines = collect_baselines(ctx.fs.as_ref(), ctx.paths.as_ref())?;
221    let mut entries: Vec<TransformCheckEntry> = Vec::with_capacity(baselines.len());
222    let mut has_findings = false;
223    // Memoise no_reverse patterns by pack within this check
224    // invocation. ConfigManager already caches resolved configs by
225    // absolute path, but each lookup still allocates and clones the
226    // Vec — for repos with many baselines per pack, that's wasted
227    // work. The map keeps the inner work to a single lookup per pack.
228    let mut no_reverse_cache: std::collections::HashMap<String, Vec<String>> =
229        std::collections::HashMap::new();
230
231    for (pack, handler, filename, baseline) in baselines {
232        let report = classify_one(
233            ctx.fs.as_ref(),
234            ctx.paths.as_ref(),
235            &pack,
236            &handler,
237            &filename,
238            &baseline,
239        );
240        // Per-pack [preprocessor.template] no_reverse opt-out: when a
241        // file matches, we treat it as Synced regardless of which
242        // divergence state the matrix reports. This keeps the file
243        // out of the reverse-merge engine (which can produce more
244        // conflict markers than usable diffs on mostly-dynamic
245        // templates) while leaving `dodot transform status` alone —
246        // status still surfaces the underlying state for visibility.
247        let no_reverse_patterns = no_reverse_cache
248            .entry(pack.clone())
249            .or_insert_with(|| pack_no_reverse_patterns(ctx, &pack));
250        let no_reverse = is_no_reverse(&report.source_path, no_reverse_patterns);
251        let action = match report.state {
252            DivergenceState::Synced => TransformAction::Synced,
253            DivergenceState::InputChanged => TransformAction::InputChanged,
254            DivergenceState::MissingSource => {
255                has_findings = true;
256                TransformAction::MissingSource
257            }
258            DivergenceState::MissingDeployed => {
259                has_findings = true;
260                TransformAction::MissingDeployed
261            }
262            DivergenceState::OutputChanged | DivergenceState::BothChanged if no_reverse => {
263                // Opted out — leave source untouched, surface as
264                // Synced. The user has explicitly chosen "detect
265                // divergence but don't auto-merge"; `transform
266                // status` still shows the real state.
267                TransformAction::Synced
268            }
269            DivergenceState::OutputChanged | DivergenceState::BothChanged => {
270                // Forward-compat short-circuit: a baseline written
271                // before the tracked-render field existed (or by a
272                // future preprocessor that opts into reverse-merge
273                // without producing a marker stream) has nothing for
274                // burgertocow to chew on. Surface as NeedsRebaseline
275                // — a finding in its own right — rather than masking
276                // it as Synced via reverse_merge's Unchanged fallback.
277                // Without this branch, an OutputChanged file with an
278                // empty tracked_render would silently report "no
279                // divergence" and the user would never know.
280                if baseline.tracked_render.is_empty() {
281                    has_findings = true;
282                    TransformAction::NeedsRebaseline
283                } else {
284                    // Run the reverse-merge engine. Unchanged → variable-
285                    // only edit, no action. Patched → write back to source.
286                    // Conflict → report the block, leave source alone.
287                    let template_src = ctx.fs.read_to_string(&report.source_path)?;
288                    let deployed = ctx.fs.read_to_string(&report.deployed_path)?;
289                    match reverse_merge(&template_src, &baseline.tracked_render, &deployed)? {
290                        ReverseMergeOutcome::Unchanged => TransformAction::Synced,
291                        ReverseMergeOutcome::Patched(patched) => {
292                            if !ctx.dry_run {
293                                ctx.fs.write_file(&report.source_path, patched.as_bytes())?;
294                            }
295                            // `Patched` is the auto-merge happy path:
296                            // burgertocow + diffy produced an
297                            // unambiguous unified patch, the source
298                            // is now in sync with the user's edit.
299                            // Nothing for the user to review →
300                            // `has_findings` stays false. The patched
301                            // source surfaces as modified on the next
302                            // `git status` for a follow-up commit.
303                            // See #113.
304                            TransformAction::Patched
305                        }
306                        ReverseMergeOutcome::Conflict(block) => {
307                            has_findings = true;
308                            return_conflict_entry(
309                                &mut entries,
310                                report,
311                                block,
312                                ctx.paths.home_dir(),
313                            );
314                            continue;
315                        }
316                    }
317                }
318            }
319        };
320
321        entries.push(make_entry(report, action, ctx.paths.home_dir()));
322    }
323
324    let mut unresolved_markers = Vec::new();
325    if strict {
326        // Re-walk the cache, scanning each source for dodot-conflict
327        // markers. Any hit blocks a commit (when this is run from the
328        // pre-commit hook). We re-walk rather than reusing the loop
329        // above because the loop may have skipped entries via
330        // MissingSource / continue paths.
331        let baselines = collect_baselines(ctx.fs.as_ref(), ctx.paths.as_ref())?;
332        for (_pack, _handler, _filename, baseline) in baselines {
333            if baseline.source_path.as_os_str().is_empty() || !ctx.fs.exists(&baseline.source_path)
334            {
335                continue;
336            }
337            let bytes = ctx.fs.read_file(&baseline.source_path)?;
338            let content = String::from_utf8_lossy(&bytes);
339            let lines = find_unresolved_marker_lines(&content);
340            if !lines.is_empty() {
341                has_findings = true;
342                unresolved_markers.push(UnresolvedMarkerEntry {
343                    source_path: render_path(&baseline.source_path, ctx.paths.home_dir()),
344                    line_numbers: lines.iter().map(|(n, _)| *n).collect(),
345                });
346            }
347        }
348    }
349
350    Ok(TransformCheckResult {
351        entries,
352        unresolved_markers,
353        has_findings,
354        strict,
355    })
356}
357
358fn make_entry(
359    report: DivergenceReport,
360    action: TransformAction,
361    home: &std::path::Path,
362) -> TransformCheckEntry {
363    TransformCheckEntry {
364        pack: report.pack,
365        handler: report.handler,
366        filename: report.filename,
367        source_path: render_path(&report.source_path, home),
368        deployed_path: render_path(&report.deployed_path, home),
369        action,
370        conflict_block: String::new(),
371    }
372}
373
374fn return_conflict_entry(
375    entries: &mut Vec<TransformCheckEntry>,
376    report: DivergenceReport,
377    block: String,
378    home: &std::path::Path,
379) {
380    entries.push(TransformCheckEntry {
381        pack: report.pack,
382        handler: report.handler,
383        filename: report.filename,
384        source_path: render_path(&report.source_path, home),
385        deployed_path: render_path(&report.deployed_path, home),
386        action: TransformAction::Conflict,
387        conflict_block: block,
388    });
389}
390
391fn render_path(p: &std::path::Path, home: &std::path::Path) -> String {
392    if let Ok(rel) = p.strip_prefix(home) {
393        format!("~/{}", rel.display())
394    } else {
395        p.display().to_string()
396    }
397}
398
399/// Resolve `[preprocessor.template] no_reverse` for the given pack.
400/// Honours the root → pack config inheritance. Returns an empty list
401/// on any config-loading hiccup (the user shouldn't lose `transform
402/// check` over a malformed pack `.dodot.toml` — the next `dodot up`
403/// will surface the actual config error).
404fn pack_no_reverse_patterns(ctx: &ExecutionContext, pack: &str) -> Vec<String> {
405    let pack_path = ctx.paths.dotfiles_root().join(pack);
406    match ctx.config_manager.config_for_pack(&pack_path) {
407        Ok(cfg) => cfg.preprocessor.template.no_reverse.clone(),
408        Err(_) => Vec::new(),
409    }
410}
411
412// ── install-hook ────────────────────────────────────────────────
413
414/// The guard line that opens our managed block in `.git/hooks/pre-commit`.
415/// Detection of this string is what makes [`install_hook`] idempotent.
416pub(crate) const HOOK_GUARD_START: &str =
417    "# >>> dodot transform check --strict (managed by `dodot transform install-hook`) >>>";
418
419/// The guard line that closes our managed block. Paired with
420/// [`HOOK_GUARD_START`].
421pub(crate) const HOOK_GUARD_END: &str = "# <<< dodot transform check --strict <<<";
422
423/// Outcome of `dodot transform install-hook`.
424#[derive(Debug, Clone, Serialize)]
425#[serde(rename_all = "snake_case")]
426pub enum InstallHookOutcome {
427    /// Hook file did not exist; we created it with shebang + our block.
428    Created,
429    /// Hook file existed; we appended our block to it. Existing content
430    /// is preserved.
431    Appended,
432    /// Hook was already installed and matches the current managed
433    /// block exactly — no change.
434    AlreadyInstalled,
435    /// Hook was installed but the managed block was an older version
436    /// (e.g. didn't yet call `dodot refresh`). We replaced the
437    /// outdated block in place. Existing non-managed content in the
438    /// hook file is preserved.
439    Updated,
440}
441
442/// Result returned by [`install_hook`]. Renders through the
443/// `transform-install-hook.jinja` template; CLI exits 0 in all three
444/// outcomes (every state is a success).
445#[derive(Debug, Clone, Serialize)]
446pub struct InstallHookResult {
447    pub outcome: InstallHookOutcome,
448    /// Absolute path of the hook file that was written or inspected.
449    pub hook_path: String,
450    /// Path of the hook rendered relative to `$HOME` for display.
451    pub hook_display_path: String,
452    /// The exact line the hook will execute on each commit. Surfaced
453    /// so the user can see what `--strict` looks like in their hook.
454    pub command_line: String,
455}
456
457/// Install (or detect-already-installed) the dodot pre-commit hook
458/// that runs `dodot transform check --strict`.
459///
460/// # Behavior
461///
462/// - If `<dotfiles_root>/.git/hooks/pre-commit` does not exist:
463///   create it with `#!/bin/sh` + our guarded block, mode `0o755`.
464/// - If it exists and already contains [`HOOK_GUARD_START`]:
465///   no-op, return [`InstallHookOutcome::AlreadyInstalled`].
466/// - If it exists without our guard: append our block (preserving
467///   existing content), ensure executable bit is set.
468///
469/// # Errors
470///
471/// Returns an error if `<dotfiles_root>/.git` doesn't exist (the
472/// dotfiles repo isn't a git working tree). The hook only makes
473/// sense in a git context.
474pub fn install_hook(ctx: &ExecutionContext) -> Result<InstallHookResult> {
475    let dotfiles_root = ctx.paths.dotfiles_root();
476    let git_dir = dotfiles_root.join(".git");
477    if !ctx.fs.is_dir(&git_dir) {
478        return Err(crate::DodotError::Other(format!(
479            "no .git directory at {}; pre-commit hooks only apply to git working \
480             trees. Run `git init` in {} first.",
481            git_dir.display(),
482            dotfiles_root.display(),
483        )));
484    }
485
486    let hooks_dir = git_dir.join("hooks");
487    let hook_path = hooks_dir.join("pre-commit");
488
489    let block = managed_block();
490
491    let outcome = if ctx.fs.exists(&hook_path) {
492        let existing = ctx.fs.read_to_string(&hook_path)?;
493        if let Some((start_byte, end_byte)) = find_managed_block(&existing) {
494            // A managed block exists. Decide whether it matches the
495            // current `block` exactly (no-op) or is stale and needs
496            // replacing.
497            let current_block = &existing[start_byte..end_byte];
498            if current_block == block {
499                InstallHookOutcome::AlreadyInstalled
500            } else {
501                // Stale block — rewrite it in place. Anything outside
502                // the marker pair is preserved.
503                let mut new_content = String::with_capacity(existing.len() + block.len());
504                new_content.push_str(&existing[..start_byte]);
505                new_content.push_str(&block);
506                new_content.push_str(&existing[end_byte..]);
507                ctx.fs.write_file(&hook_path, new_content.as_bytes())?;
508                ctx.fs.set_permissions(&hook_path, 0o755)?;
509                InstallHookOutcome::Updated
510            }
511        } else {
512            // No managed block at all — append. Preserves existing
513            // hook content (user-written or installed by another tool).
514            let mut new_content = existing.clone();
515            if !new_content.ends_with('\n') {
516                new_content.push('\n');
517            }
518            if !new_content.ends_with("\n\n") {
519                new_content.push('\n');
520            }
521            new_content.push_str(&block);
522            ctx.fs.write_file(&hook_path, new_content.as_bytes())?;
523            ctx.fs.set_permissions(&hook_path, 0o755)?;
524            InstallHookOutcome::Appended
525        }
526    } else {
527        ctx.fs.mkdir_all(&hooks_dir)?;
528        let mut new_content = String::from("#!/bin/sh\n\n");
529        new_content.push_str(&block);
530        ctx.fs.write_file(&hook_path, new_content.as_bytes())?;
531        ctx.fs.set_permissions(&hook_path, 0o755)?;
532        InstallHookOutcome::Created
533    };
534
535    Ok(InstallHookResult {
536        outcome,
537        hook_path: hook_path.display().to_string(),
538        hook_display_path: render_path(&hook_path, ctx.paths.home_dir()),
539        command_line: HOOK_COMMAND.to_string(),
540    })
541}
542
543/// Detect whether the hook is currently installed in the dotfiles
544/// repo. Used by the `dodot up` first-template-deploy prompt to
545/// decide whether to offer installation. Cheap (single read of the
546/// hook file).
547pub fn hook_is_installed(ctx: &ExecutionContext) -> Result<bool> {
548    let hook_path = ctx.paths.dotfiles_root().join(".git/hooks/pre-commit");
549    if !ctx.fs.exists(&hook_path) {
550        return Ok(false);
551    }
552    let existing = ctx.fs.read_to_string(&hook_path)?;
553    Ok(existing.contains(HOOK_GUARD_START))
554}
555
556/// Public for `dodot transform show-hook` (future) and for the
557/// onboarding prompt in `commands::up` to surface what would be
558/// installed. Includes the guard lines so callers can grep-detect
559/// the block in arbitrary contexts.
560///
561/// The block runs two commands:
562///
563/// 1. `dodot refresh --quiet` — touch source mtimes for any
564///    deployed-side edits so git's stat-cache invalidates. Without
565///    this, the clean filter (R6) wouldn't fire on the upcoming
566///    commit, and the commit could include stale template content.
567/// 2. `dodot transform check --strict` — run the 4-state matrix and
568///    refuse the commit on any finding (Conflict, missing,
569///    unresolved markers, NeedsRebaseline). `Patched` outcomes don't
570///    refuse — burgertocow's auto-merge already produced a clean
571///    unified patch and rewrote the source; the user `git add`s and
572///    commits the follow-up if they want a clean history.
573///
574/// Each step short-circuits with `|| exit 1`; a failure in either
575/// aborts the commit (with exit code 1 — the inner command's exit
576/// status is intentionally not preserved, since for git's purposes
577/// "any non-zero" is what blocks the commit).
578pub fn managed_block() -> String {
579    format!(
580        "{guard_start}\n\
581         # Aborts the commit if any template-source has drift that needs review —\n\
582         # divergent deployed file or unresolved dodot-conflict markers. Remove\n\
583         # this block to opt out.\n\
584         {refresh}\n\
585         {check}\n\
586         {guard_end}\n",
587        guard_start = HOOK_GUARD_START,
588        guard_end = HOOK_GUARD_END,
589        refresh = HOOK_COMMAND_REFRESH,
590        check = HOOK_COMMAND_CHECK,
591    )
592}
593
594/// First shell line of the managed block: invalidate git's
595/// stat-cache for any deployed-side edits. `--quiet` so a no-op
596/// refresh doesn't print on every commit.
597pub(crate) const HOOK_COMMAND_REFRESH: &str = "dodot refresh --quiet || exit 1";
598
599/// Second shell line: run the strict check. Splits across two lines
600/// in the hook so each step can be diagnosed independently.
601pub(crate) const HOOK_COMMAND_CHECK: &str = "dodot transform check --strict || exit 1";
602
603/// Combined "what the hook runs" string for display purposes
604/// (shown by the install message + the post-up prompt). The actual
605/// hook file uses the two-line form from [`managed_block`].
606pub(crate) const HOOK_COMMAND: &str = "dodot refresh --quiet && dodot transform check --strict";
607
608/// Locate the byte range of our managed block inside `text` —
609/// from the first character of `HOOK_GUARD_START` through the
610/// trailing newline after `HOOK_GUARD_END`. Returns `None` if either
611/// guard is missing or if the end guard doesn't appear after the
612/// start guard.
613///
614/// Used by the install path to detect stale managed blocks (and
615/// rewrite them to the current shape) without disturbing any
616/// non-managed content the user has in their hook.
617fn find_managed_block(text: &str) -> Option<(usize, usize)> {
618    let start = text.find(HOOK_GUARD_START)?;
619    // Find the end guard after `start`.
620    let after_start = start + HOOK_GUARD_START.len();
621    let end_rel = text[after_start..].find(HOOK_GUARD_END)?;
622    let end_guard_start = after_start + end_rel;
623    let end_byte = end_guard_start + HOOK_GUARD_END.len();
624    // Include the trailing newline (if any) so re-inserting the new
625    // block doesn't double-up the line break.
626    let end_byte = if text.as_bytes().get(end_byte) == Some(&b'\n') {
627        end_byte + 1
628    } else {
629        end_byte
630    };
631    Some((start, end_byte))
632}
633
634#[cfg(test)]
635mod tests {
636    use super::*;
637    use crate::fs::Fs;
638    use crate::paths::Pather;
639    use crate::testing::TempEnvironment;
640
641    fn make_ctx(env: &TempEnvironment) -> ExecutionContext {
642        use crate::config::ConfigManager;
643        use crate::datastore::{CommandOutput, CommandRunner, FilesystemDataStore};
644        use crate::fs::Fs;
645        use crate::paths::Pather;
646        use std::sync::Arc;
647
648        struct NoopRunner;
649        impl CommandRunner for NoopRunner {
650            fn run(&self, _e: &str, _a: &[String]) -> Result<CommandOutput> {
651                Ok(CommandOutput {
652                    exit_code: 0,
653                    stdout: String::new(),
654                    stderr: String::new(),
655                })
656            }
657        }
658        let runner: Arc<dyn CommandRunner> = Arc::new(NoopRunner);
659        let datastore = Arc::new(FilesystemDataStore::new(
660            env.fs.clone(),
661            env.paths.clone(),
662            runner.clone(),
663        ));
664        let config_manager = Arc::new(ConfigManager::new(&env.dotfiles_root).unwrap());
665        ExecutionContext {
666            fs: env.fs.clone() as Arc<dyn Fs>,
667            datastore,
668            paths: env.paths.clone() as Arc<dyn Pather>,
669            config_manager,
670            syntax_checker: Arc::new(crate::shell::NoopSyntaxChecker),
671            command_runner: runner,
672            dry_run: false,
673            no_provision: true,
674            provision_rerun: false,
675            force: false,
676            view_mode: crate::commands::ViewMode::Full,
677            group_mode: crate::commands::GroupMode::Name,
678            verbose: false,
679        }
680    }
681
682    /// Run a real `dodot up` against a single-template pack so the
683    /// baseline cache + datastore are populated the same way they
684    /// would be in production. Returns the (pack_name,
685    /// source_path_in_pack) pair for the test to drive.
686    fn deploy_template(
687        env: &TempEnvironment,
688        pack: &str,
689        template_name: &str,
690        template_body: &str,
691        config_toml: &str,
692    ) -> std::path::PathBuf {
693        // Write the template source.
694        let src_path = env.dotfiles_root.join(pack).join(template_name);
695        env.fs.mkdir_all(src_path.parent().unwrap()).unwrap();
696        env.fs
697            .write_file(&src_path, template_body.as_bytes())
698            .unwrap();
699
700        // Write a root .dodot.toml carrying the desired vars.
701        if !config_toml.is_empty() {
702            env.fs
703                .write_file(
704                    &env.dotfiles_root.join(".dodot.toml"),
705                    config_toml.as_bytes(),
706                )
707                .unwrap();
708        }
709
710        // Deploy via `dodot up`.
711        let ctx = make_ctx(env);
712        let _ = crate::commands::up::up(None, &ctx).unwrap();
713
714        src_path
715    }
716
717    fn deployed_path(env: &TempEnvironment, pack: &str, filename: &str) -> std::path::PathBuf {
718        env.paths
719            .data_dir()
720            .join("packs")
721            .join(pack)
722            .join("preprocessed")
723            .join(filename)
724    }
725
726    #[test]
727    fn empty_cache_yields_clean_no_findings() {
728        let env = TempEnvironment::builder().build();
729        let ctx = make_ctx(&env);
730        let result = check(&ctx, false).unwrap();
731        assert!(result.entries.is_empty());
732        assert!(!result.has_findings);
733        assert_eq!(result.exit_code(), 0);
734    }
735
736    #[test]
737    fn synced_files_report_synced_and_no_findings() {
738        // Run `dodot up` on a template, immediately run `transform
739        // check`. Nothing edited → all entries are Synced, no findings.
740        let env = TempEnvironment::builder().build();
741        deploy_template(
742            &env,
743            "app",
744            "config.toml.tmpl",
745            "name = {{ name }}\n",
746            "[preprocessor.template.vars]\nname = \"Alice\"\n",
747        );
748        let ctx = make_ctx(&env);
749        let result = check(&ctx, false).unwrap();
750        assert_eq!(result.entries.len(), 1);
751        assert!(matches!(result.entries[0].action, TransformAction::Synced));
752        assert!(!result.has_findings);
753    }
754
755    #[test]
756    fn output_changed_static_edit_patches_source() {
757        // Edit the deployed file's static content. The source file's
758        // template variable should be preserved; the static edit
759        // should land in the template via diffy.
760        let env = TempEnvironment::builder().build();
761        let src_path = deploy_template(
762            &env,
763            "app",
764            "config.toml.tmpl",
765            "name = {{ name }}\nport = 5432\n",
766            "[preprocessor.template.vars]\nname = \"Alice\"\n",
767        );
768        // Edit the deployed file (the rendered content in the
769        // datastore — that's what the user-side symlink dereferences
770        // to). Change the static `port` line.
771        let deployed = deployed_path(&env, "app", "config.toml");
772        env.fs
773            .write_file(&deployed, b"name = Alice\nport = 9999\n")
774            .unwrap();
775
776        let ctx = make_ctx(&env);
777        let result = check(&ctx, false).unwrap();
778        assert_eq!(result.entries.len(), 1);
779        assert!(
780            matches!(result.entries[0].action, TransformAction::Patched),
781            "got: {:?}",
782            result.entries[0].action
783        );
784        // Patched is the auto-merge happy path: clean unified diff,
785        // source rewritten, nothing for the user to review. The
786        // pre-commit hook lets the commit proceed; the user does a
787        // follow-up `git add` + commit on the patched source. See #113.
788        assert!(!result.has_findings);
789        assert_eq!(result.exit_code(), 0);
790
791        // Source was rewritten: the static line is updated, the
792        // variable-bearing line is preserved verbatim.
793        let new_src = env.fs.read_to_string(&src_path).unwrap();
794        assert!(new_src.contains("port = 9999"), "src: {new_src:?}");
795        assert!(new_src.contains("name = {{ name }}"), "src: {new_src:?}");
796    }
797
798    #[test]
799    fn output_changed_pure_data_edit_yields_synced() {
800        // The user changed only the variable's *value* in the
801        // deployed file. burgertocow flags it as a pure-data edit;
802        // the source needs no change. Action: Synced (no findings,
803        // no source mutation).
804        let env = TempEnvironment::builder().build();
805        let src_path = deploy_template(
806            &env,
807            "app",
808            "config.toml.tmpl",
809            "name = {{ name }}\n",
810            "[preprocessor.template.vars]\nname = \"Alice\"\n",
811        );
812        let original_src = env.fs.read_to_string(&src_path).unwrap();
813        let deployed = deployed_path(&env, "app", "config.toml");
814        env.fs.write_file(&deployed, b"name = Bob\n").unwrap();
815
816        let ctx = make_ctx(&env);
817        let result = check(&ctx, false).unwrap();
818        assert_eq!(result.entries.len(), 1);
819        assert!(matches!(result.entries[0].action, TransformAction::Synced));
820        // Source must be byte-identical to the original.
821        assert_eq!(env.fs.read_to_string(&src_path).unwrap(), original_src);
822    }
823
824    #[test]
825    fn no_reverse_pattern_skips_reverse_merge() {
826        // Same scenario as output_changed_static_edit_patches_source,
827        // but with `no_reverse = ["config.toml.tmpl"]` in the root
828        // config. The user opted out of reverse-merge for this file
829        // — `transform check` must report Synced, leave the source
830        // untouched, and have no findings (so the pre-commit hook
831        // would let the commit through).
832        let env = TempEnvironment::builder().build();
833        let src_path = deploy_template(
834            &env,
835            "app",
836            "config.toml.tmpl",
837            "name = {{ name }}\nport = 5432\n",
838            "[preprocessor.template.vars]\n\
839             name = \"Alice\"\n\
840             [preprocessor.template]\n\
841             no_reverse = [\"config.toml.tmpl\"]\n",
842        );
843        let original_src = env.fs.read_to_string(&src_path).unwrap();
844
845        // Edit the deployed file the same way the patching test does.
846        let deployed = deployed_path(&env, "app", "config.toml");
847        env.fs
848            .write_file(&deployed, b"name = Alice\nport = 9999\n")
849            .unwrap();
850
851        let ctx = make_ctx(&env);
852        let result = check(&ctx, false).unwrap();
853        assert_eq!(result.entries.len(), 1);
854        assert!(
855            matches!(result.entries[0].action, TransformAction::Synced),
856            "no_reverse must short-circuit to Synced; got: {:?}",
857            result.entries[0].action
858        );
859        assert!(!result.has_findings);
860        assert_eq!(result.exit_code(), 0);
861        // Source untouched on disk.
862        assert_eq!(env.fs.read_to_string(&src_path).unwrap(), original_src);
863    }
864
865    #[test]
866    fn no_reverse_glob_pattern_skips_reverse_merge() {
867        // Glob form of the opt-out — `*.gen.tmpl` matches the
868        // generated template's filename and skips reverse-merge.
869        let env = TempEnvironment::builder().build();
870        let src_path = deploy_template(
871            &env,
872            "app",
873            "foo.gen.tmpl",
874            "name = {{ name }}\nport = 5432\n",
875            "[preprocessor.template.vars]\n\
876             name = \"Alice\"\n\
877             [preprocessor.template]\n\
878             no_reverse = [\"*.gen.tmpl\"]\n",
879        );
880        let original_src = env.fs.read_to_string(&src_path).unwrap();
881        let deployed = deployed_path(&env, "app", "foo.gen");
882        env.fs
883            .write_file(&deployed, b"name = Alice\nport = 9999\n")
884            .unwrap();
885
886        let ctx = make_ctx(&env);
887        let result = check(&ctx, false).unwrap();
888        assert_eq!(result.entries.len(), 1);
889        assert!(matches!(result.entries[0].action, TransformAction::Synced));
890        assert!(!result.has_findings);
891        assert_eq!(env.fs.read_to_string(&src_path).unwrap(), original_src);
892    }
893
894    #[test]
895    fn dry_run_does_not_write_to_source() {
896        // Same scenario as the static-edit patch test, but with
897        // dry_run=true. The action is still reported as Patched (so
898        // the user sees what *would* happen), but the source is left
899        // alone on disk.
900        let env = TempEnvironment::builder().build();
901        let src_path = deploy_template(
902            &env,
903            "app",
904            "config.toml.tmpl",
905            "name = {{ name }}\nport = 5432\n",
906            "[preprocessor.template.vars]\nname = \"Alice\"\n",
907        );
908        let original_src = env.fs.read_to_string(&src_path).unwrap();
909        let deployed = deployed_path(&env, "app", "config.toml");
910        env.fs
911            .write_file(&deployed, b"name = Alice\nport = 9999\n")
912            .unwrap();
913
914        let mut ctx = make_ctx(&env);
915        ctx.dry_run = true;
916        let result = check(&ctx, false).unwrap();
917        assert!(matches!(result.entries[0].action, TransformAction::Patched));
918        // Source unchanged on disk despite the action label.
919        assert_eq!(env.fs.read_to_string(&src_path).unwrap(), original_src);
920    }
921
922    #[test]
923    fn needs_rebaseline_when_tracked_render_is_empty_and_deployed_edited() {
924        // Forward-compat surface: a baseline written before
925        // tracked_render existed (or by a future preprocessor that
926        // opts in without producing a marker stream) is unable to
927        // drive burgertocow. If the deployed file has been edited,
928        // the action MUST be NeedsRebaseline — never silently
929        // reported as Synced. This test pins that contract because
930        // the bug existed in the first cut: empty tracked_render
931        // produced reverse_merge → Unchanged → mapped to Synced,
932        // hiding real divergence from the user.
933        let env = TempEnvironment::builder().build();
934        // Stage a baseline by hand with an empty tracked_render.
935        let src_path = env.dotfiles_root.join("app/config.toml.tmpl");
936        env.fs.mkdir_all(src_path.parent().unwrap()).unwrap();
937        env.fs.write_file(&src_path, b"name = {{ name }}").unwrap();
938        let baseline = crate::preprocessing::baseline::Baseline::build(
939            &src_path,
940            b"name = Alice",
941            b"name = {{ name }}",
942            None, // <-- the load-bearing detail: no tracked render
943            None,
944        );
945        baseline
946            .write(
947                env.fs.as_ref(),
948                env.paths.as_ref(),
949                "app",
950                "preprocessed",
951                "config.toml",
952            )
953            .unwrap();
954        // Lay down a deployed file that DIVERGES from the baseline.
955        let deployed = deployed_path(&env, "app", "config.toml");
956        env.fs.mkdir_all(deployed.parent().unwrap()).unwrap();
957        env.fs
958            .write_file(&deployed, b"name = Edited\nport = 9999")
959            .unwrap();
960
961        let ctx = make_ctx(&env);
962        let result = check(&ctx, false).unwrap();
963        assert_eq!(result.entries.len(), 1);
964        assert!(
965            matches!(result.entries[0].action, TransformAction::NeedsRebaseline),
966            "got: {:?}",
967            result.entries[0].action
968        );
969        assert!(
970            result.has_findings,
971            "NeedsRebaseline must count as a finding"
972        );
973        assert_eq!(result.exit_code(), 1);
974
975        // Source must NOT have been mutated (we couldn't compute a
976        // safe diff without the marker stream).
977        let src_after = env.fs.read_to_string(&src_path).unwrap();
978        assert_eq!(src_after, "name = {{ name }}");
979    }
980
981    #[test]
982    fn missing_source_is_reported_with_finding() {
983        // Stage a baseline with a source path that doesn't exist.
984        // (Easier than going through `dodot up` and then deleting
985        // the file.)
986        let env = TempEnvironment::builder().build();
987        // Build a minimal baseline by hand at the cache path.
988        let baseline = crate::preprocessing::baseline::Baseline::build(
989            &env.dotfiles_root.join("app/missing.toml.tmpl"),
990            b"rendered",
991            b"src",
992            Some(""),
993            None,
994        );
995        baseline
996            .write(
997                env.fs.as_ref(),
998                env.paths.as_ref(),
999                "app",
1000                "preprocessed",
1001                "missing.toml",
1002            )
1003            .unwrap();
1004        // Also lay down a deployed file so we don't conflate
1005        // MissingSource with MissingDeployed.
1006        let deployed = deployed_path(&env, "app", "missing.toml");
1007        env.fs.mkdir_all(deployed.parent().unwrap()).unwrap();
1008        env.fs.write_file(&deployed, b"rendered").unwrap();
1009
1010        let ctx = make_ctx(&env);
1011        let result = check(&ctx, false).unwrap();
1012        assert!(matches!(
1013            result.entries[0].action,
1014            TransformAction::MissingSource
1015        ));
1016        assert!(result.has_findings);
1017    }
1018
1019    #[test]
1020    fn strict_mode_flags_unresolved_marker_in_source() {
1021        // Deploy a template, then write dodot-conflict markers into
1022        // the source file (simulating a previous `transform check`
1023        // run that emitted them). Strict mode catches it.
1024        let env = TempEnvironment::builder().build();
1025        let src_path = deploy_template(
1026            &env,
1027            "app",
1028            "config.toml.tmpl",
1029            "name = {{ name }}\n",
1030            "[preprocessor.template.vars]\nname = \"Alice\"\n",
1031        );
1032        let dirty = format!(
1033            "first\n{}\nbody\n{}\n",
1034            crate::preprocessing::conflict::MARKER_START,
1035            crate::preprocessing::conflict::MARKER_END,
1036        );
1037        env.fs.write_file(&src_path, dirty.as_bytes()).unwrap();
1038
1039        let ctx = make_ctx(&env);
1040        // Non-strict: no marker scan, so no findings (the source
1041        // change makes it InputChanged, which is fine).
1042        let lax = check(&ctx, false).unwrap();
1043        assert!(lax.unresolved_markers.is_empty());
1044
1045        // Strict: scan picks up the markers, has_findings=true.
1046        let strict = check(&ctx, true).unwrap();
1047        assert_eq!(strict.unresolved_markers.len(), 1);
1048        assert_eq!(strict.unresolved_markers[0].line_numbers, vec![2, 4]);
1049        assert!(strict.has_findings);
1050        assert_eq!(strict.exit_code(), 1);
1051    }
1052
1053    #[test]
1054    fn strict_mode_clean_repo_is_zero_findings() {
1055        // No source has markers → strict mode reports zero unresolved
1056        // markers and (assuming no divergence either) no findings.
1057        let env = TempEnvironment::builder().build();
1058        deploy_template(
1059            &env,
1060            "app",
1061            "config.toml.tmpl",
1062            "name = {{ name }}\n",
1063            "[preprocessor.template.vars]\nname = \"Alice\"\n",
1064        );
1065        let ctx = make_ctx(&env);
1066        let result = check(&ctx, true).unwrap();
1067        assert!(result.unresolved_markers.is_empty());
1068        assert!(!result.has_findings);
1069        assert_eq!(result.exit_code(), 0);
1070    }
1071
1072    #[test]
1073    fn paths_are_rendered_relative_to_home_for_display() {
1074        // Deployed paths under `data_dir` (which lives under the
1075        // sandbox $HOME) should render with `~/` prefix in the
1076        // report. Pure cosmetic — `dodot transform check`'s output
1077        // is meant to be readable in a terminal.
1078        let env = TempEnvironment::builder().build();
1079        deploy_template(
1080            &env,
1081            "app",
1082            "config.toml.tmpl",
1083            "name = {{ name }}\n",
1084            "[preprocessor.template.vars]\nname = \"Alice\"\n",
1085        );
1086        let ctx = make_ctx(&env);
1087        let result = check(&ctx, false).unwrap();
1088        // At least one of source/deployed should start with `~/`.
1089        let entry = &result.entries[0];
1090        assert!(
1091            entry.source_path.starts_with("~/") || entry.deployed_path.starts_with("~/"),
1092            "expected ~/-relative paths in report, got source={} deployed={}",
1093            entry.source_path,
1094            entry.deployed_path
1095        );
1096    }
1097
1098    // ── status ──────────────────────────────────────────────────
1099
1100    #[test]
1101    fn status_on_clean_repo_reports_one_synced_row() {
1102        let env = TempEnvironment::builder().build();
1103        deploy_template(
1104            &env,
1105            "app",
1106            "config.toml.tmpl",
1107            "name = {{ name }}\n",
1108            "[preprocessor.template.vars]\nname = \"Alice\"\n",
1109        );
1110        let ctx = make_ctx(&env);
1111        let result = status(&ctx).unwrap();
1112        assert_eq!(result.entries.len(), 1);
1113        assert_eq!(result.entries[0].state, "synced");
1114        assert_eq!(result.synced_count, 1);
1115        assert_eq!(result.diverged_count, 0);
1116        assert_eq!(result.missing_count, 0);
1117    }
1118
1119    #[test]
1120    fn status_classifies_output_change() {
1121        let env = TempEnvironment::builder().build();
1122        deploy_template(
1123            &env,
1124            "app",
1125            "config.toml.tmpl",
1126            "name = {{ name }}\nport = 5432\n",
1127            "[preprocessor.template.vars]\nname = \"Alice\"\n",
1128        );
1129        let deployed = deployed_path(&env, "app", "config.toml");
1130        env.fs
1131            .write_file(&deployed, b"name = Alice\nport = 9999\n")
1132            .unwrap();
1133
1134        let ctx = make_ctx(&env);
1135        let result = status(&ctx).unwrap();
1136        assert_eq!(result.entries[0].state, "output_changed");
1137        assert_eq!(result.diverged_count, 1);
1138        assert_eq!(result.synced_count, 0);
1139    }
1140
1141    #[test]
1142    fn status_does_not_mutate_anything() {
1143        // The entire point of `status` (vs `check`) is that it's
1144        // read-only. Run it on a divergent repo and confirm the
1145        // source file is byte-identical afterwards.
1146        let env = TempEnvironment::builder().build();
1147        let src = deploy_template(
1148            &env,
1149            "app",
1150            "config.toml.tmpl",
1151            "name = {{ name }}\nport = 5432\n",
1152            "[preprocessor.template.vars]\nname = \"Alice\"\n",
1153        );
1154        let original_src = env.fs.read_to_string(&src).unwrap();
1155        let deployed = deployed_path(&env, "app", "config.toml");
1156        env.fs
1157            .write_file(&deployed, b"name = Alice\nport = 9999\n")
1158            .unwrap();
1159
1160        let ctx = make_ctx(&env);
1161        let _ = status(&ctx).unwrap();
1162        assert_eq!(env.fs.read_to_string(&src).unwrap(), original_src);
1163    }
1164
1165    #[test]
1166    fn status_empty_cache_yields_zero_counts() {
1167        let env = TempEnvironment::builder().build();
1168        let ctx = make_ctx(&env);
1169        let result = status(&ctx).unwrap();
1170        assert!(result.entries.is_empty());
1171        assert_eq!(result.synced_count, 0);
1172        assert_eq!(result.diverged_count, 0);
1173        assert_eq!(result.missing_count, 0);
1174    }
1175
1176    // ── install_hook ────────────────────────────────────────────
1177
1178    /// Stand up a fake `.git` directory inside the dotfiles_root so
1179    /// `install_hook` recognises the dotfiles repo as a git working
1180    /// tree. We don't `git init` for real because every test would
1181    /// pay the subprocess cost; the installer only checks for
1182    /// `.git` as a dir, so a bare `mkdir` suffices.
1183    fn fake_git_dir(env: &TempEnvironment) {
1184        env.fs
1185            .mkdir_all(&env.dotfiles_root.join(".git/hooks"))
1186            .unwrap();
1187    }
1188
1189    #[test]
1190    fn install_hook_creates_new_pre_commit_when_absent() {
1191        let env = TempEnvironment::builder().build();
1192        fake_git_dir(&env);
1193        // Make sure the hooks dir exists but the hook file does not.
1194        let hook_path = env.dotfiles_root.join(".git/hooks/pre-commit");
1195        assert!(!env.fs.exists(&hook_path));
1196
1197        let ctx = make_ctx(&env);
1198        let result = install_hook(&ctx).unwrap();
1199        assert!(matches!(result.outcome, InstallHookOutcome::Created));
1200        assert!(env.fs.exists(&hook_path));
1201
1202        let body = env.fs.read_to_string(&hook_path).unwrap();
1203        assert!(body.starts_with("#!/bin/sh\n"), "body: {body:?}");
1204        assert!(body.contains(HOOK_GUARD_START), "body: {body:?}");
1205        assert!(body.contains(HOOK_COMMAND_REFRESH), "body: {body:?}");
1206        assert!(body.contains(HOOK_COMMAND_CHECK), "body: {body:?}");
1207        assert!(body.contains(HOOK_GUARD_END), "body: {body:?}");
1208    }
1209
1210    #[test]
1211    fn install_hook_appends_to_existing_pre_commit() {
1212        // The user already has a hook (e.g. installed by another tool
1213        // or a personal script). install_hook must preserve that
1214        // content and append our block, not clobber it.
1215        let env = TempEnvironment::builder().build();
1216        fake_git_dir(&env);
1217        let hook_path = env.dotfiles_root.join(".git/hooks/pre-commit");
1218        let existing = "#!/bin/sh\necho 'my pre-commit'\nexit 0\n";
1219        env.fs.write_file(&hook_path, existing.as_bytes()).unwrap();
1220
1221        let ctx = make_ctx(&env);
1222        let result = install_hook(&ctx).unwrap();
1223        assert!(matches!(result.outcome, InstallHookOutcome::Appended));
1224
1225        let body = env.fs.read_to_string(&hook_path).unwrap();
1226        assert!(body.starts_with(existing), "user content lost: {body:?}");
1227        assert!(body.contains(HOOK_GUARD_START));
1228        assert!(body.contains(HOOK_COMMAND_REFRESH));
1229        assert!(body.contains(HOOK_COMMAND_CHECK));
1230    }
1231
1232    #[test]
1233    fn install_hook_is_idempotent_on_second_call() {
1234        // Running `dodot transform install-hook` twice in a row must
1235        // not double-append the block. The guard line is what makes
1236        // this safe.
1237        let env = TempEnvironment::builder().build();
1238        fake_git_dir(&env);
1239        let ctx = make_ctx(&env);
1240
1241        let r1 = install_hook(&ctx).unwrap();
1242        assert!(matches!(r1.outcome, InstallHookOutcome::Created));
1243
1244        let body_after_first = env
1245            .fs
1246            .read_to_string(&env.dotfiles_root.join(".git/hooks/pre-commit"))
1247            .unwrap();
1248
1249        let r2 = install_hook(&ctx).unwrap();
1250        assert!(matches!(r2.outcome, InstallHookOutcome::AlreadyInstalled));
1251
1252        let body_after_second = env
1253            .fs
1254            .read_to_string(&env.dotfiles_root.join(".git/hooks/pre-commit"))
1255            .unwrap();
1256        assert_eq!(
1257            body_after_first, body_after_second,
1258            "body changed on second call"
1259        );
1260        // Exactly one occurrence of the guard line.
1261        assert_eq!(body_after_second.matches(HOOK_GUARD_START).count(), 1);
1262    }
1263
1264    #[test]
1265    fn install_hook_errors_if_no_git_dir() {
1266        // If the dotfiles root isn't a git working tree, refuse
1267        // with a clear error rather than silently writing a hook
1268        // that nothing will ever invoke.
1269        let env = TempEnvironment::builder().build();
1270        let ctx = make_ctx(&env);
1271        let err = install_hook(&ctx).unwrap_err();
1272        let msg = format!("{err}");
1273        assert!(msg.contains("no .git directory"), "msg: {msg}");
1274        assert!(msg.contains("git init"), "msg: {msg}");
1275    }
1276
1277    #[test]
1278    fn hook_is_installed_reports_correctly() {
1279        let env = TempEnvironment::builder().build();
1280        fake_git_dir(&env);
1281        let ctx = make_ctx(&env);
1282
1283        // No hook yet → not installed.
1284        assert!(!hook_is_installed(&ctx).unwrap());
1285
1286        // Install it → reported as installed.
1287        install_hook(&ctx).unwrap();
1288        assert!(hook_is_installed(&ctx).unwrap());
1289
1290        // A user-written hook without our guard → not installed
1291        // (from our perspective).
1292        let hook_path = env.dotfiles_root.join(".git/hooks/pre-commit");
1293        env.fs
1294            .write_file(&hook_path, b"#!/bin/sh\necho hello\n")
1295            .unwrap();
1296        assert!(!hook_is_installed(&ctx).unwrap());
1297    }
1298
1299    #[test]
1300    fn install_hook_sets_executable_bit() {
1301        // The hook needs +x to be invoked by git. Confirm we set
1302        // the bit on both the create and the append paths.
1303        use std::os::unix::fs::PermissionsExt;
1304
1305        let env = TempEnvironment::builder().build();
1306        fake_git_dir(&env);
1307        let ctx = make_ctx(&env);
1308        install_hook(&ctx).unwrap();
1309
1310        let hook_path = env.dotfiles_root.join(".git/hooks/pre-commit");
1311        let mode = std::fs::metadata(&hook_path).unwrap().permissions().mode();
1312        // owner-execute bit must be set; we test for any execute
1313        // rather than exact 0o755 because the OS may apply umask.
1314        assert!(
1315            mode & 0o100 != 0,
1316            "hook is not executable, mode = {:o}",
1317            mode
1318        );
1319    }
1320
1321    #[test]
1322    fn managed_block_is_self_contained_and_grep_detectable() {
1323        // The block alone should be enough to detect the install:
1324        // its first line is exactly HOOK_GUARD_START. This pins the
1325        // contract that downstream tools (or future `transform
1326        // uninstall-hook`) can grep for the guard line.
1327        let block = managed_block();
1328        assert!(block.starts_with(HOOK_GUARD_START));
1329        assert!(block.trim_end().ends_with(HOOK_GUARD_END));
1330        // Both shell lines (refresh + check) must appear in the
1331        // block — the hook runs them as two independent steps.
1332        assert!(block.contains(HOOK_COMMAND_REFRESH));
1333        assert!(block.contains(HOOK_COMMAND_CHECK));
1334    }
1335
1336    // ── hook upgrade (managed-block detection + replacement) ────
1337
1338    #[test]
1339    fn install_hook_replaces_a_stale_managed_block() {
1340        // An older R4-shape block (single check command, no refresh
1341        // line) must be detected and rewritten to the new two-line
1342        // form when `install-hook` runs again. Existing non-managed
1343        // content is preserved.
1344        let env = TempEnvironment::builder().build();
1345        fake_git_dir(&env);
1346
1347        // Stage an old-style block manually. This is what an R4-era
1348        // install-hook would have produced: the same guards, but the
1349        // single old `dodot transform check --strict || exit 1`
1350        // command line and the older comment.
1351        let stale = format!(
1352            "#!/bin/sh\n\
1353             echo 'user-installed pre-commit step'\n\
1354             \n\
1355             {start}\n\
1356             # Old-style block from R4. Still works, but doesn't run\n\
1357             # `dodot refresh` first, so deployed-side edits between\n\
1358             # commits aren't always picked up.\n\
1359             dodot transform check --strict || exit 1\n\
1360             {end}\n\
1361             # User content after the block.\n\
1362             echo 'trailing user step'\n",
1363            start = HOOK_GUARD_START,
1364            end = HOOK_GUARD_END,
1365        );
1366        let hook_path = env.dotfiles_root.join(".git/hooks/pre-commit");
1367        env.fs.write_file(&hook_path, stale.as_bytes()).unwrap();
1368
1369        let ctx = make_ctx(&env);
1370        let result = install_hook(&ctx).unwrap();
1371        assert!(matches!(result.outcome, InstallHookOutcome::Updated));
1372
1373        let body = env.fs.read_to_string(&hook_path).unwrap();
1374        // New shape: both refresh + check lines, comment matches the
1375        // current block.
1376        assert!(body.contains(HOOK_COMMAND_REFRESH), "body: {body:?}");
1377        assert!(body.contains(HOOK_COMMAND_CHECK), "body: {body:?}");
1378        // User content (before AND after the managed block) survived.
1379        assert!(body.contains("user-installed pre-commit step"));
1380        assert!(body.contains("trailing user step"));
1381        // Exactly one managed block — no duplicates.
1382        assert_eq!(body.matches(HOOK_GUARD_START).count(), 1);
1383        assert_eq!(body.matches(HOOK_GUARD_END).count(), 1);
1384    }
1385
1386    #[test]
1387    fn install_hook_no_op_on_current_block() {
1388        // The exact opposite of the upgrade test: if the existing
1389        // block is already the current shape, install_hook returns
1390        // AlreadyInstalled and leaves the file byte-identical.
1391        let env = TempEnvironment::builder().build();
1392        fake_git_dir(&env);
1393        let ctx = make_ctx(&env);
1394
1395        // Install fresh.
1396        let r1 = install_hook(&ctx).unwrap();
1397        assert!(matches!(r1.outcome, InstallHookOutcome::Created));
1398        let body_after_first = env
1399            .fs
1400            .read_to_string(&env.dotfiles_root.join(".git/hooks/pre-commit"))
1401            .unwrap();
1402
1403        // Re-install — current block is up to date, no change.
1404        let r2 = install_hook(&ctx).unwrap();
1405        assert!(matches!(r2.outcome, InstallHookOutcome::AlreadyInstalled));
1406        let body_after_second = env
1407            .fs
1408            .read_to_string(&env.dotfiles_root.join(".git/hooks/pre-commit"))
1409            .unwrap();
1410        assert_eq!(body_after_first, body_after_second);
1411    }
1412
1413    #[test]
1414    fn find_managed_block_locates_byte_range() {
1415        // White-box test for the byte-range finder so we don't have
1416        // to reverse-engineer it from the splice tests above.
1417        let block = managed_block();
1418        let prefix = "before\n";
1419        let suffix = "after\n";
1420        let text = format!("{prefix}{block}{suffix}");
1421        let (start, end) = find_managed_block(&text).expect("must find block");
1422        assert_eq!(&text[start..end], block);
1423    }
1424
1425    #[test]
1426    fn find_managed_block_returns_none_when_absent() {
1427        assert!(find_managed_block("nothing here").is_none());
1428        // Half-block (start without end) → also None: we treat
1429        // partial blocks as "not installed" so install_hook will
1430        // append rather than try to splice.
1431        let only_start = format!("{HOOK_GUARD_START}\nrandom content\n");
1432        assert!(find_managed_block(&only_start).is_none());
1433    }
1434}