rsconstruct 0.9.106

Rust based fast build system
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
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
use anyhow::{Context, Result};
use parking_lot::Mutex;
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::Arc;
use std::time::SystemTime;

use super::{OnceTool, PlannedOutput, add_planned_products, require_planned_output};
use crate::config::{StandardConfig, checksum_fields_of, output_config_hash, resolve_extra_inputs};
use crate::file_index::FileIndex;
use crate::graph::{BuildGraph, Product};
use crate::processor::{
    Processor, ProcessorBase, check_command_output, format_command, log_command, run_command,
};

/// The manifest schema this processor understands. A tool printing any
/// other `version` is rejected rather than guessed at.
const MANIFEST_VERSION: u32 = 1;

/// Mass generator config.
/// Custom fields: `predict_command`, `predict_args`, `output_dirs`, `loose_manifest`.
/// Unused `StandardConfig` fields: formats, `dep_auto`, `output_dir`, batch. The
/// scan fields discover no products — the manifest does that — but declare
/// the files the plan is computed from, which keys the plan cache.
#[derive(Debug, Deserialize, Serialize, Clone, Default)]
pub struct MassGeneratorConfig {
    /// The tool's plan command: prints the JSON manifest to stdout and
    /// writes nothing.
    #[serde(default)]
    pub predict_command: String,
    /// Arguments for `predict_command`.
    #[serde(default)]
    pub predict_args: Vec<String>,
    /// Directories the tool writes into. Every manifest path must fall
    /// inside one of them; the plan-vs-build check walks them.
    #[serde(default)]
    pub output_dirs: Vec<String>,
    /// Downgrade plan-vs-build mismatches from errors to warnings. For
    /// developing the tool itself; a production config leaves it false.
    #[serde(default)]
    pub loose_manifest: bool,
    #[serde(flatten)]
    pub standard: StandardConfig,
}

/// The JSON document `predict_command` prints.
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct Manifest {
    version: u32,
    outputs: Vec<PlannedOutput>,
}

/// A data-driven processor for tools that enumerate their outputs before
/// producing them: the transparent counterpart of the opaque Creator.
///
/// `predict_command` prints a manifest at discovery time; each entry becomes
/// one product (`inputs` = the entry's sources, `outputs` = its path), so
/// every generated file has an owner, a blob cache entry, and dependency
/// edges of its own. `command` then runs at most once per build — the first
/// dirty product triggers it, the rest find it done — and the files it
/// wrote are compared against the plan.
///
/// Example config:
/// ```toml
/// [processor.mass_generator.site]
/// command = "rssite"
/// args = ["build"]
/// predict_command = "rssite"
/// predict_args = ["plan"]
/// output_dirs = ["_site"]
/// ```
pub struct MassGeneratorProcessor {
    config: MassGeneratorConfig,
    /// The parsed plan, obtained once per process. Discovery is a fixed-point
    /// loop that calls `discover` several times per build; the source tree
    /// does not change between passes, so neither does the plan. Watch mode
    /// builds a fresh processor per rebuild, which clears this.
    manifest: Mutex<Option<Arc<Vec<PlannedOutput>>>>,
    tool_run: OnceTool,
}

impl MassGeneratorProcessor {
    pub fn new(config: MassGeneratorConfig) -> Result<Self> {
        for dir in &config.output_dirs {
            if let Some(why) = plain_relative_path_error(Path::new(dir)) {
                anyhow::bail!("output_dirs entry '{dir}' {why}");
            }
        }
        // `src_dirs` with no `src_extensions` matches no file (the same
        // rule as every processor). Elsewhere that only discovers nothing;
        // here it would key the plan cache on an empty set, so a new or
        // edited page would be served the stale cached plan.
        let std = &config.standard;
        let dirs = std.src_dirs.as_deref().is_some_and(|d| !d.is_empty());
        let exts = std.src_extensions.as_deref().is_some_and(|e| !e.is_empty());
        if dirs && !exts {
            anyhow::bail!(
                "src_dirs names the plan's input directories but src_extensions is empty, so no \
                 file under them would key the plan cache; list the extensions the plan reads \
                 (e.g. src_extensions = [\".md\", \".html\"])"
            );
        }
        Ok(Self {
            config,
            manifest: Mutex::new(None),
            tool_run: OnceTool::new(),
        })
    }

