1use anyhow::{Context, Result};
98use clap::{Parser, Subcommand};
99use colored::Colorize;
100use notify::{Event, RecommendedWatcher, RecursiveMode, Watcher};
101use similar::TextDiff;
102use std::collections::{BTreeMap, BTreeSet};
103use std::ffi::OsString;
104use std::path::{Path, PathBuf};
105use std::sync::mpsc;
106use std::time::{Duration, Instant};
107use typewriter_engine::drift::{self, DriftStatus};
108use typewriter_engine::emit::{self, language_label};
109use typewriter_engine::{parse_languages, project, scan};
110
111#[derive(Parser, Debug)]
112#[command(
113 name = "typewriter",
114 about = "Generate and verify cross-language types"
115)]
116struct Cli {
117 #[command(subcommand)]
118 command: Commands,
119}
120
121#[derive(Subcommand, Debug)]
122enum Commands {
123 Generate {
125 file: Option<PathBuf>,
127 #[arg(long)]
129 all: bool,
130 #[arg(long, value_delimiter = ',')]
132 lang: Vec<String>,
133 #[arg(long)]
135 diff: bool,
136 },
137 Check {
139 #[arg(long)]
141 ci: bool,
142 #[arg(long)]
144 json: bool,
145 #[arg(long)]
147 json_out: Option<PathBuf>,
148 #[arg(long, value_delimiter = ',')]
150 lang: Vec<String>,
151 },
152 Watch {
154 path: Option<PathBuf>,
156 #[arg(long, value_delimiter = ',')]
158 lang: Vec<String>,
159 #[arg(long, default_value_t = 50)]
161 debounce_ms: u64,
162 },
163}
164
165pub fn run() -> Result<i32> {
166 run_with_args(std::env::args_os())
167}
168
169pub fn run_with_args<I, T>(args: I) -> Result<i32>
170where
171 I: IntoIterator<Item = T>,
172 T: Into<OsString> + Clone,
173{
174 let cli = Cli::try_parse_from(args).map_err(|err| anyhow::anyhow!(err.to_string()))?;
175
176 match cli.command {
177 Commands::Generate {
178 file,
179 all,
180 lang,
181 diff,
182 } => cmd_generate(file, all, lang, diff),
183 Commands::Check {
184 ci,
185 json,
186 json_out,
187 lang,
188 } => cmd_check(ci, json, json_out, lang),
189 Commands::Watch {
190 path,
191 lang,
192 debounce_ms,
193 } => cmd_watch(path, lang, debounce_ms),
194 }
195}
196
197fn cmd_generate(file: Option<PathBuf>, all: bool, lang: Vec<String>, diff: bool) -> Result<i32> {
198 if all == file.is_some() {
199 anyhow::bail!("use exactly one input mode: either `generate <file>` or `generate --all`");
200 }
201
202 let cwd = std::env::current_dir()?;
203 let project_root = project::discover_project_root(&cwd)?;
204 let config = project::load_config(&project_root).unwrap_or_default();
205 let lang_filter = parse_languages(&lang)?;
206
207 let specs = if all {
208 scan::scan_project(&project_root)?
209 } else {
210 let source = resolve_input_path(file.expect("validated"), &cwd);
211 scan::scan_file(&source)?
212 };
213
214 let rendered = emit::render_specs_deduped(&specs, &project_root, &config, &lang_filter, false)?;
215
216 let started = Instant::now();
217 let mut updated = 0usize;
218 let mut created = 0usize;
219 let mut unchanged = 0usize;
220
221 let mut before_contents = BTreeMap::new();
222 for file in &rendered {
223 if let Ok(existing) = std::fs::read_to_string(&file.output_path) {
224 before_contents.insert(file.output_path.clone(), existing);
225 }
226 }
227
228 emit::write_generated_files(&rendered)?;
229
230 for file in &rendered {
231 let rel = rel_path(&project_root, &file.output_path);
232 match before_contents.get(&file.output_path) {
233 None => {
234 created += 1;
235 eprintln!(
236 "{} {} [{}]",
237 "Created".green(),
238 rel,
239 language_label(file.language)
240 );
241 if diff {
242 print_diff(&project_root, &file.output_path, "", &file.content);
243 }
244 }
245 Some(existing) if existing == &file.content => {
246 unchanged += 1;
247 eprintln!(
248 "{} {} [{}]",
249 "Unchanged".bright_black(),
250 rel,
251 language_label(file.language)
252 );
253 }
254 Some(existing) => {
255 updated += 1;
256 eprintln!(
257 "{} {} [{}]",
258 "Updated".yellow(),
259 rel,
260 language_label(file.language)
261 );
262 if diff {
263 print_diff(&project_root, &file.output_path, existing, &file.content);
264 }
265 }
266 }
267 }
268
269 eprintln!(
270 "{} in {}ms (created: {}, updated: {}, unchanged: {})",
271 "Generation complete".bold(),
272 started.elapsed().as_millis(),
273 created,
274 updated,
275 unchanged
276 );
277
278 Ok(0)
279}
280
281fn cmd_check(ci: bool, json: bool, json_out: Option<PathBuf>, lang: Vec<String>) -> Result<i32> {
282 let cwd = std::env::current_dir()?;
283 let project_root = project::discover_project_root(&cwd)?;
284 let config = project::load_config(&project_root).unwrap_or_default();
285 let lang_filter = parse_languages(&lang)?;
286
287 let specs = scan::scan_project(&project_root)?;
288 let rendered = emit::render_specs_deduped(&specs, &project_root, &config, &lang_filter, false)?;
289
290 let report = drift::build_drift_report(&rendered, &project_root, &config, &lang_filter)?;
291
292 if !json {
293 print_human_report(&report);
294 } else {
295 let output = serde_json::to_string_pretty(&report)?;
296 println!("{}", output);
297 }
298
299 if let Some(path) = json_out {
300 let full = resolve_input_path(path, &cwd);
301 if let Some(parent) = full.parent() {
302 std::fs::create_dir_all(parent)?;
303 }
304 std::fs::write(&full, serde_json::to_string_pretty(&report)?)
305 .with_context(|| format!("failed to write json report to {}", full.display()))?;
306 eprintln!("{} {}", "Wrote JSON report: ".green(), full.display());
307 }
308
309 if ci && drift::has_drift(&report.summary) {
310 eprintln!("{} drift detected in CI mode", "Error:".red().bold());
311 return Ok(1);
312 }
313
314 Ok(0)
315}
316
317fn cmd_watch(path: Option<PathBuf>, lang: Vec<String>, debounce_ms: u64) -> Result<i32> {
318 let cwd = std::env::current_dir()?;
319 let project_root = project::discover_project_root(&cwd)?;
320 let watch_root = resolve_input_path(path.unwrap_or_else(|| PathBuf::from("src")), &cwd);
321 let config = project::load_config(&project_root).unwrap_or_default();
322 let lang_filter = parse_languages(&lang)?;
323
324 let (tx, rx) = mpsc::channel();
325 let mut watcher = RecommendedWatcher::new(
326 move |result| {
327 let _ = tx.send(result);
328 },
329 notify::Config::default(),
330 )?;
331
332 watcher.watch(&watch_root, RecursiveMode::Recursive)?;
333
334 eprintln!(
335 "{} {} (debounce={}ms)",
336 "Watching".green().bold(),
337 watch_root.display(),
338 debounce_ms
339 );
340
341 loop {
342 let first = match rx.recv() {
343 Ok(event) => event,
344 Err(err) => {
345 eprintln!("{} watcher channel closed: {}", "Error:".red(), err);
346 return Ok(1);
347 }
348 };
349
350 let mut changed_files = BTreeSet::new();
351 collect_changed_rust_files(first, &mut changed_files);
352
353 while let Ok(event) = rx.recv_timeout(Duration::from_millis(debounce_ms)) {
354 collect_changed_rust_files(event, &mut changed_files);
355 }
356
357 if changed_files.is_empty() {
358 continue;
359 }
360
361 let batch_started = Instant::now();
362 let mut specs = Vec::new();
363
364 for changed in &changed_files {
365 eprintln!("{} {}", "Changed:".cyan(), rel_path(&project_root, changed));
366 if changed.exists() {
367 match scan::scan_file(changed) {
368 Ok(mut found) => specs.append(&mut found),
369 Err(err) => eprintln!("{} {}", "Scan error:".red(), err),
370 }
371 }
372 }
373
374 if specs.is_empty() {
375 continue;
376 }
377
378 let mut names: Vec<_> = specs
379 .iter()
380 .map(|s| s.type_def.name().to_string())
381 .collect();
382 names.sort();
383 names.dedup();
384 for name in names {
385 eprintln!("{} {}", "Detected TypeWriter type:".blue(), name);
386 }
387
388 let rendered =
389 emit::render_specs_deduped(&specs, &project_root, &config, &lang_filter, false)?;
390
391 let mut updated = 0usize;
392 for file in rendered {
393 let before = std::fs::read_to_string(&file.output_path).ok();
394 emit::write_generated_files(std::slice::from_ref(&file))?;
395
396 let changed = before.map(|c| c != file.content).unwrap_or(true);
397 if changed {
398 updated += 1;
399 }
400
401 eprintln!(
402 "{} {} [{}]",
403 "Regenerated".green(),
404 rel_path(&project_root, &file.output_path),
405 language_label(file.language)
406 );
407 }
408
409 eprintln!(
410 "{} {} file(s) in {}ms",
411 "Done".bold(),
412 updated,
413 batch_started.elapsed().as_millis()
414 );
415 }
416}
417
418fn print_human_report(report: &drift::DriftReport) {
419 for entry in &report.entries {
420 let symbol = match entry.status {
421 DriftStatus::UpToDate => "OK".green(),
422 DriftStatus::OutOfSync => "DRIFT".yellow(),
423 DriftStatus::Missing => "MISSING".red(),
424 DriftStatus::Orphaned => "ORPHAN".magenta(),
425 };
426
427 eprintln!(
428 "{} {} [{}] - {}",
429 symbol, entry.output_path, entry.language, entry.reason
430 );
431 }
432
433 eprintln!(
434 "{} up_to_date={}, out_of_sync={}, missing={}, orphaned={}",
435 "Summary:".bold(),
436 report.summary.up_to_date,
437 report.summary.out_of_sync,
438 report.summary.missing,
439 report.summary.orphaned
440 );
441}
442
443fn print_diff(project_root: &Path, path: &Path, before: &str, after: &str) {
444 let rel = rel_path(project_root, path);
445 let diff = TextDiff::from_lines(before, after)
446 .unified_diff()
447 .context_radius(3)
448 .header(&format!("a/{}", rel), &format!("b/{}", rel))
449 .to_string();
450
451 if !diff.trim().is_empty() {
452 println!("{}", diff);
453 }
454}
455
456fn collect_changed_rust_files(event: Result<Event, notify::Error>, files: &mut BTreeSet<PathBuf>) {
457 let event = match event {
458 Ok(event) => event,
459 Err(err) => {
460 eprintln!("{} {}", "Watch error:".red(), err);
461 return;
462 }
463 };
464
465 for path in event.paths {
466 if path.extension().map(|ext| ext == "rs").unwrap_or(false) {
467 files.insert(path);
468 }
469 }
470}
471
472fn rel_path(root: &Path, path: &Path) -> String {
473 path.strip_prefix(root)
474 .unwrap_or(path)
475 .display()
476 .to_string()
477}
478
479fn resolve_input_path(path: PathBuf, cwd: &Path) -> PathBuf {
480 if path.is_absolute() {
481 path
482 } else {
483 cwd.join(path)
484 }
485}
486
487#[cfg(test)]
488mod tests {
489 use super::*;
490
491 #[test]
492 fn rejects_invalid_generate_mode() {
493 let err = run_with_args(["typewriter", "generate"]).unwrap_err();
494 assert!(err.to_string().contains("use exactly one input mode"));
495 }
496
497 #[test]
498 fn parses_comma_separated_langs() {
499 let parsed = parse_languages(&["typescript,python".to_string()]).unwrap();
500 assert_eq!(
501 parsed,
502 vec![
503 typewriter_engine::Language::TypeScript,
504 typewriter_engine::Language::Python
505 ]
506 );
507 }
508}