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

facet-rs / facet / 14570645698

21 Apr 2025 08:57AM UTC coverage: 46.087% (-1.7%) from 47.789%
14570645698

push

github

Veykril
Implement `Facet` for (subset of) function pointers

42 of 551 new or added lines in 6 files covered. (7.62%)

1 existing line in 1 file now uncovered.

5848 of 12689 relevant lines covered (46.09%)

55.67 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 log::{LevelFilter, debug, error, warn};
2
use similar::ChangeTag;
3
use std::fs;
4
use std::io::{self, Write};
5
use std::path::{Path, PathBuf};
6
use std::process::{Command, Stdio};
7
use yansi::{Paint as _, Style};
8

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

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

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

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

52
    pub fn show_diff(&self) {
×
53
        use similar::{ChangeTag, TextDiff};
54

55
        let context_lines = 3;
×
56

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

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

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

76
        let mut show_line = vec![false; changes.len()];
×
77
        // Mark lines to show: up to context_lines before/after each change
78
        for &idx in &change_indices {
×
79
            let start = idx.saturating_sub(context_lines);
×
80
            let end = (idx + context_lines + 1).min(changes.len());
×
81
            #[allow(clippy::needless_range_loop)]
82
            for i in start..end {
×
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)]
90
        for i in 0..context_lines.min(changes.len()) {
×
91
            show_line[i] = true;
×
92
        }
×
93
        #[allow(clippy::needless_range_loop)]
94
        for i in changes.len().saturating_sub(context_lines)..changes.len() {
×
95
            show_line[i] = true;
×
96
        }
×
97

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

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

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

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

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

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

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

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

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

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

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

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

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

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

×
196
            // Read old_content from README.md if exists, otherwise None
×
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,
×
203
                new_content: readme_content.into_bytes(),
×
204
            };
×
205

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

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

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

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

×
239
        // Read old_content from README.md if exists, otherwise None
×
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,
×
246
            new_content: readme_content.into_bytes(),
×
247
        };
×
248

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

266
pub fn enqueue_tuple_job(sender: std::sync::mpsc::Sender<Job>) {
×
267
    use std::process::{Command, Stdio};
268
    use std::time::Instant;
269

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

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

275
    // Generate the tuple impls code
276
    let start = Instant::now();
×
277
    let output = tuples::generate();
×
278
    let duration = start.elapsed();
×
279

×
280
    // Format content via rustfmt edition 2024
×
281
    let mut rustfmt_cmd = Command::new("rustfmt")
×
282
        .arg("--edition")
×
283
        .arg("2024")
×
284
        .arg("--emit")
×
285
        .arg("stdout")
×
286
        .stdin(Stdio::piped())
×
287
        .stdout(Stdio::piped())
×
288
        .stderr(Stdio::piped())
×
289
        .spawn()
×
290
        .expect("failed to start rustfmt");
×
291

292
    {
293
        use std::io::Write;
294
        let stdin = rustfmt_cmd
×
295
            .stdin
×
296
            .as_mut()
×
297
            .expect("failed to open rustfmt stdin");
×
298
        stdin
×
299
            .write_all(output.as_bytes())
×
300
            .expect("failed to write to rustfmt stdin");
×
301
    }
×
302

×
303
    let rustfmt_output = rustfmt_cmd
×
304
        .wait_with_output()
×
305
        .expect("failed to read rustfmt stdout");
×
306

×
307
    if !rustfmt_output.status.success() {
×
308
        let stderr = String::from_utf8_lossy(&rustfmt_output.stderr);
×
309
        error!(
×
310
            "rustfmt failed formatting {}: {}",
×
311
            base_path.display(),
×
312
            stderr
313
        );
314
    }
×
315

316
    let content = if rustfmt_output.status.success() {
×
317
        rustfmt_output.stdout
×
318
    } else {
319
        output.into_bytes()
×
320
    };
321

322
    let size_mb = (content.len() as f64) / (1024.0 * 1024.0);
×
323
    let secs = duration.as_secs_f64();
×
324
    let mbps = if secs > 0.0 { size_mb / secs } else { 0.0 };
×
325
    debug!(
×
326
        "Generated and formatted tuple impls for {}: {:.2} MiB in {:.2} s ({:.2} MiB/s)",
×
327
        base_path.display().blue(),
×
328
        size_mb,
×
329
        secs,
×
330
        mbps.bright_magenta()
×
331
    );
332

333
    // Attempt to read existing file
334
    let old_content = fs::read(base_path).ok();
×
335

×
336
    let job = Job {
×
337
        path: base_path.to_path_buf(),
×
338
        old_content,
×
339
        new_content: content,
×
340
    };
×
341

342
    if let Err(e) = sender.send(job) {
×
343
        error!("Failed to send tuple job: {e}");
×
344
    }
×
345
}
×
346