    fn output_dirs(&self) -> Vec<PathBuf> {
        self.config.output_dirs.iter().map(PathBuf::from).collect()
    }

    fn predict_command(&self) -> Command {
        let mut cmd = Command::new(&self.config.predict_command);
        cmd.args(&self.config.predict_args);
        cmd
    }

    /// Whether the config declares the files the plan is computed from. The
    /// scan fields do that job here: no product is discovered from them —
    /// the manifest decides the products — but their files key the plan
    /// cache. Without them the plan cannot be cached safely and runs every
    /// time.
    fn declares_plan_inputs(&self) -> bool {
        let std = &self.config.standard;
        [&std.src_dirs, &std.src_files]
            .into_iter()
            .any(|v| v.as_deref().is_some_and(|v| !v.is_empty()))
    }

    /// The plan, obtained once per process: from the plan cache when the
    /// declared plan inputs are unchanged, otherwise by running
    /// `predict_command`. `discover` has no `BuildContext`, so this is a plain
    /// spawn: the plan command is by contract cheap and side-effect free, and
    /// nothing else runs alongside it at discovery time.
    fn manifest(
        &self,
        instance_name: &str,
        file_index: &FileIndex,
    ) -> Result<Arc<Vec<PlannedOutput>>> {
        let mut guard = self.manifest.lock();
        if let Some(entries) = guard.as_ref() {
            return Ok(Arc::clone(entries));
        }
        if self.config.predict_command.is_empty() {
            anyhow::bail!("'predict_command' is not set for processor '{instance_name}'");
        }
        let cache = if self.declares_plan_inputs() {
            let inputs = file_index.scan(&self.config.standard, true);
            Some((
                plan_cache_path(instance_name),
                self.plan_key(instance_name, &inputs)?,
            ))
        } else {
            None
        };
        let mut cmd = self.predict_command();
        let raw = match &cache {
            Some((path, key)) => {
                if let Some(raw) = read_plan_cache(path, key)? {
                    raw
                } else {
                    let raw = run_predict(&mut cmd, instance_name)?;
                    // Validate before caching: an invalid plan must fail
                    // now and again next time, never be replayed as cached.
                    parse_manifest(raw.as_bytes(), &self.output_dirs())
                        .with_context(|| invalid_manifest_context(instance_name, &cmd))?;
                    write_plan_cache(path, key, &raw)?;
                    raw
                }
            }
            None => run_predict(&mut cmd, instance_name)?,
        };
        let entries = parse_manifest(raw.as_bytes(), &self.output_dirs())
            .with_context(|| invalid_manifest_context(instance_name, &cmd))?;
        let entries = Arc::new(entries);
        *guard = Some(Arc::clone(&entries));
        Ok(entries)
    }

    /// The key a cached plan is valid for: the rsconstruct version (the
    /// cache format and the manifest rules), the instance's whole config
    /// (`predict_command` and its arguments included), and the path and
    /// content of every declared plan input — so a new, removed or edited
    /// source file all mean a fresh plan.
    fn plan_key(&self, instance_name: &str, inputs: &[PathBuf]) -> Result<String> {
        let config = serde_json::to_string(&self.config)
            .context("Failed to serialize mass_generator config for the plan key")?;
        let mut parts: Vec<String> = vec![
            env!("CARGO_PKG_VERSION").to_string(),
            instance_name.to_string(),
            config,
        ];
        let mut sorted: Vec<&PathBuf> = inputs.iter().collect();
        sorted.sort();
        for path in sorted {
            let data = std::fs::read(path)
                .with_context(|| format!("Failed to read plan input {}", path.display()))?;
            parts.push(path.display().to_string());
            parts.push(crate::checksum::bytes_checksum(&data));
        }
        let refs: Vec<&str> = parts.iter().map(String::as_str).collect();
        Ok(crate::checksum::hash_parts(&refs))
    }

