• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

facet-rs / facet / 14468690768

15 Apr 2025 11:51AM UTC coverage: 29.024% (-1.8%) from 30.835%
14468690768

Pull #225

github

web-flow
Merge 5109d04e7 into 00641afc4
Pull Request #225: Provide best-of-class precommit hook (facet-dev)

4 of 940 new or added lines in 9 files covered. (0.43%)

1 existing line in 1 file now uncovered.

2032 of 7001 relevant lines covered (29.02%)

24.21 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

0.0
/facet-dev/src/main.rs
1
use facet_ansi::{ColorStyle as _, Style, Stylize as _};
2
use log::{LevelFilter, debug, error, warn};
3
use similar::ChangeTag;
4
use std::fs;
5
use std::io::{self, Write};
6
use std::path::{Path, PathBuf};
7
use std::process::{Command, Stdio};
8

9
mod menu;
10
mod readme;
11
mod sample;
12
mod tuples;
13

14
#[derive(Debug, Clone)]
15
pub struct Job {
16
    pub path: PathBuf,
17
    pub old_content: Option<Vec<u8>>,
18
    pub new_content: Vec<u8>,
19
}
20

21
impl Job {
NEW
22
    pub fn is_noop(&self) -> bool {
×
NEW
23
        match &self.old_content {
×
NEW
24
            Some(old) => &self.new_content == old,
×
NEW
25
            None => self.new_content.is_empty(),
×
26
        }
27
    }
28

29
    /// Computes a summary of the diff between old_content and new_content.
30
    /// Returns (num_plus, num_minus): plus lines (insertions), minus lines (deletions).
NEW
31
    pub fn diff_plus_minus(&self) -> (usize, usize) {
×
32
        use similar::TextDiff;
NEW
33
        let old = match &self.old_content {
×
NEW
34
            Some(bytes) => String::from_utf8_lossy(bytes),
×
NEW
35
            None => "".into(),
×
36
        };
NEW
37
        let new = String::from_utf8_lossy(&self.new_content);
×
NEW
38
        let diff = TextDiff::from_lines(&old, &new);
×
NEW
39
        let mut plus = 0;
×
NEW
40
        let mut minus = 0;
×
NEW
41
        for change in diff.iter_all_changes() {
×
NEW
42
            match change.tag() {
×
NEW
43
                ChangeTag::Insert => plus += 1,
×
NEW
44
                ChangeTag::Delete => minus += 1,
×
NEW
45
                ChangeTag::Equal => {}
×
46
            }
47
        }
NEW
48
        (plus, minus)
×
49
    }
50

NEW
51
    pub fn show_diff(&self) {
×
52
        use facet_ansi::Stylize as _;
53
        use similar::{ChangeTag, TextDiff};
54

NEW
55
        let context_lines = 3;
×
56

NEW
57
        let old = match &self.old_content {
×
NEW
58
            Some(bytes) => String::from_utf8_lossy(bytes),
×
NEW
59
            None => "".into(),
×
60
        };
NEW
61
        let new = String::from_utf8_lossy(&self.new_content);
×
NEW
62
        let diff = TextDiff::from_lines(&old, &new);
×
63

64
        // Collect the changes for random access
NEW
65
        let changes: Vec<_> = diff.iter_all_changes().collect();
×
66

67
        // Identify the indices of changes (added/removed lines)
NEW
68
        let mut change_indices = vec![];
×
NEW
69
        for (i, change) in changes.iter().enumerate() {
×
NEW
70
            match change.tag() {
×
NEW
71
                ChangeTag::Insert | ChangeTag::Delete => change_indices.push(i),
×
NEW
72
                _ => {}
×
73
            }
74
        }
75

NEW
76
        let mut show_line = vec![false; changes.len()];
×
77
        // Mark lines to show: up to context_lines before/after each change
NEW
78
        for &idx in &change_indices {
×
NEW
79
            let start = idx.saturating_sub(context_lines);
×
NEW
80
            let end = (idx + context_lines + 1).min(changes.len());
×
81
            #[allow(clippy::needless_range_loop)]
NEW
82
            for i in start..end {
×
NEW
83
                show_line[i] = true;
×
84
            }
85
        }
86

87
        // Always show a few lines at the top and bottom of the diff for context,
88
        // in case the first or last lines are not changes.
89
        #[allow(clippy::needless_range_loop)]
NEW
90
        for i in 0..context_lines.min(changes.len()) {
×
NEW
91
            show_line[i] = true;
×
92
        }
93
        #[allow(clippy::needless_range_loop)]
NEW
94
        for i in changes.len().saturating_sub(context_lines)..changes.len() {
×
NEW
95
            show_line[i] = true;
×
96
        }
97

NEW
98
        let mut last_was_ellipsis = false;
×
NEW
99
        for (i, change) in changes.iter().enumerate() {
×
NEW
100
            if show_line[i] {
×
NEW
101
                match change.tag() {
×
NEW
102
                    ChangeTag::Insert => print!("{}", format!("    +{}", change).green()),
×
NEW
103
                    ChangeTag::Delete => print!("{}", format!("    -{}", change).red()),
×
NEW
104
                    ChangeTag::Equal => print!("{}", format!("    {}", change).dim()),
×
105
                }
NEW
106
                last_was_ellipsis = false;
×
NEW
107
            } else if !last_was_ellipsis {
×
NEW
108
                println!("{}", "    ...".dim());
×
NEW
109
                last_was_ellipsis = true;
×
110
            }
111
        }
NEW
112
        println!();
×
113
    }
114

115
    /// Applies the job by writing out the new_content to path and staging the file.
NEW
116
    pub fn apply(&self) -> std::io::Result<()> {
×
117
        use std::fs;
118
        use std::process::Command;
NEW
119
        fs::write(&self.path, &self.new_content)?;
×
120
        // Now stage it, best effort
NEW
121
        let _ = Command::new("git").arg("add").arg(&self.path).status();
×
NEW
122
        Ok(())
×
123
    }
124
}
125

