1use 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#[derive(Debug, Clone, Serialize)]
47#[serde(rename_all = "snake_case")]
48pub enum TransformAction {
49 Synced,
51 InputChanged,
53 Patched,
56 Conflict,
59 NeedsRebaseline,
63 MissingSource,
65 MissingDeployed,
67}
68
69#[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 #[serde(default, skip_serializing_if = "String::is_empty")]
81 pub conflict_block: String,
82}
83
84#[derive(Debug, Clone, Serialize)]
87pub struct UnresolvedMarkerEntry {
88 pub source_path: String,
89 pub line_numbers: Vec<usize>,
90}
91
92#[derive(Debug, Clone, Serialize)]
94pub struct TransformCheckResult {
95 pub entries: Vec<TransformCheckEntry>,
96 pub unresolved_markers: Vec<UnresolvedMarkerEntry>,
99 pub has_findings: bool,
114 pub strict: bool,
115}
116
117impl TransformCheckResult {
118 pub fn exit_code(&self) -> i32 {
121 if self.has_findings {
122 1
123 } else {
124 0
125 }
126 }
127}
128
129#[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 #[serde(rename = "state")]
145 pub state: String,
146}
147
148#[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
158pub 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
218pub 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 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 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 TransformAction::Synced
268 }
269 DivergenceState::OutputChanged | DivergenceState::BothChanged => {
270 if baseline.tracked_render.is_empty() {
281 has_findings = true;
282 TransformAction::NeedsRebaseline
283 } else {
284 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 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 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
399fn 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
412pub(crate) const HOOK_GUARD_START: &str =
417 "# >>> dodot transform check --strict (managed by `dodot transform install-hook`) >>>";
418
419pub(crate) const HOOK_GUARD_END: &str = "# <<< dodot transform check --strict <<<";
422
423#[derive(Debug, Clone, Serialize)]
425#[serde(rename_all = "snake_case")]
426pub enum InstallHookOutcome {
427 Created,
429 Appended,
432 AlreadyInstalled,
435 Updated,
440}
441
442#[derive(Debug, Clone, Serialize)]
446pub struct InstallHookResult {
447 pub outcome: InstallHookOutcome,
448 pub hook_path: String,
450 pub hook_display_path: String,
452 pub command_line: String,
455}
456
457pub 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 let current_block = &existing[start_byte..end_byte];
498 if current_block == block {
499 InstallHookOutcome::AlreadyInstalled
500 } else {
501 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 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
543pub 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
556pub 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
594pub(crate) const HOOK_COMMAND_REFRESH: &str = "dodot refresh --quiet || exit 1";
598
599pub(crate) const HOOK_COMMAND_CHECK: &str = "dodot transform check --strict || exit 1";
602
603pub(crate) const HOOK_COMMAND: &str = "dodot refresh --quiet && dodot transform check --strict";
607
608fn find_managed_block(text: &str) -> Option<(usize, usize)> {
618 let start = text.find(HOOK_GUARD_START)?;
619 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 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 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 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 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 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 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 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 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 assert!(!result.has_findings);
789 assert_eq!(result.exit_code(), 0);
790
791 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 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 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 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 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 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 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 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 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 let env = TempEnvironment::builder().build();
934 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, 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 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 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 let env = TempEnvironment::builder().build();
987 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 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 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 let lax = check(&ctx, false).unwrap();
1043 assert!(lax.unresolved_markers.is_empty());
1044
1045 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 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 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 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 #[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 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 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 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 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 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 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 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 assert!(!hook_is_installed(&ctx).unwrap());
1285
1286 install_hook(&ctx).unwrap();
1288 assert!(hook_is_installed(&ctx).unwrap());
1289
1290 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 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 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 let block = managed_block();
1328 assert!(block.starts_with(HOOK_GUARD_START));
1329 assert!(block.trim_end().ends_with(HOOK_GUARD_END));
1330 assert!(block.contains(HOOK_COMMAND_REFRESH));
1333 assert!(block.contains(HOOK_COMMAND_CHECK));
1334 }
1335
1336 #[test]
1339 fn install_hook_replaces_a_stale_managed_block() {
1340 let env = TempEnvironment::builder().build();
1345 fake_git_dir(&env);
1346
1347 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 assert!(body.contains(HOOK_COMMAND_REFRESH), "body: {body:?}");
1377 assert!(body.contains(HOOK_COMMAND_CHECK), "body: {body:?}");
1378 assert!(body.contains("user-installed pre-commit step"));
1380 assert!(body.contains("trailing user step"));
1381 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 let env = TempEnvironment::builder().build();
1392 fake_git_dir(&env);
1393 let ctx = make_ctx(&env);
1394
1395 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 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 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 let only_start = format!("{HOOK_GUARD_START}\nrandom content\n");
1432 assert!(find_managed_block(&only_start).is_none());
1433 }
1434}