    /// One invocation of `command`, bracketed by the plan-vs-build check.
    fn run_tool(
        &self,
        ctx: &crate::build_context::BuildContext,
        instance_name: &str,
    ) -> Result<()> {
        // Discovery obtained the plan before any product could execute.
        let entries =
            self.manifest.lock().clone().with_context(|| {
                format!("[{instance_name}] executed before its plan was loaded")
            })?;
        let predicted: BTreeSet<&Path> = entries.iter().map(|e| e.path.as_path()).collect();

        // The tool rewrites every predicted file, including the ones whose
        // products are clean this build. A clean file may be a read-only
        // hardlink left by a cache restore, which the tool cannot open for
        // writing; unlinking first gives it a clear path. The executor only
        // unlinks the outputs of the products it is about to build.
        for path in &predicted {
            match std::fs::remove_file(path) {
                Ok(()) => {}
                Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
                Err(e) => {
                    return Err(anyhow::Error::from(e)
                        .context(format!("Failed to remove stale output {}", path.display())));
                }
            }
        }

        let output_dirs = self.output_dirs();
        let before = snapshot_mtimes(&output_dirs)?;

        let command = self.config.standard.require_command(instance_name)?;
        let mut cmd = Command::new(command);
        cmd.args(&self.config.standard.args);
        let output = run_command(ctx, &cmd)?;
        check_command_output(
            &output,
            format_args!("[{instance_name}] {}", format_command(&cmd)),
        )?;

        let after = snapshot_mtimes(&output_dirs)?;
        let foreign = |path: &Path| ctx.is_declared_output(path);
        let mismatches = plan_mismatches(&predicted, &before, &after, &foreign);
        if !mismatches.is_empty() {
            let msg = format!(
                "[{instance_name}] the files {} wrote do not match the plan from {}:\n  {}",
                format_command(&cmd),
                format_command(&self.predict_command()),
                mismatches.join("\n  ")
            );
            if self.config.loose_manifest {
                crate::output::warn(&format!("{msg}\n  (loose_manifest = true: continuing)"));
            } else {
                anyhow::bail!("{msg}");
            }
        }
        Ok(())
    }
}

/// Where cached plans live, one file per instance.
const PLAN_CACHE_DIR: &str = ".rsconstruct/mass_generator";

fn plan_cache_path(instance_name: &str) -> PathBuf {
    Path::new(PLAN_CACHE_DIR).join(format!("{instance_name}.json"))
}

/// A cached plan: the manifest exactly as `predict_command` printed it, and
/// the key it is valid for.
#[derive(Serialize, Deserialize)]
struct PlanCache {
    key: String,
    manifest: String,
}

/// The cached manifest if the cache file exists and was written for `key`;
/// `None` for a missing file or another key (both mean "plan again"). A file
/// that exists but does not parse is an error: it is rsconstruct's own state,
/// so garbage there is a bug or tampering, not a reason to quietly re-plan.
fn read_plan_cache(path: &Path, key: &str) -> Result<Option<String>> {
    let text = match std::fs::read_to_string(path) {
        Ok(text) => text,
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
        Err(e) => {
            return Err(anyhow::Error::from(e)
                .context(format!("Failed to read plan cache {}", path.display())));
        }
    };
    let cache: PlanCache = serde_json::from_str(&text).with_context(|| {
        format!(
            "Plan cache {} is corrupt; delete it and rerun",
            path.display()
        )
    })?;
    Ok((cache.key == key).then_some(cache.manifest))
}