NEW
126
pub fn enqueue_readme_jobs(sender: std::sync::mpsc::Sender<Job>) {
×
127
    use std::path::Path;
128

NEW
129
    let workspace_dir = std::env::current_dir().unwrap();
×
NEW
130
    let entries = match fs_err::read_dir(&workspace_dir) {
×
NEW
131
        Ok(e) => e,
×
NEW
132
        Err(e) => {
×
NEW
133
            error!("Failed to read workspace directory ({})", e);
×
NEW
134
            return;
×
135
        }
136
    };
137

NEW
138
    let template_name = "README.md.in";
×
139

NEW
140
    for entry in entries {
×
NEW
141
        let entry = match entry {
×
NEW
142
            Ok(entry) => entry,
×
NEW
143
            Err(e) => {
×
NEW
144
                warn!("Skipping entry: {e}");
×
NEW
145
                continue;
×
146
            }
147
        };
NEW
148
        let crate_path = entry.path();
×
149

NEW
150
        if !crate_path.is_dir()
×
NEW
151
            || crate_path.file_name().is_some_and(|name| {
×
NEW
152
                let name = name.to_string_lossy();
×
NEW
153
                name.starts_with('.') || name.starts_with('_')
×
154
            })
155
        {
NEW
156
            continue;
×
157
        }
158

NEW
159
        let dir_name = crate_path.file_name().unwrap().to_string_lossy();
×
NEW
160
        if dir_name == "target" {
×
NEW
161
            continue;
×
162
        }
163

NEW
164
        let cargo_toml_path = crate_path.join("Cargo.toml");
×
NEW
165
        if !cargo_toml_path.exists() {
×
NEW
166
            continue;
×
167
        }
168

NEW
169
        let crate_name = dir_name.to_string();
×
170

NEW
171
        let template_path = if crate_name == "facet" {
×
NEW
172
            Path::new(template_name).to_path_buf()
×
173
        } else {
NEW
174
            crate_path.join(template_name)
×
175
        };
176

NEW
177
        if template_path.exists() {
×
178
            // Read the template file
NEW
179
            let template_input = match fs::read_to_string(&template_path) {
×
NEW
180
                Ok(s) => s,
×
NEW
181
                Err(e) => {
×
NEW
182
                    error!("Failed to read template {}: {e}", template_path.display());
×
NEW
183
                    continue;
×
184
                }
185
            };
186

187
            // Generate the README content using readme::generate
NEW
188
            let readme_content = readme::generate(readme::GenerateReadmeOpts {
×
NEW
189
                crate_name: crate_name.clone(),
×
NEW
190
                input: template_input,
×
191
            });
192

193
            // Determine the README.md output path
NEW
194
            let readme_path = crate_path.join("README.md");
×
195

196
            // Read old_content from README.md if exists, otherwise None
NEW
197
            let old_content = fs::read(&readme_path).ok();
×
198

199
            // Build the job
200
            let job = Job {
201
                path: readme_path,
202
                old_content,
NEW
203
                new_content: readme_content.into_bytes(),
×
204
            };
205

206
            // Send job
NEW
207
            if let Err(e) = sender.send(job) {
×
NEW
208
                error!("Failed to send job: {e}");
×
209
            }
210
        } else {
NEW
211
            error!("🚫 Missing template: {}", template_path.display().red());
×
212
        }
213
    }
214

215
    // Also handle the workspace README (the "facet" crate at root)
NEW
216
    let workspace_template_path = workspace_dir.join(template_name);
×
NEW
217
    if workspace_template_path.exists() {
×
218
        // Read the template file
NEW
219
        let template_input = match fs::read_to_string(&workspace_template_path) {
×
NEW
220
            Ok(s) => s,
×
NEW
221
            Err(e) => {
×
NEW
222
                error!(
×
NEW
223
                    "Failed to read template {}: {e}",
×
NEW
224
                    workspace_template_path.display()
×
225
                );
NEW
226
                return;
×
227
            }
228
        };
229

230
        // Generate the README content using readme::generate
NEW
231
        let readme_content = readme::generate(readme::GenerateReadmeOpts {
×
NEW
232
            crate_name: "facet".to_string(),
×
NEW
233
            input: template_input,
×
234
        });
235

236
        // Determine the README.md output path
NEW
237
        let readme_path = workspace_dir.join("README.md");
×
238

239
        // Read old_content from README.md if exists, otherwise None
NEW
240
        let old_content = fs::read(&readme_path).ok();
×
241

242
        // Build the job
243
        let job = Job {
244
            path: readme_path,
245
            old_content,
NEW
246
            new_content: readme_content.into_bytes(),
×
247
        };
248

249
        // Send job
NEW
250
        if let Err(e) = sender.send(job) {
×
NEW
251
            error!("Failed to send workspace job: {e}");
×
252
        }
253
    } else {
NEW
254
        error!(
×
NEW
255
            "🚫 {}",
×
NEW
256
            format!(
×
NEW
257
                "Template file {} not found for workspace. We looked at {}",
×
NEW
258
                template_name,
×
NEW
259
                workspace_template_path.display()
×
260
            )
NEW
261
            .red()
×
262
        );
263
    }
264
}
265

