Skip to main content

typebridge_cli/
lib.rs

1//! # typebridge-cli
2//!
3//! Standalone CLI for typewriter type synchronization.
4//!
5//! This crate provides the `typewriter` and `cargo-typewriter` command-line tools
6//! for generating, checking, and watching cross-language type definitions.
7//!
8//! ## Installation
9//!
10//! ### Pre-built Binary
11//!
12//! ```bash
13//! cargo install typebridge-cli
14//! ```
15//!
16//! ### As Cargo Plugin
17//!
18//! After installing, run via cargo:
19//!
20//! ```bash
21//! cargo install typebridge-cli
22//! cargo typewriter --help
23//! ```
24//!
25//! ## Commands
26//!
27//! ### `typewriter generate`
28//!
29//! Generate type files from Rust source definitions.
30//!
31//! ```bash
32//! # Generate from a single file
33//! typewriter generate src/models.rs
34//!
35//! # Generate from all Rust files in the project
36//! typewriter generate --all
37//!
38//! # Generate only TypeScript and Python
39//! typewriter generate --all --lang typescript,python
40//!
41//! # Show unified diffs for changed files
42//! typewriter generate --all --diff
43//! ```
44//!
45//! ### `typewriter check`
46//!
47//! Check if generated files are in sync with Rust source.
48//!
49//! ```bash
50//! # Check all types
51//! typewriter check
52//!
53//! # Check with CI exit code (non-zero on drift)
54//! typewriter check --ci
55//!
56//! # Output as JSON
57//! typewriter check --json
58//!
59//! # Write JSON report to file
60//! typewriter check --json-out drift-report.json
61//!
62//! # Check specific languages
63//! typewriter check --lang typescript,python
64//! ```
65//!
66//! ### `typewriter watch`
67//!
68//! Watch Rust files and regenerate types on save.
69//!
70//! ```bash
71//! # Watch src directory (default)
72//! typewriter watch
73//!
74//! # Watch custom directory
75//! typewriter watch src/models/
76//!
77//! # Watch with specific languages
78//! typewriter watch --lang typescript,python
79//!
80//! # Set debounce interval (milliseconds)
81//! typewriter watch --debounce-ms 100
82//! ```
83//!
84//! ## Exit Codes
85//!
86//! | Code | Meaning |
87//! |------|---------|
88//! | 0 | Success (no drift for `check --ci`) |
89//! | 1 | Error or drift detected (for `check --ci`) |
90//!
91//! ## Configuration
92//!
93//! The CLI respects `typewriter.toml` in the project root for output directories,
94//! file naming styles, and other configuration options. See the main typewriter
95//! documentation for configuration details.
96
97use 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 type files from Rust source definitions.
126    Generate {
127        /// Generate from a single Rust source file.
128        file: Option<PathBuf>,
129        /// Generate from all Rust files in project root.
130        #[arg(long)]
131        all: bool,
132        /// Restrict generation to languages (comma-separated).
133        #[arg(long, value_delimiter = ',')]
134        lang: Vec<String>,
135        /// Show unified diffs for changed files.
136        #[arg(long)]
137        diff: bool,
138    },
139    /// Check if generated files are in sync with Rust source.
140    Check {
141        /// Exit with code 1 if drift is detected.
142        #[arg(long)]
143        ci: bool,
144        /// Print structured JSON drift report to stdout.
145        #[arg(long)]
146        json: bool,
147        /// Write structured JSON drift report to a file.
148        #[arg(long)]
149        json_out: Option<PathBuf>,
150        /// Restrict check to languages (comma-separated).
151        #[arg(long, value_delimiter = ',')]
152        lang: Vec<String>,
153    },
154    /// Watch Rust files and regenerate on save.
155    Watch {
156        /// Directory to watch recursively (default: ./src).
157        path: Option<PathBuf>,
158        /// Restrict generation to languages (comma-separated).
159        #[arg(long, value_delimiter = ',')]
160        lang: Vec<String>,
161        /// Debounce interval for filesystem events.
162        #[arg(long, default_value_t = 50)]
163        debounce_ms: u64,
164    },
165    /// Manage typewriter plugins.
166    Plugin {
167        #[command(subcommand)]
168        action: PluginAction,
169    },
170}
171
172#[derive(Subcommand, Debug)]
173enum PluginAction {
174    /// List all loaded plugins.
175    List,
176    /// Validate a plugin shared library.
177    Validate {
178        /// Path to the plugin shared library.
179        path: PathBuf,
180    },
181    /// Show info about a specific loaded plugin.
182    Info {
183        /// Plugin language ID (e.g. "ruby", "php", "dart").
184        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
221/// Extract only the built-in Language values from a list of LanguageTargets.
222fn 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(&registry),
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(&registry),
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(&registry),
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}