fn write_plan_cache(path: &Path, key: &str, manifest: &str) -> Result<()> {
    if let Some(parent) = path.parent() {
        std::fs::create_dir_all(parent)
            .with_context(|| format!("Failed to create {}", parent.display()))?;
    }
    let cache = PlanCache {
        key: key.to_string(),
        manifest: manifest.to_string(),
    };
    let text = serde_json::to_string(&cache).context("Failed to serialize the plan cache")?;
    std::fs::write(path, text)
        .with_context(|| format!("Failed to write plan cache {}", path.display()))
}

/// Run `predict_command` and return what it printed.
fn run_predict(cmd: &mut Command, instance_name: &str) -> Result<String> {
    log_command(cmd);
    let output = cmd
        .output()
        .with_context(|| format!("Failed to spawn predict_command: {}", format_command(cmd)))?;
    check_command_output(
        &output,
        format_args!("[{instance_name}] predict_command {}", format_command(cmd)),
    )?;
    String::from_utf8(output.stdout).with_context(|| {
        format!(
            "[{instance_name}] predict_command {} printed non-UTF-8 output",
            format_command(cmd)
        )
    })
}

fn invalid_manifest_context(instance_name: &str, cmd: &Command) -> String {
    format!(
        "[{instance_name}] predict_command {} printed an invalid manifest",
        format_command(cmd)
    )
}

/// Why `path` is not a plain relative path, or `None` if it is one. Plain
/// means every component is a name: no root, no `.`, no `..`. Graph paths
/// are compared literally against other processors' declared outputs, so
/// `_site/./index.html` would silently fail to match `_site/index.html`.
fn plain_relative_path_error(path: &Path) -> Option<&'static str> {
    // Split the raw text rather than using `Path::components()`, which
    // normalizes an interior `./` away and would let `_site/./x.html`
    // through with its literal `./` intact.
    let text = path.to_string_lossy();
    if text.is_empty() {
        return Some("is empty");
    }
    if text.starts_with('/') {
        return Some("is absolute");
    }
    for piece in text.split('/') {
        match piece {
            "" => return Some("contains an empty component (`//` or a trailing `/`)"),
            "." => return Some("contains a `.` component"),
            ".." => return Some("contains a `..` component"),
            _ => {}
        }
    }
    None
}

/// Parse and validate the manifest `predict_command` printed. Entries come
/// back sorted by path, so product ids do not depend on the tool's output
/// order.
fn parse_manifest(json: &[u8], output_dirs: &[PathBuf]) -> Result<Vec<PlannedOutput>> {
    let manifest: Manifest = serde_json::from_slice(json).context("manifest is not valid JSON")?;
    if manifest.version != MANIFEST_VERSION {
        anyhow::bail!(
            "manifest version {} is not supported (this rsconstruct understands version {MANIFEST_VERSION})",
            manifest.version
        );
    }
    let mut entries = manifest.outputs;
    for entry in &entries {
        let path = &entry.path;
        if let Some(why) = plain_relative_path_error(path) {
            anyhow::bail!("manifest output path '{}' {why}", path.display());
        }
        if !output_dirs.iter().any(|dir| path.starts_with(dir)) {
            anyhow::bail!(
                "manifest output path '{}' is outside every output_dirs entry ({})",
                path.display(),
                output_dirs
                    .iter()
                    .map(|d| d.display().to_string())
                    .collect::<Vec<_>>()
                    .join(", ")
            );
        }
        if entry.sources.is_empty() {
            anyhow::bail!(
                "manifest output '{}' lists no sources; every output must name at least one \
                 file whose content determines it (the tool's own config file, if nothing else)",
                path.display()
            );
        }
        for source in &entry.sources {
            if let Some(why) = plain_relative_path_error(source) {
                anyhow::bail!(
                    "manifest output '{}' has source '{}' which {why}",
                    path.display(),
                    source.display()
                );
            }
        }
    }
    entries.sort_by(|a, b| a.path.cmp(&b.path));
    for pair in entries.windows(2) {
        if pair[0].path == pair[1].path {
            anyhow::bail!(
                "manifest lists output path '{}' twice",
                pair[0].path.display()
            );
        }
    }
    Ok(entries)
}

