1use 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}