NEW
347
pub fn enqueue_fn_ptr_job(sender: std::sync::mpsc::Sender<Job>) {
×
348
    use std::process::{Command, Stdio};
349
    use std::time::Instant;
350

351
    // Path where function pointer impls should be written
NEW
352
    let base_path = Path::new("facet-core/src/impls_core/fn_ptr.rs");
×
NEW
353

×
NEW
354
    debug!(
×
NEW
355
        "Generating function pointer impls for {}",
×
NEW
356
        base_path.display().blue()
×
357
    );
358

359
    // Generate the function pointer impls code
NEW
360
    let start = Instant::now();
×
NEW
361
    let output = fn_ptr::generate();
×
NEW
362
    let duration = start.elapsed();
×
NEW
363

×
NEW
364
    // Format content via rustfmt edition 2024
×
NEW
365
    let mut rustfmt_cmd = Command::new("rustfmt")
×
NEW
366
        .arg("--edition")
×
NEW
367
        .arg("2024")
×
NEW
368
        .arg("--emit")
×
NEW
369
        .arg("stdout")
×
NEW
370
        .stdin(Stdio::piped())
×
NEW
371
        .stdout(Stdio::piped())
×
NEW
372
        .stderr(Stdio::piped())
×
NEW
373
        .spawn()
×
NEW
374
        .expect("failed to start rustfmt");
×
375

376
    {
377
        use std::io::Write;
NEW
378
        let stdin = rustfmt_cmd
×
NEW
379
            .stdin
×
NEW
380
            .as_mut()
×
NEW
381
            .expect("failed to open rustfmt stdin");
×
NEW
382
        stdin
×
NEW
383
            .write_all(output.as_bytes())
×
NEW
384
            .expect("failed to write to rustfmt stdin");
×
NEW
385
    }
×
NEW
386

×
NEW
387
    let rustfmt_output = rustfmt_cmd
×
NEW
388
        .wait_with_output()
×
NEW
389
        .expect("failed to read rustfmt stdout");
×
NEW
390

×
NEW
391
    if !rustfmt_output.status.success() {
×
NEW
392
        let stderr = String::from_utf8_lossy(&rustfmt_output.stderr);
×
NEW
393
        error!(
×
NEW
394
            "rustfmt failed formatting {}: {}",
×
NEW
395
            base_path.display(),
×
396
            stderr
397
        );
NEW
398
    }
×
399

NEW
400
    let content = if rustfmt_output.status.success() {
×
NEW
401
        rustfmt_output.stdout
×
402
    } else {
NEW
403
        output.into_bytes()
×
404
    };
405

NEW
406
    let size_mb = (content.len() as f64) / (1024.0 * 1024.0);
×
NEW
407
    let secs = duration.as_secs_f64();
×
NEW
408
    let mbps = if secs > 0.0 { size_mb / secs } else { 0.0 };
×
NEW
409
    debug!(
×
NEW
410
        "Generated and formatted function pointer impls for {}: {:.2} MiB in {:.2} s ({:.2} MiB/s)",
×
NEW
411
        base_path.display().blue(),
×
NEW
412
        size_mb,
×
NEW
413
        secs,
×
NEW
414
        mbps.bright_magenta()
×
415
    );
416

417
    // Attempt to read existing file
NEW
418
    let old_content = fs::read(base_path).ok();
×
NEW
419

×
NEW
420
    let job = Job {
×
NEW
421
        path: base_path.to_path_buf(),
×
NEW
422
        old_content,
×
NEW
423
        new_content: content,
×
NEW
424
    };
×
425

NEW
426
    if let Err(e) = sender.send(job) {
×
NEW
427
        error!("Failed to send tuple job: {e}");
×
NEW
428
    }
×
NEW
429
}
×
430

