alef 0.71.0

Opinionated polyglot binding generator for Rust libraries
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
use anyhow::{Context, Result};

pub(crate) mod frozen;
pub(crate) use frozen::FrozenFile;
use frozen::frozen_managed_paths;

mod post_build;
use post_build::run_required_post_builds;
pub(crate) use post_build::{languages_have_post_build_steps, languages_with_post_build_steps};

/// Complete every generated artifact that depends on a backend build and then
/// enforce FFI source/header parity. Keeping these operations together prevents
/// commands from validating a cbindgen header before its producer runs or from
/// omitting the final parity gate. ~keep
pub(crate) fn complete_generated_artifacts(
    languages: &[crate::core::config::Language],
    config: &crate::core::config::ResolvedCrateConfig,
    base_dir: &std::path::Path,
) -> Result<()> {
    run_required_post_builds(languages, config, base_dir)?;
    if !languages.contains(&crate::core::config::Language::Ffi) {
        return Ok(());
    }

    crate::cli::pipeline::ensure_ffi_header_freshness(config, base_dir, || {
        crate::cli::pipeline::build_with_environment(
            config,
            &[crate::core::config::Language::Ffi],
            false,
            &[("ALEF_EXPORT_GENERATED_HEADERS", "1")],
            false,
        )
    })
}

/// Returns true when every freshly generated file already matches the file on disk,
/// using the same hash-line-insensitive body comparison as [`crate::cli::pipeline::write_files`].
///
/// The per-run side cache (`.alef/hashes/*.output_hashes`) records what was last
/// generated, but the files on disk can drift from it out-of-band — a `git restore`,
/// a hand-edit, a partial write, or an interrupted run. Treating the cache as the
/// sole authority for an "up to date" skip silently retains that stale output: the
/// generator would emit different bytes, yet the skip fires and `write_files` is
/// never reached. Gating the skip on actual disk agreement closes that gap while
/// staying a no-op for the common clean case.
pub(crate) fn generated_files_match_disk(
    lang_files: &[crate::core::backend::GeneratedFile],
    base_dir: &std::path::Path,
) -> bool {
    lang_files.iter().all(|file| {
        let normalized = crate::cli::pipeline::normalize_content(&file.path, &file.content);
        match std::fs::read_to_string(base_dir.join(&file.path)) {
            Ok(disk) => crate::core::hash::strip_hash_line(&disk) == crate::core::hash::strip_hash_line(&normalized),
            Err(_) => false,
        }
    })
}

/// Map the CLI verbosity flags to a default `tracing` level filter.
///
/// A single verbosity channel drives the whole binary: `--quiet` pins the level to
/// `error`; otherwise the `-v` count raises it — no flag is `info` (progress visible),
/// `-v` is `debug` (per-file/per-item detail), `-vv`+ is `trace`. `RUST_LOG` overrides
/// this default when set (see [`init_tracing`]).
pub(crate) fn default_log_level(verbose: u8, quiet: bool) -> &'static str {
    if quiet {
        return "error";
    }
    match verbose {
        0 => "info",
        1 => "debug",
        _ => "trace",
    }
}

pub(crate) fn init_tracing(verbose: u8, quiet: bool, no_color: bool) {
    use tracing_subscriber::EnvFilter;
    let default_level = default_log_level(verbose, quiet);
    let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(default_level));
    tracing_subscriber::fmt()
        .with_env_filter(filter)
        .with_ansi(!no_color)
        .with_writer(std::io::stderr)
        .without_time()
        .with_target(false)
        .init();
}

/// Load and resolve an alef.toml, returning the workspace-level config and
/// the per-crate resolved configs.  Detects legacy schema and returns an error
/// with a migration hint rather than a confusing parse error.
pub(crate) fn load_config(
    path: &std::path::Path,
) -> Result<(
    crate::core::config::WorkspaceConfig,
    Vec<crate::core::config::ResolvedCrateConfig>,
)> {
    let content =
        std::fs::read_to_string(path).with_context(|| format!("Failed to read config: {}", path.display()))?;
    crate::core::config::detect_legacy_keys(&content).with_context(|| {
        format!(
            "legacy schema detected in {} — run `alef migrate` to update automatically",
            path.display()
        )
    })?;
    let mut toml_value: toml::Value =
        toml::from_str(&content).with_context(|| format!("Failed to parse alef.toml ({})", path.display()))?;
    let deprecation_warnings = crate::core::config::legacy::strip_deprecated_keys(&mut toml_value);
    for warning in &deprecation_warnings {
        tracing::warn!("{}", warning);
    }
    let cfg: crate::core::config::NewAlefConfig = toml_value
        .try_into()
        .with_context(|| format!("Failed to deserialize alef.toml ({})", path.display()))?;
    let resolved = cfg
        .resolve()
        .with_context(|| format!("failed to resolve crates in {}", path.display()))?;
    for resolved_cfg in &resolved {
        crate::core::config::validation::validate_resolved(resolved_cfg)
            .with_context(|| format!("invalid resolved config for crate `{}`", resolved_cfg.name))?;
    }
    Ok((cfg.workspace, resolved))
}