NEW
266
pub fn enqueue_tuple_job(sender: std::sync::mpsc::Sender<Job>) {
×
267
    use std::time::Instant;
268

269
    // Path where tuple impls should be written
NEW
270
    let base_path = Path::new("facet-core/src/impls_core/tuple.rs");
×
271

NEW
272
    debug!("Generating tuple impls for {}", base_path.display().blue());
×
273

274
    // Generate the tuple impls code
NEW
275
    let start = Instant::now();
×
NEW
276
    let output = tuples::generate();
×
NEW
277
    let content = output.into_bytes();
×
NEW
278
    let duration = start.elapsed();
×
NEW
279
    let size_mb = (content.len() as f64) / (1024.0 * 1024.0);
×
NEW
280
    let secs = duration.as_secs_f64();
×
NEW
281
    let mbps = if secs > 0.0 { size_mb / secs } else { 0.0 };
×
NEW
282
    debug!(
×
NEW
283
        "Generated and formatted tuple impls for {}: {:.2} MiB in {:.2} s ({:.2} MiB/s)",
×
NEW
284
        base_path.display().blue(),
×
NEW
285
        size_mb,
×
NEW
286
        secs,
×
NEW
287
        mbps.bright_magenta()
×
288
    );
289

290
    // Attempt to read existing file
NEW
291
    let old_content = fs::read(base_path).ok();
×
292

293
    let job = Job {
NEW
294
        path: base_path.to_path_buf(),
×
295
        old_content,
296
        new_content: content,
297
    };
298

NEW
299
    if let Err(e) = sender.send(job) {
×
NEW
300
        error!("Failed to send tuple job: {e}");
×
301
    }
302
}
303

NEW
304
pub fn enqueue_sample_job(sender: std::sync::mpsc::Sender<Job>) {
×
305
    use log::trace;
306
    use std::time::Instant;
307

308
    // Path where sample generated code should be written
NEW
309
    let rel_path = std::path::PathBuf::from("facet/src/sample_generated_code.rs");
×
NEW
310
    let workspace_dir = std::env::current_dir().unwrap();
×
NEW
311
    let target_path = workspace_dir.join(&rel_path);
×
312

NEW
313
    trace!(
×
NEW
314
        "Expanding sample code at {:?}",
×
NEW
315
        target_path.display().blue()
×
316
    );
NEW
317
    let start = Instant::now();
×
318

319
    // Generate the sample expanded and formatted code
NEW
320
    let code = sample::cargo_expand_and_format();
×
NEW
321
    let content = code.into_bytes();
×
NEW
322
    let size_mb = (content.len() as f64) / (1024.0 * 1024.0);
×
323

NEW
324
    let duration = start.elapsed();
×
NEW
325
    let secs = duration.as_secs_f64();
×
NEW
326
    let mbps = if secs > 0.0 { size_mb / secs } else { 0.0 };
×
327

NEW
328
    debug!(
×
NEW
329
        "Generated and formatted sample code for {}: {:.2} MiB in {:.2} s ({:.2} MiB/s)",
×
NEW
330
        rel_path.display().blue(),
×
NEW
331
        size_mb,
×
NEW
332
        secs,
×
NEW
333
        mbps.bright_magenta()
×
334
    );
335

336
    // Attempt to read existing file
NEW
337
    let old_content = fs::read(&target_path).ok();
×
338

339
    let job = Job {
340
        path: target_path,
341
        old_content,
342
        new_content: content,
343
    };
344

NEW
345
    if let Err(e) = sender.send(job) {
×
NEW
346
        error!("Failed to send sample job: {e}");
×
347
    }
348
}
349

