Skip to main content

typebridge_cli/
lib.rs

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 type files from Rust source definitions.
28    Generate {
29        /// Generate from a single Rust source file.
30        file: Option<PathBuf>,
31        /// Generate from all Rust files in project root.
32        #[arg(long)]
33        all: bool,
34        /// Restrict generation to languages (comma-separated).
35        #[arg(long, value_delimiter = ',')]
36        lang: Vec<String>,
37        /// Show unified diffs for changed files.
38        #[arg(long)]
39        diff: bool,
40    },
41    /// Check if generated files are in sync with Rust source.
42    Check {
43        /// Exit with code 1 if drift is detected.
44        #[arg(long)]
45        ci: bool,
46        /// Print structured JSON drift report to stdout.
47        #[arg(long)]
48        json: bool,
49        /// Write structured JSON drift report to a file.
50        #[arg(long)]
51        json_out: Option<PathBuf>,
52        /// Restrict check to languages (comma-separated).
53        #[arg(long, value_delimiter = ',')]
54        lang: Vec<String>,
55    },
56    /// Watch Rust files and regenerate on save.
57    Watch {
58        /// Directory to watch recursively (default: ./src).
59        path: Option<PathBuf>,
60        /// Restrict generation to languages (comma-separated).
61        #[arg(long, value_delimiter = ',')]
62        lang: Vec<String>,
63        /// Debounce interval for filesystem events.
64        #[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}