pub(crate) fn resolve_languages(
    config: &crate::core::config::ResolvedCrateConfig,
    filter: Option<&[String]>,
) -> Result<Vec<crate::core::config::Language>> {
    resolve_languages_inner(config, filter, false)
}

/// Like `resolve_languages` but also allows `rust` regardless of the config languages list.
/// Docs can always be generated for Rust since it's the source language.
pub(crate) fn resolve_doc_languages(
    config: &crate::core::config::ResolvedCrateConfig,
    filter: Option<&[String]>,
) -> Result<Vec<crate::core::config::Language>> {
    resolve_languages_inner(config, filter, true)
}

/// Like `resolve_languages` but also allows `rust` regardless of the config languages list.
///
/// Every Rust crate that publishes to crates.io needs a `crates/<lib>/README.md`,
/// so the readme command must regenerate it from the same templates that produce
/// the per-binding READMEs. Configure with `[crates.readme.languages.rust]` in
/// `alef.toml` to opt in.
pub(crate) fn resolve_readme_languages(
    config: &crate::core::config::ResolvedCrateConfig,
    filter: Option<&[String]>,
) -> Result<Vec<crate::core::config::Language>> {
    resolve_languages_inner(config, filter, true)
}

/// Resolve languages for `alef test`.
///
/// Test suites can exist for targets that do not generate host bindings, such
/// as Rust e2e tests for the source crate. Keep binding language resolution
/// strict for generation/build commands, but allow explicit test targets and
/// include e2e-only entries when `alef test --e2e` runs without a filter.
pub(crate) fn resolve_test_languages(
    config: &crate::core::config::ResolvedCrateConfig,
    filter: Option<&[String]>,
    include_e2e: bool,
) -> Result<Vec<crate::core::config::Language>> {
    match filter {
        Some(langs) => {
            let mut result = vec![];
            for lang_str in langs {
                let lang = parse_language(lang_str)?;
                if config.languages.contains(&lang) || config.test.contains_key(&lang.to_string()) {
                    result.push(lang);
                } else {
                    anyhow::bail!("Language '{lang_str}' not in config languages list or test configuration");
                }
            }
            Ok(result)
        }
        None => {
            let mut langs = config.languages.clone();
            if include_e2e {
                let mut extra_test_langs = vec![];
                for (lang_str, test_config) in &config.test {
                    if test_config.e2e.is_none() {
                        continue;
                    }
                    let lang = parse_language(lang_str)
                        .with_context(|| format!("Invalid test language in alef.toml: {lang_str}"))?;
                    if !langs.contains(&lang) {
                        extra_test_langs.push(lang);
                    }
                }
                extra_test_langs.sort_by_key(|lang| lang.to_string());
                for lang in extra_test_langs {
                    if !langs.contains(&lang) {
                        langs.push(lang);
                    }
                }
            }
            Ok(langs)
        }
    }
}

pub(crate) fn resolve_languages_inner(
    config: &crate::core::config::ResolvedCrateConfig,
    filter: Option<&[String]>,
    allow_rust: bool,
) -> Result<Vec<crate::core::config::Language>> {
    match filter {
        Some(langs) => {
            let mut result = vec![];
            for lang_str in langs {
                let lang = parse_language(lang_str)?;
                if config.languages.contains(&lang) || (allow_rust && lang == crate::core::config::Language::Rust) {
                    result.push(lang);
                } else {
                    anyhow::bail!("Language '{lang_str}' not in config languages list");
                }
            }
            Ok(result)
        }
        None => {
            let mut langs = config.languages.clone();
            if allow_rust && !langs.contains(&crate::core::config::Language::Rust) {
                langs.push(crate::core::config::Language::Rust);
            }
            Ok(langs)
        }
    }
}

pub(crate) fn parse_language(lang_str: &str) -> Result<crate::core::config::Language> {
    toml::Value::String(lang_str.to_string())
        .try_into()
        .with_context(|| format!("Unknown language: {lang_str}"))
}