NEW
350
pub fn enqueue_rustfmt_jobs(sender: std::sync::mpsc::Sender<Job>, staged_files: &StagedFiles) {
×
351
    use log::trace;
352
    use std::time::Instant;
353

NEW
354
    for path in &staged_files.clean {
×
355
        // Only process .rs files
NEW
356
        if let Some(ext) = path.extension() {
×
NEW
357
            if ext != "rs" {
×
NEW
358
                continue;
×
359
            }
360
        } else {
NEW
361
            continue;
×
362
        }
363

NEW
364
        trace!("rustfmt: formatting {}", path.display());
×
365

NEW
366
        let original = match fs::read(path) {
×
NEW
367
            Ok(val) => val,
×
NEW
368
            Err(e) => {
×
NEW
369
                error!(
×
NEW
370
                    "{} {}: {}",
×
NEW
371
                    "❌".red(),
×
NEW
372
                    path.display().to_string().blue(),
×
NEW
373
                    format!("Failed to read: {e}").dim()
×
374
                );
NEW
375
                continue;
×
376
            }
377
        };
378

NEW
379
        let size_mb = (original.len() as f64) / (1024.0 * 1024.0);
×
380

381
        // Format the content via rustfmt (edition 2024)
NEW
382
        let start = Instant::now();
×
NEW
383
        let cmd = Command::new("rustfmt")
×
384
            .arg("--edition")
385
            .arg("2024")
386
            .arg("--emit")
387
            .arg("stdout")
NEW
388
            .stdin(Stdio::piped())
×
NEW
389
            .stdout(Stdio::piped())
×
NEW
390
            .stderr(Stdio::piped())
×
391
            .spawn();
392

NEW
393
        let mut cmd = match cmd {
×
NEW
394
            Ok(child) => child,
×
NEW
395
            Err(e) => {
×
NEW
396
                error!("Failed to spawn rustfmt for {}: {}", path.display(), e);
×
NEW
397
                continue;
×
398
            }
399
        };
400

401
        // Write source to rustfmt's stdin
402
        {
NEW
403
            let mut stdin = cmd.stdin.take().expect("Failed to take rustfmt stdin");
×
NEW
404
            if stdin.write_all(&original).is_err() {
×
NEW
405
                error!(
×
NEW
406
                    "{} {}: {}",
×
NEW
407
                    "❌".red(),
×
NEW
408
                    path.display().to_string().blue(),
×
NEW
409
                    "Failed to write src to rustfmt".dim()
×
410
                );
NEW
411
                continue;
×
412
            }
413
        }
414

NEW
415
        let output = match cmd.wait_with_output() {
×
NEW
416
            Ok(out) => out,
×
NEW
417
            Err(e) => {
×
NEW
418
                error!("Failed to get rustfmt output for {}: {}", path.display(), e);
×
NEW
419
                continue;
×
420
            }
421
        };
422

NEW
423
        let duration = start.elapsed();
×
NEW
424
        let secs = duration.as_secs_f64();
×
NEW
425
        let mbps = if secs > 0.0 { size_mb / secs } else { 0.0 };
×
NEW
426
        debug!(
×
NEW
427
            "rustfmt: {} formatted {:.2} MiB in {:.2} s ({:.2} MiB/s)",
×
NEW
428
            path.display(),
×
NEW
429
            size_mb,
×
NEW
430
            secs,
×
NEW
431
            mbps.magenta()
×
432
        );
433

NEW
434
        if !output.status.success() {
×
NEW
435
            error!(
×
NEW
436
                "{} {}: rustfmt failed\n{}\n{}",
×
NEW
437
                "❌".red(),
×
NEW
438
                path.display().to_string().blue(),
×
NEW
439
                String::from_utf8_lossy(&output.stderr).dim(),
×
NEW
440
                String::from_utf8_lossy(&output.stdout).dim()
×
441
            );
NEW
442
            continue;
×
443
        }
444

NEW
445
        let formatted = output.stdout;
×
446

447
        // Only enqueue a job if the formatted output is different
NEW
448
        if formatted != original {
×
449
            let job = Job {
NEW
450
                path: path.clone(),
×
NEW
451
                old_content: Some(original),
×
452
                new_content: formatted,
453
            };
NEW
454
            if let Err(e) = sender.send(job) {
×
NEW
455
                error!("Failed to send rustfmt job for {}: {}", path.display(), e);
×
456
            }
457
        }
458
    }
459
}
460