UNCOV
431
pub fn enqueue_sample_job(sender: std::sync::mpsc::Sender<Job>) {
×
432
    use log::trace;
433
    use std::time::Instant;
434

435
    // Path where sample generated code should be written
436
    let rel_path = std::path::PathBuf::from("facet/src/sample_generated_code.rs");
×
437
    let workspace_dir = std::env::current_dir().unwrap();
×
438
    let target_path = workspace_dir.join(&rel_path);
×
439

×
440
    trace!(
×
441
        "Expanding sample code at {:?}",
×
442
        target_path.display().blue()
×
443
    );
444
    let start = Instant::now();
×
445

×
446
    // Generate the sample expanded and formatted code
×
447
    let code = sample::cargo_expand_and_format();
×
448
    let content = code.into_bytes();
×
449
    let size_mb = (content.len() as f64) / (1024.0 * 1024.0);
×
450

×
451
    let duration = start.elapsed();
×
452
    let secs = duration.as_secs_f64();
×
453
    let mbps = if secs > 0.0 { size_mb / secs } else { 0.0 };
×
454

455
    debug!(
×
456
        "Generated and formatted sample code for {}: {:.2} MiB in {:.2} s ({:.2} MiB/s)",
×
457
        rel_path.display().blue(),
×
458
        size_mb,
×
459
        secs,
×
460
        mbps.bright_magenta()
×
461
    );
462

463
    // Attempt to read existing file
464
    let old_content = fs::read(&target_path).ok();
×
465

×
466
    let job = Job {
×
467
        path: target_path,
×
468
        old_content,
×
469
        new_content: content,
×
470
    };
×
471

472
    if let Err(e) = sender.send(job) {
×
473
        error!("Failed to send sample job: {e}");
×
474
    }
×
475
}
×
476

477
pub fn enqueue_rustfmt_jobs(sender: std::sync::mpsc::Sender<Job>, staged_files: &StagedFiles) {
×
478
    use log::trace;
479
    use std::time::Instant;
480

481
    for path in &staged_files.clean {
×
482
        // Only process .rs files
483
        if let Some(ext) = path.extension() {
×
484
            if ext != "rs" {
×
485
                continue;
×
486
            }
×
487
        } else {
488
            continue;
×
489
        }
490

491
        trace!("rustfmt: formatting {}", path.display());
×
492

493
        let original = match fs::read(path) {
×
494
            Ok(val) => val,
×
495
            Err(e) => {
×
496
                error!(
×
497
                    "{} {}: {}",
×
498
                    "❌".red(),
×
499
                    path.display().to_string().blue(),
×
500
                    format_args!("Failed to read: {e}").dim()
×
501
                );
502
                continue;
×
503
            }
504
        };
505

506
        let size_mb = (original.len() as f64) / (1024.0 * 1024.0);
×
507

×
508
        // Format the content via rustfmt (edition 2024)
×
509
        let start = Instant::now();
×
510
        let cmd = Command::new("rustfmt")
×
511
            .arg("--edition")
×
512
            .arg("2024")
×
513
            .arg("--emit")
×
514
            .arg("stdout")
×
515
            .stdin(Stdio::piped())
×
516
            .stdout(Stdio::piped())
×
517
            .stderr(Stdio::piped())
×
518
            .spawn();
×
519

520
        let mut cmd = match cmd {
×
521
            Ok(child) => child,
×
522
            Err(e) => {
×
523
                error!("Failed to spawn rustfmt for {}: {}", path.display(), e);
×
524
                continue;
×
525
            }
526
        };
527

528
        // Write source to rustfmt's stdin
529
        {
530
            let mut stdin = cmd.stdin.take().expect("Failed to take rustfmt stdin");
×
531
            if stdin.write_all(&original).is_err() {
×
532
                error!(
×
533
                    "{} {}: {}",
×
534
                    "❌".red(),
×
535
                    path.display().to_string().blue(),
×
536
                    "Failed to write src to rustfmt".dim()
×
537
                );
538
                continue;
×
539
            }
×
540
        }
541

542
        let output = match cmd.wait_with_output() {
×
543
            Ok(out) => out,
×
544
            Err(e) => {
×
545
                error!("Failed to get rustfmt output for {}: {}", path.display(), e);
×
546
                continue;
×
547
            }
548
        };
549

550
        let duration = start.elapsed();
×
551
        let secs = duration.as_secs_f64();
×
552
        let mbps = if secs > 0.0 { size_mb / secs } else { 0.0 };
×
553
        debug!(
×
554
            "rustfmt: {} formatted {:.2} MiB in {:.2} s ({:.2} MiB/s)",
×
555
            path.display(),
×
556
            size_mb,
×
557
            secs,
×
558
            mbps.magenta()
×
559
        );
560

561
        if !output.status.success() {
×
562
            error!(
×
563
                "{} {}: rustfmt failed\n{}\n{}",
×
564
                "❌".red(),
×
565
                path.display().to_string().blue(),
×
566
                String::from_utf8_lossy(&output.stderr).dim(),
×
567
                String::from_utf8_lossy(&output.stdout).dim()
×
568
            );
569
            continue;
×
570
        }
×
571

×
572
        let formatted = output.stdout;
×
573

×
574
        // Only enqueue a job if the formatted output is different
×
575
        if formatted != original {
×
576
            let job = Job {
×
577
                path: path.clone(),
×
578
                old_content: Some(original),
×
579
                new_content: formatted,
×
580
            };
×
581
            if let Err(e) = sender.send(job) {
×
582
                error!("Failed to send rustfmt job for {}: {}", path.display(), e);
×
583
            }
×
584
        }
×
585
    }
586
}
×
587

