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::LanguageTarget;
108use typewriter_engine::drift::{self, DriftStatus};
109use typewriter_engine::emit;
110use typewriter_engine::plugin_registry;
111use typewriter_engine::{parse_languages, project, scan};
112
113#[derive(Parser, Debug)]
114#[command(
115 name = "typewriter",
116 about = "Generate and verify cross-language types"
117)]
118struct Cli {
119 #[command(subcommand)]
120 command: Commands,
121}
122
123#[derive(Subcommand, Debug)]
124enum Commands {
125 Generate {
127 file: Option<PathBuf>,
129 #[arg(long)]
131 all: bool,
132 #[arg(long, value_delimiter = ',')]
134 lang: Vec<String>,
135 #[arg(long)]
137 diff: bool,
138 },
139 Check {
141 #[arg(long)]
143 ci: bool,
144 #[arg(long)]
146 json: bool,
147 #[arg(long)]
149 json_out: Option<PathBuf>,
150 #[arg(long, value_delimiter = ',')]
152 lang: Vec<String>,
153 },
154 Watch {
156 path: Option<PathBuf>,
158 #[arg(long, value_delimiter = ',')]
160 lang: Vec<String>,
161 #[arg(long, default_value_t = 50)]
163 debounce_ms: u64,
164 },
165 Plugin {
167 #[command(subcommand)]
168 action: PluginAction,
169 },
170}
171
172#[derive(Subcommand, Debug)]
173enum PluginAction {
174 List,
176 Validate {
178 path: PathBuf,
180 },
181 Info {
183 name: String,
185 },
186}
187
188pub fn run() -> Result<i32> {
189 run_with_args(std::env::args_os())
190}
191
192pub fn run_with_args<I, T>(args: I) -> Result<i32>
193where
194 I: IntoIterator<Item = T>,
195 T: Into<OsString> + Clone,
196{
197 let cli = Cli::try_parse_from(args).map_err(|err| anyhow::anyhow!(err.to_string()))?;
198
199 match cli.command {
200 Commands::Generate {
201 file,
202 all,
203 lang,
204 diff,
205 } => cmd_generate(file, all, lang, diff),
206 Commands::Check {
207 ci,
208 json,
209 json_out,
210 lang,
211 } => cmd_check(ci, json, json_out, lang),
212 Commands::Watch {
213 path,
214 lang,
215 debounce_ms,
216 } => cmd_watch(path, lang, debounce_ms),
217 Commands::Plugin { action } => cmd_plugin(action),
218 }
219}
220
221fn extract_builtin_filter(targets: &[LanguageTarget]) -> Vec<typewriter_engine::Language> {
223 targets
224 .iter()
225 .filter_map(|t| match t {
226 LanguageTarget::BuiltIn(lang) => Some(*lang),
227 LanguageTarget::Plugin(_) => None,
228 })
229 .collect()
230}
231
232fn cmd_generate(file: Option<PathBuf>, all: bool, lang: Vec<String>, diff: bool) -> Result<i32> {
233 if all == file.is_some() {
234 anyhow::bail!("use exactly one input mode: either `generate <file>` or `generate --all`");
235 }
236
237 let cwd = std::env::current_dir()?;
238 let project_root = project::discover_project_root(&cwd)?;
239 let config = project::load_config(&project_root).unwrap_or_default();
240 let lang_targets = parse_languages(&lang)?;
241 let lang_filter = extract_builtin_filter(&lang_targets);
242 let registry = plugin_registry::build_registry_from_config(&config).unwrap_or_default();
243
244 let specs = if all {
245 scan::scan_project(&project_root)?
246 } else {
247 let source = resolve_input_path(file.expect("validated"), &cwd);
248 scan::scan_file(&source)?
249 };
250
251 let rendered = emit::render_specs_deduped_with_plugins(
252 &specs, &project_root, &config, &lang_filter, false, Some(®istry),
253 )?;
254
255 let started = Instant::now();
256 let mut updated = 0usize;
257 let mut created = 0usize;
258 let mut unchanged = 0usize;
259
260 let mut before_contents = BTreeMap::new();
261 for file in &rendered {
262 if let Ok(existing) = std::fs::read_to_string(&file.output_path) {
263 before_contents.insert(file.output_path.clone(), existing);
264 }
265 }
266
267 emit::write_generated_files(&rendered)?;
268
269 for file in &rendered {
270 let rel = rel_path(&project_root, &file.output_path);
271 match before_contents.get(&file.output_path) {
272 None => {
273 created += 1;
274 eprintln!(
275 "{} {} [{}]",
276 "Created".green(),
277 rel,
278 file.language_label
279 );
280 if diff {
281 print_diff(&project_root, &file.output_path, "", &file.content);
282 }
283 }
284 Some(existing) if existing == &file.content => {
285 unchanged += 1;
286 eprintln!(
287 "{} {} [{}]",
288 "Unchanged".bright_black(),
289 rel,
290 file.language_label
291 );
292 }
293 Some(existing) => {
294 updated += 1;
295 eprintln!(
296 "{} {} [{}]",
297 "Updated".yellow(),
298 rel,
299 file.language_label
300 );
301 if diff {
302 print_diff(&project_root, &file.output_path, existing, &file.content);
303 }
304 }
305 }
306 }
307
308 eprintln!(
309 "{} in {}ms (created: {}, updated: {}, unchanged: {})",
310 "Generation complete".bold(),
311 started.elapsed().as_millis(),
312 created,
313 updated,
314 unchanged
315 );
316
317 Ok(0)
318}
319
320fn cmd_check(ci: bool, json: bool, json_out: Option<PathBuf>, lang: Vec<String>) -> Result<i32> {
321 let cwd = std::env::current_dir()?;
322 let project_root = project::discover_project_root(&cwd)?;
323 let config = project::load_config(&project_root).unwrap_or_default();
324 let lang_targets = parse_languages(&lang)?;
325 let lang_filter = extract_builtin_filter(&lang_targets);
326 let registry = plugin_registry::build_registry_from_config(&config).unwrap_or_default();
327
328 let specs = scan::scan_project(&project_root)?;
329 let rendered = emit::render_specs_deduped_with_plugins(
330 &specs, &project_root, &config, &lang_filter, false, Some(®istry),
331 )?;
332
333 let report = drift::build_drift_report(&rendered, &project_root, &config, &lang_filter)?;
334
335 if !json {
336 print_human_report(&report);
337 } else {
338 let output = serde_json::to_string_pretty(&report)?;
339 println!("{}", output);
340 }
341
342 if let Some(path) = json_out {
343 let full = resolve_input_path(path, &cwd);
344 if let Some(parent) = full.parent() {
345 std::fs::create_dir_all(parent)?;
346 }
347 std::fs::write(&full, serde_json::to_string_pretty(&report)?)
348 .with_context(|| format!("failed to write json report to {}", full.display()))?;
349 eprintln!("{} {}", "Wrote JSON report: ".green(), full.display());
350 }
351
352 if ci && drift::has_drift(&report.summary) {
353 eprintln!("{} drift detected in CI mode", "Error:".red().bold());
354 return Ok(1);
355 }
356
357 Ok(0)
358}
359
360fn cmd_watch(path: Option<PathBuf>, lang: Vec<String>, debounce_ms: u64) -> Result<i32> {
361 let cwd = std::env::current_dir()?;
362 let project_root = project::discover_project_root(&cwd)?;
363 let watch_root = resolve_input_path(path.unwrap_or_else(|| PathBuf::from("src")), &cwd);
364 let config = project::load_config(&project_root).unwrap_or_default();
365 let lang_targets = parse_languages(&lang)?;
366 let lang_filter = extract_builtin_filter(&lang_targets);
367 let registry = plugin_registry::build_registry_from_config(&config).unwrap_or_default();
368
369 let (tx, rx) = mpsc::channel();
370 let mut watcher = RecommendedWatcher::new(
371 move |result| {
372 let _ = tx.send(result);
373 },
374 notify::Config::default(),
375 )?;
376
377 watcher.watch(&watch_root, RecursiveMode::Recursive)?;
378
379 eprintln!(
380 "{} {} (debounce={}ms)",
381 "Watching".green().bold(),
382 watch_root.display(),
383 debounce_ms
384 );
385
386 loop {
387 let first = match rx.recv() {
388 Ok(event) => event,
389 Err(err) => {
390 eprintln!("{} watcher channel closed: {}", "Error:".red(), err);
391 return Ok(1);
392 }
393 };
394
395 let mut changed_files = BTreeSet::new();
396 collect_changed_rust_files(first, &mut changed_files);
397
398 while let Ok(event) = rx.recv_timeout(Duration::from_millis(debounce_ms)) {
399 collect_changed_rust_files(event, &mut changed_files);
400 }
401
402 if changed_files.is_empty() {
403 continue;
404 }
405
406 let batch_started = Instant::now();
407 let mut specs = Vec::new();
408
409 for changed in &changed_files {
410 eprintln!("{} {}", "Changed:".cyan(), rel_path(&project_root, changed));
411 if changed.exists() {
412 match scan::scan_file(changed) {
413 Ok(mut found) => specs.append(&mut found),
414 Err(err) => eprintln!("{} {}", "Scan error:".red(), err),
415 }
416 }
417 }
418
419 if specs.is_empty() {
420 continue;
421 }
422
423 let mut names: Vec<_> = specs
424 .iter()
425 .map(|s| s.type_def.name().to_string())
426 .collect();
427 names.sort();
428 names.dedup();
429 for name in names {
430 eprintln!("{} {}", "Detected TypeWriter type:".blue(), name);
431 }
432
433 let rendered = emit::render_specs_deduped_with_plugins(
434 &specs, &project_root, &config, &lang_filter, false, Some(®istry),
435 )?;
436
437 let mut updated = 0usize;
438 for file in rendered {
439 let before = std::fs::read_to_string(&file.output_path).ok();
440 emit::write_generated_files(std::slice::from_ref(&file))?;
441
442 let changed = before.map(|c| c != file.content).unwrap_or(true);
443 if changed {
444 updated += 1;
445 }
446
447 eprintln!(
448 "{} {} [{}]",
449 "Regenerated".green(),
450 rel_path(&project_root, &file.output_path),
451 file.language_label
452 );
453 }
454
455 eprintln!(
456 "{} {} file(s) in {}ms",
457 "Done".bold(),
458 updated,
459 batch_started.elapsed().as_millis()
460 );
461 }
462}
463
464fn cmd_plugin(action: PluginAction) -> Result<i32> {
465 let cwd = std::env::current_dir()?;
466 let project_root = project::discover_project_root(&cwd).unwrap_or(cwd);
467 let config = project::load_config(&project_root).unwrap_or_default();
468 let registry = plugin_registry::build_registry_from_config(&config)?;
469
470 match action {
471 PluginAction::List => {
472 let plugins = registry.list();
473 if plugins.is_empty() {
474 eprintln!("{}", "No plugins loaded.".bright_black());
475 eprintln!(
476 " Install plugins to {} or configure [plugins] in typewriter.toml",
477 "~/.typewriter/plugins/"
478 );
479 } else {
480 eprintln!(
481 "{} {} plugin(s) loaded:\n",
482 "Plugins:".bold(),
483 plugins.len()
484 );
485 for p in &plugins {
486 eprintln!(
487 " {} {} v{}",
488 "●".green(),
489 p.language_name.bold(),
490 p.version
491 );
492 eprintln!(" Language ID: {}", p.language_id);
493 eprintln!(" Extension: .{}", p.file_extension);
494 eprintln!(" Output dir: {}", p.default_output_dir);
495 if let Some(path) = &p.source_path {
496 eprintln!(" Loaded from: {}", path.display());
497 }
498 eprintln!();
499 }
500 }
501 Ok(0)
502 }
503 PluginAction::Validate { path } => {
504 eprintln!("Validating plugin: {}...", path.display());
505 let mut test_registry = typewriter_engine::PluginRegistry::new();
506 match test_registry.load_plugin(&path) {
507 Ok(()) => {
508 let plugins = test_registry.list();
509 if let Some(p) = plugins.first() {
510 eprintln!(
511 "{} Plugin is valid!",
512 "✓".green().bold()
513 );
514 eprintln!(" Name: {}", p.language_name);
515 eprintln!(" Language: {}", p.language_id);
516 eprintln!(" Version: {}", p.version);
517 eprintln!(" Extension: .{}", p.file_extension);
518 }
519 Ok(0)
520 }
521 Err(err) => {
522 eprintln!(
523 "{} Plugin validation failed: {}",
524 "✗".red().bold(),
525 err
526 );
527 Ok(1)
528 }
529 }
530 }
531 PluginAction::Info { name } => {
532 let plugins = registry.list();
533 if let Some(p) = plugins.iter().find(|p| p.language_id == name) {
534 eprintln!("{} {}", "Plugin:".bold(), p.language_name);
535 eprintln!(" Language ID: {}", p.language_id);
536 eprintln!(" Version: {}", p.version);
537 eprintln!(" Extension: .{}", p.file_extension);
538 eprintln!(" Output dir: {}", p.default_output_dir);
539 if let Some(path) = &p.source_path {
540 eprintln!(" Loaded from: {}", path.display());
541 }
542 Ok(0)
543 } else {
544 eprintln!(
545 "{} No plugin found with language ID '{}'",
546 "Error:".red().bold(),
547 name
548 );
549 Ok(1)
550 }
551 }
552 }
553}
554
555fn print_human_report(report: &drift::DriftReport) {
556 for entry in &report.entries {
557 let symbol = match entry.status {
558 DriftStatus::UpToDate => "OK".green(),
559 DriftStatus::OutOfSync => "DRIFT".yellow(),
560 DriftStatus::Missing => "MISSING".red(),
561 DriftStatus::Orphaned => "ORPHAN".magenta(),
562 };
563
564 eprintln!(
565 "{} {} [{}] - {}",
566 symbol, entry.output_path, entry.language, entry.reason
567 );
568 }
569
570 eprintln!(
571 "{} up_to_date={}, out_of_sync={}, missing={}, orphaned={}",
572 "Summary:".bold(),
573 report.summary.up_to_date,
574 report.summary.out_of_sync,
575 report.summary.missing,
576 report.summary.orphaned
577 );
578}
579
580fn print_diff(project_root: &Path, path: &Path, before: &str, after: &str) {
581 let rel = rel_path(project_root, path);
582 let diff = TextDiff::from_lines(before, after)
583 .unified_diff()
584 .context_radius(3)
585 .header(&format!("a/{}", rel), &format!("b/{}", rel))
586 .to_string();
587
588 if !diff.trim().is_empty() {
589 println!("{}", diff);
590 }
591}
592
593fn collect_changed_rust_files(event: Result<Event, notify::Error>, files: &mut BTreeSet<PathBuf>) {
594 let event = match event {
595 Ok(event) => event,
596 Err(err) => {
597 eprintln!("{} {}", "Watch error:".red(), err);
598 return;
599 }
600 };
601
602 for path in event.paths {
603 if path.extension().map(|ext| ext == "rs").unwrap_or(false) {
604 files.insert(path);
605 }
606 }
607}
608
609fn rel_path(root: &Path, path: &Path) -> String {
610 path.strip_prefix(root)
611 .unwrap_or(path)
612 .display()
613 .to_string()
614}
615
616fn resolve_input_path(path: PathBuf, cwd: &Path) -> PathBuf {
617 if path.is_absolute() {
618 path
619 } else {
620 cwd.join(path)
621 }
622}
623
624#[cfg(test)]
625mod tests {
626 use super::*;
627
628 #[test]
629 fn rejects_invalid_generate_mode() {
630 let err = run_with_args(["typewriter", "generate"]).unwrap_err();
631 assert!(err.to_string().contains("use exactly one input mode"));
632 }
633
634 #[test]
635 fn parses_comma_separated_langs() {
636 let parsed = parse_languages(&["typescript,python".to_string()]).unwrap();
637 assert_eq!(
638 parsed,
639 vec![
640 LanguageTarget::BuiltIn(typewriter_engine::Language::TypeScript),
641 LanguageTarget::BuiltIn(typewriter_engine::Language::Python),
642 ]
643 );
644 }
645
646 #[test]
647 fn parses_plugin_languages() {
648 let parsed = parse_languages(&["ruby,php".to_string()]).unwrap();
649 assert_eq!(
650 parsed,
651 vec![
652 LanguageTarget::Plugin("ruby".to_string()),
653 LanguageTarget::Plugin("php".to_string()),
654 ]
655 );
656 }
657}