pub(crate) fn format_languages(languages: &[crate::core::config::Language]) -> String {
    languages.iter().map(|l| l.to_string()).collect::<Vec<_>>().join(", ")
}

/// A file whose embedded `alef:hash:` did not match any expected inputs hash.
///
/// Returned by [`verify_walk_multi`] and [`verify_walk`] for every stale file
/// found during a verify walk. The `computed` field holds all candidate hashes
/// from the current workspace (one per crate); `embedded` is what was found in
/// the file's header. Pass `--verbose` / `-v` to `alef verify` to print these.
pub(crate) struct StaleMismatch {
    /// Absolute path of the stale generated file.
    pub(crate) path: String,
    /// The `alef:hash:` value embedded in the file's header.
    pub(crate) embedded: String,
    /// All candidate inputs hashes computed from the current workspace.
    /// The file is stale because none of these equals `embedded`.
    pub(crate) computed: Vec<String>,
}

/// Re-exported so `helpers`' own callers and tests keep one spelling for the ownership walk
/// after it moved to [`super::verify_scan`]. ~keep
pub(crate) use super::verify_scan::collect_alef_hashes;

/// A cross-artifact ABI-generation disagreement: two or more alef-owned files
/// in the tree carry different values for the same `alef:<key>:` stamp (see
/// [`crate::core::hash::inject_stamp_line`]/[`extract_stamp`]).
///
/// A single `alef generate` run stamps every file it touches with the same
/// value, so two different values coexisting can only mean files from two
/// different generation runs are mixed in the tree — e.g. an FFI header
/// regenerated after a handle-representation change sitting next to a
/// binding backend's opaque-handle file that was not. `alef verify`'s
/// per-file `alef:hash:` staleness check cannot see this: each file's hash is
/// compared against the *current* generation inputs in isolation, never
/// against what a *different* file on disk was stamped with. ~keep
///
/// [`extract_stamp`]: crate::core::hash::extract_stamp
pub(crate) struct StampDisagreement {
    pub(crate) key: String,
    /// One `(display_path, value)` pair per distinct value found, so the
    /// report can show a representative example from each side of the split.
    pub(crate) examples: Vec<(String, String)>,
}

/// Find whether every alef-owned file under `base_dir` that carries an
/// `alef:<key>:` stamp agrees on its value.
///
/// Returns `None` when zero or one distinct value is present. Both are
/// silently fine, deliberately: zero stamped files means no backend in this
/// tree emits `key` yet — every consumer repo today, since nothing calls
/// `inject_stamp_line` — and that tree cannot be verified this way, not that
/// it disagrees; one distinct value is the healthy up-to-date case. Only 2+
/// distinct values is a provable disagreement (see [`StampDisagreement`]).
/// This intentionally does not attempt to flag "some files stamped, others
/// plausibly should be but aren't" as a softer warning: that requires knowing
/// which unstamped files are ABI-relevant, which a generic content walk
/// cannot determine — only the backend that emits a given file knows whether
/// it encodes the handle representation. ~keep
pub(crate) fn find_stamp_disagreement(base_dir: &std::path::Path, key: &str) -> Option<StampDisagreement> {
    let mut by_value: std::collections::BTreeMap<String, String> = std::collections::BTreeMap::new();
    for (path, _hash, content) in collect_alef_hashes(base_dir) {
        let Some(value) = crate::core::hash::extract_stamp(&content, key) else {
            continue;
        };
        by_value.entry(value).or_insert_with(|| path.display().to_string());
    }
    if by_value.len() < 2 {
        return None;
    }
    Some(StampDisagreement {
        key: key.to_string(),
        examples: by_value.into_iter().map(|(value, path)| (path, value)).collect(),
    })
}

/// Display paths (joined onto `base_dir`, matching `StaleMismatch::path`'s
/// convention) of every alef-owned file in `files` that is entirely absent
/// from disk.
///
/// Ownership is decided by [`crate::core::backend::GeneratedFile::carries_alef_marker`]
/// via [`crate::cli::pipeline::managed_generated_files`] — the same predicate
/// [`collect_alef_hashes`] uses to select existing files for the hash walk.
/// A file without the marker (scaffold-once `Cargo.toml`/`package.json`/gemspec
/// templates, lockfiles) is outside alef's freshness claim and is never
/// reported missing here, even when absent — matching [`verify_walk`]'s scope
/// so a clean repo whose scaffold hasn't been (re-)run never fails verify. ~keep
fn missing_managed_paths(files: &[crate::core::backend::GeneratedFile], base_dir: &std::path::Path) -> Vec<String> {
    crate::cli::pipeline::managed_generated_files(files)
        .into_iter()
        .filter(|file| !base_dir.join(&file.path).exists())
        .map(|file| base_dir.join(&file.path).display().to_string())
        .collect()
}