588
pub fn show_jobs_and_apply_if_consent_is_given(jobs: &mut [Job]) {
×
589
    use std::io::{self, Write};
590

591
    // Emojis for display
592
    const ACTION_REQUIRED: &str = "🚧";
593
    const DIFF: &str = "📝";
594
    const OK: &str = "✅";
595
    const CANCEL: &str = "🛑";
596

597
    jobs.sort_by_key(|job| job.path.clone());
×
598

599
    if jobs.is_empty() {
×
600
        println!(
×
601
            "{}",
×
602
            "All generated files are up-to-date and all Rust files are formatted properly"
×
603
                .green()
×
604
                .bold()
×
605
        );
×
606
        return;
×
607
    }
×
608

×
609
    println!(
×
610
        "\n{}\n{}\n",
×
611
        format_args!("{} GENERATION CHANGES {}", ACTION_REQUIRED, ACTION_REQUIRED)
×
612
            .on_black()
×
613
            .bold()
×
614
            .yellow()
×
615
            .italic()
×
616
            .underline(),
×
617
        format_args!(
×
618
            "The following {} file{} would be updated/generated:",
×
619
            jobs.len(),
×
620
            if jobs.len() == 1 { "" } else { "s" }
×
621
        )
622
        .magenta()
×
623
    );
624
    for (idx, job) in jobs.iter().enumerate() {
×
625
        let (plus, minus) = job.diff_plus_minus();
×
626
        println!(
×
627
            "  {}. {} {}{}",
×
628
            (idx + 1).bold().cyan(),
×
629
            job.path.display().yellow(),
×
630
            if plus > 0 {
×
631
                format!("+{}", plus).green().to_string()
×
632
            } else {
633
                String::new()
×
634
            },
635
            if minus > 0 {
×
636
                format!(" -{}", minus).red().to_string()
×
637
            } else {
638
                String::new()
×
639
            }
640
        );
641
    }
642

643
    let jobs_vec = jobs.to_vec();
×
644

645
    // Define menu items as a Vec<MenuItem>
646
    static MENU_LABEL: &str = "facet-dev fixed some files for you:";
647
    let menu_items = [
×
648
        menu::MenuItem {
×
649
            label: "[y]es: 🚀 Apply the above fixes".to_string(),
×
650
            action: "apply".to_string(),
×
651
            style: Style::new().green().bold(),
×
652
        },
×
653
        menu::MenuItem {
×
654
            label: "[d]iff: 🔍 Show details of all diffs".to_string(),
×
655
            action: "diff".to_string(),
×
656
            style: Style::new().cyan(),
×
657
        },
×
658
        menu::MenuItem {
×
659
            label: "[c]ontinue: ➡️ Continue with the commit without applying fixes".to_string(),
×
660
            action: "continue".to_string(),
×
661
            style: Style::new().yellow(),
×
662
        },
×
663
        menu::MenuItem {
×
664
            label: "[a]bort: 💥 Exit with error (this'll abort the commit)".to_string(),
×
665
            action: "abort".to_string(),
×
666
            style: Style::new().red(),
×
667
        },
×
668
    ];
×
669

670
    loop {
671
        let action =
×
672
            menu::show_menu(MENU_LABEL, &menu_items[..]).unwrap_or_else(|| "apply".to_string());
×
673
        match action.as_str() {
×
674
            "apply" => {
×
675
                for job in &jobs_vec {
×
676
                    print!("{} Applying {} ... ", OK, job.path.display().yellow());
×
677
                    io::stdout().flush().unwrap();
×
678
                    match job.apply() {
×
679
                        Ok(_) => {
×
680
                            println!("{}", "ok".green());
×
681
                        }
×
682
                        Err(e) => {
×
683
                            println!("{} {}", CANCEL, format_args!("failed: {e}").red());
×
684
                        }
×
685
                    }
686
                }
687
                println!("{} {}", OK, "All fixes applied and staged!".green().bold());
×
688
                std::process::exit(0);
×
689
            }
690
            "diff" => {
×
691
                println!(
×
692
                    "\n{} {}\n",
×
693
                    DIFF,
×
694
                    "Showing diffs for all changed/generated files:"
×
695
                        .magenta()
×
696
                        .bold()
×
697
                );
698
                for job in &jobs_vec {
×
699
                    println!(
×
700
                        "{} {}\n{}",
×
701
                        DIFF,
×
702
                        job.path.display().yellow(),
×
703
                        "───────────────────────────────────────────────".dim()
×
704
                    );
×
705
                    job.show_diff();
×
706
                }
×
707
                // After showing diffs, continue loop to show menu again
708
            }
709
            "continue" => {
×
710
                println!(
×
711
                    "{} {}",
×
712
                    CANCEL,
×
713
                    "Continuing without applying fixes.".yellow().bold()
×
714
                );
×
715
                return;
×
716
            }
717
            "abort" => {
×
718
                println!("{} {}", CANCEL, "Aborting. No changes made.".red().bold());
×
719
                std::process::exit(1);
×
720
            }
721
            _ => todo!(),
×
722
        }
723
    }
724
}
×
725

