1use anyhow::{Context, Result};
2use clap::{Parser, Subcommand};
3use colored::Colorize;
4use notify::{Event, RecommendedWatcher, RecursiveMode, Watcher};
5use similar::TextDiff;
6use std::collections::{BTreeMap, BTreeSet};
7use std::ffi::OsString;
8use std::path::{Path, PathBuf};
9use std::sync::mpsc;
10use std::time::{Duration, Instant};
11use typewriter_engine::drift::{self, DriftStatus};
12use typewriter_engine::emit::{self, language_label};
13use typewriter_engine::{parse_languages, project, scan};
14
15#[derive(Parser, Debug)]
16#[command(
17 name = "typewriter",
18 about = "Generate and verify cross-language types"
19)]
20struct Cli {
21 #[command(subcommand)]
22 command: Commands,
23}
24
25#[derive(Subcommand, Debug)]
26enum Commands {
27 Generate {
29 file: Option<PathBuf>,
31 #[arg(long)]
33 all: bool,
34 #[arg(long, value_delimiter = ',')]
36 lang: Vec<String>,
37 #[arg(long)]
39 diff: bool,
40 },
41 Check {
43 #[arg(long)]
45 ci: bool,
46 #[arg(long)]
48 json: bool,
49 #[arg(long)]
51 json_out: Option<PathBuf>,
52 #[arg(long, value_delimiter = ',')]
54 lang: Vec<String>,
55 },
56 Watch {
58 path: Option<PathBuf>,
60 #[arg(long, value_delimiter = ',')]
62 lang: Vec<String>,
63 #[arg(long, default_value_t = 50)]
65 debounce_ms: u64,
66 },
67}
68
69pub fn run() -> Result<i32> {
70 run_with_args(std::env::args_os())
71}
72
73pub fn run_with_args<I, T>(args: I) -> Result<i32>
74where
75 I: IntoIterator<Item = T>,
76 T: Into<OsString> + Clone,
77{
78 let cli = Cli::try_parse_from(args).map_err(|err| anyhow::anyhow!(err.to_string()))?;
79
80 match cli.command {
81 Commands::Generate {
82 file,
83 all,
84 lang,
85 diff,
86 } => cmd_generate(file, all, lang, diff),
87 Commands::Check {
88 ci,
89 json,
90 json_out,
91 lang,
92 } => cmd_check(ci, json, json_out, lang),
93 Commands::Watch {
94 path,
95 lang,
96 debounce_ms,
97 } => cmd_watch(path, lang, debounce_ms),
98 }
99}
100
101fn cmd_generate(file: Option<PathBuf>, all: bool, lang: Vec<String>, diff: bool) -> Result<i32> {
102 if all == file.is_some() {
103 anyhow::bail!("use exactly one input mode: either `generate <file>` or `generate --all`");
104 }
105
106 let cwd = std::env::current_dir()?;
107 let project_root = project::discover_project_root(&cwd)?;
108 let config = project::load_config(&project_root).unwrap_or_default();
109 let lang_filter = parse_languages(&lang)?;
110
111 let specs = if all {
112 scan::scan_project(&project_root)?
113 } else {
114 let source = resolve_input_path(file.expect("validated"), &cwd);
115 scan::scan_file(&source)?
116 };
117
118 let rendered = emit::render_specs_deduped(&specs, &project_root, &config, &lang_filter, false)?;
119
120 let started = Instant::now();
121 let mut updated = 0usize;
122 let mut created = 0usize;
123 let mut unchanged = 0usize;
124
125 let mut before_contents = BTreeMap::new();
126 for file in &rendered {
127 if let Ok(existing) = std::fs::read_to_string(&file.output_path) {
128 before_contents.insert(file.output_path.clone(), existing);
129 }
130 }
131
132 emit::write_generated_files(&rendered)?;
133
134 for file in &rendered {
135 let rel = rel_path(&project_root, &file.output_path);
136 match before_contents.get(&file.output_path) {
137 None => {
138 created += 1;
139 eprintln!(
140 "{} {} [{}]",
141 "Created".green(),
142 rel,
143 language_label(file.language)
144 );
145 if diff {
146 print_diff(&project_root, &file.output_path, "", &file.content);
147 }
148 }
149 Some(existing) if existing == &file.content => {
150 unchanged += 1;
151 eprintln!(
152 "{} {} [{}]",
153 "Unchanged".bright_black(),
154 rel,
155 language_label(file.language)
156 );
157 }
158 Some(existing) => {
159 updated += 1;
160 eprintln!(
161 "{} {} [{}]",
162 "Updated".yellow(),
163 rel,
164 language_label(file.language)
165 );
166 if diff {
167 print_diff(&project_root, &file.output_path, existing, &file.content);
168 }
169 }
170 }
171 }
172
173 eprintln!(
174 "{} in {}ms (created: {}, updated: {}, unchanged: {})",
175 "Generation complete".bold(),
176 started.elapsed().as_millis(),
177 created,
178 updated,
179 unchanged
180 );
181
182 Ok(0)
183}
184
185fn cmd_check(ci: bool, json: bool, json_out: Option<PathBuf>, lang: Vec<String>) -> Result<i32> {
186 let cwd = std::env::current_dir()?;
187 let project_root = project::discover_project_root(&cwd)?;
188 let config = project::load_config(&project_root).unwrap_or_default();
189 let lang_filter = parse_languages(&lang)?;
190
191 let specs = scan::scan_project(&project_root)?;
192 let rendered = emit::render_specs_deduped(&specs, &project_root, &config, &lang_filter, false)?;
193
194 let report = drift::build_drift_report(&rendered, &project_root, &config, &lang_filter)?;
195
196 if !json {
197 print_human_report(&report);
198 } else {
199 let output = serde_json::to_string_pretty(&report)?;
200 println!("{}", output);
201 }
202
203 if let Some(path) = json_out {
204 let full = resolve_input_path(path, &cwd);
205 if let Some(parent) = full.parent() {
206 std::fs::create_dir_all(parent)?;
207 }
208 std::fs::write(&full, serde_json::to_string_pretty(&report)?)
209 .with_context(|| format!("failed to write json report to {}", full.display()))?;
210 eprintln!("{} {}", "Wrote JSON report: ".green(), full.display());
211 }
212
213 if ci && drift::has_drift(&report.summary) {
214 eprintln!("{} drift detected in CI mode", "Error:".red().bold());
215 return Ok(1);
216 }
217
218 Ok(0)
219}
220
221fn cmd_watch(path: Option<PathBuf>, lang: Vec<String>, debounce_ms: u64) -> Result<i32> {
222 let cwd = std::env::current_dir()?;
223 let project_root = project::discover_project_root(&cwd)?;
224 let watch_root = resolve_input_path(path.unwrap_or_else(|| PathBuf::from("src")), &cwd);
225 let config = project::load_config(&project_root).unwrap_or_default();
226 let lang_filter = parse_languages(&lang)?;
227
228 let (tx, rx) = mpsc::channel();
229 let mut watcher = RecommendedWatcher::new(
230 move |result| {
231 let _ = tx.send(result);
232 },
233 notify::Config::default(),
234 )?;
235
236 watcher.watch(&watch_root, RecursiveMode::Recursive)?;
237
238 eprintln!(
239 "{} {} (debounce={}ms)",
240 "Watching".green().bold(),
241 watch_root.display(),
242 debounce_ms
243 );
244
245 loop {
246 let first = match rx.recv() {
247 Ok(event) => event,
248 Err(err) => {
249 eprintln!("{} watcher channel closed: {}", "Error:".red(), err);
250 return Ok(1);
251 }
252 };
253
254 let mut changed_files = BTreeSet::new();
255 collect_changed_rust_files(first, &mut changed_files);
256
257 while let Ok(event) = rx.recv_timeout(Duration::from_millis(debounce_ms)) {
258 collect_changed_rust_files(event, &mut changed_files);
259 }
260
261 if changed_files.is_empty() {
262 continue;
263 }
264
265 let batch_started = Instant::now();
266 let mut specs = Vec::new();
267
268 for changed in &changed_files {
269 eprintln!("{} {}", "Changed:".cyan(), rel_path(&project_root, changed));
270 if changed.exists() {
271 match scan::scan_file(changed) {
272 Ok(mut found) => specs.append(&mut found),
273 Err(err) => eprintln!("{} {}", "Scan error:".red(), err),
274 }
275 }
276 }
277
278 if specs.is_empty() {
279 continue;
280 }
281
282 let mut names: Vec<_> = specs
283 .iter()
284 .map(|s| s.type_def.name().to_string())
285 .collect();
286 names.sort();
287 names.dedup();
288 for name in names {
289 eprintln!("{} {}", "Detected TypeWriter type:".blue(), name);
290 }
291
292 let rendered =
293 emit::render_specs_deduped(&specs, &project_root, &config, &lang_filter, false)?;
294
295 let mut updated = 0usize;
296 for file in rendered {
297 let before = std::fs::read_to_string(&file.output_path).ok();
298 emit::write_generated_files(std::slice::from_ref(&file))?;
299
300 let changed = before.map(|c| c != file.content).unwrap_or(true);
301 if changed {
302 updated += 1;
303 }
304
305 eprintln!(
306 "{} {} [{}]",
307 "Regenerated".green(),
308 rel_path(&project_root, &file.output_path),
309 language_label(file.language)
310 );
311 }
312
313 eprintln!(
314 "{} {} file(s) in {}ms",
315 "Done".bold(),
316 updated,
317 batch_started.elapsed().as_millis()
318 );
319 }
320}
321
322fn print_human_report(report: &drift::DriftReport) {
323 for entry in &report.entries {
324 let symbol = match entry.status {
325 DriftStatus::UpToDate => "OK".green(),
326 DriftStatus::OutOfSync => "DRIFT".yellow(),
327 DriftStatus::Missing => "MISSING".red(),
328 DriftStatus::Orphaned => "ORPHAN".magenta(),
329 };
330
331 eprintln!(
332 "{} {} [{}] - {}",
333 symbol, entry.output_path, entry.language, entry.reason
334 );
335 }
336
337 eprintln!(
338 "{} up_to_date={}, out_of_sync={}, missing={}, orphaned={}",
339 "Summary:".bold(),
340 report.summary.up_to_date,
341 report.summary.out_of_sync,
342 report.summary.missing,
343 report.summary.orphaned
344 );
345}
346
347fn print_diff(project_root: &Path, path: &Path, before: &str, after: &str) {
348 let rel = rel_path(project_root, path);
349 let diff = TextDiff::from_lines(before, after)
350 .unified_diff()
351 .context_radius(3)
352 .header(&format!("a/{}", rel), &format!("b/{}", rel))
353 .to_string();
354
355 if !diff.trim().is_empty() {
356 println!("{}", diff);
357 }
358}
359
360fn collect_changed_rust_files(event: Result<Event, notify::Error>, files: &mut BTreeSet<PathBuf>) {
361 let event = match event {
362 Ok(event) => event,
363 Err(err) => {
364 eprintln!("{} {}", "Watch error:".red(), err);
365 return;
366 }
367 };
368
369 for path in event.paths {
370 if path.extension().map(|ext| ext == "rs").unwrap_or(false) {
371 files.insert(path);
372 }
373 }
374}
375
376fn rel_path(root: &Path, path: &Path) -> String {
377 path.strip_prefix(root)
378 .unwrap_or(path)
379 .display()
380 .to_string()
381}
382
383fn resolve_input_path(path: PathBuf, cwd: &Path) -> PathBuf {
384 if path.is_absolute() {
385 path
386 } else {
387 cwd.join(path)
388 }
389}
390
391#[cfg(test)]
392mod tests {
393 use super::*;
394
395 #[test]
396 fn rejects_invalid_generate_mode() {
397 let err = run_with_args(["typewriter", "generate"]).unwrap_err();
398 assert!(err.to_string().contains("use exactly one input mode"));
399 }
400
401 #[test]
402 fn parses_comma_separated_langs() {
403 let parsed = parse_languages(&["typescript,python".to_string()]).unwrap();
404 assert_eq!(
405 parsed,
406 vec![
407 typewriter_engine::Language::TypeScript,
408 typewriter_engine::Language::Python
409 ]
410 );
411 }
412}