/// The line within `content` that [`crate::core::hash::content_has_alef_marker`]
/// would recognize as the provenance marker, if any.
///
/// Delegates the actual match to `content_has_alef_marker` itself, applied one
/// line at a time, instead of re-implementing its marker text here — so the
/// two can never drift apart. ~keep
fn marker_line(content: &str) -> Option<&str> {
    content
        .lines()
        .find(|line| crate::core::hash::content_has_alef_marker(line))
}

/// Missing and frozen generated files found for one crate — see
/// [`find_missing_and_frozen_generated_files`].
#[derive(Default)]
pub(crate) struct MissingAndFrozenFiles {
    pub(crate) missing: Vec<String>,
    /// The subset of what would otherwise be in `missing` that git also reports ignored --
    /// split out by [`super::verify_gitignore::split_missing_by_gitignore`] because the remedy
    /// differs: `alef generate` writes these paths and the ignore rule discards them again
    /// before commit, so it can never close this gap the way it closes a plain `missing`
    /// entry. See that function's doc. ~keep
    pub(crate) missing_gitignored: Vec<String>,
    pub(crate) frozen: Vec<FrozenFile>,
    /// Absolute paths of every file this crate's configuration would produce this run, from the
    /// same `surface` `missing`/`frozen` are derived from -- deliberately every path, not only
    /// [`crate::cli::pipeline::managed_output_paths`]'s marker-carrying subset: a self-marking
    /// backend (pyo3's `lib.rs`, `docs::render`'s HTML-commented pages) bakes its header into
    /// `content` at write time via `ensure_generated_header`, not at this in-memory render, so
    /// `carries_alef_marker()` reads `false` here for a file that is unquestionably still
    /// produced this run -- filtering by it would misreport that file as an orphan the moment it
    /// left the marker-carrying subset. A multi-crate caller unions this across every crate
    /// before handing it to [`super::verify_orphans::find_orphaned_generated_files`], so a file
    /// legitimately owned by crate B is never mistaken for an orphan while diffing crate A alone. ~keep
    pub(crate) managed_paths: std::collections::HashSet<std::path::PathBuf>,
    /// [`StageFailure`]s [`collect_managed_surface`] tolerated while still building the
    /// rest of the surface, rendered as `[<stage>] <message>`. `alef verify` is
    /// read-only, so it has no target to decide "does this affect me" the way `alef
    /// adopt` does -- every tolerated failure is real debt and belongs in the report,
    /// never silently absorbed into a clean-looking zero. The `missing`/`frozen` lists
    /// above are still accurate despite these: the failing stage's own files are
    /// absorbed into the surface regardless of its error (see
    /// [`collect_managed_surface`]'s doc), so this is additional signal, not a
    /// disclaimer that the rest of the report cannot be trusted. ~keep
    pub(crate) stage_failures: Vec<String>,
}