726
#[derive(Debug, Clone)]
727
struct Options {
728
    check: bool,
729
}
730

731
fn main() {
×
732
    facet_testhelpers::setup();
×
733
    // Accept allowed log levels: trace, debug, error, warn, info
×
734
    log::set_max_level(LevelFilter::Info);
×
735
    if let Ok(log_level) = std::env::var("RUST_LOG") {
×
736
        let allowed = ["trace", "debug", "error", "warn", "info"];
×
737
        let log_level_lc = log_level.to_lowercase();
×
738
        if allowed.contains(&log_level_lc.as_str()) {
×
739
            let level = match log_level_lc.as_str() {
×
740
                "trace" => LevelFilter::Trace,
×
741
                "debug" => LevelFilter::Debug,
×
742
                "info" => LevelFilter::Info,
×
743
                "warn" => LevelFilter::Warn,
×
744
                "error" => LevelFilter::Error,
×
745
                _ => LevelFilter::Info,
×
746
            };
747
            log::set_max_level(level);
×
748
        }
×
749
    }
×
750

751
    // Parse opts from args
752
    let args: Vec<String> = std::env::args().collect();
×
753
    let mut opts = Options { check: false };
×
754
    for arg in &args[1..] {
×
755
        if arg == "--check" || arg == "-c" {
×
756
            opts.check = true;
×
757
        }
×
758
    }
759

760
    // Check if current directory has a Cargo.toml with [workspace]
761
    let cargo_toml_path = std::env::current_dir().unwrap().join("Cargo.toml");
×
762
    let cargo_toml_content =
×
763
        fs_err::read_to_string(cargo_toml_path).expect("Failed to read Cargo.toml");
×
764
    if !cargo_toml_content.contains("[workspace]") {
×
765
        error!(
×
766
            "🚫 {}",
×
767
            "Cargo.toml does not contain [workspace] (you must run codegen from the workspace root)"
×
768
                .red()
×
769
        );
770
        std::process::exit(1);
×
771
    }
×
772

773
    let staged_files = match collect_staged_files() {
×
774
        Ok(sf) => sf,
×
775
        Err(e) => {
×
776
            if std::env::var("GITHUB_ACTIONS").is_ok() {
×
777
                // In GitHub Actions, continue without error.
778
                error!("Failed to collect staged files: {e} (continuing due to GITHUB_ACTIONS)");
×
779
                StagedFiles {
×
780
                    clean: Vec::new(),
×
781
                    dirty: Vec::new(),
×
782
                    unstaged: Vec::new(),
×
783
                }
×
784
            } else {
785
                error!(
×
786
                    "Failed to collect staged files: {e}\n\
×
787
                    This tool requires Git to be installed and a Git repository initialized."
×
788
                );
789
                std::process::exit(1);
×
790
            }
791
        }
792
    };
793

794
    // Use a channel to collect jobs from all tasks.
795
    use std::sync::mpsc;
796
    let (sender, receiver) = mpsc::channel();
×
797

×
798
    // Start threads for each codegen job enqueuer
×
799
    let send1 = sender.clone();
×
800
    let handle_readme = std::thread::spawn(move || {
×
801
        enqueue_readme_jobs(send1);
×
802
    });
×
803
    let send2 = sender.clone();
×
804
    let handle_tuple = std::thread::spawn(move || {
×
805
        enqueue_tuple_job(send2);
×
806
    });
×
807
    let send3 = sender.clone();
×
NEW
808
    let handle_fn_ptr = std::thread::spawn(move || {
×
NEW
809
        enqueue_fn_ptr_job(send3);
×
NEW
810
    });
×
NEW
811
    let send4 = sender.clone();
×
812
    let handle_sample = std::thread::spawn(move || {
×
NEW
813
        enqueue_sample_job(send4);
×
814
    });
×
815
    // Rustfmt job: enqueue formatting for staged .rs files
NEW
816
    let send5 = sender.clone();
×
817
    let handle_rustfmt = std::thread::spawn(move || {
×
NEW
818
        enqueue_rustfmt_jobs(send5, &staged_files);
×
819
    });
×
820

821
    // Drop original sender so the channel closes when all workers finish
822
    drop(sender);
×
823

×
824
    // Collect jobs
×
825
    let mut jobs: Vec<Job> = Vec::new();
×
826
    for job in receiver {
×
827
        jobs.push(job);
×
828
    }
×
829

830
    // Wait for all job enqueuers to finish
831
    handle_readme.join().unwrap();
×
832
    handle_tuple.join().unwrap();
×
NEW
833
    handle_fn_ptr.join().unwrap();
×
834
    handle_sample.join().unwrap();
×
835
    handle_rustfmt.join().unwrap();
×
836

×
837
    if jobs.is_empty() {
×
838
        println!("{}", "No codegen changes detected.".green().bold());
×
839
        return;
×
840
    }
×
841

×
842
    if opts.check {
×
843
        let mut any_diffs = false;
×
844
        for job in &jobs {
×
845
            // Compare old_content (current file content) to new_content (generated content)
846
            let disk_content = std::fs::read(&job.path).unwrap_or_default();
×
847
            if disk_content != job.new_content {
×
848
                error!(
×
849
                    "Diff detected in {}",
×
850
                    job.path.display().to_string().yellow().bold()
×
851
                );
852
                any_diffs = true;
×
853
            }
×
854
        }
855
        if any_diffs {
×
856
            // Print a big banner with error message about generated files
857
            error!(
×
858
                "┌────────────────────────────────────────────────────────────────────────────┐"
×
859
            );
860
            error!(
×
861
                "│                                                                            │"
×
862
            );
863
            error!(
×
864
                "│  GENERATED FILES HAVE CHANGED - RUN `just codegen` TO UPDATE THEM          │"
×
865
            );
866
            error!(
×
867
                "│                                                                            │"
×
868
            );
869
            error!(
×
870
                "│  For README.md files:                                                      │"
×
871
            );
872
            error!(
×
873
                "│                                                                            │"
×
874
            );
875
            error!(
×
876
                "│  • Don't edit README.md directly - edit the README.md.in template instead  │"
×
877
            );
878
            error!(
×
879
                "│  • Then run `just codegen` to regenerate the README.md files               │"
×
880
            );
881
            error!(
×
882
                "│  • A pre-commit hook is set up by cargo-husky to do just that              │"
×
883
            );
884
            error!(
×
885
                "│                                                                            │"
×
886
            );
887
            error!(
×
888
                "│  See CONTRIBUTING.md                                                       │"
×
889
            );
890
            error!(
×
891
                "│                                                                            │"
×
892
            );
893
            error!(
×
894
                "└────────────────────────────────────────────────────────────────────────────┘"
×
895
            );
896
            std::process::exit(1);
×
897
        } else {
×
898
            println!("{}", "✅ All generated files up to date.".green().bold());
×
899
        }
×
900
    } else {
901
        // Remove no-op jobs (where the content is unchanged).
902
        jobs.retain(|job| !job.is_noop());
×
903
        show_jobs_and_apply_if_consent_is_given(&mut jobs);
×
904
    }
905
}
×
906

907
#[derive(Debug)]
908
pub struct StagedFiles {
909
    /// Files that are staged (in the index) and not dirty (working tree matches index).
910
    pub clean: Vec<PathBuf>,
911
    /// Files that are staged and dirty (index does NOT match working tree).
912
    pub dirty: Vec<PathBuf>,
913
    /// Files that are untracked or unstaged (not added to the index).
914
    pub unstaged: Vec<PathBuf>,
915
}
916

917
// -- Formatting support types --
918

919
#[derive(Debug)]
920
pub struct FormatCandidate {
921
    pub path: PathBuf,
922
    pub original: Vec<u8>,
923
    pub formatted: Vec<u8>,
924
    pub diff: Option<String>,
925
}
926

927
pub fn collect_staged_files() -> io::Result<StagedFiles> {
×
928
    // Run `git status --porcelain`
929
    let output = Command::new("git")
×
930
        .arg("status")
×
931
        .arg("--porcelain")
×
932
        .output()?;
×
933

934
    if !output.status.success() {
×
935
        panic!("Failed to run `git status --porcelain`");
×
936
    }
×
937
    let stdout = String::from_utf8_lossy(&output.stdout);
×
938

×
939
    log::trace!("Parsing {} output:", "`git status --porcelain`".blue());
×
940
    log::trace!("---\n{}\n---", stdout);
×
941

942
    let mut clean = Vec::new();
×
943
    let mut dirty = Vec::new();
×
944
    let mut unstaged = Vec::new();
×
945

946
    for line in stdout.lines() {
×
947
        log::trace!("Parsing git status line: {:?}", line.dim());
×
948
        // E.g. "M  src/main.rs", "A  foo.rs", "AM foo/bar.rs"
949
        if line.len() < 3 {
×
950
            log::trace!("Skipping short line: {:?}", line.dim());
×
951
            continue;
×
952
        }
×
953
        let x = line.chars().next().unwrap();
×
954
        let y = line.chars().nth(1).unwrap();
×
955
        let path = line[3..].to_string();
×
956

×
957
        log::trace!(
×
958
            "x: {:?}, y: {:?}, path: {:?}",
×
959
            x.magenta(),
×
960
            y.cyan(),
×
961
            path.dim()
×
962
        );
963

964
        // Staged and not dirty (to be formatted/committed)
965
        if x != ' ' && x != '?' && y == ' ' {
×
966
            log::debug!(
×
967
                "{} {}",
×
968
                "-> clean (staged, not dirty):".green().bold(),
×
969
                path.as_str().blue()
×
970
            );
971
            clean.push(PathBuf::from(&path));
×
972
        }
973
        // Staged + dirty (index does not match worktree; skip and warn)
974
        else if x != ' ' && x != '?' && y != ' ' {
×
975
            log::debug!(
×
976
                "{} {}",
×
977
                "-> dirty (staged and dirty):".yellow().bold(),
×
978
                path.as_str().blue()
×
979
            );
980
            dirty.push(PathBuf::from(&path));
×
981
        }
982
        // Untracked or unstaged files (may be useful for warning)
983
        else if x == '?' {
×
984
            log::debug!(
×
985
                "{} {}",
×
986
                "-> unstaged/untracked:".cyan().bold(),
×
987
                path.as_str().blue()
×
988
            );
989
            unstaged.push(PathBuf::from(&path));
×
990
        } else {
991
            log::debug!("{} {}", "-> not categorized:".red(), path.as_str().blue());
×
992
        }
993
    }
994
    Ok(StagedFiles {
×
995
        clean,
×
996
        dirty,
×
997
        unstaged,
×
998
    })
×
999
}
×
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