Skip to main content

typewriter_engine/
drift.rs

1//! Drift detection between expected generated output and on-disk files.
2
3use anyhow::Result;
4use serde::Serialize;
5use std::collections::{BTreeMap, BTreeSet};
6use std::path::Path;
7use std::time::{SystemTime, UNIX_EPOCH};
8use walkdir::WalkDir;
9
10use crate::emit::{file_extension, language_label, output_dir_for_language, GeneratedFile};
11use crate::{all_languages, Language, TypewriterConfig};
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
14#[serde(rename_all = "snake_case")]
15pub enum DriftStatus {
16    UpToDate,
17    OutOfSync,
18    Missing,
19    Orphaned,
20}
21
22#[derive(Debug, Clone, Serialize)]
23pub struct DriftEntry {
24    pub type_name: String,
25    pub language: String,
26    pub output_path: String,
27    pub status: DriftStatus,
28    pub reason: String,
29}
30
31#[derive(Debug, Clone, Default, Serialize)]
32pub struct DriftSummary {
33    pub up_to_date: usize,
34    pub out_of_sync: usize,
35    pub missing: usize,
36    pub orphaned: usize,
37}
38
39#[derive(Debug, Clone, Serialize)]
40pub struct DriftReport {
41    pub project_root: String,
42    pub generated_at: String,
43    pub summary: DriftSummary,
44    pub entries: Vec<DriftEntry>,
45}
46
47pub fn build_drift_report(
48    expected_files: &[GeneratedFile],
49    project_root: &Path,
50    config: &TypewriterConfig,
51    language_scope: &[Language],
52) -> Result<DriftReport> {
53    let mut entries = Vec::new();
54    let mut expected_by_path = BTreeMap::new();
55
56    for file in expected_files {
57        expected_by_path.insert(file.output_path.clone(), file);
58    }
59
60    for (path, file) in &expected_by_path {
61        let status = if !path.exists() {
62            DriftStatus::Missing
63        } else {
64            let existing = std::fs::read_to_string(path).unwrap_or_default();
65            if existing == file.content {
66                DriftStatus::UpToDate
67            } else {
68                DriftStatus::OutOfSync
69            }
70        };
71
72        let reason = match status {
73            DriftStatus::UpToDate => "generated content matches existing file",
74            DriftStatus::OutOfSync => "existing file differs from generated content",
75            DriftStatus::Missing => "expected generated file does not exist",
76            DriftStatus::Orphaned => "",
77        }
78        .to_string();
79
80        entries.push(DriftEntry {
81            type_name: file.type_name.clone(),
82            language: language_label(file.language).to_string(),
83            output_path: rel_path(project_root, path),
84            status,
85            reason,
86        });
87    }
88
89    let scope = if language_scope.is_empty() {
90        all_languages()
91    } else {
92        language_scope.to_vec()
93    };
94
95    let expected_paths: BTreeSet<_> = expected_by_path.keys().cloned().collect();
96
97    for language in scope {
98        let output_dir = project_root.join(output_dir_for_language(config, language));
99        let ext = file_extension(language);
100
101        if !output_dir.exists() {
102            continue;
103        }
104
105        for entry in WalkDir::new(&output_dir).into_iter().filter_map(|e| e.ok()) {
106            if !entry.file_type().is_file() {
107                continue;
108            }
109
110            if entry
111                .path()
112                .extension()
113                .map(|found| found == ext)
114                .unwrap_or(false)
115                && !expected_paths.contains(entry.path())
116                && is_typewriter_generated_file(entry.path())
117            {
118                let type_name = entry
119                    .path()
120                    .file_stem()
121                    .and_then(|s| s.to_str())
122                    .unwrap_or("unknown")
123                    .to_string();
124
125                entries.push(DriftEntry {
126                    type_name,
127                    language: language_label(language).to_string(),
128                    output_path: rel_path(project_root, entry.path()),
129                    status: DriftStatus::Orphaned,
130                    reason:
131                        "generated file exists but no matching Rust TypeWriter source was found"
132                            .to_string(),
133                });
134            }
135        }
136    }
137
138    entries.sort_by(|a, b| a.output_path.cmp(&b.output_path));
139
140    let summary = summarize(&entries);
141
142    let generated_at = SystemTime::now()
143        .duration_since(UNIX_EPOCH)
144        .map(|d| d.as_secs().to_string())
145        .unwrap_or_else(|_| "0".to_string());
146
147    Ok(DriftReport {
148        project_root: project_root.display().to_string(),
149        generated_at,
150        summary,
151        entries,
152    })
153}
154
155pub fn summarize(entries: &[DriftEntry]) -> DriftSummary {
156    let mut summary = DriftSummary::default();
157    for entry in entries {
158        match entry.status {
159            DriftStatus::UpToDate => summary.up_to_date += 1,
160            DriftStatus::OutOfSync => summary.out_of_sync += 1,
161            DriftStatus::Missing => summary.missing += 1,
162            DriftStatus::Orphaned => summary.orphaned += 1,
163        }
164    }
165    summary
166}
167
168pub fn has_drift(summary: &DriftSummary) -> bool {
169    summary.out_of_sync > 0 || summary.missing > 0 || summary.orphaned > 0
170}
171
172fn rel_path(root: &Path, path: &Path) -> String {
173    path.strip_prefix(root)
174        .unwrap_or(path)
175        .display()
176        .to_string()
177}
178
179fn is_typewriter_generated_file(path: &Path) -> bool {
180    let content = match std::fs::read_to_string(path) {
181        Ok(content) => content,
182        Err(_) => return false,
183    };
184
185    content.contains("generated by typewriter")
186}
187
188#[cfg(test)]
189mod tests {
190    use super::*;
191
192    #[test]
193    fn summarizes_status_counts() {
194        let entries = vec![
195            DriftEntry {
196                type_name: "A".into(),
197                language: "typescript".into(),
198                output_path: "a.ts".into(),
199                status: DriftStatus::UpToDate,
200                reason: String::new(),
201            },
202            DriftEntry {
203                type_name: "B".into(),
204                language: "typescript".into(),
205                output_path: "b.ts".into(),
206                status: DriftStatus::OutOfSync,
207                reason: String::new(),
208            },
209            DriftEntry {
210                type_name: "C".into(),
211                language: "python".into(),
212                output_path: "c.py".into(),
213                status: DriftStatus::Missing,
214                reason: String::new(),
215            },
216            DriftEntry {
217                type_name: "D".into(),
218                language: "go".into(),
219                output_path: "d.go".into(),
220                status: DriftStatus::Orphaned,
221                reason: String::new(),
222            },
223        ];
224
225        let summary = summarize(&entries);
226        assert_eq!(summary.up_to_date, 1);
227        assert_eq!(summary.out_of_sync, 1);
228        assert_eq!(summary.missing, 1);
229        assert_eq!(summary.orphaned, 1);
230        assert!(has_drift(&summary));
231    }
232}