/// Find both generated files that generation would now produce for `config`
/// but that do not exist on disk at all ([`missing_managed_paths`]), and
/// generated files that do exist but were never marked
/// ([`frozen_managed_paths`]).
///
/// `verify_walk`/`verify_walk_multi` only ever see files that already exist and
/// carry an `alef:hash:` header, so a file generation would emit but that was
/// never written — a new API item a backend maps to a brand-new per-type file
/// (`src/backends/java/gen_bindings/mod.rs`, `src/backends/csharp/gen_bindings/mod.rs`
/// both emit one file per public type), forgotten after adding the item — is
/// invisible to a pure disk walk. Same for a frozen file: it fails ownership,
/// not freshness, so a walk that only opens marker-bearing files by
/// construction cannot see it either (see [`collect_alef_hashes`]'s doc). Both
/// gaps require knowing what generation would produce, which is IR-derived for
/// some backends and cannot be answered from `alef.toml` alone; this mirrors
/// `alef diff`'s approach ([`crate::cli::pipeline::diff_files`],
/// `src/cli/pipeline/generate/diff.rs`) and pays a comparable cost: every stage
/// in [`collect_managed_surface`] is regenerated in memory (never written to
/// disk) for every configured language — paid once here for both checks
/// together, since paying it twice (one full regeneration pass per check) would
/// double the cost of every `alef verify` run for no benefit. ~keep
///
/// `clean = false` is what [`collect_managed_surface`] passes to
/// `crate::cli::pipeline::generate` deliberately,
/// not to save cost in CI (a fresh checkout's `.alef/` cache is always cold, so
/// CI always pays full regeneration regardless of this flag) but because it is
/// free and safe on a warm local machine: `crate::cli::cache::is_lang_cached`
/// only reports a cache hit when the IR+config hash still matches *and* every
/// previously recorded output path still exists on disk, so skipping a
/// cache-hit language can never hide a genuinely missing or frozen file. ~keep
pub(crate) fn find_missing_and_frozen_generated_files(
    languages: &[crate::core::config::Language],
    api: &crate::core::ir::ApiSurface,
    config: &crate::core::config::ResolvedCrateConfig,
    config_path: &std::path::Path,
    base_dir: &std::path::Path,
) -> anyhow::Result<MissingAndFrozenFiles> {
    let (surface, stage_failures) = collect_managed_surface(languages, api, config, config_path, base_dir)?;
    // `surface` alone omits post-build-owned paths; see `verify_orphans::post_build_owned_paths`. ~keep
    let mut managed_paths: std::collections::HashSet<std::path::PathBuf> =
        surface.iter().map(|file| base_dir.join(&file.path)).collect();
    managed_paths.extend(super::verify_orphans::post_build_owned_paths(
        languages, config, base_dir,
    ));
    let (missing, missing_gitignored) =
        super::verify_gitignore::split_missing_by_gitignore(base_dir, &missing_managed_paths(&surface, base_dir));
    let mut result = MissingAndFrozenFiles {
        missing,
        missing_gitignored,
        frozen: frozen_managed_paths(&surface, base_dir),
        managed_paths,
        stage_failures: stage_failures
            .into_iter()
            .map(|failure| format!("[{}] {}", failure.stage, failure.message))
            .collect(),
    };
    result.missing.sort();
    result.missing.dedup();
    result.missing_gitignored.sort();
    result.missing_gitignored.dedup();
    result.frozen.sort_by(|a, b| a.path.cmp(&b.path));
    result.frozen.dedup_by(|a, b| a.path == b.path);
    result.stage_failures.sort();
    result.stage_failures.dedup();
    Ok(result)
}

/// Insert `files` into `surface`, letting a later stage win the path.
///
/// Last-write-wins mirrors disk: `alef all` runs these stages in sequence and each
/// one overwrites whatever the previous stage left at the same path, so the bytes a
/// reader is asked to consent to (and the bytes `alef verify` reasons about) must be
/// the *last* stage's, not the first's. The only stages that actually collide are
/// local-mode e2e and registry-mode test apps, which share the snippet output root. ~keep
fn absorb_stage(
    surface: &mut std::collections::BTreeMap<std::path::PathBuf, crate::core::backend::GeneratedFile>,
    files: Vec<crate::core::backend::GeneratedFile>,
) {
    for file in files {
        surface.insert(file.path.clone(), file);
    }
}

/// One [`collect_managed_surface`] stage that rendered files and also reported a
/// failure alongside them.
///
/// Only the two e2e stages can produce one of these today: they are the only stages
/// whose `Result` already separates "files it rendered" from "whether it is happy with
/// them" (see `generate_e2e`'s doc) — every other stage's failure has no partial output
/// to report and stays a hard `collect_managed_surface` error via `?`. `paths` is what
/// that stage's own invocation actually rendered, kept so a caller with a specific
/// target (`alef adopt`) can decide whether this failure is even about a path it asked
/// for, via [`Self::affects_any`]. `alef verify` has no target and reports every one of
/// these unconditionally instead. ~keep
pub(crate) struct StageFailure {
    pub(crate) stage: &'static str,
    pub(crate) message: String,
    pub(crate) paths: Vec<std::path::PathBuf>,
}

impl StageFailure {
    /// Whether any of `targets` could have come from this stage — derived from
    /// [`crate::cli::commands::adopt::matches_target`], the identical predicate `alef
    /// adopt`'s own candidate selection trusts, rather than a second, hand-maintained
    /// notion of what counts as a match. ~keep
    pub(crate) fn affects_any(&self, targets: &[String]) -> bool {
        self.paths.iter().any(|path| {
            targets
                .iter()
                .any(|target| crate::cli::commands::adopt::matches_target(target, path))
        })
    }
}