/// Modification time of every regular file under `dirs`. A directory that
/// does not exist yet contributes nothing.
fn snapshot_mtimes(dirs: &[PathBuf]) -> Result<BTreeMap<PathBuf, SystemTime>> {
    let mut snapshot = BTreeMap::new();
    for dir in dirs {
        for path in crate::object_store::walk_files(dir) {
            let mtime = std::fs::metadata(&path)
                .and_then(|m| m.modified())
                .with_context(|| format!("Failed to stat {}", path.display()))?;
            snapshot.insert(path, mtime);
        }
    }
    Ok(snapshot)
}

/// Compare what the tool wrote against what it predicted. A predicted file
/// that is absent afterwards is missing. A file that is new or rewritten
/// since the `before` snapshot, is not predicted, and is not another
/// product's declared output (`foreign`) was written off-plan. Files the
/// tool left untouched — a neighbor processor's output, a committed CNAME,
/// a page from an older plan — are not its business and are not reported.
fn plan_mismatches(
    predicted: &BTreeSet<&Path>,
    before: &BTreeMap<PathBuf, SystemTime>,
    after: &BTreeMap<PathBuf, SystemTime>,
    foreign: &dyn Fn(&Path) -> bool,
) -> Vec<String> {
    let mut mismatches = Vec::new();
    for path in predicted {
        if !after.contains_key(*path) {
            mismatches.push(format!(
                "missing: {} (predicted but not produced)",
                path.display()
            ));
        }
    }
    for (path, mtime) in after {
        if predicted.contains(path.as_path()) || foreign(path) {
            continue;
        }
        if before.get(path) != Some(mtime) {
            mismatches.push(format!(
                "unexpected: {} (written but not in the plan)",
                path.display()
            ));
        }
    }
    mismatches
}

impl Processor for MassGeneratorProcessor {
    fn scan_config(&self) -> &StandardConfig {
        &self.config.standard
    }

    fn auto_detect(&self, _file_index: &FileIndex) -> bool {
        !self.config.standard.command.is_empty()
            && !self.config.predict_command.is_empty()
            && !self.config.output_dirs.is_empty()
    }

    fn required_tools(&self) -> Vec<String> {
        let mut tools = Vec::new();
        for tool in [&self.config.standard.command, &self.config.predict_command]
            .into_iter()
            .chain(self.config.standard.required_tools.iter())
        {
            if !tool.is_empty() && !tools.contains(tool) {
                tools.push(tool.clone());
            }
        }
        tools
    }

    fn discover(
        &self,
        graph: &mut BuildGraph,
        file_index: &FileIndex,
        instance_name: &str,
    ) -> Result<()> {
        let entries = self.manifest(instance_name, file_index)?;
        let config_hash = output_config_hash(&self.config, &checksum_fields_of(instance_name));
        let extra = resolve_extra_inputs(&self.config.standard.dep_inputs)?;
        add_planned_products(graph, &entries, instance_name, &config_hash, &extra)?;
        Ok(())
    }

    fn execute(&self, ctx: &crate::build_context::BuildContext, product: &Product) -> Result<()> {
        let instance_name = &product.processor;
        self.tool_run.run(&self.config.standard.command, || {
            self.run_tool(ctx, instance_name)
        })?;
        require_planned_output(product, &self.config.standard.command)
    }

    fn clean(&self, product: &Product, verbose: bool) -> Result<usize> {
        ProcessorBase::clean(product, &product.processor, verbose)
    }

    fn config_json(&self) -> Option<String> {
        ProcessorBase::config_json(&self.config)
    }
}

