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 #[serde(default, skip_serializing_if = "Vec::is_empty")]
156 pub secret_references: Vec<String>,
157}
158
159#[derive(Debug, Clone, Serialize)]
162pub struct TransformStatusResult {
163 pub entries: Vec<TransformStatusEntry>,
164 pub synced_count: usize,
165 pub diverged_count: usize,
166 pub missing_count: usize,
167}
168
169pub fn status(ctx: &ExecutionContext) -> Result<TransformStatusResult> {
177 use crate::preprocessing::divergence::{collect_divergences, DivergenceState};
178 let reports = collect_divergences(ctx.fs.as_ref(), ctx.paths.as_ref())?;
179 let mut synced_count = 0usize;
180 let mut diverged_count = 0usize;
181 let mut missing_count = 0usize;
182 let entries: Vec<TransformStatusEntry> = reports
183 .into_iter()
184 .map(|r| {
185 let state_str = match r.state {
186 DivergenceState::Synced => {
187 synced_count += 1;
188 "synced"
189 }
190 DivergenceState::InputChanged => {
191 diverged_count += 1;
192 "input_changed"
193 }
194 DivergenceState::OutputChanged => {
195 diverged_count += 1;
196 "output_changed"
197 }
198 DivergenceState::BothChanged => {
199 diverged_count += 1;
200 "both_changed"
201 }
202 DivergenceState::MissingSource => {
203 missing_count += 1;
204 "missing_source"
205 }
206 DivergenceState::MissingDeployed => {
207 missing_count += 1;
208 "missing_deployed"
209 }
210 };
211 let secret_references = crate::preprocessing::baseline::SecretsSidecar::load(
217 ctx.fs.as_ref(),
218 ctx.paths.as_ref(),
219 &r.pack,
220 &r.handler,
221 &r.filename,
222 )
223 .ok()
224 .flatten()
225 .map(|s| {
226 s.secret_line_ranges
227 .into_iter()
228 .map(|range| range.reference)
229 .collect::<Vec<_>>()
230 })
231 .unwrap_or_default();
232 TransformStatusEntry {
233 pack: r.pack,
234 handler: r.handler,
235 filename: r.filename,
236 source_path: render_path(&r.source_path, ctx.paths.home_dir()),
237 deployed_path: render_path(&r.deployed_path, ctx.paths.home_dir()),
238 state: state_str.to_string(),
239 secret_references,
240 }
241 })
242 .collect();
243 Ok(TransformStatusResult {
244 entries,
245 synced_count,
246 diverged_count,
247 missing_count,
248 })
249}
250
251pub fn check(ctx: &ExecutionContext, strict: bool) -> Result<TransformCheckResult> {
253 let baselines = collect_baselines(ctx.fs.as_ref(), ctx.paths.as_ref())?;
254 let mut entries: Vec<TransformCheckEntry> = Vec::with_capacity(baselines.len());
255 let mut has_findings = false;
256 let mut no_reverse_cache: std::collections::HashMap<String, Vec<String>> =
262 std::collections::HashMap::new();
263
264 for (pack, handler, filename, baseline) in baselines {
265 let report = classify_one(
266 ctx.fs.as_ref(),
267 ctx.paths.as_ref(),
268 &pack,
269 &handler,
270 &filename,
271 &baseline,
272 );
273 let no_reverse_patterns = no_reverse_cache
281 .entry(pack.clone())
282 .or_insert_with(|| pack_no_reverse_patterns(ctx, &pack));
283 let no_reverse = is_no_reverse(&report.source_path, no_reverse_patterns);
284 let action = match report.state {
285 DivergenceState::Synced => TransformAction::Synced,
286 DivergenceState::InputChanged => TransformAction::InputChanged,
287 DivergenceState::MissingSource => {
288 has_findings = true;
289 TransformAction::MissingSource
290 }
291 DivergenceState::MissingDeployed => {
292 has_findings = true;
293 TransformAction::MissingDeployed
294 }
295 DivergenceState::OutputChanged | DivergenceState::BothChanged if no_reverse => {
296 TransformAction::Synced
301 }
302 DivergenceState::OutputChanged | DivergenceState::BothChanged => {
303 if baseline.tracked_render.is_empty() {
314 has_findings = true;
315 TransformAction::NeedsRebaseline
316 } else {
317 let template_src = ctx.fs.read_to_string(&report.source_path)?;
321 let deployed = ctx.fs.read_to_string(&report.deployed_path)?;
322 let secret_ranges = crate::preprocessing::baseline::SecretsSidecar::load(
329 ctx.fs.as_ref(),
330 ctx.paths.as_ref(),
331 &pack,
332 &handler,
333 &filename,
334 )?
335 .map(|s| s.secret_line_ranges)
336 .unwrap_or_default();
337 match reverse_merge(
338 &template_src,
339 &baseline.tracked_render,
340 &deployed,
341 &secret_ranges,
342 )? {
343 ReverseMergeOutcome::Unchanged => TransformAction::Synced,
344 ReverseMergeOutcome::Patched(patched) => {
345 if !ctx.dry_run {
346 ctx.fs.write_file(&report.source_path, patched.as_bytes())?;
347 }
348 TransformAction::Patched
358 }
359 ReverseMergeOutcome::Conflict(block) => {
360 has_findings = true;
361 return_conflict_entry(
362 &mut entries,
363 report,
364 block,
365 ctx.paths.home_dir(),
366 );
367 continue;
368 }
369 }
370 }
371 }
372 };
373
374 entries.push(make_entry(report, action, ctx.paths.home_dir()));
375 }
376
377 let mut unresolved_markers = Vec::new();
378 if strict {
379 let baselines = collect_baselines(ctx.fs.as_ref(), ctx.paths.as_ref())?;
385 for (_pack, _handler, _filename, baseline) in baselines {
386 if baseline.source_path.as_os_str().is_empty() || !ctx.fs.exists(&baseline.source_path)
387 {
388 continue;
389 }
390 let bytes = ctx.fs.read_file(&baseline.source_path)?;
391 let content = String::from_utf8_lossy(&bytes);
392 let lines = find_unresolved_marker_lines(&content);
393 if !lines.is_empty() {
394 has_findings = true;
395 unresolved_markers.push(UnresolvedMarkerEntry {
396 source_path: render_path(&baseline.source_path, ctx.paths.home_dir()),
397 line_numbers: lines.iter().map(|(n, _)| *n).collect(),
398 });
399 }
400 }
401 }
402
403 Ok(TransformCheckResult {
404 entries,
405 unresolved_markers,
406 has_findings,
407 strict,
408 })
409}
410
411fn make_entry(
412 report: DivergenceReport,
413 action: TransformAction,
414 home: &std::path::Path,
415) -> TransformCheckEntry {
416 TransformCheckEntry {
417 pack: report.pack,
418 handler: report.handler,
419 filename: report.filename,
420 source_path: render_path(&report.source_path, home),
421 deployed_path: render_path(&report.deployed_path, home),
422 action,
423 conflict_block: String::new(),
424 }
425}
426
427fn return_conflict_entry(
428 entries: &mut Vec<TransformCheckEntry>,
429 report: DivergenceReport,
430 block: String,
431 home: &std::path::Path,
432) {
433 entries.push(TransformCheckEntry {
434 pack: report.pack,
435 handler: report.handler,
436 filename: report.filename,
437 source_path: render_path(&report.source_path, home),
438 deployed_path: render_path(&report.deployed_path, home),
439 action: TransformAction::Conflict,
440 conflict_block: block,
441 });
442}
443
444fn render_path(p: &std::path::Path, home: &std::path::Path) -> String {
445 if let Ok(rel) = p.strip_prefix(home) {
446 format!("~/{}", rel.display())
447 } else {
448 p.display().to_string()
449 }
450}
451
452fn pack_no_reverse_patterns(ctx: &ExecutionContext, pack: &str) -> Vec<String> {
458 let pack_path = ctx.paths.dotfiles_root().join(pack);
459 match ctx.config_manager.config_for_pack(&pack_path) {
460 Ok(cfg) => cfg.preprocessor.template.no_reverse.clone(),
461 Err(_) => Vec::new(),
462 }
463}
464
465pub(crate) const HOOK_GUARD_START: &str =
470 "# >>> dodot transform check --strict (managed by `dodot transform install-hook`) >>>";
471
472pub(crate) const HOOK_GUARD_END: &str = "# <<< dodot transform check --strict <<<";
475
476#[derive(Debug, Clone, Serialize)]
478#[serde(rename_all = "snake_case")]
479pub enum InstallHookOutcome {
480 Created,
482 Appended,
485 AlreadyInstalled,
488 Updated,
493}
494
495#[derive(Debug, Clone, Serialize)]
499pub struct InstallHookResult {
500 pub outcome: InstallHookOutcome,
501 pub hook_path: String,
503 pub hook_display_path: String,
505 pub command_line: String,
508}
509
510pub fn install_hook(ctx: &ExecutionContext) -> Result<InstallHookResult> {
528 let dotfiles_root = ctx.paths.dotfiles_root();
529 let git_dir = dotfiles_root.join(".git");
530 if !ctx.fs.is_dir(&git_dir) {
531 return Err(crate::DodotError::Other(format!(
532 "no .git directory at {}; pre-commit hooks only apply to git working \
533 trees. Run `git init` in {} first.",
534 git_dir.display(),
535 dotfiles_root.display(),
536 )));
537 }
538
539 let hooks_dir = git_dir.join("hooks");
540 let hook_path = hooks_dir.join("pre-commit");
541
542 let block = managed_block();
543
544 let outcome = if ctx.fs.exists(&hook_path) {
545 let existing = ctx.fs.read_to_string(&hook_path)?;
546 if let Some((start_byte, end_byte)) = find_managed_block(&existing) {
547 let current_block = &existing[start_byte..end_byte];
551 if current_block == block {
552 InstallHookOutcome::AlreadyInstalled
553 } else {
554 let mut new_content = String::with_capacity(existing.len() + block.len());
557 new_content.push_str(&existing[..start_byte]);
558 new_content.push_str(&block);
559 new_content.push_str(&existing[end_byte..]);
560 ctx.fs.write_file(&hook_path, new_content.as_bytes())?;
561 ctx.fs.set_permissions(&hook_path, 0o755)?;
562 InstallHookOutcome::Updated
563 }
564 } else {
565 let mut new_content = existing.clone();
568 if !new_content.ends_with('\n') {
569 new_content.push('\n');
570 }
571 if !new_content.ends_with("\n\n") {
572 new_content.push('\n');
573 }
574 new_content.push_str(&block);
575 ctx.fs.write_file(&hook_path, new_content.as_bytes())?;
576 ctx.fs.set_permissions(&hook_path, 0o755)?;
577 InstallHookOutcome::Appended
578 }
579 } else {
580 ctx.fs.mkdir_all(&hooks_dir)?;
581 let mut new_content = String::from("#!/bin/sh\n\n");
582 new_content.push_str(&block);
583 ctx.fs.write_file(&hook_path, new_content.as_bytes())?;
584 ctx.fs.set_permissions(&hook_path, 0o755)?;
585 InstallHookOutcome::Created
586 };
587
588 Ok(InstallHookResult {
589 outcome,
590 hook_path: hook_path.display().to_string(),
591 hook_display_path: render_path(&hook_path, ctx.paths.home_dir()),
592 command_line: HOOK_COMMAND.to_string(),
593 })
594}
595
596pub fn hook_is_installed(ctx: &ExecutionContext) -> Result<bool> {
601 let hook_path = ctx.paths.dotfiles_root().join(".git/hooks/pre-commit");
602 if !ctx.fs.exists(&hook_path) {
603 return Ok(false);
604 }
605 let existing = ctx.fs.read_to_string(&hook_path)?;
606 Ok(existing.contains(HOOK_GUARD_START))
607}
608
609pub fn managed_block() -> String {
632 format!(
633 "{guard_start}\n\
634 # Aborts the commit if any template-source has drift that needs review —\n\
635 # divergent deployed file or unresolved dodot-conflict markers. Remove\n\
636 # this block to opt out.\n\
637 {refresh}\n\
638 {check}\n\
639 {guard_end}\n",
640 guard_start = HOOK_GUARD_START,
641 guard_end = HOOK_GUARD_END,
642 refresh = HOOK_COMMAND_REFRESH,
643 check = HOOK_COMMAND_CHECK,
644 )
645}
646
647pub(crate) const HOOK_COMMAND_REFRESH: &str = "dodot refresh --quiet || exit 1";
651
652pub(crate) const HOOK_COMMAND_CHECK: &str = "dodot transform check --strict || exit 1";
655
656pub(crate) const HOOK_COMMAND: &str = "dodot refresh --quiet && dodot transform check --strict";
660
661fn find_managed_block(text: &str) -> Option<(usize, usize)> {
671 let start = text.find(HOOK_GUARD_START)?;
672 let after_start = start + HOOK_GUARD_START.len();
674 let end_rel = text[after_start..].find(HOOK_GUARD_END)?;
675 let end_guard_start = after_start + end_rel;
676 let end_byte = end_guard_start + HOOK_GUARD_END.len();
677 let end_byte = if text.as_bytes().get(end_byte) == Some(&b'\n') {
680 end_byte + 1
681 } else {
682 end_byte
683 };
684 Some((start, end_byte))
685}
686
687#[cfg(test)]
688mod tests {
689 use super::*;
690 use crate::fs::Fs;
691 use crate::paths::Pather;
692 use crate::testing::TempEnvironment;
693
694 fn make_ctx(env: &TempEnvironment) -> ExecutionContext {
695 use crate::config::ConfigManager;
696 use crate::datastore::{CommandOutput, CommandRunner, FilesystemDataStore};
697 use crate::fs::Fs;
698 use crate::paths::Pather;
699 use std::sync::Arc;
700
701 struct NoopRunner;
702 impl CommandRunner for NoopRunner {
703 fn run(&self, _e: &str, _a: &[String]) -> Result<CommandOutput> {
704 Ok(CommandOutput {
705 exit_code: 0,
706 stdout: String::new(),
707 stderr: String::new(),
708 })
709 }
710 }
711 let runner: Arc<dyn CommandRunner> = Arc::new(NoopRunner);
712 let datastore = Arc::new(FilesystemDataStore::new(
713 env.fs.clone(),
714 env.paths.clone(),
715 runner.clone(),
716 ));
717 let config_manager = Arc::new(ConfigManager::new(&env.dotfiles_root).unwrap());
718 ExecutionContext {
719 fs: env.fs.clone() as Arc<dyn Fs>,
720 datastore,
721 paths: env.paths.clone() as Arc<dyn Pather>,
722 config_manager,
723 syntax_checker: Arc::new(crate::shell::NoopSyntaxChecker),
724 command_runner: runner,
725 dry_run: false,
726 no_provision: true,
727 provision_rerun: false,
728 force: false,
729 view_mode: crate::commands::ViewMode::Full,
730 group_mode: crate::commands::GroupMode::Name,
731 verbose: false,
732 }
733 }
734
735 fn deploy_template(
740 env: &TempEnvironment,
741 pack: &str,
742 template_name: &str,
743 template_body: &str,
744 config_toml: &str,
745 ) -> std::path::PathBuf {
746 let src_path = env.dotfiles_root.join(pack).join(template_name);
748 env.fs.mkdir_all(src_path.parent().unwrap()).unwrap();
749 env.fs
750 .write_file(&src_path, template_body.as_bytes())
751 .unwrap();
752
753 if !config_toml.is_empty() {
755 env.fs
756 .write_file(
757 &env.dotfiles_root.join(".dodot.toml"),
758 config_toml.as_bytes(),
759 )
760 .unwrap();
761 }
762
763 let ctx = make_ctx(env);
765 let _ = crate::commands::up::up(None, &ctx).unwrap();
766
767 src_path
768 }
769
770 fn deployed_path(env: &TempEnvironment, pack: &str, filename: &str) -> std::path::PathBuf {
771 env.paths
772 .data_dir()
773 .join("packs")
774 .join(pack)
775 .join("preprocessed")
776 .join(filename)
777 }
778
779 #[test]
780 fn empty_cache_yields_clean_no_findings() {
781 let env = TempEnvironment::builder().build();
782 let ctx = make_ctx(&env);
783 let result = check(&ctx, false).unwrap();
784 assert!(result.entries.is_empty());
785 assert!(!result.has_findings);
786 assert_eq!(result.exit_code(), 0);
787 }
788
789 #[test]
790 fn synced_files_report_synced_and_no_findings() {
791 let env = TempEnvironment::builder().build();
794 deploy_template(
795 &env,
796 "app",
797 "config.toml.tmpl",
798 "name = {{ name }}\n",
799 "[preprocessor.template.vars]\nname = \"Alice\"\n",
800 );
801 let ctx = make_ctx(&env);
802 let result = check(&ctx, false).unwrap();
803 assert_eq!(result.entries.len(), 1);
804 assert!(matches!(result.entries[0].action, TransformAction::Synced));
805 assert!(!result.has_findings);
806 }
807
808 #[test]
809 fn output_changed_static_edit_patches_source() {
810 let env = TempEnvironment::builder().build();
814 let src_path = deploy_template(
815 &env,
816 "app",
817 "config.toml.tmpl",
818 "name = {{ name }}\nport = 5432\n",
819 "[preprocessor.template.vars]\nname = \"Alice\"\n",
820 );
821 let deployed = deployed_path(&env, "app", "config.toml");
825 env.fs
826 .write_file(&deployed, b"name = Alice\nport = 9999\n")
827 .unwrap();
828
829 let ctx = make_ctx(&env);
830 let result = check(&ctx, false).unwrap();
831 assert_eq!(result.entries.len(), 1);
832 assert!(
833 matches!(result.entries[0].action, TransformAction::Patched),
834 "got: {:?}",
835 result.entries[0].action
836 );
837 assert!(!result.has_findings);
842 assert_eq!(result.exit_code(), 0);
843
844 let new_src = env.fs.read_to_string(&src_path).unwrap();
847 assert!(new_src.contains("port = 9999"), "src: {new_src:?}");
848 assert!(new_src.contains("name = {{ name }}"), "src: {new_src:?}");
849 }
850
851 #[test]
852 fn output_changed_pure_data_edit_yields_synced() {
853 let env = TempEnvironment::builder().build();
858 let src_path = deploy_template(
859 &env,
860 "app",
861 "config.toml.tmpl",
862 "name = {{ name }}\n",
863 "[preprocessor.template.vars]\nname = \"Alice\"\n",
864 );
865 let original_src = env.fs.read_to_string(&src_path).unwrap();
866 let deployed = deployed_path(&env, "app", "config.toml");
867 env.fs.write_file(&deployed, b"name = Bob\n").unwrap();
868
869 let ctx = make_ctx(&env);
870 let result = check(&ctx, false).unwrap();
871 assert_eq!(result.entries.len(), 1);
872 assert!(matches!(result.entries[0].action, TransformAction::Synced));
873 assert_eq!(env.fs.read_to_string(&src_path).unwrap(), original_src);
875 }
876
877 #[test]
878 fn no_reverse_pattern_skips_reverse_merge() {
879 let env = TempEnvironment::builder().build();
886 let src_path = deploy_template(
887 &env,
888 "app",
889 "config.toml.tmpl",
890 "name = {{ name }}\nport = 5432\n",
891 "[preprocessor.template.vars]\n\
892 name = \"Alice\"\n\
893 [preprocessor.template]\n\
894 no_reverse = [\"config.toml.tmpl\"]\n",
895 );
896 let original_src = env.fs.read_to_string(&src_path).unwrap();
897
898 let deployed = deployed_path(&env, "app", "config.toml");
900 env.fs
901 .write_file(&deployed, b"name = Alice\nport = 9999\n")
902 .unwrap();
903
904 let ctx = make_ctx(&env);
905 let result = check(&ctx, false).unwrap();
906 assert_eq!(result.entries.len(), 1);
907 assert!(
908 matches!(result.entries[0].action, TransformAction::Synced),
909 "no_reverse must short-circuit to Synced; got: {:?}",
910 result.entries[0].action
911 );
912 assert!(!result.has_findings);
913 assert_eq!(result.exit_code(), 0);
914 assert_eq!(env.fs.read_to_string(&src_path).unwrap(), original_src);
916 }
917
918 #[test]
919 fn no_reverse_glob_pattern_skips_reverse_merge() {
920 let env = TempEnvironment::builder().build();
923 let src_path = deploy_template(
924 &env,
925 "app",
926 "foo.gen.tmpl",
927 "name = {{ name }}\nport = 5432\n",
928 "[preprocessor.template.vars]\n\
929 name = \"Alice\"\n\
930 [preprocessor.template]\n\
931 no_reverse = [\"*.gen.tmpl\"]\n",
932 );
933 let original_src = env.fs.read_to_string(&src_path).unwrap();
934 let deployed = deployed_path(&env, "app", "foo.gen");
935 env.fs
936 .write_file(&deployed, b"name = Alice\nport = 9999\n")
937 .unwrap();
938
939 let ctx = make_ctx(&env);
940 let result = check(&ctx, false).unwrap();
941 assert_eq!(result.entries.len(), 1);
942 assert!(matches!(result.entries[0].action, TransformAction::Synced));
943 assert!(!result.has_findings);
944 assert_eq!(env.fs.read_to_string(&src_path).unwrap(), original_src);
945 }
946
947 #[test]
948 fn dry_run_does_not_write_to_source() {
949 let env = TempEnvironment::builder().build();
954 let src_path = deploy_template(
955 &env,
956 "app",
957 "config.toml.tmpl",
958 "name = {{ name }}\nport = 5432\n",
959 "[preprocessor.template.vars]\nname = \"Alice\"\n",
960 );
961 let original_src = env.fs.read_to_string(&src_path).unwrap();
962 let deployed = deployed_path(&env, "app", "config.toml");
963 env.fs
964 .write_file(&deployed, b"name = Alice\nport = 9999\n")
965 .unwrap();
966
967 let mut ctx = make_ctx(&env);
968 ctx.dry_run = true;
969 let result = check(&ctx, false).unwrap();
970 assert!(matches!(result.entries[0].action, TransformAction::Patched));
971 assert_eq!(env.fs.read_to_string(&src_path).unwrap(), original_src);
973 }
974
975 #[test]
976 fn needs_rebaseline_when_tracked_render_is_empty_and_deployed_edited() {
977 let env = TempEnvironment::builder().build();
987 let src_path = env.dotfiles_root.join("app/config.toml.tmpl");
989 env.fs.mkdir_all(src_path.parent().unwrap()).unwrap();
990 env.fs.write_file(&src_path, b"name = {{ name }}").unwrap();
991 let baseline = crate::preprocessing::baseline::Baseline::build(
992 &src_path,
993 b"name = Alice",
994 b"name = {{ name }}",
995 None, None,
997 );
998 baseline
999 .write(
1000 env.fs.as_ref(),
1001 env.paths.as_ref(),
1002 "app",
1003 "preprocessed",
1004 "config.toml",
1005 )
1006 .unwrap();
1007 let deployed = deployed_path(&env, "app", "config.toml");
1009 env.fs.mkdir_all(deployed.parent().unwrap()).unwrap();
1010 env.fs
1011 .write_file(&deployed, b"name = Edited\nport = 9999")
1012 .unwrap();
1013
1014 let ctx = make_ctx(&env);
1015 let result = check(&ctx, false).unwrap();
1016 assert_eq!(result.entries.len(), 1);
1017 assert!(
1018 matches!(result.entries[0].action, TransformAction::NeedsRebaseline),
1019 "got: {:?}",
1020 result.entries[0].action
1021 );
1022 assert!(
1023 result.has_findings,
1024 "NeedsRebaseline must count as a finding"
1025 );
1026 assert_eq!(result.exit_code(), 1);
1027
1028 let src_after = env.fs.read_to_string(&src_path).unwrap();
1031 assert_eq!(src_after, "name = {{ name }}");
1032 }
1033
1034 #[test]
1035 fn missing_source_is_reported_with_finding() {
1036 let env = TempEnvironment::builder().build();
1040 let baseline = crate::preprocessing::baseline::Baseline::build(
1042 &env.dotfiles_root.join("app/missing.toml.tmpl"),
1043 b"rendered",
1044 b"src",
1045 Some(""),
1046 None,
1047 );
1048 baseline
1049 .write(
1050 env.fs.as_ref(),
1051 env.paths.as_ref(),
1052 "app",
1053 "preprocessed",
1054 "missing.toml",
1055 )
1056 .unwrap();
1057 let deployed = deployed_path(&env, "app", "missing.toml");
1060 env.fs.mkdir_all(deployed.parent().unwrap()).unwrap();
1061 env.fs.write_file(&deployed, b"rendered").unwrap();
1062
1063 let ctx = make_ctx(&env);
1064 let result = check(&ctx, false).unwrap();
1065 assert!(matches!(
1066 result.entries[0].action,
1067 TransformAction::MissingSource
1068 ));
1069 assert!(result.has_findings);
1070 }
1071
1072 #[test]
1073 fn strict_mode_flags_unresolved_marker_in_source() {
1074 let env = TempEnvironment::builder().build();
1078 let src_path = deploy_template(
1079 &env,
1080 "app",
1081 "config.toml.tmpl",
1082 "name = {{ name }}\n",
1083 "[preprocessor.template.vars]\nname = \"Alice\"\n",
1084 );
1085 let dirty = format!(
1086 "first\n{}\nbody\n{}\n",
1087 crate::preprocessing::conflict::MARKER_START,
1088 crate::preprocessing::conflict::MARKER_END,
1089 );
1090 env.fs.write_file(&src_path, dirty.as_bytes()).unwrap();
1091
1092 let ctx = make_ctx(&env);
1093 let lax = check(&ctx, false).unwrap();
1096 assert!(lax.unresolved_markers.is_empty());
1097
1098 let strict = check(&ctx, true).unwrap();
1100 assert_eq!(strict.unresolved_markers.len(), 1);
1101 assert_eq!(strict.unresolved_markers[0].line_numbers, vec![2, 4]);
1102 assert!(strict.has_findings);
1103 assert_eq!(strict.exit_code(), 1);
1104 }
1105
1106 #[test]
1107 fn strict_mode_clean_repo_is_zero_findings() {
1108 let env = TempEnvironment::builder().build();
1111 deploy_template(
1112 &env,
1113 "app",
1114 "config.toml.tmpl",
1115 "name = {{ name }}\n",
1116 "[preprocessor.template.vars]\nname = \"Alice\"\n",
1117 );
1118 let ctx = make_ctx(&env);
1119 let result = check(&ctx, true).unwrap();
1120 assert!(result.unresolved_markers.is_empty());
1121 assert!(!result.has_findings);
1122 assert_eq!(result.exit_code(), 0);
1123 }
1124
1125 #[test]
1126 fn paths_are_rendered_relative_to_home_for_display() {
1127 let env = TempEnvironment::builder().build();
1132 deploy_template(
1133 &env,
1134 "app",
1135 "config.toml.tmpl",
1136 "name = {{ name }}\n",
1137 "[preprocessor.template.vars]\nname = \"Alice\"\n",
1138 );
1139 let ctx = make_ctx(&env);
1140 let result = check(&ctx, false).unwrap();
1141 let entry = &result.entries[0];
1143 assert!(
1144 entry.source_path.starts_with("~/") || entry.deployed_path.starts_with("~/"),
1145 "expected ~/-relative paths in report, got source={} deployed={}",
1146 entry.source_path,
1147 entry.deployed_path
1148 );
1149 }
1150
1151 #[test]
1154 fn status_on_clean_repo_reports_one_synced_row() {
1155 let env = TempEnvironment::builder().build();
1156 deploy_template(
1157 &env,
1158 "app",
1159 "config.toml.tmpl",
1160 "name = {{ name }}\n",
1161 "[preprocessor.template.vars]\nname = \"Alice\"\n",
1162 );
1163 let ctx = make_ctx(&env);
1164 let result = status(&ctx).unwrap();
1165 assert_eq!(result.entries.len(), 1);
1166 assert_eq!(result.entries[0].state, "synced");
1167 assert_eq!(result.synced_count, 1);
1168 assert_eq!(result.diverged_count, 0);
1169 assert_eq!(result.missing_count, 0);
1170 }
1171
1172 #[test]
1173 fn status_surfaces_secret_references_from_sidecar() {
1174 let env = TempEnvironment::builder().build();
1179 deploy_template(
1180 &env,
1181 "app",
1182 "config.toml.tmpl",
1183 "name = {{ name }}\n",
1184 "[preprocessor.template.vars]\nname = \"Alice\"\n",
1185 );
1186 let sidecar = crate::preprocessing::baseline::SecretsSidecar::new(vec![
1190 crate::preprocessing::SecretLineRange {
1191 start: 0,
1192 end: 1,
1193 reference: "pass:test/db_password".into(),
1194 },
1195 crate::preprocessing::SecretLineRange {
1196 start: 2,
1197 end: 3,
1198 reference: "op://Personal/api/token".into(),
1199 },
1200 ]);
1201 sidecar
1202 .write(
1203 env.fs.as_ref(),
1204 env.paths.as_ref(),
1205 "app",
1206 "preprocessed",
1207 "config.toml",
1208 )
1209 .unwrap();
1210
1211 let ctx = make_ctx(&env);
1212 let result = status(&ctx).unwrap();
1213 assert_eq!(result.entries.len(), 1);
1214 assert_eq!(
1215 result.entries[0].secret_references,
1216 vec![
1217 "pass:test/db_password".to_string(),
1218 "op://Personal/api/token".to_string(),
1219 ]
1220 );
1221 }
1222
1223 #[test]
1224 fn status_returns_empty_secret_references_when_no_sidecar() {
1225 let env = TempEnvironment::builder().build();
1231 deploy_template(
1232 &env,
1233 "app",
1234 "config.toml.tmpl",
1235 "name = {{ name }}\n",
1236 "[preprocessor.template.vars]\nname = \"Alice\"\n",
1237 );
1238 let ctx = make_ctx(&env);
1239 let result = status(&ctx).unwrap();
1240 assert!(result.entries[0].secret_references.is_empty());
1241 }
1242
1243 #[test]
1244 fn status_classifies_output_change() {
1245 let env = TempEnvironment::builder().build();
1246 deploy_template(
1247 &env,
1248 "app",
1249 "config.toml.tmpl",
1250 "name = {{ name }}\nport = 5432\n",
1251 "[preprocessor.template.vars]\nname = \"Alice\"\n",
1252 );
1253 let deployed = deployed_path(&env, "app", "config.toml");
1254 env.fs
1255 .write_file(&deployed, b"name = Alice\nport = 9999\n")
1256 .unwrap();
1257
1258 let ctx = make_ctx(&env);
1259 let result = status(&ctx).unwrap();
1260 assert_eq!(result.entries[0].state, "output_changed");
1261 assert_eq!(result.diverged_count, 1);
1262 assert_eq!(result.synced_count, 0);
1263 }
1264
1265 #[test]
1266 fn status_does_not_mutate_anything() {
1267 let env = TempEnvironment::builder().build();
1271 let src = deploy_template(
1272 &env,
1273 "app",
1274 "config.toml.tmpl",
1275 "name = {{ name }}\nport = 5432\n",
1276 "[preprocessor.template.vars]\nname = \"Alice\"\n",
1277 );
1278 let original_src = env.fs.read_to_string(&src).unwrap();
1279 let deployed = deployed_path(&env, "app", "config.toml");
1280 env.fs
1281 .write_file(&deployed, b"name = Alice\nport = 9999\n")
1282 .unwrap();
1283
1284 let ctx = make_ctx(&env);
1285 let _ = status(&ctx).unwrap();
1286 assert_eq!(env.fs.read_to_string(&src).unwrap(), original_src);
1287 }
1288
1289 #[test]
1290 fn status_empty_cache_yields_zero_counts() {
1291 let env = TempEnvironment::builder().build();
1292 let ctx = make_ctx(&env);
1293 let result = status(&ctx).unwrap();
1294 assert!(result.entries.is_empty());
1295 assert_eq!(result.synced_count, 0);
1296 assert_eq!(result.diverged_count, 0);
1297 assert_eq!(result.missing_count, 0);
1298 }
1299
1300 fn fake_git_dir(env: &TempEnvironment) {
1308 env.fs
1309 .mkdir_all(&env.dotfiles_root.join(".git/hooks"))
1310 .unwrap();
1311 }
1312
1313 #[test]
1314 fn install_hook_creates_new_pre_commit_when_absent() {
1315 let env = TempEnvironment::builder().build();
1316 fake_git_dir(&env);
1317 let hook_path = env.dotfiles_root.join(".git/hooks/pre-commit");
1319 assert!(!env.fs.exists(&hook_path));
1320
1321 let ctx = make_ctx(&env);
1322 let result = install_hook(&ctx).unwrap();
1323 assert!(matches!(result.outcome, InstallHookOutcome::Created));
1324 assert!(env.fs.exists(&hook_path));
1325
1326 let body = env.fs.read_to_string(&hook_path).unwrap();
1327 assert!(body.starts_with("#!/bin/sh\n"), "body: {body:?}");
1328 assert!(body.contains(HOOK_GUARD_START), "body: {body:?}");
1329 assert!(body.contains(HOOK_COMMAND_REFRESH), "body: {body:?}");
1330 assert!(body.contains(HOOK_COMMAND_CHECK), "body: {body:?}");
1331 assert!(body.contains(HOOK_GUARD_END), "body: {body:?}");
1332 }
1333
1334 #[test]
1335 fn install_hook_appends_to_existing_pre_commit() {
1336 let env = TempEnvironment::builder().build();
1340 fake_git_dir(&env);
1341 let hook_path = env.dotfiles_root.join(".git/hooks/pre-commit");
1342 let existing = "#!/bin/sh\necho 'my pre-commit'\nexit 0\n";
1343 env.fs.write_file(&hook_path, existing.as_bytes()).unwrap();
1344
1345 let ctx = make_ctx(&env);
1346 let result = install_hook(&ctx).unwrap();
1347 assert!(matches!(result.outcome, InstallHookOutcome::Appended));
1348
1349 let body = env.fs.read_to_string(&hook_path).unwrap();
1350 assert!(body.starts_with(existing), "user content lost: {body:?}");
1351 assert!(body.contains(HOOK_GUARD_START));
1352 assert!(body.contains(HOOK_COMMAND_REFRESH));
1353 assert!(body.contains(HOOK_COMMAND_CHECK));
1354 }
1355
1356 #[test]
1357 fn install_hook_is_idempotent_on_second_call() {
1358 let env = TempEnvironment::builder().build();
1362 fake_git_dir(&env);
1363 let ctx = make_ctx(&env);
1364
1365 let r1 = install_hook(&ctx).unwrap();
1366 assert!(matches!(r1.outcome, InstallHookOutcome::Created));
1367
1368 let body_after_first = env
1369 .fs
1370 .read_to_string(&env.dotfiles_root.join(".git/hooks/pre-commit"))
1371 .unwrap();
1372
1373 let r2 = install_hook(&ctx).unwrap();
1374 assert!(matches!(r2.outcome, InstallHookOutcome::AlreadyInstalled));
1375
1376 let body_after_second = env
1377 .fs
1378 .read_to_string(&env.dotfiles_root.join(".git/hooks/pre-commit"))
1379 .unwrap();
1380 assert_eq!(
1381 body_after_first, body_after_second,
1382 "body changed on second call"
1383 );
1384 assert_eq!(body_after_second.matches(HOOK_GUARD_START).count(), 1);
1386 }
1387
1388 #[test]
1389 fn install_hook_errors_if_no_git_dir() {
1390 let env = TempEnvironment::builder().build();
1394 let ctx = make_ctx(&env);
1395 let err = install_hook(&ctx).unwrap_err();
1396 let msg = format!("{err}");
1397 assert!(msg.contains("no .git directory"), "msg: {msg}");
1398 assert!(msg.contains("git init"), "msg: {msg}");
1399 }
1400
1401 #[test]
1402 fn hook_is_installed_reports_correctly() {
1403 let env = TempEnvironment::builder().build();
1404 fake_git_dir(&env);
1405 let ctx = make_ctx(&env);
1406
1407 assert!(!hook_is_installed(&ctx).unwrap());
1409
1410 install_hook(&ctx).unwrap();
1412 assert!(hook_is_installed(&ctx).unwrap());
1413
1414 let hook_path = env.dotfiles_root.join(".git/hooks/pre-commit");
1417 env.fs
1418 .write_file(&hook_path, b"#!/bin/sh\necho hello\n")
1419 .unwrap();
1420 assert!(!hook_is_installed(&ctx).unwrap());
1421 }
1422
1423 #[test]
1424 fn install_hook_sets_executable_bit() {
1425 use std::os::unix::fs::PermissionsExt;
1428
1429 let env = TempEnvironment::builder().build();
1430 fake_git_dir(&env);
1431 let ctx = make_ctx(&env);
1432 install_hook(&ctx).unwrap();
1433
1434 let hook_path = env.dotfiles_root.join(".git/hooks/pre-commit");
1435 let mode = std::fs::metadata(&hook_path).unwrap().permissions().mode();
1436 assert!(
1439 mode & 0o100 != 0,
1440 "hook is not executable, mode = {:o}",
1441 mode
1442 );
1443 }
1444
1445 #[test]
1446 fn managed_block_is_self_contained_and_grep_detectable() {
1447 let block = managed_block();
1452 assert!(block.starts_with(HOOK_GUARD_START));
1453 assert!(block.trim_end().ends_with(HOOK_GUARD_END));
1454 assert!(block.contains(HOOK_COMMAND_REFRESH));
1457 assert!(block.contains(HOOK_COMMAND_CHECK));
1458 }
1459
1460 #[test]
1463 fn install_hook_replaces_a_stale_managed_block() {
1464 let env = TempEnvironment::builder().build();
1469 fake_git_dir(&env);
1470
1471 let stale = format!(
1476 "#!/bin/sh\n\
1477 echo 'user-installed pre-commit step'\n\
1478 \n\
1479 {start}\n\
1480 # Old-style block from R4. Still works, but doesn't run\n\
1481 # `dodot refresh` first, so deployed-side edits between\n\
1482 # commits aren't always picked up.\n\
1483 dodot transform check --strict || exit 1\n\
1484 {end}\n\
1485 # User content after the block.\n\
1486 echo 'trailing user step'\n",
1487 start = HOOK_GUARD_START,
1488 end = HOOK_GUARD_END,
1489 );
1490 let hook_path = env.dotfiles_root.join(".git/hooks/pre-commit");
1491 env.fs.write_file(&hook_path, stale.as_bytes()).unwrap();
1492
1493 let ctx = make_ctx(&env);
1494 let result = install_hook(&ctx).unwrap();
1495 assert!(matches!(result.outcome, InstallHookOutcome::Updated));
1496
1497 let body = env.fs.read_to_string(&hook_path).unwrap();
1498 assert!(body.contains(HOOK_COMMAND_REFRESH), "body: {body:?}");
1501 assert!(body.contains(HOOK_COMMAND_CHECK), "body: {body:?}");
1502 assert!(body.contains("user-installed pre-commit step"));
1504 assert!(body.contains("trailing user step"));
1505 assert_eq!(body.matches(HOOK_GUARD_START).count(), 1);
1507 assert_eq!(body.matches(HOOK_GUARD_END).count(), 1);
1508 }
1509
1510 #[test]
1511 fn install_hook_no_op_on_current_block() {
1512 let env = TempEnvironment::builder().build();
1516 fake_git_dir(&env);
1517 let ctx = make_ctx(&env);
1518
1519 let r1 = install_hook(&ctx).unwrap();
1521 assert!(matches!(r1.outcome, InstallHookOutcome::Created));
1522 let body_after_first = env
1523 .fs
1524 .read_to_string(&env.dotfiles_root.join(".git/hooks/pre-commit"))
1525 .unwrap();
1526
1527 let r2 = install_hook(&ctx).unwrap();
1529 assert!(matches!(r2.outcome, InstallHookOutcome::AlreadyInstalled));
1530 let body_after_second = env
1531 .fs
1532 .read_to_string(&env.dotfiles_root.join(".git/hooks/pre-commit"))
1533 .unwrap();
1534 assert_eq!(body_after_first, body_after_second);
1535 }
1536
1537 #[test]
1538 fn find_managed_block_locates_byte_range() {
1539 let block = managed_block();
1542 let prefix = "before\n";
1543 let suffix = "after\n";
1544 let text = format!("{prefix}{block}{suffix}");
1545 let (start, end) = find_managed_block(&text).expect("must find block");
1546 assert_eq!(&text[start..end], block);
1547 }
1548
1549 #[test]
1550 fn find_managed_block_returns_none_when_absent() {
1551 assert!(find_managed_block("nothing here").is_none());
1552 let only_start = format!("{HOOK_GUARD_START}\nrandom content\n");
1556 assert!(find_managed_block(&only_start).is_none());
1557 }
1558}