NEW
461
pub fn show_jobs_and_apply_if_consent_is_given(jobs: &mut [Job]) {
×
462
    use console::{Emoji, style};
463
    use std::io::{self, Write};
464

465
    // Emojis for display
466
    static ACTION_REQUIRED: Emoji<'_, '_> = Emoji("🚧", "");
467
    static DIFF: Emoji<'_, '_> = Emoji("📝", "");
468
    static OK: Emoji<'_, '_> = Emoji("✅", "");
469
    static CANCEL: Emoji<'_, '_> = Emoji("❌", "");
470

NEW
471
    jobs.sort_by_key(|job| job.path.clone());
×
472

NEW
473
    if jobs.is_empty() {
×
NEW
474
        println!(
×
NEW
475
            "{}",
×
NEW
476
            style("All generated files are up-to-date and all Rust files are formatted properly")
×
NEW
477
                .green()
×
NEW
478
                .bold()
×
479
        );
NEW
480
        return;
×
481
    }
482

NEW
483
    println!(
×
NEW
484
        "\n{}\n{}\n",
×
NEW
485
        style(format!(
×
NEW
486
            "{} GENERATION CHANGES {}",
×
NEW
487
            ACTION_REQUIRED, ACTION_REQUIRED
×
488
        ))
NEW
489
        .on_black()
×
NEW
490
        .bold()
×
NEW
491
        .yellow()
×
NEW
492
        .italic()
×
NEW
493
        .underlined(),
×
NEW
494
        style(format!(
×
NEW
495
            "The following {} file{} would be updated/generated:",
×
NEW
496
            jobs.len(),
×
NEW
497
            if jobs.len() == 1 { "" } else { "s" }
×
498
        ))
NEW
499
        .magenta()
×
500
    );
NEW
501
    for (idx, job) in jobs.iter().enumerate() {
×
NEW
502
        let (plus, minus) = job.diff_plus_minus();
×
NEW
503
        println!(
×
NEW
504
            "  {}. {} {}{}",
×
NEW
505
            style(idx + 1).bold().cyan(),
×
NEW
506
            style(job.path.display()).yellow(),
×
NEW
507
            if plus > 0 {
×
NEW
508
                format!("+{}", plus).green().to_string()
×
509
            } else {
NEW
510
                String::new()
×
511
            },
NEW
512
            if minus > 0 {
×
NEW
513
                format!(" -{}", minus).red().to_string()
×
514
            } else {
NEW
515
                String::new()
×
516
            }
517
        );
518
    }
519

NEW
520
    let jobs_vec = jobs.to_vec();
×
521

522
    // Define menu items as a Vec<MenuItem>
523
    static MENU_LABEL: &str = "facet-dev fixed some files for you:";
NEW
524
    let menu_items = [
×
NEW
525
        menu::MenuItem {
×
NEW
526
            label: "[y]es: 🚀 Apply the above fixes".to_string(),
×
NEW
527
            action: "apply".to_string(),
×
NEW
528
            style: Style::new().fg_green().with_bold(),
×
529
        },
NEW
530
        menu::MenuItem {
×
NEW
531
            label: "[d]iff: 🔍 Show details of all diffs".to_string(),
×
NEW
532
            action: "diff".to_string(),
×
NEW
533
            style: Style::new().fg_cyan(),
×
534
        },
NEW
535
        menu::MenuItem {
×
NEW
536
            label: "[c]ontinue: ➡️ Continue with the commit without applying fixes".to_string(),
×
NEW
537
            action: "continue".to_string(),
×
NEW
538
            style: Style::new().fg_yellow(),
×
539
        },
NEW
540
        menu::MenuItem {
×
NEW
541
            label: "[a]bort: 💥 Exit with error (this'll abort the commit)".to_string(),
×
NEW
542
            action: "abort".to_string(),
×
NEW
543
            style: Style::new().fg_red(),
×
544
        },
545
    ];
546

NEW
547
    loop {
×
NEW
548
        let action =
×
NEW
549
            menu::show_menu(MENU_LABEL, &menu_items[..]).unwrap_or_else(|| "apply".to_string());
×
NEW
550
        match action.as_str() {
×
NEW
551
            "apply" => {
×
NEW
552
                for job in &jobs_vec {
×
NEW
553
                    print!(
×
NEW
554
                        "{} Applying {} ... ",
×
NEW
555
                        OK,
×
NEW
556
                        style(job.path.display()).yellow()
×
557
                    );
NEW
558
                    io::stdout().flush().unwrap();
×
NEW
559
                    match job.apply() {
×
NEW
560
                        Ok(_) => {
×
NEW
561
                            println!("{}", "ok".green());
×
562
                        }
NEW
563
                        Err(e) => {
×
NEW
564
                            println!("{} {}", CANCEL, format!("failed: {e}").red());
×
565
                        }
566
                    }
567
                }
NEW
568
                println!(
×
NEW
569
                    "{} {}",
×
NEW
570
                    OK,
×
NEW
571
                    style("All fixes applied and staged!").green().bold()
×
572
                );
NEW
573
                std::process::exit(0);
×
574
            }
NEW
575
            "diff" => {
×
NEW
576
                println!(
×
NEW
577
                    "\n{} {}\n",
×
NEW
578
                    DIFF,
×
NEW
579
                    style("Showing diffs for all changed/generated files:")
×
NEW
580
                        .magenta()
×
NEW
581
                        .bold()
×
582
                );
NEW
583
                for job in &jobs_vec {
×
NEW
584
                    println!(
×
NEW
585
                        "{} {}\n{}",
×
NEW
586
                        DIFF,
×
NEW
587
                        style(job.path.display()).yellow(),
×
NEW
588
                        style("───────────────────────────────────────────────").dim()
×
589
                    );
NEW
590
                    job.show_diff();
×
591
                }
592
                // After showing diffs, continue loop to show menu again
593
            }
NEW
594
            "continue" => {
×
NEW
595
                println!(
×
NEW
596
                    "{} {}",
×
NEW
597
                    CANCEL,
×
NEW
598
                    style("Continuing without applying fixes.").yellow().bold()
×
599
                );
NEW
600
                return;
×
601
            }
NEW
602
            "abort" => {
×
NEW
603
                println!(
×
NEW
604
                    "{} {}",
×
NEW
605
                    CANCEL,
×
NEW
606
                    style("Aborting. No changes made.").red().bold()
×
607
                );
NEW
608
                std::process::exit(1);
×
609
            }
610
            _ => todo!(),
611
        }
612
    }
613
}
614

