Skip to main content

driftsync_core/
lib.rs

1use std::collections::BTreeMap;
2use std::fs;
3use std::path::{Component, Path, PathBuf};
4
5use serde::{Deserialize, Serialize};
6use thiserror::Error;
7
8pub mod security;
9pub mod structural_merge;
10pub use security::{
11    MAX_MANAGED_FILE_BYTES, ensure_no_symlink_in_chain, is_windows_reserved_device_name,
12    read_bounded_file, redact_sensitive_text, scan_for_hardcoded_secrets, validate_target_name,
13};
14pub use structural_merge::{
15    FileFormat, StructuralConflict, merge_json_structural, merge_toml_structural,
16    merge_yaml_structural, three_way_merge_by_path,
17};
18
19pub const MANIFEST_FILE_NAME: &str = "drift.toml";
20pub const LOCK_FILE_NAME: &str = ".driftsync.lock";
21
22#[derive(Debug, Error)]
23pub enum DriftError {
24    #[error("invalid or unsafe relative path: '{0}'")]
25    UnsafePath(String),
26    #[error("invalid or unsafe target repository name: '{0}'")]
27    InvalidTargetName(String),
28    #[error("symlink traversal blocked at '{0}'")]
29    SymlinkDetected(PathBuf),
30    #[error("file exceeds size limit ({path}: {size_bytes} bytes > max {max_bytes} bytes)")]
31    FileTooLarge {
32        path: PathBuf,
33        size_bytes: u64,
34        max_bytes: u64,
35    },
36    #[error(
37        "hardcoded secret detected in '{location}' ({secret_kind}); use environment variables or CI secrets instead"
38    )]
39    SecretDetected {
40        location: String,
41        secret_kind: &'static str,
42    },
43    #[error("I/O error ({path}): {source}")]
44    Io {
45        path: PathBuf,
46        #[source]
47        source: std::io::Error,
48    },
49    #[error("TOML parse error ({path}): {source}")]
50    TomlDe {
51        path: PathBuf,
52        #[source]
53        source: Box<toml::de::Error>,
54    },
55    #[error("TOML serialization error: {0}")]
56    TomlSer(#[from] Box<toml::ser::Error>),
57    #[error("unsupported manifest version: {0}")]
58    UnsupportedVersion(u32),
59    #[error("undefined template variable '{variable}' (template: {path}, target: '{target_name}')")]
60    UndefinedTemplateVar {
61        path: PathBuf,
62        variable: String,
63        target_name: String,
64    },
65}
66
67pub fn validate_relative_path(raw: &str) -> Result<PathBuf, DriftError> {
68    let trimmed = raw.trim();
69    if trimmed.is_empty()
70        || trimmed.contains('\\')
71        || trimmed.starts_with('/')
72        || trimmed.chars().any(|c| c.is_ascii_control())
73    {
74        return Err(DriftError::UnsafePath(raw.to_string()));
75    }
76    let path = Path::new(trimmed);
77    let mut normalized = PathBuf::new();
78    for comp in path.components() {
79        match comp {
80            Component::Normal(part) => {
81                let s = part.to_string_lossy();
82                if s.is_empty()
83                    || s.contains(':')
84                    || s.ends_with('.')
85                    || s.ends_with(' ')
86                    || s.eq_ignore_ascii_case(".git")
87                    || s.eq_ignore_ascii_case(LOCK_FILE_NAME)
88                    || is_windows_reserved_device_name(&s)
89                {
90                    return Err(DriftError::UnsafePath(raw.to_string()));
91                }
92                normalized.push(part);
93            }
94            Component::CurDir => {}
95            Component::ParentDir | Component::RootDir | Component::Prefix(_) => {
96                return Err(DriftError::UnsafePath(raw.to_string()));
97            }
98        }
99    }
100    if normalized.as_os_str().is_empty() {
101        return Err(DriftError::UnsafePath(raw.to_string()));
102    }
103    Ok(normalized)
104}
105
106pub fn hash_content(content: &str) -> String {
107    let normalized = normalize_newlines(content);
108    let hash = blake3::hash(normalized.as_bytes());
109    format!("blake3:{}", hash.to_hex())
110}
111
112pub fn normalize_newlines(input: &str) -> String {
113    input.replace("\r\n", "\n")
114}
115
116#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
117#[serde(rename_all = "kebab-case")]
118pub enum SyncStrategy {
119    #[default]
120    ThreeWay,
121    Overwrite,
122    KeepLocal,
123}
124
125#[derive(Debug, Clone, Serialize, Deserialize)]
126pub struct Manifest {
127    #[serde(default = "default_version")]
128    pub version: u32,
129    #[serde(default)]
130    pub vars: BTreeMap<String, String>,
131    #[serde(default)]
132    pub targets: Vec<TargetRepo>,
133    #[serde(default)]
134    pub files: Vec<ManagedFile>,
135}
136
137fn default_version() -> u32 {
138    1
139}
140
141#[derive(Debug, Clone, Default, Serialize, Deserialize)]
142pub struct TargetRepo {
143    pub name: String,
144    #[serde(default)]
145    pub path: String,
146    #[serde(default)]
147    pub repo: Option<String>,
148    #[serde(default)]
149    pub vars: BTreeMap<String, String>,
150}
151
152impl TargetRepo {
153    pub fn effective_path(&self) -> String {
154        if !self.path.trim().is_empty() {
155            self.path.clone()
156        } else {
157            format!(".driftsync-remotes/{}", self.name)
158        }
159    }
160}
161
162#[derive(Debug, Clone, Serialize, Deserialize)]
163pub struct ManagedFile {
164    pub source: String,
165    pub target: String,
166    #[serde(default)]
167    pub strategy: SyncStrategy,
168}
169
170impl Manifest {
171    pub fn load_from_path(path: &Path) -> Result<Self, DriftError> {
172        let raw = read_bounded_file(path)?;
173        let manifest: Self = toml::from_str(&raw).map_err(|source| DriftError::TomlDe {
174            path: path.to_path_buf(),
175            source: Box::new(source),
176        })?;
177        manifest.validate()?;
178        Ok(manifest)
179    }
180
181    pub fn validate(&self) -> Result<(), DriftError> {
182        if self.version != 1 {
183            return Err(DriftError::UnsupportedVersion(self.version));
184        }
185        for (k, v) in &self.vars {
186            if let Some(secret_kind) = scan_for_hardcoded_secrets(v) {
187                return Err(DriftError::SecretDetected {
188                    location: format!("drift.toml [vars.{k}]"),
189                    secret_kind,
190                });
191            }
192        }
193        for target in &self.targets {
194            validate_target_name(&target.name)?;
195            if !target.path.trim().is_empty() {
196                validate_relative_path(&target.path)?;
197            }
198            for (k, v) in &target.vars {
199                if let Some(secret_kind) = scan_for_hardcoded_secrets(v) {
200                    return Err(DriftError::SecretDetected {
201                        location: format!("drift.toml [targets({}).vars.{k}]", target.name),
202                        secret_kind,
203                    });
204                }
205            }
206        }
207        for file in &self.files {
208            validate_relative_path(&file.source)?;
209            validate_relative_path(&file.target)?;
210        }
211        Ok(())
212    }
213}
214
215#[derive(Debug, Clone, Serialize, Deserialize)]
216pub struct LockFile {
217    #[serde(default = "default_version")]
218    pub version: u32,
219    #[serde(default)]
220    pub upstream_revision: Option<String>,
221    #[serde(default)]
222    pub files: BTreeMap<String, LockedFileEntry>,
223}
224
225impl Default for LockFile {
226    fn default() -> Self {
227        Self {
228            version: 1,
229            upstream_revision: None,
230            files: BTreeMap::new(),
231        }
232    }
233}
234
235#[derive(Debug, Clone, Serialize, Deserialize)]
236pub struct LockedFileEntry {
237    pub base_hash: String,
238    pub base_content: String,
239}
240
241impl LockFile {
242    pub fn load_optional(repo_root: &Path) -> Result<Self, DriftError> {
243        let lock_path = ensure_no_symlink_in_chain(repo_root, Path::new(LOCK_FILE_NAME))?;
244        if !lock_path.exists() {
245            return Ok(Self::default());
246        }
247        let raw = read_bounded_file(&lock_path)?;
248        let lock: Self = toml::from_str(&raw).map_err(|source| DriftError::TomlDe {
249            path: lock_path,
250            source: Box::new(source),
251        })?;
252        Ok(lock)
253    }
254
255    pub fn save(&self, repo_root: &Path) -> Result<(), DriftError> {
256        let lock_path = ensure_no_symlink_in_chain(repo_root, Path::new(LOCK_FILE_NAME))?;
257        let serialized = toml::to_string_pretty(self).map_err(Box::new)?;
258        fs::write(&lock_path, serialized).map_err(|source| DriftError::Io {
259            path: lock_path,
260            source,
261        })
262    }
263}
264
265#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
266#[serde(rename_all = "kebab-case")]
267pub enum DriftStatus {
268    InSync,
269    MissingInTarget,
270    UntrackedIdentical,
271    UntrackedDifferent,
272    UpstreamChanged,
273    LocallyModified,
274    BothChangedClean,
275    BothChangedConflict,
276}
277
278#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
279#[serde(tag = "action", rename_all = "kebab-case")]
280pub enum PlanAction {
281    NoOp,
282    Create {
283        new_content: String,
284    },
285    RecordLock {
286        base_content: String,
287    },
288    NeedsAdopt,
289    UpdateFastForward {
290        new_content: String,
291    },
292    KeepLocal,
293    MergeClean {
294        merged_content: String,
295        new_base_content: String,
296    },
297    OverwriteWithUpstream {
298        new_content: String,
299    },
300    Conflict {
301        conflict_preview: String,
302    },
303}
304
305#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
306pub struct FileEvaluation {
307    pub source_path: String,
308    pub target_path: String,
309    pub strategy: SyncStrategy,
310    pub status: DriftStatus,
311    pub plan: PlanAction,
312    pub unified_diff: String,
313}
314
315#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
316pub struct RepoReport {
317    pub target_name: String,
318    pub target_path: String,
319    pub evaluations: Vec<FileEvaluation>,
320}
321
322pub fn evaluate_file(
323    source_path: &str,
324    target_path: &str,
325    strategy: SyncStrategy,
326    base: Option<&str>,
327    ours: Option<&str>,
328    theirs: &str,
329) -> FileEvaluation {
330    let base_norm = base.map(normalize_newlines);
331    let ours_norm = ours.map(normalize_newlines);
332    let theirs_norm = normalize_newlines(theirs);
333
334    let (status, plan, preview_after) = match (base_norm.as_deref(), ours_norm.as_deref()) {
335        (_, None) => (
336            DriftStatus::MissingInTarget,
337            PlanAction::Create {
338                new_content: theirs_norm.clone(),
339            },
340            theirs_norm.clone(),
341        ),
342        (None, Some(ours_str)) => {
343            if ours_str == theirs_norm {
344                (
345                    DriftStatus::UntrackedIdentical,
346                    PlanAction::RecordLock {
347                        base_content: theirs_norm.clone(),
348                    },
349                    ours_str.to_string(),
350                )
351            } else {
352                match strategy {
353                    SyncStrategy::Overwrite => (
354                        DriftStatus::UntrackedDifferent,
355                        PlanAction::OverwriteWithUpstream {
356                            new_content: theirs_norm.clone(),
357                        },
358                        theirs_norm.clone(),
359                    ),
360                    SyncStrategy::KeepLocal => (
361                        DriftStatus::UntrackedDifferent,
362                        PlanAction::KeepLocal,
363                        ours_str.to_string(),
364                    ),
365                    SyncStrategy::ThreeWay => (
366                        DriftStatus::UntrackedDifferent,
367                        PlanAction::NeedsAdopt,
368                        ours_str.to_string(),
369                    ),
370                }
371            }
372        }
373        (Some(base_str), Some(ours_str)) => {
374            if ours_str == theirs_norm && base_str == theirs_norm {
375                (DriftStatus::InSync, PlanAction::NoOp, ours_str.to_string())
376            } else if ours_str == theirs_norm && base_str != theirs_norm {
377                (
378                    DriftStatus::InSync,
379                    PlanAction::RecordLock {
380                        base_content: theirs_norm.clone(),
381                    },
382                    ours_str.to_string(),
383                )
384            } else if ours_str == base_str && theirs_norm != base_str {
385                match strategy {
386                    SyncStrategy::KeepLocal => (
387                        DriftStatus::UpstreamChanged,
388                        PlanAction::KeepLocal,
389                        ours_str.to_string(),
390                    ),
391                    SyncStrategy::ThreeWay | SyncStrategy::Overwrite => (
392                        DriftStatus::UpstreamChanged,
393                        PlanAction::UpdateFastForward {
394                            new_content: theirs_norm.clone(),
395                        },
396                        theirs_norm.clone(),
397                    ),
398                }
399            } else if ours_str != base_str && theirs_norm == base_str {
400                match strategy {
401                    SyncStrategy::Overwrite => (
402                        DriftStatus::LocallyModified,
403                        PlanAction::OverwriteWithUpstream {
404                            new_content: theirs_norm.clone(),
405                        },
406                        theirs_norm.clone(),
407                    ),
408                    SyncStrategy::ThreeWay | SyncStrategy::KeepLocal => (
409                        DriftStatus::LocallyModified,
410                        PlanAction::KeepLocal,
411                        ours_str.to_string(),
412                    ),
413                }
414            } else {
415                match strategy {
416                    SyncStrategy::Overwrite => (
417                        DriftStatus::BothChangedClean,
418                        PlanAction::OverwriteWithUpstream {
419                            new_content: theirs_norm.clone(),
420                        },
421                        theirs_norm.clone(),
422                    ),
423                    SyncStrategy::KeepLocal => (
424                        DriftStatus::BothChangedClean,
425                        PlanAction::KeepLocal,
426                        ours_str.to_string(),
427                    ),
428                    SyncStrategy::ThreeWay => {
429                        match three_way_merge_by_path(target_path, base_str, ours_str, &theirs_norm)
430                        {
431                            Ok(merged) => (
432                                DriftStatus::BothChangedClean,
433                                PlanAction::MergeClean {
434                                    merged_content: merged.clone(),
435                                    new_base_content: theirs_norm.clone(),
436                                },
437                                merged,
438                            ),
439                            Err(conflict_text) => (
440                                DriftStatus::BothChangedConflict,
441                                PlanAction::Conflict {
442                                    conflict_preview: conflict_text.clone(),
443                                },
444                                conflict_text,
445                            ),
446                        }
447                    }
448                }
449            }
450        }
451    };
452
453    let current_for_diff = ours_norm.as_deref().unwrap_or("");
454    let unified_diff = if current_for_diff == preview_after {
455        String::new()
456    } else {
457        let raw_diff = similar::TextDiff::from_lines(current_for_diff, &preview_after)
458            .unified_diff()
459            .context_radius(3)
460            .header(
461                &format!("a/{target_path} (ours)"),
462                &format!("b/{target_path} (proposed)"),
463            )
464            .to_string();
465        redact_sensitive_text(&raw_diff)
466    };
467
468    FileEvaluation {
469        source_path: source_path.to_string(),
470        target_path: target_path.to_string(),
471        strategy,
472        status,
473        plan,
474        unified_diff,
475    }
476}
477
478pub fn render_template(
479    input: &str,
480    target: &TargetRepo,
481    global_vars: &BTreeMap<String, String>,
482    source_path: &Path,
483) -> Result<String, DriftError> {
484    let mut output = String::with_capacity(input.len());
485    let mut rest = input;
486    let mut prev_ended_with_dollar = false;
487
488    while let Some(open_idx) = rest.find("{{") {
489        let before = &rest[..open_idx];
490        output.push_str(before);
491
492        let is_escaped_by_dollar = if open_idx > 0 {
493            before.ends_with('$')
494        } else {
495            prev_ended_with_dollar
496        };
497
498        let after_open = &rest[open_idx + 2..];
499        if is_escaped_by_dollar {
500            output.push_str("{{");
501            rest = after_open;
502            prev_ended_with_dollar = false;
503            continue;
504        }
505
506        if let Some(close_idx) = after_open.find("}}") {
507            let expr = after_open[..close_idx].trim();
508            if let Some(field) = expr.strip_prefix("target.") {
509                let field = field.trim();
510                match field {
511                    "name" => output.push_str(&target.name),
512                    "path" => output.push_str(&target.path),
513                    _ => {
514                        return Err(DriftError::UndefinedTemplateVar {
515                            path: source_path.to_path_buf(),
516                            variable: expr.to_string(),
517                            target_name: target.name.clone(),
518                        });
519                    }
520                }
521            } else if let Some(var_key) = expr.strip_prefix("vars.") {
522                let var_key = var_key.trim();
523                if let Some(val) = target
524                    .vars
525                    .get(var_key)
526                    .or_else(|| global_vars.get(var_key))
527                {
528                    output.push_str(val);
529                } else {
530                    return Err(DriftError::UndefinedTemplateVar {
531                        path: source_path.to_path_buf(),
532                        variable: expr.to_string(),
533                        target_name: target.name.clone(),
534                    });
535                }
536            } else {
537                output.push_str("{{");
538                output.push_str(&after_open[..close_idx]);
539                output.push_str("}}");
540            }
541
542            let next_start = close_idx + 2;
543            prev_ended_with_dollar = after_open[..next_start].ends_with('$');
544            rest = &after_open[next_start..];
545        } else {
546            output.push_str(&rest[open_idx..]);
547            rest = "";
548            break;
549        }
550    }
551
552    output.push_str(rest);
553    Ok(output)
554}
555
556pub fn evaluate_workspace(
557    source_root: &Path,
558    manifest: &Manifest,
559) -> Result<Vec<RepoReport>, DriftError> {
560    manifest.validate()?;
561    let mut reports = Vec::with_capacity(manifest.targets.len());
562
563    for target in &manifest.targets {
564        let effective_path = target.effective_path();
565        let repo_rel = validate_relative_path(&effective_path)?;
566        let repo_root = ensure_no_symlink_in_chain(source_root, &repo_rel)?;
567        let lock = LockFile::load_optional(&repo_root)?;
568        let mut evaluations = Vec::with_capacity(manifest.files.len());
569
570        for file in &manifest.files {
571            let src_rel = validate_relative_path(&file.source)?;
572            let tgt_rel = validate_relative_path(&file.target)?;
573
574            let src_full = ensure_no_symlink_in_chain(source_root, &src_rel)?;
575            let raw_theirs = read_bounded_file(&src_full)?;
576            if let Some(secret_kind) = scan_for_hardcoded_secrets(&raw_theirs) {
577                return Err(DriftError::SecretDetected {
578                    location: file.source.clone(),
579                    secret_kind,
580                });
581            }
582            let theirs = render_template(&raw_theirs, target, &manifest.vars, &src_full)?;
583            if let Some(secret_kind) = scan_for_hardcoded_secrets(&theirs) {
584                return Err(DriftError::SecretDetected {
585                    location: format!("{} (target: {})", file.source, target.name),
586                    secret_kind,
587                });
588            }
589
590            let tgt_full = ensure_no_symlink_in_chain(&repo_root, &tgt_rel)?;
591            let ours = if tgt_full.exists() {
592                Some(read_bounded_file(&tgt_full)?)
593            } else {
594                None
595            };
596
597            let base = lock
598                .files
599                .get(&file.target)
600                .map(|entry| entry.base_content.as_str());
601
602            let eval = evaluate_file(
603                &file.source,
604                &file.target,
605                file.strategy,
606                base,
607                ours.as_deref(),
608                &theirs,
609            );
610            evaluations.push(eval);
611        }
612
613        reports.push(RepoReport {
614            target_name: target.name.clone(),
615            target_path: effective_path,
616            evaluations,
617        });
618    }
619
620    Ok(reports)
621}
622
623#[derive(Debug, Clone, Default, Serialize, Deserialize)]
624pub struct ApplySummary {
625    pub dry_run: bool,
626    pub files_written: usize,
627    pub locks_updated: usize,
628    pub conflicts_skipped: usize,
629    pub needs_adopt_skipped: usize,
630}
631
632pub fn apply_workspace(
633    source_root: &Path,
634    manifest: &Manifest,
635    upstream_revision: Option<String>,
636    write: bool,
637) -> Result<(Vec<RepoReport>, ApplySummary), DriftError> {
638    let reports = evaluate_workspace(source_root, manifest)?;
639    let mut summary = ApplySummary {
640        dry_run: !write,
641        ..Default::default()
642    };
643
644    for (target, report) in manifest.targets.iter().zip(reports.iter()) {
645        let repo_rel = validate_relative_path(&target.effective_path())?;
646        let repo_root = ensure_no_symlink_in_chain(source_root, &repo_rel)?;
647        let mut lock = LockFile::load_optional(&repo_root)?;
648        let mut lock_dirty = false;
649
650        for eval in &report.evaluations {
651            let tgt_rel = validate_relative_path(&eval.target_path)?;
652            let tgt_full = ensure_no_symlink_in_chain(&repo_root, &tgt_rel)?;
653
654            match &eval.plan {
655                PlanAction::NoOp | PlanAction::KeepLocal => {}
656                PlanAction::NeedsAdopt => {
657                    summary.needs_adopt_skipped += 1;
658                }
659                PlanAction::Conflict { .. } => {
660                    summary.conflicts_skipped += 1;
661                }
662                PlanAction::RecordLock { base_content } => {
663                    lock.files.insert(
664                        eval.target_path.clone(),
665                        LockedFileEntry {
666                            base_hash: hash_content(base_content),
667                            base_content: base_content.clone(),
668                        },
669                    );
670                    lock_dirty = true;
671                }
672                PlanAction::Create { new_content }
673                | PlanAction::UpdateFastForward { new_content }
674                | PlanAction::OverwriteWithUpstream { new_content } => {
675                    if write {
676                        if let Some(parent) = tgt_full.parent() {
677                            fs::create_dir_all(parent).map_err(|source| DriftError::Io {
678                                path: parent.to_path_buf(),
679                                source,
680                            })?;
681                        }
682                        fs::write(&tgt_full, new_content).map_err(|source| DriftError::Io {
683                            path: tgt_full.clone(),
684                            source,
685                        })?;
686                    }
687                    summary.files_written += 1;
688                    lock.files.insert(
689                        eval.target_path.clone(),
690                        LockedFileEntry {
691                            base_hash: hash_content(new_content),
692                            base_content: new_content.clone(),
693                        },
694                    );
695                    lock_dirty = true;
696                }
697                PlanAction::MergeClean {
698                    merged_content,
699                    new_base_content,
700                } => {
701                    if write {
702                        if let Some(parent) = tgt_full.parent() {
703                            fs::create_dir_all(parent).map_err(|source| DriftError::Io {
704                                path: parent.to_path_buf(),
705                                source,
706                            })?;
707                        }
708                        fs::write(&tgt_full, merged_content).map_err(|source| DriftError::Io {
709                            path: tgt_full.clone(),
710                            source,
711                        })?;
712                    }
713                    summary.files_written += 1;
714                    lock.files.insert(
715                        eval.target_path.clone(),
716                        LockedFileEntry {
717                            base_hash: hash_content(new_base_content),
718                            base_content: new_base_content.clone(),
719                        },
720                    );
721                    lock_dirty = true;
722                }
723            }
724        }
725
726        if lock_dirty {
727            if let Some(rev) = &upstream_revision {
728                lock.upstream_revision = Some(rev.clone());
729            }
730            if write {
731                fs::create_dir_all(&repo_root).map_err(|source| DriftError::Io {
732                    path: repo_root.clone(),
733                    source,
734                })?;
735                lock.save(&repo_root)?;
736            }
737            summary.locks_updated += 1;
738        }
739    }
740
741    Ok((reports, summary))
742}
743
744pub fn adopt_workspace(
745    source_root: &Path,
746    manifest: &Manifest,
747    upstream_revision: Option<String>,
748) -> Result<usize, DriftError> {
749    manifest.validate()?;
750    let mut updated_repos = 0usize;
751
752    for target in &manifest.targets {
753        let repo_rel = validate_relative_path(&target.effective_path())?;
754        let repo_root = ensure_no_symlink_in_chain(source_root, &repo_rel)?;
755        let mut lock = LockFile::load_optional(&repo_root)?;
756        let mut changed = false;
757
758        for file in &manifest.files {
759            if lock.files.contains_key(&file.target) {
760                continue;
761            }
762            let src_rel = validate_relative_path(&file.source)?;
763            let src_full = ensure_no_symlink_in_chain(source_root, &src_rel)?;
764            let raw_theirs = read_bounded_file(&src_full)?;
765            if let Some(secret_kind) = scan_for_hardcoded_secrets(&raw_theirs) {
766                return Err(DriftError::SecretDetected {
767                    location: file.source.clone(),
768                    secret_kind,
769                });
770            }
771            let rendered_theirs = render_template(&raw_theirs, target, &manifest.vars, &src_full)?;
772            if let Some(secret_kind) = scan_for_hardcoded_secrets(&rendered_theirs) {
773                return Err(DriftError::SecretDetected {
774                    location: format!("{} (target: {})", file.source, target.name),
775                    secret_kind,
776                });
777            }
778            let theirs = normalize_newlines(&rendered_theirs);
779
780            lock.files.insert(
781                file.target.clone(),
782                LockedFileEntry {
783                    base_hash: hash_content(&theirs),
784                    base_content: theirs,
785                },
786            );
787            changed = true;
788        }
789
790        if changed {
791            if let Some(rev) = &upstream_revision {
792                lock.upstream_revision = Some(rev.clone());
793            }
794            fs::create_dir_all(&repo_root).map_err(|source| DriftError::Io {
795                path: repo_root.clone(),
796                source,
797            })?;
798            lock.save(&repo_root)?;
799            updated_repos += 1;
800        }
801    }
802
803    Ok(updated_repos)
804}
805
806pub fn has_actionable_drift(reports: &[RepoReport]) -> bool {
807    reports.iter().any(|repo| {
808        repo.evaluations.iter().any(|eval| {
809            !matches!(
810                eval.status,
811                DriftStatus::InSync | DriftStatus::LocallyModified
812            )
813        })
814    })
815}
816
817fn status_markdown_badge(status: DriftStatus) -> &'static str {
818    match status {
819        DriftStatus::InSync => "✅ `IN-SYNC`",
820        DriftStatus::MissingInTarget => "➕ `MISSING`",
821        DriftStatus::UntrackedIdentical => "🔵 `UNTRACKED-SAME`",
822        DriftStatus::UntrackedDifferent => "⚠️ `NEEDS-ADOPT`",
823        DriftStatus::UpstreamChanged => "⚡ `UPSTREAM-CHANGED`",
824        DriftStatus::LocallyModified => "🛡️ `LOCALLY-MODIFIED`",
825        DriftStatus::BothChangedClean => "🔀 `3-WAY-MERGE`",
826        DriftStatus::BothChangedConflict => "❌ `CONFLICT`",
827    }
828}
829
830fn plan_markdown_label(plan: &PlanAction) -> &'static str {
831    match plan {
832        PlanAction::NoOp => "No changes",
833        PlanAction::Create { .. } => "Create",
834        PlanAction::RecordLock { .. } => "Record lock",
835        PlanAction::NeedsAdopt => "Adoption required (`driftsync adopt`)",
836        PlanAction::UpdateFastForward { .. } => "Fast-forward update",
837        PlanAction::KeepLocal => "Keep local customization",
838        PlanAction::MergeClean { .. } => "3-way clean merge",
839        PlanAction::OverwriteWithUpstream { .. } => "Overwrite with upstream",
840        PlanAction::Conflict { .. } => "Conflict (`driftsync resolve` required)",
841    }
842}
843
844pub fn generate_markdown_report(reports: &[RepoReport]) -> String {
845    let mut out = String::from("## 🔄 driftsync — Multi-Repo Drift Report\n\n");
846    out.push_str("| Target Repo | File | Strategy | Status | Planned Action |\n");
847    out.push_str("|---|---|---|---|---|\n");
848
849    let mut diff_sections = String::new();
850    for repo in reports {
851        for eval in &repo.evaluations {
852            out.push_str(&format!(
853                "| **{}** (`{}`) | `{}` | `{:?}` | {} | {} |\n",
854                repo.target_name,
855                repo.target_path,
856                eval.target_path,
857                eval.strategy,
858                status_markdown_badge(eval.status),
859                plan_markdown_label(&eval.plan)
860            ));
861            if !eval.unified_diff.is_empty() {
862                let safe_diff = redact_sensitive_text(eval.unified_diff.trim_end());
863                diff_sections.push_str(&format!(
864                    "\n<details>\n<summary><b>{} :: {}</b> ({})</summary>\n\n```diff\n{}\n```\n</details>\n",
865                    repo.target_name,
866                    eval.target_path,
867                    status_markdown_badge(eval.status),
868                    safe_diff
869                ));
870            }
871        }
872    }
873
874    if !diff_sections.is_empty() {
875        out.push_str("\n### 📝 Proposed Changes (Diff Preview)\n");
876        out.push_str(&diff_sections);
877    }
878
879    out
880}
881
882pub fn generate_pr_body(report: &RepoReport, upstream_revision: Option<&str>) -> String {
883    let mut out = format!(
884        "## 🔄 `driftsync` Configuration Sync (`{}`)\n\n",
885        report.target_name
886    );
887    if let Some(rev) = upstream_revision {
888        out.push_str(&format!("- **Upstream Revision:** `{rev}`\n"));
889    }
890    out.push_str(
891        "- **Policy:** 3-way structural merge applied while preserving local customizations.\n\n",
892    );
893
894    out.push_str("| Managed File | Drift Status | Applied Action |\n");
895    out.push_str("|---|---|---|\n");
896    for eval in &report.evaluations {
897        out.push_str(&format!(
898            "| `{}` | {} | {} |\n",
899            eval.target_path,
900            status_markdown_badge(eval.status),
901            plan_markdown_label(&eval.plan)
902        ));
903    }
904
905    let mut any_diff = false;
906    for eval in &report.evaluations {
907        if !eval.unified_diff.is_empty() {
908            if !any_diff {
909                out.push_str("\n### 🔍 File Diffs\n");
910                any_diff = true;
911            }
912            let safe_diff = redact_sensitive_text(eval.unified_diff.trim_end());
913            out.push_str(&format!(
914                "\n<details>\n<summary><code>{}</code></summary>\n\n```diff\n{}\n```\n</details>\n",
915                eval.target_path, safe_diff
916            ));
917        }
918    }
919
920    out
921}
922
923#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
924#[serde(rename_all = "kebab-case")]
925pub enum ResolutionChoice {
926    Ours,
927    Theirs,
928}
929
930#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
931pub struct ResolvedFileRecord {
932    pub target_name: String,
933    pub target_path: String,
934    pub choice: ResolutionChoice,
935    pub dry_run: bool,
936}
937
938pub fn resolve_target_file(
939    source_root: &Path,
940    target: &TargetRepo,
941    file: &ManagedFile,
942    global_vars: &BTreeMap<String, String>,
943    upstream_revision: Option<&str>,
944    choice: ResolutionChoice,
945    write: bool,
946) -> Result<ResolvedFileRecord, DriftError> {
947    let src_rel = validate_relative_path(&file.source)?;
948    let tgt_rel = validate_relative_path(&file.target)?;
949
950    let src_full = ensure_no_symlink_in_chain(source_root, &src_rel)?;
951    let raw_theirs = read_bounded_file(&src_full)?;
952    if let Some(secret_kind) = scan_for_hardcoded_secrets(&raw_theirs) {
953        return Err(DriftError::SecretDetected {
954            location: file.source.clone(),
955            secret_kind,
956        });
957    }
958    let rendered_theirs = render_template(&raw_theirs, target, global_vars, &src_full)?;
959    if let Some(secret_kind) = scan_for_hardcoded_secrets(&rendered_theirs) {
960        return Err(DriftError::SecretDetected {
961            location: format!("{} (target: {})", file.source, target.name),
962            secret_kind,
963        });
964    }
965    let theirs_norm = normalize_newlines(&rendered_theirs);
966
967    let repo_rel = validate_relative_path(&target.effective_path())?;
968    let repo_root = ensure_no_symlink_in_chain(source_root, &repo_rel)?;
969    let tgt_full = ensure_no_symlink_in_chain(&repo_root, &tgt_rel)?;
970
971    if write {
972        if choice == ResolutionChoice::Theirs {
973            if let Some(parent) = tgt_full.parent() {
974                fs::create_dir_all(parent).map_err(|source| DriftError::Io {
975                    path: parent.to_path_buf(),
976                    source,
977                })?;
978            }
979            fs::write(&tgt_full, &theirs_norm).map_err(|source| DriftError::Io {
980                path: tgt_full,
981                source,
982            })?;
983        }
984
985        let mut lock = LockFile::load_optional(&repo_root)?;
986        lock.files.insert(
987            file.target.clone(),
988            LockedFileEntry {
989                base_hash: hash_content(&theirs_norm),
990                base_content: theirs_norm,
991            },
992        );
993        if let Some(rev) = upstream_revision {
994            lock.upstream_revision = Some(rev.to_string());
995        }
996        fs::create_dir_all(&repo_root).map_err(|source| DriftError::Io {
997            path: repo_root.clone(),
998            source,
999        })?;
1000        lock.save(&repo_root)?;
1001    }
1002
1003    Ok(ResolvedFileRecord {
1004        target_name: target.name.clone(),
1005        target_path: file.target.clone(),
1006        choice,
1007        dry_run: !write,
1008    })
1009}
1010
1011#[cfg(test)]
1012mod tests {
1013    use super::*;
1014    use proptest::prelude::*;
1015
1016    #[test]
1017    fn rejects_unsafe_paths() {
1018        assert!(validate_relative_path("../secret").is_err());
1019        assert!(validate_relative_path("a/../../b").is_err());
1020        assert!(validate_relative_path("/etc/passwd").is_err());
1021        assert!(validate_relative_path("C:\\Windows\\win.ini").is_err());
1022        assert!(validate_relative_path("").is_err());
1023
1024        assert!(validate_relative_path(".git/config").is_err());
1025        assert!(validate_relative_path(".GIT/hooks/pre-commit").is_err());
1026        assert!(validate_relative_path("nested/.git/hooks/post-checkout").is_err());
1027        assert!(validate_relative_path(".driftsync.lock").is_err());
1028        assert!(validate_relative_path("CON").is_err());
1029        assert!(validate_relative_path("dir/NUL.txt").is_err());
1030        assert!(validate_relative_path("COM1.json").is_err());
1031        assert!(validate_relative_path("LPT9").is_err());
1032        assert!(validate_relative_path("trailing./file.txt").is_err());
1033
1034        assert!(validate_relative_path(".github/workflows/ci.yml").is_ok());
1035        assert!(validate_relative_path(".editorconfig").is_ok());
1036    }
1037
1038    #[test]
1039    fn rejects_invalid_target_names() {
1040        assert!(validate_target_name("service-auth").is_ok());
1041        assert!(validate_target_name("web_dashboard_01").is_ok());
1042
1043        assert!(validate_target_name("").is_err());
1044        assert!(validate_target_name("-flag-injection").is_err());
1045        assert!(validate_target_name(".hidden").is_err());
1046        assert!(validate_target_name("../escape").is_err());
1047        assert!(validate_target_name("org/repo").is_err());
1048        assert!(validate_target_name("CON").is_err());
1049        assert!(validate_target_name("with space").is_err());
1050    }
1051
1052    #[test]
1053    fn secret_scanner_blocks_hardcoded_tokens_and_private_keys() {
1054        let ghp = format!("ghp_{}", "a1B2c3D4e5F6g7H8i9J0k1L2m3N4o5P6q7R8");
1055        assert_eq!(
1056            scan_for_hardcoded_secrets(&format!("token = \"{ghp}\"")),
1057            Some("GitHub Personal Access Token")
1058        );
1059
1060        let gh_pat = format!("github_pat_{}", "11AABBCCDD0123456789_abcdefghijklmnop");
1061        assert_eq!(
1062            scan_for_hardcoded_secrets(&gh_pat),
1063            Some("GitHub Fine-Grained PAT")
1064        );
1065
1066        let glpat = format!("glpat-{}", "1234567890abcdefghij");
1067        assert_eq!(
1068            scan_for_hardcoded_secrets(&glpat),
1069            Some("GitLab Personal Access Token")
1070        );
1071
1072        let aws = format!("AKIA{}", "IOSFODNN7EXAMPLE");
1073        assert_eq!(scan_for_hardcoded_secrets(&aws), Some("AWS Access Key ID"));
1074
1075        let pem = "-----BEGIN OPENSSH PRIVATE KEY-----\nAAAA...\n";
1076        assert_eq!(scan_for_hardcoded_secrets(pem), Some("PEM Private Key"));
1077
1078        assert_eq!(
1079            scan_for_hardcoded_secrets("token: ${{ secrets.GITHUB_TOKEN }}"),
1080            None
1081        );
1082
1083        let mut bad_vars = BTreeMap::new();
1084        bad_vars.insert("api_token".to_string(), ghp);
1085        let manifest = Manifest {
1086            version: 1,
1087            vars: bad_vars,
1088            targets: vec![],
1089            files: vec![],
1090        };
1091        let err = manifest.validate().expect_err("secret in vars must fail");
1092        assert!(matches!(err, DriftError::SecretDetected { .. }));
1093    }
1094
1095    #[test]
1096    fn redacts_sensitive_urls_and_tokens_in_diffs_and_markdown() {
1097        let ghp = format!("ghp_{}", "a1B2c3D4e5F6g7H8i9J0k1L2m3N4o5P6q7R8");
1098        let ours = "registry = \"https://admin:superSecretPass@npm.example.com/private\"\n";
1099        let theirs = format!("registry = \"https://ci:{ghp}@npm.example.com/private\"\n");
1100
1101        let eval = evaluate_file(
1102            "templates/.npmrc",
1103            ".npmrc",
1104            SyncStrategy::ThreeWay,
1105            Some(ours),
1106            Some(ours),
1107            &theirs,
1108        );
1109
1110        assert!(!eval.unified_diff.contains("superSecretPass"));
1111        assert!(!eval.unified_diff.contains(&ghp));
1112        assert!(
1113            eval.unified_diff
1114                .contains("https://[REDACTED]@npm.example.com/private")
1115        );
1116
1117        let report = RepoReport {
1118            target_name: "web-app".to_string(),
1119            target_path: "repos/web-app".to_string(),
1120            evaluations: vec![eval],
1121        };
1122        let md = generate_markdown_report(&[report]);
1123        assert!(!md.contains("superSecretPass"));
1124        assert!(!md.contains(&ghp));
1125    }
1126
1127    #[test]
1128    fn bounded_file_reader_enforces_size_limit() {
1129        let tmp = tempfile::tempdir().expect("tempdir");
1130        let oversized = tmp.path().join("huge.toml");
1131        let f = fs::File::create(&oversized).expect("create");
1132        f.set_len(MAX_MANAGED_FILE_BYTES + 1024).expect("set_len");
1133
1134        let err = read_bounded_file(&oversized).expect_err("must reject oversized file");
1135        assert!(matches!(err, DriftError::FileTooLarge { .. }));
1136    }
1137
1138    #[test]
1139    fn template_rendering_interpolates_target_and_vars_while_preserving_github_actions() {
1140        let mut global_vars = BTreeMap::new();
1141        global_vars.insert("rust_version".to_string(), "1.85".to_string());
1142        global_vars.insert("port".to_string(), "8080".to_string());
1143
1144        let mut target_vars = BTreeMap::new();
1145        target_vars.insert("port".to_string(), "9091".to_string());
1146
1147        let target = TargetRepo {
1148            name: "service-auth".to_string(),
1149            path: "repos/service-auth".to_string(),
1150            repo: None,
1151            vars: target_vars,
1152        };
1153
1154        let tpl = r#"name: CI ({{ target.name }})
1155env:
1156  SERVICE_NAME: "{{target.name}}"
1157  SERVICE_PATH: "{{ target.path }}"
1158  PORT: "{{ vars.port }}"
1159  RUST_VERSION: "{{ vars.rust_version }}"
1160  GH_SECRET: "${{ secrets.API_TOKEN }}"
1161  HELM_EXPR: "{{ .Values.image }}"
1162"#;
1163
1164        let rendered =
1165            render_template(tpl, &target, &global_vars, Path::new("templates/ci.yml")).expect("ok");
1166        assert!(rendered.contains("name: CI (service-auth)"));
1167        assert!(rendered.contains("SERVICE_NAME: \"service-auth\""));
1168        assert!(rendered.contains("SERVICE_PATH: \"repos/service-auth\""));
1169        assert!(rendered.contains("PORT: \"9091\""));
1170        assert!(rendered.contains("RUST_VERSION: \"1.85\""));
1171        assert!(rendered.contains("GH_SECRET: \"${{ secrets.API_TOKEN }}\""));
1172        assert!(rendered.contains("HELM_EXPR: \"{{ .Values.image }}\""));
1173    }
1174
1175    #[test]
1176    fn template_rendering_rejects_undefined_target_or_vars_keys() {
1177        let global_vars = BTreeMap::new();
1178        let target = TargetRepo {
1179            name: "service-auth".to_string(),
1180            path: "repos/service-auth".to_string(),
1181            repo: None,
1182            vars: BTreeMap::new(),
1183        };
1184
1185        let err1 = render_template(
1186            "service: {{ target.unknown }}",
1187            &target,
1188            &global_vars,
1189            Path::new("templates/ci.yml"),
1190        )
1191        .expect_err("should fail on unknown target field");
1192        assert!(matches!(err1, DriftError::UndefinedTemplateVar { .. }));
1193
1194        let err2 = render_template(
1195            "port: {{ vars.missing_port }}",
1196            &target,
1197            &global_vars,
1198            Path::new("templates/ci.yml"),
1199        )
1200        .expect_err("should fail on missing var");
1201        assert!(matches!(err2, DriftError::UndefinedTemplateVar { .. }));
1202    }
1203
1204    #[test]
1205    fn three_way_merge_preserves_local_edits_when_non_overlapping() {
1206        let base = "line1\nline2\nline3\nline4\n";
1207        let ours = "line1\nline2-local-custom\nline3\nline4\n";
1208        let theirs = "line1\nline2\nline3\nline4-upstream-update\n";
1209
1210        let eval = evaluate_file(
1211            "templates/cfg",
1212            "cfg",
1213            SyncStrategy::ThreeWay,
1214            Some(base),
1215            Some(ours),
1216            theirs,
1217        );
1218
1219        assert_eq!(eval.status, DriftStatus::BothChangedClean);
1220        assert_eq!(
1221            eval.plan,
1222            PlanAction::MergeClean {
1223                merged_content: "line1\nline2-local-custom\nline3\nline4-upstream-update\n"
1224                    .to_string(),
1225                new_base_content: theirs.to_string(),
1226            }
1227        );
1228    }
1229
1230    #[test]
1231    fn conflicting_edits_produce_conflict_without_overwriting() {
1232        let base = "timeout = 30\n";
1233        let ours = "timeout = 60\n";
1234        let theirs = "timeout = 15\n";
1235
1236        let eval = evaluate_file(
1237            "templates/cfg.toml",
1238            "cfg.toml",
1239            SyncStrategy::ThreeWay,
1240            Some(base),
1241            Some(ours),
1242            theirs,
1243        );
1244
1245        assert_eq!(eval.status, DriftStatus::BothChangedConflict);
1246        match &eval.plan {
1247            PlanAction::Conflict { conflict_preview } => {
1248                assert!(conflict_preview.contains("timeout"));
1249                assert!(conflict_preview.contains("60"));
1250                assert!(conflict_preview.contains("15"));
1251            }
1252            other => panic!("unexpected plan: {other:?}"),
1253        }
1254    }
1255
1256    #[test]
1257    fn toml_structural_merge_resolves_adjacent_keys_and_preserves_comments() {
1258        let base = r#"# Shared Rust settings
1259[package]
1260name = "service-a"
1261edition = "2021"
1262
1263[dependencies]
1264serde = "1.0"
1265"#;
1266        let ours = r#"# Shared Rust settings
1267[package]
1268name = "service-a"
1269edition = "2021" # local note
1270publish = false
1271
1272[dependencies]
1273# Database driver
1274sqlx = "0.8"
1275serde = "1.0"
1276"#;
1277        let theirs = r#"# Shared Rust settings
1278[package]
1279name = "service-a"
1280edition = "2024"
1281rust-version = "1.85"
1282
1283[dependencies]
1284serde = "1.0"
1285tracing = "0.1"
1286"#;
1287
1288        let eval = evaluate_file(
1289            "templates/Cargo.toml",
1290            "Cargo.toml",
1291            SyncStrategy::ThreeWay,
1292            Some(base),
1293            Some(ours),
1294            theirs,
1295        );
1296
1297        assert_eq!(eval.status, DriftStatus::BothChangedClean);
1298        match &eval.plan {
1299            PlanAction::MergeClean { merged_content, .. } => {
1300                assert!(merged_content.contains("# Shared Rust settings"));
1301                assert!(merged_content.contains("# Database driver"));
1302                assert!(merged_content.contains("edition = \"2024\" # local note"));
1303                assert!(merged_content.contains("publish = false"));
1304                assert!(merged_content.contains("rust-version = \"1.85\""));
1305                assert!(merged_content.contains("sqlx = \"0.8\""));
1306                assert!(merged_content.contains("tracing = \"0.1\""));
1307            }
1308            other => panic!("unexpected plan: {other:?}"),
1309        }
1310    }
1311
1312    #[test]
1313    fn toml_structural_merge_handles_inline_tables_recursively() {
1314        let base = "[dependencies]\ntokio = { version = \"1.38\", features = [\"rt\"] }\n";
1315        let ours = "[dependencies]\ntokio = { version = \"1.38\", features = [\"rt\", \"net\"] }\n";
1316        let theirs = "[dependencies]\ntokio = { version = \"1.40\", features = [\"rt\"] }\n";
1317
1318        let merged = merge_toml_structural(base, ours, theirs).expect("inline table merge");
1319        let doc: toml::Value = toml::from_str(&merged).expect("valid toml");
1320        assert_eq!(
1321            doc["dependencies"]["tokio"]["version"].as_str(),
1322            Some("1.40")
1323        );
1324        let features = doc["dependencies"]["tokio"]["features"]
1325            .as_array()
1326            .expect("features array");
1327        assert_eq!(features.len(), 2);
1328    }
1329
1330    #[test]
1331    fn json_structural_merge_combines_nested_objects_cleanly() {
1332        let base = r#"{
1333  "name": "web-app",
1334  "scripts": {
1335    "build": "tsc",
1336    "test": "vitest"
1337  }
1338}
1339"#;
1340        let ours = r#"{
1341  "name": "web-app",
1342  "scripts": {
1343    "build": "tsc",
1344    "dev": "vite",
1345    "test": "vitest"
1346  }
1347}
1348"#;
1349        let theirs = r#"{
1350  "name": "web-app",
1351  "scripts": {
1352    "build": "tsc",
1353    "lint": "eslint .",
1354    "test": "vitest run"
1355  }
1356}
1357"#;
1358
1359        let eval = evaluate_file(
1360            "templates/package.json",
1361            "package.json",
1362            SyncStrategy::ThreeWay,
1363            Some(base),
1364            Some(ours),
1365            theirs,
1366        );
1367
1368        assert_eq!(eval.status, DriftStatus::BothChangedClean);
1369        match &eval.plan {
1370            PlanAction::MergeClean { merged_content, .. } => {
1371                let parsed: serde_json::Value =
1372                    serde_json::from_str(merged_content).expect("valid json");
1373                assert_eq!(parsed["scripts"]["dev"], "vite");
1374                assert_eq!(parsed["scripts"]["lint"], "eslint .");
1375                assert_eq!(parsed["scripts"]["test"], "vitest run");
1376            }
1377            other => panic!("unexpected plan: {other:?}"),
1378        }
1379    }
1380
1381    #[test]
1382    fn yaml_structural_merge_combines_adjacent_keys_and_steps_without_conflict() {
1383        let base = r#"# CI Workflow
1384name: CI
1385on: [push]
1386jobs:
1387  test:
1388    runs-on: ubuntu-latest
1389    steps:
1390      - uses: actions/checkout@v4
1391      - run: cargo test --locked
1392"#;
1393        let ours = r#"# CI Workflow
1394name: CI
1395on: [push]
1396jobs:
1397  test:
1398    runs-on: ubuntu-latest
1399    permissions: read-all
1400    steps:
1401      - uses: actions/checkout@v4
1402      - run: cargo test --locked
1403      - run: ./scripts/verify-pci.sh
1404"#;
1405        let theirs = r#"# CI Workflow
1406name: CI
1407on: [push]
1408jobs:
1409  test:
1410    runs-on: ubuntu-24.04
1411    timeout-minutes: 20
1412    steps:
1413      - uses: actions/checkout@v4
1414      - run: cargo test --locked
1415      - run: cargo fmt --all -- --check
1416"#;
1417
1418        let eval = evaluate_file(
1419            "templates/ci.yml",
1420            ".github/workflows/ci.yml",
1421            SyncStrategy::ThreeWay,
1422            Some(base),
1423            Some(ours),
1424            theirs,
1425        );
1426
1427        assert_eq!(eval.status, DriftStatus::BothChangedClean);
1428        match &eval.plan {
1429            PlanAction::MergeClean { merged_content, .. } => {
1430                assert!(merged_content.contains("# CI Workflow"));
1431                assert!(merged_content.contains("runs-on: ubuntu-24.04"));
1432                assert!(merged_content.contains("permissions: read-all"));
1433                assert!(merged_content.contains("timeout-minutes: 20"));
1434                assert!(merged_content.contains("cargo fmt --all -- --check"));
1435                assert!(merged_content.contains("./scripts/verify-pci.sh"));
1436            }
1437            other => panic!("unexpected plan: {other:?}"),
1438        }
1439    }
1440
1441    #[test]
1442    fn markdown_report_and_pr_body_and_audit_check_work() {
1443        let eval = evaluate_file(
1444            "templates/.editorconfig",
1445            ".editorconfig",
1446            SyncStrategy::ThreeWay,
1447            Some("root = true\n"),
1448            Some("root = true\n"),
1449            "root = true\nindent_size = 4\n",
1450        );
1451        let report = RepoReport {
1452            target_name: "service-auth".to_string(),
1453            target_path: "repos/service-auth".to_string(),
1454            evaluations: vec![eval],
1455        };
1456
1457        assert!(has_actionable_drift(std::slice::from_ref(&report)));
1458        let md = generate_markdown_report(std::slice::from_ref(&report));
1459        assert!(md.contains("service-auth"));
1460        assert!(md.contains("UPSTREAM-CHANGED"));
1461
1462        let pr_body = generate_pr_body(&report, Some("abc1234"));
1463        assert!(pr_body.contains("abc1234"));
1464        assert!(pr_body.contains(".editorconfig"));
1465    }
1466
1467    proptest! {
1468        #[test]
1469        fn prop_local_changes_never_overwritten_when_upstream_unchanged(
1470            base in "[a-zA-Z0-9\n]{0,80}",
1471            ours in "[a-zA-Z0-9\n]{0,80}",
1472        ) {
1473            let eval = evaluate_file(
1474                "src.txt",
1475                "dst.txt",
1476                SyncStrategy::ThreeWay,
1477                Some(&base),
1478                Some(&ours),
1479                &base,
1480            );
1481            let preserves_local = matches!(eval.plan, PlanAction::NoOp | PlanAction::KeepLocal);
1482            prop_assert!(preserves_local, "unexpected plan: {:?}", eval.plan);
1483        }
1484
1485        #[test]
1486        fn prop_fast_forward_when_target_unmodified(
1487            base in "[a-zA-Z0-9\n]{0,80}",
1488            theirs in "[a-zA-Z0-9\n]{0,80}",
1489        ) {
1490            let eval = evaluate_file(
1491                "src.txt",
1492                "dst.txt",
1493                SyncStrategy::ThreeWay,
1494                Some(&base),
1495                Some(&base),
1496                &theirs,
1497            );
1498            let expected_theirs = normalize_newlines(&theirs);
1499            let expected_base = normalize_newlines(&base);
1500            if expected_base == expected_theirs {
1501                prop_assert_eq!(eval.status, DriftStatus::InSync);
1502                prop_assert_eq!(eval.plan, PlanAction::NoOp);
1503            } else {
1504                prop_assert_eq!(eval.status, DriftStatus::UpstreamChanged);
1505                prop_assert_eq!(
1506                    eval.plan,
1507                    PlanAction::UpdateFastForward {
1508                        new_content: expected_theirs
1509                    }
1510                );
1511            }
1512        }
1513
1514        #[test]
1515        fn prop_idempotent_after_apply(
1516            base in "[a-z]{1,10}\n[a-z]{1,10}\n[a-z]{1,10}\n",
1517            ours in "[a-z]{1,10}\n[a-z]{1,10}\n[a-z]{1,10}\n",
1518            theirs in "[a-z]{1,10}\n[a-z]{1,10}\n[a-z]{1,10}\n",
1519        ) {
1520            let first = evaluate_file(
1521                "src.txt",
1522                "dst.txt",
1523                SyncStrategy::ThreeWay,
1524                Some(&base),
1525                Some(&ours),
1526                &theirs,
1527            );
1528
1529            let (next_base, next_ours) = match &first.plan {
1530                PlanAction::Create { new_content }
1531                | PlanAction::UpdateFastForward { new_content }
1532                | PlanAction::OverwriteWithUpstream { new_content } => {
1533                    (new_content.clone(), new_content.clone())
1534                }
1535                PlanAction::MergeClean { merged_content, new_base_content } => {
1536                    (new_base_content.clone(), merged_content.clone())
1537                }
1538                PlanAction::RecordLock { base_content } => {
1539                    (base_content.clone(), normalize_newlines(&ours))
1540                }
1541                PlanAction::NoOp | PlanAction::KeepLocal | PlanAction::NeedsAdopt | PlanAction::Conflict { .. } => {
1542                    return Ok(());
1543                }
1544            };
1545
1546            let second = evaluate_file(
1547                "src.txt",
1548                "dst.txt",
1549                SyncStrategy::ThreeWay,
1550                Some(&next_base),
1551                Some(&next_ours),
1552                &theirs,
1553            );
1554
1555            let no_second_write = matches!(second.plan, PlanAction::NoOp | PlanAction::KeepLocal);
1556            prop_assert!(no_second_write, "second evaluation produced write: {:?}", second);
1557        }
1558
1559        #[test]
1560        fn prop_toml_disjoint_keys_always_merge_cleanly(
1561            local_key in "local_[a-z]{1,8}",
1562            upstream_key in "upstream_[a-z]{1,8}",
1563            local_val in 1i64..10_000,
1564            upstream_val in 1i64..10_000,
1565        ) {
1566            let base = "[settings]\nshared = 1\n";
1567            let ours = format!("[settings]\nshared = 1\n{local_key} = {local_val}\n");
1568            let theirs = format!("[settings]\nshared = 1\n{upstream_key} = {upstream_val}\n");
1569
1570            let eval = evaluate_file(
1571                "templates/config.toml",
1572                "config.toml",
1573                SyncStrategy::ThreeWay,
1574                Some(base),
1575                Some(&ours),
1576                &theirs,
1577            );
1578
1579            prop_assert_eq!(eval.status, DriftStatus::BothChangedClean);
1580            if let PlanAction::MergeClean { merged_content, .. } = eval.plan {
1581                let parsed: toml::Value = toml::from_str(&merged_content).expect("valid merged toml");
1582                prop_assert_eq!(parsed["settings"][&local_key].as_integer(), Some(local_val));
1583                prop_assert_eq!(parsed["settings"][&upstream_key].as_integer(), Some(upstream_val));
1584                prop_assert_eq!(parsed["settings"]["shared"].as_integer(), Some(1));
1585            } else {
1586                prop_assert!(false, "expected MergeClean");
1587            }
1588        }
1589
1590        #[test]
1591        fn prop_json_disjoint_keys_always_merge_cleanly(
1592            local_key in "local_[a-z]{1,8}",
1593            upstream_key in "upstream_[a-z]{1,8}",
1594            local_val in 1i64..10_000,
1595            upstream_val in 1i64..10_000,
1596        ) {
1597            let base = "{\"settings\":{\"shared\":1}}\n";
1598            let ours = format!("{{\"settings\":{{\"shared\":1,\"{local_key}\":{local_val}}}}}\n");
1599            let theirs = format!("{{\"settings\":{{\"shared\":1,\"{upstream_key}\":{upstream_val}}}}}\n");
1600
1601            let eval = evaluate_file(
1602                "templates/config.json",
1603                "config.json",
1604                SyncStrategy::ThreeWay,
1605                Some(base),
1606                Some(&ours),
1607                &theirs,
1608            );
1609
1610            prop_assert_eq!(eval.status, DriftStatus::BothChangedClean);
1611            if let PlanAction::MergeClean { merged_content, .. } = eval.plan {
1612                let parsed: serde_json::Value = serde_json::from_str(&merged_content).expect("valid merged json");
1613                prop_assert_eq!(parsed["settings"][&local_key].as_i64(), Some(local_val));
1614                prop_assert_eq!(parsed["settings"][&upstream_key].as_i64(), Some(upstream_val));
1615                prop_assert_eq!(parsed["settings"]["shared"].as_i64(), Some(1));
1616            } else {
1617                prop_assert!(false, "expected MergeClean");
1618            }
1619        }
1620    }
1621}