/// Every file this crate's configuration would cause alef to emit, from **all**
/// generation stages, deduplicated by path.
///
/// This is the single answer to "what does alef own here", and it has exactly two
/// consumers: [`find_missing_and_frozen_generated_files`] (`alef verify`'s report)
/// and `bin_cli::adopt_command` (the remedy the report points
/// at). They are the report and the fix for the same fact, so they must not be able
/// to disagree — and they did. Both were built from hand-maintained stage lists
/// that each omitted different stages: adopt covered bindings + stubs + scaffold and
/// verify covered those plus service/public API, and *neither* covered e2e, test
/// apps, READMEs or docs. Consumer repos then committed regenerated e2e snippet
/// `.md` files that carried no marker (`e2e::snippets::render_snippet_markdown` did
/// not route through `docs::render::with_html_header` at the time), so the write
/// guard froze 15,677 of them in one repo and 9,139 in another, `alef verify` was
/// structurally blind to every one, and `alef adopt` on a snippet glob bailed with
/// "no alef-managed output matches" — the designed way out did not reach the files
/// that needed it. A new emitter added to `alef all` must be added here too, or it
/// re-opens exactly that hole. ~keep
///
/// Every stage below is a pure in-memory render; nothing here writes to disk. Docs
/// are the one stage whose failure is downgraded: `docs::generate_docs_stage`
/// deliberately returns the pages it rendered *alongside* an error from a later
/// sub-step (snippet validation, CLI/MCP extraction), and neither of this function's
/// consumers should lose the whole managed set — or refuse to unfreeze a binding
/// file — because a docs sub-step is unhappy. ~keep
///
/// Returns the surface alongside every [`StageFailure`] this call tolerated rather than
/// aborting on. Both consumers used to see a stage failure as `Err` for the *whole*
/// function -- including `alef verify`, a read-only report, which would abort before
/// its own frozen-file walk ever ran, and `alef adopt`, which would refuse a path with
/// no relationship whatsoever to the failing stage. Neither reads a `Result::Err` here
/// as license to skip reporting the failure: `alef verify` prints every
/// [`StageFailure`] as its own section (see [`find_missing_and_frozen_generated_files`]);
/// `alef adopt` bails with it when [`StageFailure::affects_any`] says the operator's own
/// target could have come from that stage, and otherwise logs it at DEBUG and proceeds.
/// A caller that dropped the returned `Vec<StageFailure>` on the floor would silently
/// reintroduce the exact bug this return shape exists to prevent -- a report that looks
/// clean because the thing that would have said otherwise never ran. ~keep
pub(crate) fn collect_managed_surface(
    languages: &[crate::core::config::Language],
    api: &crate::core::ir::ApiSurface,
    config: &crate::core::config::ResolvedCrateConfig,
    config_path: &std::path::Path,
    base_dir: &std::path::Path,
) -> anyhow::Result<(Vec<crate::core::backend::GeneratedFile>, Vec<StageFailure>)> {
    // Every stage below reads the same `&api`/`&config` and returns owned files, so rendering
    // them is embarrassingly parallel -- and rendering is where the time goes: the two e2e
    // stages alone emit several thousand files each on a full consumer tree, which is most of
    // what made a single-path `alef adopt` cost half a minute.
    //
    // ABSORPTION is not parallel-safe, and the distinction is the whole point of this shape:
    // `absorb_stage` is last-wins on a path collision, so the sequence stages are folded into
    // `surface` is what decides which stage owns a contested path. Render concurrently, fold in
    // the original order -- `rayon`'s indexed `collect` preserves it. ~keep
    type StageOutcome = anyhow::Result<(Vec<crate::core::backend::GeneratedFile>, Vec<StageFailure>)>;
    type Stage<'a> = Box<dyn Fn() -> StageOutcome + Send + Sync + 'a>;

    // `generate_service_api` already no-ops when `api.services` is empty, so it is
    // safe to call unconditionally. `generate_public_api` has no such internal
    // guard — mirror `Commands::Generate`'s `config.generate.public_api` gate so
    // this stays a faithful picture of what `alef generate` would produce, not a
    // superset of it.
    //
    // `clean: true`, not `false`: `pipeline::generate`'s own `clean` parameter gates a
    // language-level cache short-circuit (`!clean && cache::is_lang_cached(..)` in
    // `generate/generation.rs`) that returns zero files for a language whose lang-hash
    // already matches the on-disk `.alef/<crate>/hashes/<lang>.hash` -- exactly the state
    // every language is in immediately after the `alef generate` run that wrote it, which is
    // the single most common moment `alef verify` runs. `clean: false` here silently dropped
    // every one of that language's bindings-stage files from `surface` in precisely that
    // steady state, which `missing_managed_paths`/`frozen_managed_paths` tolerate (an absent
    // `surface` entry just means that path is not checked) but which made every one of that
    // language's self-marking bindings files -- `packages/python/lib.rs` and its pyo3
    // equivalents on every other self-marking backend -- a guaranteed, permanent orphan false
    // positive on exactly the run `alef verify` is normally expected to pass. `clean: true`
    // forces full in-memory regeneration regardless of cache state, matching this function's
    // own contract: `surface` is "what this run's backends would produce," not "what they
    // would bother producing given whatever happens to already be cached." ~keep
    let bindings_stage: Stage<'_> = Box::new(|| {
        let mut files = Vec::new();
        for (_, produced) in crate::cli::pipeline::generate(api, config, languages, true, config_path, false)? {
            files.extend(produced);
        }
        for (_, produced) in crate::cli::pipeline::generate_service_api(api, config, languages)? {
            files.extend(produced);
        }
        Ok((files, Vec::new()))
    });
    let scaffold_stage: Stage<'_> = Box::new(|| {
        let mut files = crate::cli::pipeline::scaffold(api, config, languages, config_path)?;
        for (_, produced) in crate::cli::pipeline::generate_stubs(api, config, languages)? {
            files.extend(produced);
        }
        if config.generate.public_api {
            for (_, produced) in crate::cli::pipeline::generate_public_api(api, config, languages, config_path)? {
                files.extend(produced);
            }
        }
        Ok((files, Vec::new()))
    });
    // One diagnostic log across both e2e renders below. The two differ only in `dep_mode`,
    // which no validator reads, so the registry pass recomputes a bit-identical diagnostic set;
    // sharing the log reports each finding once per crate instead of twice. Scoped to this call,
    // not a static, so a later invocation's identical finding is still reported -- see
    // `crate::e2e::diagnostic_log`. ~keep
    let diagnostic_log = crate::e2e::diagnostic_log::DiagnosticLog::new();

    // Both e2e modes, because they emit to different roots (`e2e.output` versus
    // `e2e.registry.output`) and a file can be frozen under either. `generate_e2e`
    // returns its per-backend generator failure alongside the files it did produce
    // rather than folding it into this `Result`, so a failure here no longer aborts
    // `collect_managed_surface` (as it used to, with `alef verify`'s frozen-file walk
    // and every later stage as collateral): it is absorbed into `stage_failures`
    // instead, and every caller of this function is required to look at that list --
    // see this function's own doc for why neither consumer may drop it silently. ~keep
    let e2e_local_stage: Stage<'_> = Box::new(|| {
        let Some(e2e_config) = &config.e2e else {
            return Ok((Vec::new(), Vec::new()));
        };
        let (files, generator_error) = crate::e2e::generate_e2e_with_log(
            config,
            e2e_config,
            None,
            &api.types,
            &api.enums,
            &api.functions,
            &api.errors,
            &diagnostic_log,
        )
        .context("failed to render the e2e stage of alef's managed output")?;
        Ok(stage_failure_for("e2e", generator_error, files))
    });
    let e2e_registry_stage: Stage<'_> = Box::new(|| {
        let Some(e2e_config) = &config.e2e else {
            return Ok((Vec::new(), Vec::new()));
        };
        let mut registry_config = e2e_config.clone();
        registry_config.dep_mode = crate::core::config::e2e::DependencyMode::Registry;
        let (files, generator_error) = crate::e2e::generate_e2e_with_log(
            config,
            &registry_config,
            None,
            &api.types,
            &api.enums,
            &api.functions,
            &api.errors,
            &diagnostic_log,
        )
        .context("failed to render the registry-mode test-app stage of alef's managed output")?;
        Ok(stage_failure_for("test-apps (registry mode)", generator_error, files))
    });
    let readme_stage: Stage<'_> = Box::new(|| {
        let readme_languages = crate::readme::expand_configured_readme_languages(config, languages);
        Ok((
            crate::cli::pipeline::readme(api, config, &readme_languages)?,
            Vec::new(),
        ))
    });
    // Neither `alef adopt` nor `alef verify` asks whether a snippet compiles, type-checks, or
    // runs -- they ask what file surface alef's configuration owns, and that compile step
    // produces no files (see `generate_docs_stage_without_snippet_compile_validation`'s doc).
    // Running it here was the entire cost of the 90-minute `alef adopt` on a single `Cargo.toml`:
    // thousands of per-backend toolchain subprocess invocations to answer an ownership question
    // that never looked at their result, since a docs-stage `Err` is already downgraded to the
    // debug log below regardless of which sub-step produced it. ~keep
    let docs_stage: Stage<'_> = Box::new(|| {
        let doc_languages = resolve_doc_languages(config, None)?;
        let (doc_files, doc_result) = crate::docs::generate_docs_stage_without_snippet_compile_validation(
            api,
            config,
            &doc_languages,
            None,
            base_dir,
        );
        if let Err(error) = doc_result {
            tracing::debug!("docs stage reported {error:#}; using the pages it rendered before the failure");
        }
        Ok((doc_files, Vec::new()))
    });

    let stages = [
        bindings_stage,
        scaffold_stage,
        e2e_local_stage,
        e2e_registry_stage,
        readme_stage,
        docs_stage,
    ];
    let outcomes: Vec<StageOutcome> = {
        use rayon::prelude::*;
        stages.par_iter().map(|stage| stage()).collect()
    };

    let mut surface = std::collections::BTreeMap::new();
    let mut stage_failures = Vec::new();
    for outcome in outcomes {
        let (files, failures) = outcome?;
        stage_failures.extend(failures);
        absorb_stage(&mut surface, files);
    }

    Ok((surface.into_values().collect(), stage_failures))
}