615
#[derive(Debug, Clone)]
616
struct Options {
617
    check: bool,
618
}
619

NEW
620
fn main() {
×
NEW
621
    facet_testhelpers::setup();
×
622
    // Accept allowed log levels: trace, debug, error, warn, info
NEW
623
    log::set_max_level(LevelFilter::Info);
×
NEW
624
    if let Ok(log_level) = std::env::var("RUST_LOG") {
×
NEW
625
        let allowed = ["trace", "debug", "error", "warn", "info"];
×
NEW
626
        let log_level_lc = log_level.to_lowercase();
×
NEW
627
        if allowed.contains(&log_level_lc.as_str()) {
×
NEW
628
            let level = match log_level_lc.as_str() {
×
NEW
629
                "trace" => LevelFilter::Trace,
×
NEW
630
                "debug" => LevelFilter::Debug,
×
NEW
631
                "info" => LevelFilter::Info,
×
NEW
632
                "warn" => LevelFilter::Warn,
×
NEW
633
                "error" => LevelFilter::Error,
×
NEW
634
                _ => LevelFilter::Info,
×
635
            };
NEW
636
            log::set_max_level(level);
×
637
        }
638
    }
639

640
    // Parse opts from args
NEW
641
    let args: Vec<String> = std::env::args().collect();
×
NEW
642
    let mut opts = Options { check: false };
×
NEW
643
    for arg in &args[1..] {
×
NEW
644
        if arg == "--check" || arg == "-c" {
×
NEW
645
            opts.check = true;
×
646
        }
647
    }
648

649
    // Check if current directory has a Cargo.toml with [workspace]
NEW
650
    let cargo_toml_path = std::env::current_dir().unwrap().join("Cargo.toml");
×
NEW
651
    let cargo_toml_content =
×
NEW
652
        fs_err::read_to_string(cargo_toml_path).expect("Failed to read Cargo.toml");
×
NEW
653
    if !cargo_toml_content.contains("[workspace]") {
×
NEW
654
        error!(
×
NEW
655
            "🚫 {}",
×
NEW
656
            "Cargo.toml does not contain [workspace] (you must run codegen from the workspace root)"
×
NEW
657
                .red()
×
658
        );
NEW
659
        std::process::exit(1);
×
660
    }
661

NEW
662
    let staged_files = match collect_staged_files() {
×
NEW
663
        Ok(sf) => sf,
×
NEW
664
        Err(e) => {
×
NEW
665
            error!("Failed to collect staged files: {e}");
×
NEW
666
            std::process::exit(1);
×
667
        }
668
    };
669

670
    // Use a channel to collect jobs from all tasks.
671
    use std::sync::mpsc;
NEW
672
    let (sender, receiver) = mpsc::channel();
×
673

674
    // Start threads for each codegen job enqueuer
NEW
675
    let send1 = sender.clone();
×
NEW
676
    let handle_readme = std::thread::spawn(move || {
×
NEW
677
        enqueue_readme_jobs(send1);
×
678
    });
NEW
679
    let send2 = sender.clone();
×
NEW
680
    let handle_tuple = std::thread::spawn(move || {
×
NEW
681
        enqueue_tuple_job(send2);
×
682
    });
NEW
683
    let send3 = sender.clone();
×
NEW
684
    let handle_sample = std::thread::spawn(move || {
×
NEW
685
        enqueue_sample_job(send3);
×
686
    });
687
    // Rustfmt job: enqueue formatting for staged .rs files
NEW
688
    let send4 = sender.clone();
×
NEW
689
    let handle_rustfmt = std::thread::spawn(move || {
×
NEW
690
        enqueue_rustfmt_jobs(send4, &staged_files);
×
691
    });
692

693
    // Drop original sender so the channel closes when all workers finish
NEW
694
    drop(sender);
×
695

696
    // Collect jobs
NEW
697
    let mut jobs: Vec<Job> = Vec::new();
×
NEW
698
    for job in receiver {
×
NEW
699
        jobs.push(job);
×
700
    }
701

702
    // Wait for all job enqueuers to finish
NEW
703
    handle_readme.join().unwrap();
×
NEW
704
    handle_tuple.join().unwrap();
×
NEW
705
    handle_sample.join().unwrap();
×
NEW
706
    handle_rustfmt.join().unwrap();
×
707

NEW
708
    if jobs.is_empty() {
×
NEW
709
        println!("{}", "No codegen changes detected.".green().bold());
×
NEW
710
        return;
×
711
    }
712

NEW
713
    if opts.check {
×
NEW
714
        let mut any_diffs = false;
×
NEW
715
        for job in &jobs {
×
716
            // Compare old_content (current file content) to new_content (generated content)
NEW
717
            let disk_content = std::fs::read(&job.path).unwrap_or_default();
×
NEW
718
            if disk_content != job.new_content {
×
NEW
719
                error!(
×
NEW
720
                    "Diff detected in {}",
×
NEW
721
                    job.path.display().to_string().yellow().bold()
×
722
                );
NEW
723
                any_diffs = true;
×
724
            }
725
        }
NEW
726
        if any_diffs {
×
727
            // Print a big banner with error message about generated files
NEW
728
            error!(
×
NEW
729
                "┌────────────────────────────────────────────────────────────────────────────┐"
×
730
            );
NEW
731
            error!(
×
NEW
732
                "│                                                                            │"
×
733
            );
NEW
734
            error!(
×
NEW
735
                "│  GENERATED FILES HAVE CHANGED - RUN `just codegen` TO UPDATE THEM          │"
×
736
            );
NEW
737
            error!(
×
NEW
738
                "│                                                                            │"
×
739
            );
NEW
740
            error!(
×
NEW
741
                "│  For README.md files:                                                      │"
×
742
            );
NEW
743
            error!(
×
NEW
744
                "│                                                                            │"
×
745
            );
NEW
746
            error!(
×
NEW
747
                "│  • Don't edit README.md directly - edit the README.md.in template instead  │"
×
748
            );
NEW
749
            error!(
×
NEW
750
                "│  • Then run `just codegen` to regenerate the README.md files               │"
×
751
            );
NEW
752
            error!(
×
NEW
753
                "│  • A pre-commit hook is set up by cargo-husky to do just that              │"
×
754
            );
NEW
755
            error!(
×
NEW
756
                "│                                                                            │"
×
757
            );
NEW
758
            error!(
×
NEW
759
                "│  See CONTRIBUTING.md                                                       │"
×
760
            );
NEW
761
            error!(
×
NEW
762
                "│                                                                            │"
×
763
            );
NEW
764
            error!(
×
NEW
765
                "└────────────────────────────────────────────────────────────────────────────┘"
×
766
            );
NEW
767
            std::process::exit(1);
×
768
        } else {
NEW
769
            println!("{}", "✅ All generated files up to date.".green().bold());
×
770
        }
771
    } else {
772
        // Remove no-op jobs (where the content is unchanged).
NEW
773
        jobs.retain(|job| !job.is_noop());
×
NEW
774
        show_jobs_and_apply_if_consent_is_given(&mut jobs);
×
775
    }
776
}
777