fn plugin_create(toml: &toml::Value) -> anyhow::Result<Box<dyn crate::processor::Processor>> {
    crate::registries::deserialize_and_try_create(toml, |cfg| {
        MassGeneratorProcessor::new(cfg).map(|p| Box::new(p) as Box<dyn Processor>)
    })
}
inventory::submit! {
    crate::registries::ProcessorPlugin {
        version: 1,
        name: "generic",
        processor_type: crate::processor::ProcessorType::MassGenerator,
        create: plugin_create,
        fields: &[
            crate::config::FieldSpec { name: "command", ty: crate::config::FieldType::String,
                affects_output: true, required: true,
                doc: "The tool's build command; runs at most once per build, when any predicted file is dirty" },
            crate::config::FieldSpec { name: "predict_command", ty: crate::config::FieldType::String,
                affects_output: true, required: true,
                doc: "The tool's plan command; prints the JSON manifest of predicted outputs to stdout" },
            crate::config::FieldSpec { name: "predict_args", ty: crate::config::FieldType::StringArray,
                affects_output: true, required: false,
                doc: "Arguments passed to predict_command" },
            crate::config::FieldSpec { name: "output_dirs", ty: crate::config::FieldType::StringArray,
                affects_output: true, required: true,
                doc: "Directories the tool writes into; every manifest path must fall inside one" },
            crate::config::FieldSpec { name: "loose_manifest", ty: crate::config::FieldType::Bool,
                affects_output: false, required: false,
                doc: "Report plan-vs-build mismatches as warnings instead of failing the build" },
        ],
        omit_standard_fields: &["formats", "dep_auto", "output_dir", "batch"],
        scan_defaults: Some(crate::config::ScanDefaultsData { src_dirs: &[], src_extensions: &[], src_exclude_dirs: &[] }),
        defaults: Some(crate::config::ProcessorDefaults::EMPTY),
        defconfig_json: crate::registries::default_config_json::<MassGeneratorConfig>,
        keywords: &["mass", "generator", "predict", "manifest", "site", "transparent", "creator"],
        description: "Run a tool that enumerates its outputs in advance; one cached product per predicted file",
        is_native: false,
        is_rust: false,
        can_fix: false,
        supports_batch: false,
        // Products of one instance execute one at a time: the first runs the
        // tool, the rest find it done. Letting them run in parallel would
        // only park threads on the tool_run lock.
        max_jobs_cap: Some(1),
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::time::Duration;

    fn dirs(list: &[&str]) -> Vec<PathBuf> {
        list.iter().map(PathBuf::from).collect()
    }

    #[test]
    fn parse_sorts_entries_by_path() {
        let json = br#"{"version":1,"outputs":[
            {"path":"_site/b.html","sources":["docs/b.md"]},
            {"path":"_site/a.html","sources":["docs/a.md","templates/t.html"]}
        ]}"#;
        let entries = parse_manifest(json, &dirs(&["_site"])).unwrap();
        let paths: Vec<_> = entries
            .iter()
            .map(|e| e.path.display().to_string())
            .collect();
        assert_eq!(paths, vec!["_site/a.html", "_site/b.html"]);
        assert_eq!(entries[0].sources, dirs(&["docs/a.md", "templates/t.html"]));
    }

    #[test]
    fn parse_accepts_an_empty_plan() {
        let entries = parse_manifest(br#"{"version":1,"outputs":[]}"#, &dirs(&["_site"])).unwrap();
        assert_eq!(entries.len(), 0);
    }

    /// Each rejection names the offending path, so the tool author can
    /// find it in a long manifest.
    #[test]
    fn parse_rejects_malformed_manifests() {
        let cases: &[(&[u8], &str)] = &[
            (b"not json", "not valid JSON"),
            (
                br#"{"version":2,"outputs":[]}"#,
                "version 2 is not supported",
            ),
            (br#"{"version":1,"outputs":[],"extra":1}"#, "unknown field"),
            (
                br#"{"version":1,"outputs":[{"path":"elsewhere/x.html","sources":["a.md"]}]}"#,
                "'elsewhere/x.html' is outside every output_dirs entry (_site)",
            ),
            (
                br#"{"version":1,"outputs":[{"path":"/_site/x.html","sources":["a.md"]}]}"#,
                "'/_site/x.html' is absolute",
            ),
            (
                br#"{"version":1,"outputs":[{"path":"_site/../x.html","sources":["a.md"]}]}"#,
                "contains a `..` component",
            ),
            (
                br#"{"version":1,"outputs":[{"path":"_site/./x.html","sources":["a.md"]}]}"#,
                "contains a `.` component",
            ),
            (
                br#"{"version":1,"outputs":[{"path":"_site/x.html","sources":[]}]}"#,
                "'_site/x.html' lists no sources",
            ),
            (
                br#"{"version":1,"outputs":[{"path":"_site/x.html","sources":["../a.md"]}]}"#,
                "source '../a.md' which contains a `..` component",
            ),
            (
                br#"{"version":1,"outputs":[
                    {"path":"_site/x.html","sources":["a.md"]},
                    {"path":"_site/x.html","sources":["b.md"]}]}"#,
                "lists output path '_site/x.html' twice",
            ),
        ];
        for (json, expected) in cases {
            let err = parse_manifest(json, &dirs(&["_site"]))
                .err()
                .unwrap_or_else(|| panic!("accepted: {}", String::from_utf8_lossy(json)));
            let msg = format!("{err:#}");
            assert!(msg.contains(expected), "expected '{expected}' in: {msg}");
        }
    }

    #[test]
    fn output_dir_match_is_by_component() {
        // `_site2/x.html` starts with the string `_site` but is not inside it.
        let json = br#"{"version":1,"outputs":[{"path":"_site2/x.html","sources":["a.md"]}]}"#;
        assert!(parse_manifest(json, &dirs(&["_site"])).is_err());
    }

    fn snapshot(entries: &[(&str, u64)]) -> BTreeMap<PathBuf, SystemTime> {
        entries
            .iter()
            .map(|(p, secs)| {
                (
                    PathBuf::from(p),
                    SystemTime::UNIX_EPOCH + Duration::from_secs(*secs),
                )
            })
            .collect()
    }

    /// The decision table of the plan-vs-build check: missing predicted
    /// files and off-plan writes are reported; untouched strangers, files
    /// another product declares, and the predicted files themselves are not.
    #[test]
    fn plan_mismatch_decision_table() {
        let predicted_paths = [Path::new("_site/a.html"), Path::new("_site/b.html")];
        let predicted: BTreeSet<&Path> = predicted_paths.into_iter().collect();
        let before = snapshot(&[
            ("_site/stale.html", 10),
            ("_site/CNAME", 10),
            ("_site/about.html", 10),
            ("_site/a.html", 10),
        ]);
        let after = snapshot(&[
            ("_site/stale.html", 10), // untouched stranger: not reported
            ("_site/CNAME", 10),      // untouched stranger: not reported
            ("_site/about.html", 20), // rewritten but foreign-owned: not reported
            ("_site/a.html", 20),     // predicted and produced
            ("_site/extra.html", 20), // new and off-plan: reported
            ("_site/stray.txt", 20),  // new and off-plan: reported
                                      // _site/b.html predicted but absent: reported
        ]);
        let foreign = |p: &Path| p == Path::new("_site/about.html");
        let mismatches = plan_mismatches(&predicted, &before, &after, &foreign);
        assert_eq!(
            mismatches,
            vec![
                "missing: _site/b.html (predicted but not produced)",
                "unexpected: _site/extra.html (written but not in the plan)",
                "unexpected: _site/stray.txt (written but not in the plan)",
            ]
        );
    }

    #[test]
    fn plan_mismatch_flags_rewritten_stranger() {
        let predicted_paths = [Path::new("_site/a.html")];
        let predicted: BTreeSet<&Path> = predicted_paths.into_iter().collect();
        let before = snapshot(&[("_site/old.html", 10), ("_site/a.html", 10)]);
        let after = snapshot(&[("_site/old.html", 11), ("_site/a.html", 11)]);
        let mismatches = plan_mismatches(&predicted, &before, &after, &|_| false);
        assert_eq!(
            mismatches,
            vec!["unexpected: _site/old.html (written but not in the plan)"]
        );
    }

    #[test]
    fn constructor_rejects_bad_output_dirs() {
        let cfg = MassGeneratorConfig {
            output_dirs: vec!["../out".to_string()],
            ..Default::default()
        };
        let err = MassGeneratorProcessor::new(cfg)
            .err()
            .expect("`..` must be rejected");
        assert!(
            format!("{err:#}").contains("output_dirs entry '../out' contains a `..` component")
        );
    }

    /// Scan fields declare the plan inputs; only `src_dirs` or `src_files`
    /// actually name files, so only they turn the plan cache on.
    #[test]
    fn plan_inputs_are_declared_by_src_dirs_or_src_files() {
        let proc = |dirs: Option<Vec<&str>>, files: Option<Vec<&str>>| {
            let mut cfg = MassGeneratorConfig::default();
            cfg.standard.src_dirs = dirs.map(|v| v.into_iter().map(String::from).collect());
            cfg.standard.src_files = files.map(|v| v.into_iter().map(String::from).collect());
            MassGeneratorProcessor::new(cfg).unwrap()
        };
        assert!(!proc(None, None).declares_plan_inputs());
        assert!(!proc(Some(vec![]), Some(vec![])).declares_plan_inputs());
        assert!(proc(Some(vec![]), Some(vec!["site.toml"])).declares_plan_inputs());
        assert!(proc(None, Some(vec!["site.toml"])).declares_plan_inputs());
    }

    #[test]
    fn plan_cache_round_trips_and_rejects_other_keys_and_garbage() {
        let tmp = tempfile::TempDir::new().unwrap();
        let path = tmp.path().join("sub/site.json");
        assert_eq!(
            read_plan_cache(&path, "k1").unwrap(),
            None,
            "missing file is a miss"
        );
        write_plan_cache(&path, "k1", "{\"version\":1,\"outputs\":[]}").unwrap();
        assert_eq!(
            read_plan_cache(&path, "k1").unwrap().as_deref(),
            Some("{\"version\":1,\"outputs\":[]}")
        );
        assert_eq!(
            read_plan_cache(&path, "k2").unwrap(),
            None,
            "other key is a miss"
        );
        std::fs::write(&path, "not json").unwrap();
        let err = read_plan_cache(&path, "k1").unwrap_err();
        assert!(
            format!("{err:#}").contains("is corrupt; delete it"),
            "{err:#}"
        );
    }

    #[test]
    fn required_tools_lists_both_commands_once() {
        let mut cfg = MassGeneratorConfig {
            predict_command: "rssite".to_string(),
            ..Default::default()
        };
        cfg.standard.command = "rssite".to_string();
        cfg.standard.required_tools = vec!["pandoc".to_string()];
        let proc = MassGeneratorProcessor::new(cfg).unwrap();
        assert_eq!(proc.required_tools(), vec!["rssite", "pandoc"]);

        let mut cfg = MassGeneratorConfig {
            predict_command: "./plan.sh".to_string(),
            ..Default::default()
        };
        cfg.standard.command = "./build.sh".to_string();
        let proc = MassGeneratorProcessor::new(cfg).unwrap();
        assert_eq!(proc.required_tools(), vec!["./build.sh", "./plan.sh"]);
    }
}