/// Pair a stage's rendered files with the [`StageFailure`] its generator reported alongside them,
/// if any. Split out only so the two e2e stage closures state the pairing once instead of twice.
fn stage_failure_for(
    stage: &'static str,
    generator_error: Option<anyhow::Error>,
    files: Vec<crate::core::backend::GeneratedFile>,
) -> (Vec<crate::core::backend::GeneratedFile>, Vec<StageFailure>) {
    let failures = generator_error
        .map(|error| StageFailure {
            stage,
            message: format!("{error:#}"),
            paths: files.iter().map(|file| file.path.clone()).collect(),
        })
        .into_iter()
        .collect();
    (files, failures)
}

/// The stale subset of an ownership walk's output, against every candidate inputs hash.
///
/// Takes the walk's result rather than a directory so a caller that needs both the staleness
/// verdict AND the walk's own coverage tally (`alef verify`, via
/// [`super::verify_scan::scan`]) can get them from ONE walk. Two walks would be two
/// answers to "what is on disk", free to disagree about the very set the report describes. ~keep
pub(crate) fn stale_among(
    scanned: &[(std::path::PathBuf, Option<String>, String)],
    inputs_hashes: &[String],
) -> Vec<StaleMismatch> {
    if inputs_hashes.is_empty() {
        return Vec::new();
    }
    let mut stale: Vec<StaleMismatch> = scanned
        .iter()
        .filter(|(_, disk_hash, content)| {
            disk_hash.as_ref().is_none_or(|disk_hash| {
                !inputs_hashes
                    .iter()
                    .any(|inputs_hash| crate::core::hash::compute_file_hash(inputs_hash, content) == *disk_hash)
            })
        })
        .map(|(path, disk_hash, content)| StaleMismatch {
            path: path.display().to_string(),
            embedded: disk_hash.clone().unwrap_or_else(|| "<missing>".to_owned()),
            computed: inputs_hashes
                .iter()
                .map(|inputs_hash| crate::core::hash::compute_file_hash(inputs_hash, content))
                .collect(),
        })
        .collect();

    stale.sort_by(|a, b| a.path.cmp(&b.path));
    stale
}