778
#[derive(Debug)]
779
pub struct StagedFiles {
780
    /// Files that are staged (in the index) and not dirty (working tree matches index).
781
    pub clean: Vec<PathBuf>,
782
    /// Files that are staged and dirty (index does NOT match working tree).
783
    pub dirty: Vec<PathBuf>,
784
    /// Files that are untracked or unstaged (not added to the index).
785
    pub unstaged: Vec<PathBuf>,
786
}
787

788
// -- Formatting support types --
789

790
#[derive(Debug)]
791
pub struct FormatCandidate {
792
    pub path: PathBuf,
793
    pub original: Vec<u8>,
794
    pub formatted: Vec<u8>,
795
    pub diff: Option<String>,
796
}
797

NEW
798
pub fn collect_staged_files() -> io::Result<StagedFiles> {
×
799
    // Run `git status --porcelain`
NEW
800
    let output = Command::new("git")
×
801
        .arg("status")
802
        .arg("--porcelain")
803
        .output()?;
804

NEW
805
    if !output.status.success() {
×
NEW
806
        panic!("Failed to run `git status --porcelain`");
×
807
    }
NEW
808
    let stdout = String::from_utf8_lossy(&output.stdout);
×
809

NEW
810
    log::trace!("Parsing {} output:", "`git status --porcelain`".blue());
×
NEW
811
    log::trace!("---\n{}\n---", stdout);
×
812

NEW
813
    let mut clean = Vec::new();
×
NEW
814
    let mut dirty = Vec::new();
×
NEW
815
    let mut unstaged = Vec::new();
×
816

NEW
817
    for line in stdout.lines() {
×
NEW
818
        log::trace!("Parsing git status line: {:?}", line.dim());
×
819
        // E.g. "M  src/main.rs", "A  foo.rs", "AM foo/bar.rs"
NEW
820
        if line.len() < 3 {
×
NEW
821
            log::trace!("Skipping short line: {:?}", line.dim());
×
NEW
822
            continue;
×
823
        }
NEW
824
        let x = line.chars().next().unwrap();
×
NEW
825
        let y = line.chars().nth(1).unwrap();
×
NEW
826
        let path = line[3..].to_string();
×
827

NEW
828
        log::trace!(
×
NEW
829
            "x: {:?}, y: {:?}, path: {:?}",
×
NEW
830
            x.magenta(),
×
NEW
831
            y.cyan(),
×
NEW
832
            (&path).dim()
×
833
        );
834

835
        // Staged and not dirty (to be formatted/committed)
NEW
836
        if x != ' ' && x != '?' && y == ' ' {
×
NEW
837
            log::debug!(
×
NEW
838
                "{} {}",
×
NEW
839
                "-> clean (staged, not dirty):".green().bold(),
×
NEW
840
                path.as_str().blue()
×
841
            );
NEW
842
            clean.push(PathBuf::from(&path));
×
843
        }
844
        // Staged + dirty (index does not match worktree; skip and warn)
NEW
845
        else if x != ' ' && x != '?' && y != ' ' {
×
NEW
846
            log::debug!(
×
NEW
847
                "{} {}",
×
NEW
848
                "-> dirty (staged and dirty):".yellow().bold(),
×
NEW
849
                path.as_str().blue()
×
850
            );
NEW
851
            dirty.push(PathBuf::from(&path));
×
852
        }
853
        // Untracked or unstaged files (may be useful for warning)
NEW
854
        else if x == '?' {
×
NEW
855
            log::debug!(
×
NEW
856
                "{} {}",
×
NEW
857
                "-> unstaged/untracked:".cyan().bold(),
×
NEW
858
                path.as_str().blue()
×
859
            );
NEW
860
            unstaged.push(PathBuf::from(&path));
×
861
        } else {
NEW
862
            log::debug!("{} {}", "-> not categorized:".red(), path.as_str().blue());
×
863
        }
864
    }
NEW
865
    Ok(StagedFiles {
×
NEW
866
        clean,
×
NEW
867
        dirty,
×
NEW
868
        unstaged,
×
869
    })
870
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc