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::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 type files from Rust source definitions.
124    Generate {
125        /// Generate from a single Rust source file.
126        file: Option<PathBuf>,
127        /// Generate from all Rust files in project root.
128        #[arg(long)]
129        all: bool,
130        /// Restrict generation to languages (comma-separated).
131        #[arg(long, value_delimiter = ',')]
132        lang: Vec<String>,
133        /// Show unified diffs for changed files.
134        #[arg(long)]
135        diff: bool,
136    },
137    /// Check if generated files are in sync with Rust source.
138    Check {
139        /// Exit with code 1 if drift is detected.
140        #[arg(long)]
141        ci: bool,
142        /// Print structured JSON drift report to stdout.
143        #[arg(long)]
144        json: bool,
145        /// Write structured JSON drift report to a file.
146        #[arg(long)]
147        json_out: Option<PathBuf>,
148        /// Restrict check to languages (comma-separated).
149        #[arg(long, value_delimiter = ',')]
150        lang: Vec<String>,
151    },
152    /// Watch Rust files and regenerate on save.
153    Watch {
154        /// Directory to watch recursively (default: ./src).
155        path: Option<PathBuf>,
156        /// Restrict generation to languages (comma-separated).
157        #[arg(long, value_delimiter = ',')]
158        lang: Vec<String>,
159        /// Debounce interval for filesystem events.
160        #[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}