/// Walk the consumer's repo from `base_dir`, find every alef-headered file, and
/// return the list of stale ones — where the embedded `alef:hash:<hex>` does not
/// equal the content-derived hash seeded by `inputs_hash`.
///
/// Skips obvious build/cache directories (`target/`, `node_modules/`, `_build/`,
/// `.alef/`, `parsers/`, `dist/`, `vendor/`, `.git/`), and every directory git considers
/// ignored (see [`super::verify_gitignore::gitignored_dirs`]) — a consumer's own
/// dependency-fetch cache or build-output directory is neither this run's generated output
/// nor its generation input — so verify stays fast and never claims foreign content. Files
/// without the alef header marker are skipped silently — those are user-owned (scaffold-once
/// Cargo.toml templates, composer.json, package.json, lockfiles, etc.) and alef has
/// no claim. The Ruby gemspec and `.rubocop.yml` are NOT in this category — both carry the
/// alef header (`generated_header: true`) and are alef-owned, overwritten on every `alef build`.
///
/// `cfg(test)` since `alef verify` moved to the single-walk [`stale_among`] path: the writer-side
/// stamp-scope tests deliberately assert against the reader side's own function rather than a
/// local restatement of it, so this stays the shared oracle for them even though the command no
/// longer routes through it. ~keep
#[cfg(test)]
pub(crate) fn verify_walk(base_dir: &std::path::Path, inputs_hash: &str) -> anyhow::Result<Vec<StaleMismatch>> {
    Ok(stale_among(&collect_alef_hashes(base_dir), &[inputs_hash.to_owned()]))
}

#[cfg(test)]
mod tests;