forked from EduCraft/curriculum-project-hub
935 lines
35 KiB
Rust
935 lines
35 KiB
Rust
//! `cph-check` — check-pipeline orchestration.
|
||
//!
|
||
//! Owned by **WU-5**. Ties together `cph-model` (load), `cph-schema`
|
||
//! (validate), and `cph-typst` (compile), runs the render-coverage rule
|
||
//! (ADR-0005's "missing render ⇒ warning"), and collects all diagnostics.
|
||
//!
|
||
//! The orchestrator owns *sequencing and gating*, not the individual checks:
|
||
//! each phase is implemented by an upstream crate, and `cph-check` decides the
|
||
//! order, what gates what, and how the results are merged / deduped. See
|
||
//! [`check`] for the full pipeline and [`build`] for the export path.
|
||
|
||
use std::path::Path;
|
||
|
||
use cph_diag::{DiagCode, Diagnostic, Severity};
|
||
use cph_typst::Engine;
|
||
|
||
/// The default render target when a lesson declares no `[targets.*]` at all.
|
||
const DEFAULT_TARGET: &str = "student";
|
||
|
||
/// Severity of the render-coverage ("element ignored under a target") diagnostic.
|
||
///
|
||
/// **PINNED to `warning` by the contract.** Mirrors the Lean master's
|
||
/// `Spec.Courseware.renderIgnoredSeverity : Severity := .warning`
|
||
/// (`spec/Spec/Courseware/Check/Diagnostic.lean`), itself citing ADR-0005: when a
|
||
/// `(kind, target)` pair has no render rule the checker reports that the element
|
||
/// is ignored under that target and **does not block the export**. Naming the
|
||
/// severity as a const makes "it is a warning, not an error" a greppable,
|
||
/// alignable fact rather than an inline literal.
|
||
const RENDER_IGNORED_SEVERITY: Severity = Severity::Warning;
|
||
|
||
/// The result of running [`check`] (or the check phases of [`build`]).
|
||
#[derive(Debug, Clone, PartialEq)]
|
||
pub struct CheckReport {
|
||
/// Every diagnostic collected across all phases, deduplicated.
|
||
pub diagnostics: Vec<Diagnostic>,
|
||
/// `false` if `cph_model::load` returned `None` (a hard manifest failure
|
||
/// that prevents any further phase from running).
|
||
pub lesson_loaded: bool,
|
||
}
|
||
|
||
impl CheckReport {
|
||
/// How many collected diagnostics are `Error`-severity.
|
||
pub fn error_count(&self) -> usize {
|
||
self.diagnostics
|
||
.iter()
|
||
.filter(|d| d.severity == Severity::Error)
|
||
.count()
|
||
}
|
||
|
||
/// How many collected diagnostics are `Warning`-severity.
|
||
pub fn warning_count(&self) -> usize {
|
||
self.diagnostics
|
||
.iter()
|
||
.filter(|d| d.severity == Severity::Warning)
|
||
.count()
|
||
}
|
||
|
||
/// Whether any collected diagnostic is `Error`-severity.
|
||
///
|
||
/// **Legality decision (spec alignment).** `!has_errors()` is the
|
||
/// implementation of `Spec.Courseware.Legal` (`spec/Spec/Courseware/Check/Diagnostic.lean`):
|
||
/// a lesson is *legal* iff its diagnostics contain no error-level diagnostic
|
||
/// (warnings are non-blocking — see `Severity` / ADR-0010). There is no CI
|
||
/// gate enforcing this alignment (repo constitution); it is kept greppable
|
||
/// here so a reviewer can tie the orchestrator's gate to the Lean master.
|
||
pub fn has_errors(&self) -> bool {
|
||
self.diagnostics
|
||
.iter()
|
||
.any(|d| d.severity == Severity::Error)
|
||
}
|
||
}
|
||
|
||
/// Run the full check pipeline against the engineering file at `root`.
|
||
///
|
||
/// Phases, in order, with gating:
|
||
/// - **(a) load** — `cph_model::load`. Its diagnostics are collected. On a hard
|
||
/// failure (`None`) the report is returned immediately with
|
||
/// `lesson_loaded == false`; nothing downstream can run.
|
||
/// - **(b) structural** — for each part, check `part.kind` is in
|
||
/// `cph_schema::known_kinds()`; an unknown kind is an `UnknownKind` error.
|
||
/// (cph-model already checked path existence, `..` traversal, and
|
||
/// part/element kind *mismatch*; this complements that with the *known-set*
|
||
/// check and does not duplicate it.) Parts whose folder cph-model already
|
||
/// flagged as missing are skipped for the rest of the pipeline.
|
||
/// - **(c) schema** — for each part whose folder exists and whose kind is
|
||
/// known, `cph_schema::validate(&part.descriptor)`.
|
||
/// - **(d) typst compile** — only if phases (a)–(c) produced **zero** errors
|
||
/// (otherwise the compile is noise on known-broken input). Compiles each
|
||
/// declared `lesson.targets` (defaulting to `"student"` if none), deduping
|
||
/// identical diagnostics across targets.
|
||
/// - **(e) render-coverage** — a `warning` (never an error) for each
|
||
/// `(kind, target)` the target's `covers` declaration does not include. Runs
|
||
/// regardless of (d) gating.
|
||
///
|
||
/// The engine is taken as a parameter so the caller owns it (and tests can
|
||
/// inject `Engine::with_render_dir`).
|
||
pub fn check(root: &Path, engine: &Engine) -> CheckReport {
|
||
let mut diags = Vec::new();
|
||
|
||
// (a) load.
|
||
let (lesson, load_diags) = cph_model::load(root);
|
||
diags.extend(load_diags);
|
||
|
||
let Some(lesson) = lesson else {
|
||
// Hard manifest failure: nothing else can run.
|
||
return CheckReport {
|
||
diagnostics: dedup(diags),
|
||
lesson_loaded: false,
|
||
};
|
||
};
|
||
|
||
let known = cph_schema::known_kinds();
|
||
|
||
// (b) structural + (c) schema.
|
||
run_structural_and_schema(&lesson, known, &mut diags);
|
||
|
||
// (d) typst compile — gated on zero errors from (a)–(c).
|
||
let pre_compile_has_error = diags.iter().any(|d| d.severity == Severity::Error);
|
||
if !pre_compile_has_error {
|
||
for target in compile_targets(&lesson) {
|
||
diags.extend(engine.compile_check(&lesson, target));
|
||
}
|
||
}
|
||
|
||
// (e) render-coverage — always runs; only a warning. Skips parts already
|
||
// broken in (b) (missing folder / unknown kind).
|
||
diags.extend(render_coverage(&lesson, known));
|
||
|
||
CheckReport {
|
||
diagnostics: dedup(diags),
|
||
lesson_loaded: true,
|
||
}
|
||
}
|
||
|
||
/// Build a PDF for `target`.
|
||
///
|
||
/// Runs the check phases **(a)–(c)** (load → structural → schema). If those
|
||
/// produce any `Error`, the build is refused: returns `(None, report)` with the
|
||
/// blocking diagnostics. Otherwise it compiles + exports the PDF for `target`
|
||
/// via `engine.build_pdf`; on a clean compile returns `(Some(bytes), report)`,
|
||
/// and on a typst error returns `(None, report)` with the compile diagnostics
|
||
/// folded into the report.
|
||
///
|
||
/// Note `build` does **not** run render-coverage (phase (e)): coverage warnings
|
||
/// describe export *loss*, which is informational for a `check`, not a gate on
|
||
/// producing a single target's PDF.
|
||
pub fn build(root: &Path, engine: &Engine, target: &str) -> (Option<Vec<u8>>, CheckReport) {
|
||
let mut diags = Vec::new();
|
||
|
||
// (a) load.
|
||
let (lesson, load_diags) = cph_model::load(root);
|
||
diags.extend(load_diags);
|
||
|
||
let Some(lesson) = lesson else {
|
||
return (
|
||
None,
|
||
CheckReport {
|
||
diagnostics: dedup(diags),
|
||
lesson_loaded: false,
|
||
},
|
||
);
|
||
};
|
||
|
||
let known = cph_schema::known_kinds();
|
||
|
||
// (b) structural + (c) schema.
|
||
run_structural_and_schema(&lesson, known, &mut diags);
|
||
|
||
if diags.iter().any(|d| d.severity == Severity::Error) {
|
||
return (
|
||
None,
|
||
CheckReport {
|
||
diagnostics: dedup(diags),
|
||
lesson_loaded: true,
|
||
},
|
||
);
|
||
}
|
||
|
||
// Phases passed: compile + export the requested target.
|
||
match engine.build_pdf(&lesson, target) {
|
||
Ok(bytes) => (
|
||
Some(bytes),
|
||
CheckReport {
|
||
diagnostics: dedup(diags),
|
||
lesson_loaded: true,
|
||
},
|
||
),
|
||
Err(compile_diags) => {
|
||
diags.extend(compile_diags);
|
||
(
|
||
None,
|
||
CheckReport {
|
||
diagnostics: dedup(diags),
|
||
lesson_loaded: true,
|
||
},
|
||
)
|
||
}
|
||
}
|
||
}
|
||
|
||
/// One shell step's execution outcome (for [`run_shell_target`]).
|
||
#[derive(Debug, Clone, PartialEq)]
|
||
pub struct ShellStepOutcome {
|
||
/// The command line that was run (verbatim from the manifest `run` field).
|
||
pub run: String,
|
||
/// Process exit code (`None` if the process was killed by a signal).
|
||
pub status: Option<i32>,
|
||
/// Captured stdout.
|
||
pub stdout: String,
|
||
/// Captured stderr.
|
||
pub stderr: String,
|
||
}
|
||
|
||
impl ShellStepOutcome {
|
||
/// Whether the step exited successfully (status 0).
|
||
pub fn ok(&self) -> bool {
|
||
self.status == Some(0)
|
||
}
|
||
}
|
||
|
||
/// The result of [`run_shell_target`].
|
||
#[derive(Debug, Clone, PartialEq)]
|
||
pub struct ShellRunReport {
|
||
/// The check phases' report (load → structural → schema). Shell steps run
|
||
/// only when this has no errors.
|
||
pub check: CheckReport,
|
||
/// Each shell step's outcome, in declared order. Empty if the build was
|
||
/// refused (check errors, unknown target, or the target has no shell steps).
|
||
pub outcomes: Vec<ShellStepOutcome>,
|
||
/// `true` if the target was found, check passed, and every shell step exited 0.
|
||
pub ok: bool,
|
||
}
|
||
|
||
/// Execute the `Shell` steps of a `file-tree`/tool-generated target (ADR-0009
|
||
/// category (b); e.g. the KenKen interactives produced by the `kendoku` CLI).
|
||
///
|
||
/// This is the **escape-hatch executor**: unlike [`build`] (which compiles a
|
||
/// typst template to a PDF), a shell target hands off to an external tool that
|
||
/// writes files itself. cph only *runs the declared command* in the engineering
|
||
/// root and reports what happened — it assembles nothing.
|
||
///
|
||
/// Contract / safety:
|
||
/// - Runs the check phases first (load → structural → schema). Any error refuses
|
||
/// the run (`ok == false`, no command executed) — a shell build of a broken
|
||
/// lesson is not attempted.
|
||
/// - Each `Step::Shell { run }` is executed via the platform shell
|
||
/// (`sh -c <run>` / `cmd /C <run>`) with the **engineering root as the working
|
||
/// directory**, so a manifest can use root-relative paths.
|
||
/// - **Arbitrary command execution is opt-in by construction**: this function is
|
||
/// only reached from `cph build --target <name>`, never from `check`. Callers
|
||
/// (the CLI) surface the command before running it.
|
||
/// - `TypstCompile` steps inside the same target are skipped here (a shell target
|
||
/// is not a typst build); a target with no shell steps yields `ok == false`.
|
||
pub fn run_shell_target(root: &Path, engine: &Engine, target: &str) -> ShellRunReport {
|
||
let mut diags = Vec::new();
|
||
|
||
let (lesson, load_diags) = cph_model::load(root);
|
||
diags.extend(load_diags);
|
||
|
||
let Some(lesson) = lesson else {
|
||
return ShellRunReport {
|
||
check: CheckReport {
|
||
diagnostics: dedup(diags),
|
||
lesson_loaded: false,
|
||
},
|
||
outcomes: Vec::new(),
|
||
ok: false,
|
||
};
|
||
};
|
||
|
||
let known = cph_schema::known_kinds();
|
||
run_structural_and_schema(&lesson, known, &mut diags);
|
||
|
||
// Suppress the unused-parameter warning while keeping the signature uniform
|
||
// with `build`/`check` (the engine is not needed to run shell steps, but
|
||
// callers pass it so the API is consistent and future steps may use it).
|
||
let _ = engine;
|
||
|
||
let has_error = diags.iter().any(|d| d.severity == Severity::Error);
|
||
|
||
let Some(tc) = lesson.targets.iter().find(|t| t.name == target) else {
|
||
diags.push(
|
||
Diagnostic::error(
|
||
DiagCode::SchemaViolation,
|
||
format!("target '{target}' not declared in manifest"),
|
||
)
|
||
.with_hint(format!(
|
||
"add a `[targets.{target}]` table, or run a declared target"
|
||
)),
|
||
);
|
||
return ShellRunReport {
|
||
check: CheckReport {
|
||
diagnostics: dedup(diags),
|
||
lesson_loaded: true,
|
||
},
|
||
outcomes: Vec::new(),
|
||
ok: false,
|
||
};
|
||
};
|
||
|
||
let shell_steps: Vec<&str> = tc
|
||
.steps
|
||
.iter()
|
||
.filter_map(|s| match s {
|
||
cph_model::Step::Shell { run } => Some(run.as_str()),
|
||
cph_model::Step::TypstCompile { .. } => None,
|
||
cph_model::Step::AssembleMarkdown { .. } => None,
|
||
})
|
||
.collect();
|
||
|
||
if has_error || shell_steps.is_empty() {
|
||
return ShellRunReport {
|
||
check: CheckReport {
|
||
diagnostics: dedup(diags),
|
||
lesson_loaded: true,
|
||
},
|
||
outcomes: Vec::new(),
|
||
ok: false,
|
||
};
|
||
}
|
||
|
||
// Run each shell step in the engineering root. First non-zero exit stops the
|
||
// sequence (a failed step's successors likely depend on it).
|
||
let mut outcomes = Vec::new();
|
||
let mut all_ok = true;
|
||
for run in shell_steps {
|
||
let outcome = run_one_shell(&lesson.root, run);
|
||
let ok = outcome.ok();
|
||
outcomes.push(outcome);
|
||
if !ok {
|
||
all_ok = false;
|
||
break;
|
||
}
|
||
}
|
||
|
||
ShellRunReport {
|
||
check: CheckReport {
|
||
diagnostics: dedup(diags),
|
||
lesson_loaded: true,
|
||
},
|
||
outcomes,
|
||
ok: all_ok,
|
||
}
|
||
}
|
||
|
||
/// Whether the named target is a **shell-step** target (its steps contain at
|
||
/// least one `Step::Shell` and no `Step::TypstCompile`) — i.e. a tool-generated
|
||
/// asset bundle that [`run_shell_target`] executes rather than [`build`]
|
||
/// compiling to PDF.
|
||
///
|
||
/// Loads the lesson read-only (ignoring diagnostics); an unloadable lesson or an
|
||
/// unknown target returns `false`, so the caller falls through to the normal
|
||
/// (typst) build path, which then reports the real error.
|
||
pub fn target_is_shell(root: &Path, target: &str) -> bool {
|
||
let (Some(lesson), _) = cph_model::load(root) else {
|
||
return false;
|
||
};
|
||
lesson
|
||
.targets
|
||
.iter()
|
||
.find(|t| t.name == target)
|
||
.is_some_and(|t| {
|
||
t.steps
|
||
.iter()
|
||
.any(|s| matches!(s, cph_model::Step::Shell { .. }))
|
||
&& !t
|
||
.steps
|
||
.iter()
|
||
.any(|s| matches!(s, cph_model::Step::TypstCompile { .. }))
|
||
})
|
||
}
|
||
|
||
/// Run one shell command line in `cwd` via the platform shell, capturing output.
|
||
fn run_one_shell(cwd: &Path, run: &str) -> ShellStepOutcome {
|
||
use std::process::Command;
|
||
|
||
let output = if cfg!(target_os = "windows") {
|
||
Command::new("cmd")
|
||
.arg("/C")
|
||
.arg(run)
|
||
.current_dir(cwd)
|
||
.output()
|
||
} else {
|
||
Command::new("sh")
|
||
.arg("-c")
|
||
.arg(run)
|
||
.current_dir(cwd)
|
||
.output()
|
||
};
|
||
|
||
match output {
|
||
Ok(out) => ShellStepOutcome {
|
||
run: run.to_string(),
|
||
status: out.status.code(),
|
||
stdout: String::from_utf8_lossy(&out.stdout).into_owned(),
|
||
stderr: String::from_utf8_lossy(&out.stderr).into_owned(),
|
||
},
|
||
Err(e) => ShellStepOutcome {
|
||
run: run.to_string(),
|
||
status: None,
|
||
stdout: String::new(),
|
||
stderr: format!("failed to spawn command: {e}"),
|
||
},
|
||
}
|
||
}
|
||
|
||
/// One assemble-markdown step's outcome (for [`run_markdown_assemble_target`]).
|
||
#[derive(Debug, Clone, PartialEq)]
|
||
pub struct MarkdownAssembleOutcome {
|
||
/// The field name that was assembled (verbatim from the step's `field`).
|
||
pub field: String,
|
||
/// How many elements' `<field>.md` files were read and concatenated.
|
||
pub parts_read: usize,
|
||
/// The assembled markdown body (empty if nothing was read).
|
||
pub body: String,
|
||
/// Where the assembled file was written (relative to the engineering root);
|
||
/// `None` if the write failed or was skipped.
|
||
pub written: Option<String>,
|
||
/// `None` on success; an error message if reading a required file or writing
|
||
/// the artifact failed.
|
||
pub error: Option<String>,
|
||
}
|
||
|
||
impl MarkdownAssembleOutcome {
|
||
/// Whether the step succeeded (wrote the artifact with no error).
|
||
pub fn ok(&self) -> bool {
|
||
self.error.is_none() && self.written.is_some()
|
||
}
|
||
}
|
||
|
||
/// The result of [`run_markdown_assemble_target`].
|
||
#[derive(Debug, Clone, PartialEq)]
|
||
pub struct MarkdownAssembleReport {
|
||
/// The check phases' report (load → structural → schema). Assembly runs only
|
||
/// when this has no errors.
|
||
pub check: CheckReport,
|
||
/// Each assemble-markdown step's outcome, in declared order. Empty if the
|
||
/// build was refused (check errors, unknown target, no assemble steps).
|
||
pub outcomes: Vec<MarkdownAssembleOutcome>,
|
||
/// `true` if the target was found, check passed, and every assemble step wrote
|
||
/// its artifact without error.
|
||
pub ok: bool,
|
||
}
|
||
|
||
/// Execute the `AssembleMarkdown` steps of a target (ADR-0015): concatenate each
|
||
/// element's `<field>.md` markdown content file in `[[parts]]` order into the
|
||
/// target's single-file artifact. This is the **third typed step**: unlike
|
||
/// [`build`] (typst template → PDF) the framework owns the read/concatenate/write
|
||
/// itself (not a typst compile, not an external tool like [`run_shell_target`]).
|
||
///
|
||
/// Contract / safety (same three boundaries as the shell step, ADR-0013/0015):
|
||
/// - Runs the check phases first (load → structural → schema). Any error refuses
|
||
/// the run (`ok == false`, nothing written) — assembling a broken lesson is
|
||
/// not attempted.
|
||
/// - Walks `lesson.parts` **in declared order**, reading `<part.path>/<field>.md`
|
||
/// when it exists (skipping absent ones — these fields are optional), joining
|
||
/// them with a blank line, and writing to the artifact's single-file
|
||
/// `filepath` (relative to the engineering root). Each `slides.md`/etc.
|
||
/// authors its own heading; the assembler injects nothing (presentation in the
|
||
/// content, ADR-0011).
|
||
/// - **Opt-in by construction**: only reached from `cph build --target <name>`,
|
||
/// never from `check`. A non-typst target, so `check`'s compile phase skips it.
|
||
/// - A write error is a **build-process error**, not a lesson diagnostic — it is
|
||
/// reported in `MarkdownAssembleOutcome::error`, not the 6-class taxonomy.
|
||
pub fn run_markdown_assemble_target(
|
||
root: &Path,
|
||
engine: &Engine,
|
||
target: &str,
|
||
) -> MarkdownAssembleReport {
|
||
let mut diags = Vec::new();
|
||
|
||
let (lesson, load_diags) = cph_model::load(root);
|
||
diags.extend(load_diags);
|
||
|
||
let Some(lesson) = lesson else {
|
||
return MarkdownAssembleReport {
|
||
check: CheckReport {
|
||
diagnostics: dedup(diags),
|
||
lesson_loaded: false,
|
||
},
|
||
outcomes: Vec::new(),
|
||
ok: false,
|
||
};
|
||
};
|
||
|
||
let known = cph_schema::known_kinds();
|
||
run_structural_and_schema(&lesson, known, &mut diags);
|
||
|
||
// Signature uniform with `build`/`run_shell_target`; the engine is not used
|
||
// to assemble markdown (no typst compile), but callers pass it for API
|
||
// consistency.
|
||
let _ = engine;
|
||
|
||
let has_error = diags.iter().any(|d| d.severity == Severity::Error);
|
||
|
||
let Some(tc) = lesson.targets.iter().find(|t| t.name == target) else {
|
||
diags.push(
|
||
Diagnostic::error(
|
||
DiagCode::SchemaViolation,
|
||
format!("target '{target}' not declared in manifest"),
|
||
)
|
||
.with_hint(format!(
|
||
"add a `[targets.{target}]` table, or run a declared target"
|
||
)),
|
||
);
|
||
return MarkdownAssembleReport {
|
||
check: CheckReport {
|
||
diagnostics: dedup(diags),
|
||
lesson_loaded: true,
|
||
},
|
||
outcomes: Vec::new(),
|
||
ok: false,
|
||
};
|
||
};
|
||
|
||
let assemble_steps: Vec<&str> = tc
|
||
.steps
|
||
.iter()
|
||
.filter_map(|s| match s {
|
||
cph_model::Step::AssembleMarkdown { field } => Some(field.as_str()),
|
||
_ => None,
|
||
})
|
||
.collect();
|
||
|
||
if has_error || assemble_steps.is_empty() {
|
||
return MarkdownAssembleReport {
|
||
check: CheckReport {
|
||
diagnostics: dedup(diags),
|
||
lesson_loaded: true,
|
||
},
|
||
outcomes: Vec::new(),
|
||
ok: false,
|
||
};
|
||
}
|
||
|
||
// Run each assemble-markdown step. First failure stops the sequence.
|
||
let mut outcomes = Vec::new();
|
||
let mut all_ok = true;
|
||
for field in assemble_steps {
|
||
let outcome = assemble_one(&lesson, field);
|
||
let ok = outcome.ok();
|
||
outcomes.push(outcome);
|
||
if !ok {
|
||
all_ok = false;
|
||
break;
|
||
}
|
||
}
|
||
|
||
MarkdownAssembleReport {
|
||
check: CheckReport {
|
||
diagnostics: dedup(diags),
|
||
lesson_loaded: true,
|
||
},
|
||
outcomes,
|
||
ok: all_ok,
|
||
}
|
||
}
|
||
|
||
/// Assemble one markdown `field` across all parts in order, writing the
|
||
/// single-file artifact.
|
||
fn assemble_one(lesson: &cph_model::Lesson, field: &str) -> MarkdownAssembleOutcome {
|
||
let mut chunks: Vec<String> = Vec::new();
|
||
let mut parts_read = 0usize;
|
||
|
||
// Inject the course-level title as the document's h1. The title is course
|
||
// metadata (from `[info]` in the manifest), not per-element content, so the
|
||
// assembler prepends it; each element's `<field>.md` then contributes its
|
||
// `##` part heading and `###` 小节. (ADR-0011's "presentation lives in the
|
||
// content" is about per-element presentation; the single course title is a
|
||
// document-level fact the assembler owns.)
|
||
if !lesson.info.title.trim().is_empty() {
|
||
chunks.push(format!("# {}", lesson.info.title.trim()));
|
||
}
|
||
|
||
for part in &lesson.parts {
|
||
if !part.descriptor.dir.is_dir() {
|
||
continue;
|
||
}
|
||
let md = part.descriptor.dir.join(format!("{field}.md"));
|
||
if md.is_file() {
|
||
match std::fs::read_to_string(&md) {
|
||
Ok(body) => {
|
||
chunks.push(body);
|
||
parts_read += 1;
|
||
}
|
||
Err(e) => {
|
||
return MarkdownAssembleOutcome {
|
||
field: field.to_string(),
|
||
parts_read,
|
||
body: String::new(),
|
||
written: None,
|
||
error: Some(format!("failed to read '{}': {e}", md.display())),
|
||
};
|
||
}
|
||
}
|
||
}
|
||
// Absent `<field>.md` is fine — the field is optional (ADR-0015).
|
||
}
|
||
|
||
let body = chunks.join("\n\n");
|
||
|
||
// Resolve the target's single-file artifact path. (The assemble target is a
|
||
// single-file artifact by construction; a file-tree target has no single
|
||
// filepath to write to, which is a manifest error surfaced here.)
|
||
let tc = lesson.targets.iter().find(|t| {
|
||
t.steps
|
||
.iter()
|
||
.any(|s| matches!(s, cph_model::Step::AssembleMarkdown { field: f } if f == field))
|
||
});
|
||
let Some(tc) = tc else {
|
||
return MarkdownAssembleOutcome {
|
||
field: field.to_string(),
|
||
parts_read,
|
||
body,
|
||
written: None,
|
||
error: Some("no target carries this assemble-markdown step".into()),
|
||
};
|
||
};
|
||
let cph_model::Artifact::SingleFile { filepath } = &tc.artifact else {
|
||
return MarkdownAssembleOutcome {
|
||
field: field.to_string(),
|
||
parts_read,
|
||
body,
|
||
written: None,
|
||
error: Some(format!(
|
||
"target '{}' has a non-single-file artifact; assemble-markdown writes a single file",
|
||
tc.name
|
||
)),
|
||
};
|
||
};
|
||
|
||
let out_path = lesson.root.join(filepath);
|
||
if let Some(parent) = out_path.parent() {
|
||
if let Err(e) = std::fs::create_dir_all(parent) {
|
||
return MarkdownAssembleOutcome {
|
||
field: field.to_string(),
|
||
parts_read,
|
||
body,
|
||
written: None,
|
||
error: Some(format!("failed to create '{}': {e}", parent.display())),
|
||
};
|
||
}
|
||
}
|
||
match std::fs::write(&out_path, &body) {
|
||
Ok(()) => {
|
||
// Collect referenced image assets so the build dir is self-contained.
|
||
// The assembled `body` is written under `build_root` (the artifact
|
||
// filepath's parent); its `` image references resolve against
|
||
// that build root. So each referenced local image is copied from the
|
||
// engineering root (`lesson.root/<rel>`) into the build root
|
||
// (`build_root/<rel>`), preserving its relative path. A referenced
|
||
// image that is missing at the engineering root is a build-process
|
||
// error (a self-contained build with a dangling ref is broken) — it
|
||
// does not enter the 6-class diagnostic taxonomy (ADR-0015).
|
||
if let Some(build_root) = out_path.parent() {
|
||
if let Err(e) = collect_image_assets(&lesson.root, build_root, &body) {
|
||
return MarkdownAssembleOutcome {
|
||
field: field.to_string(),
|
||
parts_read,
|
||
body,
|
||
written: Some(filepath.display().to_string()),
|
||
error: Some(e),
|
||
};
|
||
}
|
||
}
|
||
MarkdownAssembleOutcome {
|
||
field: field.to_string(),
|
||
parts_read,
|
||
body,
|
||
written: Some(filepath.display().to_string()),
|
||
error: None,
|
||
}
|
||
}
|
||
Err(e) => MarkdownAssembleOutcome {
|
||
field: field.to_string(),
|
||
parts_read,
|
||
body,
|
||
written: None,
|
||
error: Some(format!("failed to write '{}': {e}", out_path.display())),
|
||
},
|
||
}
|
||
}
|
||
|
||
/// Copy each local markdown image reference in `body` from the engineering root
|
||
/// `eng_root` into the build root `build_root`, preserving the reference's
|
||
/// relative path. Returns `Err(message)` on the first referenced image that is
|
||
/// missing at `eng_root` (a build-process error, ADR-0015).
|
||
///
|
||
/// References are `` markdown images; `target` is taken up to the
|
||
/// first whitespace (a possible ` "title"` suffix is stripped). Absolute URLs
|
||
/// (`http://`, `https://`, `data:`) are left untouched — they are not local
|
||
/// assets to collect. Duplicate references copy once.
|
||
fn collect_image_assets(eng_root: &Path, build_root: &Path, body: &str) -> Result<(), String> {
|
||
let mut seen: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
|
||
for target in image_refs(body) {
|
||
if target.is_empty()
|
||
|| target.starts_with("http://")
|
||
|| target.starts_with("https://")
|
||
|| target.starts_with("data:")
|
||
{
|
||
continue;
|
||
}
|
||
if !seen.insert(target.to_string()) {
|
||
continue;
|
||
}
|
||
let src = eng_root.join(target);
|
||
if !src.is_file() {
|
||
return Err(format!(
|
||
"slides references image '{}' which is missing from the engineering root",
|
||
src.display()
|
||
));
|
||
}
|
||
let dst = build_root.join(target);
|
||
if let Some(parent) = dst.parent() {
|
||
if let Err(e) = std::fs::create_dir_all(parent) {
|
||
return Err(format!("failed to create '{}': {e}", parent.display()));
|
||
}
|
||
}
|
||
if let Err(e) = std::fs::copy(&src, &dst) {
|
||
return Err(format!(
|
||
"failed to copy '{}' → '{}': {e}",
|
||
src.display(),
|
||
dst.display()
|
||
));
|
||
}
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// Scan `body` for markdown image references `` and yield each
|
||
/// `target` (whitespace-trimmed of a trailing ` "title"`). Hand-rolled to avoid
|
||
/// pulling a regex dependency for a single, simple pattern.
|
||
fn image_refs(body: &str) -> Vec<&str> {
|
||
let bytes = body.as_bytes();
|
||
let mut out = Vec::new();
|
||
let mut i = 0;
|
||
while i + 1 < bytes.len() {
|
||
// Look for the `![` opener.
|
||
if bytes[i] == b'!' && bytes[i + 1] == b'[' {
|
||
// Find the matching `]`.
|
||
if let Some(bracket_close) = body[i + 2..].find(']') {
|
||
let after_bracket = i + 2 + bracket_close + 1;
|
||
if after_bracket < bytes.len() && bytes[after_bracket] == b'(' {
|
||
// Find the closing `)` of the link target.
|
||
if let Some(paren_close) = body[after_bracket + 1..].find(')') {
|
||
let target_start = after_bracket + 1;
|
||
let target_end = target_start + paren_close;
|
||
let target = &body[target_start..target_end];
|
||
// Trim a possible ` "title"` suffix: take up to first space.
|
||
let target = target.split_whitespace().next().unwrap_or("");
|
||
out.push(target);
|
||
i = target_end + 1;
|
||
continue;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
i += 1;
|
||
}
|
||
out
|
||
}
|
||
|
||
/// Whether the named target is an **assemble-markdown** target (its steps
|
||
/// contain at least one `Step::AssembleMarkdown` and no `Step::TypstCompile`) —
|
||
/// i.e. a markdown deliverable that [`run_markdown_assemble_target`] builds
|
||
/// rather than [`build`] compiling to PDF.
|
||
///
|
||
/// Loads the lesson read-only (ignoring diagnostics); an unloadable lesson or an
|
||
/// unknown target returns `false`, so the caller falls through to the normal
|
||
/// (typst) build path, which then reports the real error.
|
||
pub fn target_is_markdown_assemble(root: &Path, target: &str) -> bool {
|
||
let (Some(lesson), _) = cph_model::load(root) else {
|
||
return false;
|
||
};
|
||
lesson
|
||
.targets
|
||
.iter()
|
||
.find(|t| t.name == target)
|
||
.is_some_and(|t| {
|
||
t.steps
|
||
.iter()
|
||
.any(|s| matches!(s, cph_model::Step::AssembleMarkdown { .. }))
|
||
&& !t
|
||
.steps
|
||
.iter()
|
||
.any(|s| matches!(s, cph_model::Step::TypstCompile { .. }))
|
||
})
|
||
}
|
||
|
||
/// Whether a part's folder exists on disk. cph-model emits `PartPathMissing`
|
||
/// for parts whose folder is absent, so the orchestrator skips those parts in
|
||
/// the structural/schema/coverage phases to avoid piling diagnostics on the
|
||
/// same root cause.
|
||
fn part_exists(p: &cph_model::Part) -> bool {
|
||
p.descriptor.dir.is_dir()
|
||
}
|
||
|
||
/// Phases (b) + (c): the known-kind structural check and per-part schema
|
||
/// validation. Shared by [`check`] and [`build`] so their gating stays
|
||
/// identical.
|
||
fn run_structural_and_schema(
|
||
lesson: &cph_model::Lesson,
|
||
known: &[&str],
|
||
diags: &mut Vec<Diagnostic>,
|
||
) {
|
||
// (b) structural: known-kind check.
|
||
for part in &lesson.parts {
|
||
if !part_exists(part) {
|
||
continue;
|
||
}
|
||
if !known.contains(&part.kind.as_str()) {
|
||
diags.push(
|
||
Diagnostic::error(
|
||
DiagCode::UnknownKind,
|
||
format!(
|
||
"part '{}' has unknown kind '{}'",
|
||
part.path.display(),
|
||
part.kind
|
||
),
|
||
)
|
||
.with_hint(format!("valid kinds are: {}", known.join(", "))),
|
||
);
|
||
}
|
||
}
|
||
|
||
// (c) schema: validate each existing part with a known kind. Skipping
|
||
// unknown kinds avoids a second `UnknownKind` from `cph_schema::validate`
|
||
// (already reported in (b)).
|
||
for part in &lesson.parts {
|
||
if !part_exists(part) || !known.contains(&part.kind.as_str()) {
|
||
continue;
|
||
}
|
||
diags.extend(cph_schema::validate(&part.descriptor));
|
||
}
|
||
}
|
||
|
||
/// The targets to compile-check for [`check`]: every declared **typst-compile**
|
||
/// target, or `["student"]` if the lesson declares none.
|
||
///
|
||
/// A target whose steps are all `Shell` (e.g. a tool-generated asset bundle —
|
||
/// ADR-0005 category (b), like the KenKen interactives produced by `kendoku`) is
|
||
/// **not** typst-compiled, so it is excluded here: compile-checking it would
|
||
/// wrongly route a non-typst build through the typst engine. Such targets are
|
||
/// executed only on an explicit `cph build --target <name>` (see
|
||
/// [`run_shell_target`]); `check` validates the lesson structure, not the
|
||
/// outcome of running an external tool.
|
||
fn compile_targets(lesson: &cph_model::Lesson) -> Vec<&str> {
|
||
if lesson.targets.is_empty() {
|
||
return vec![DEFAULT_TARGET];
|
||
}
|
||
lesson
|
||
.targets
|
||
.iter()
|
||
.filter(|t| {
|
||
t.steps
|
||
.iter()
|
||
.any(|s| matches!(s, cph_model::Step::TypstCompile { .. }))
|
||
})
|
||
.map(|t| t.name.as_str())
|
||
.collect()
|
||
}
|
||
|
||
/// Phase (e): for each `(part.kind, target)` over the lesson's declared targets,
|
||
/// emit a `RenderIgnored` **warning** when the target does not cover that kind.
|
||
///
|
||
/// Coverage is the per-target **declaration** ADR-0011 pinned (and the spec's
|
||
/// `RenderConfig.covers k t` in `Export/Render.lean`): a target's
|
||
/// `cph_model::TargetConfig::covers` lists the kinds it renders. A used kind not
|
||
/// in that list draws the warning. An **absent** `covers` (the loader's `None`)
|
||
/// is resolved here — the orchestrator owns the kind universe via `cph-schema` —
|
||
/// to "covers all known kinds", so an undeclared target is fully covering rather
|
||
/// than covering nothing. (An explicit `covers = []` stays empty: it covers
|
||
/// nothing on purpose.) Parts already broken in (b) (missing folder / unknown
|
||
/// kind) are skipped — they are reported there.
|
||
fn render_coverage(lesson: &cph_model::Lesson, known: &[&str]) -> Vec<Diagnostic> {
|
||
let mut out = Vec::new();
|
||
// De-dupe (kind, target) pairs so we emit one warning per pair, not per part.
|
||
let mut seen: Vec<(String, String)> = Vec::new();
|
||
|
||
for part in &lesson.parts {
|
||
// Skip parts already flagged structurally.
|
||
if !part_exists(part) || !known.contains(&part.kind.as_str()) {
|
||
continue;
|
||
}
|
||
for target in &lesson.targets {
|
||
// Resolve the coverage set: an absent declaration defaults to the
|
||
// full known-kind universe; an explicit list (possibly empty) is
|
||
// taken verbatim.
|
||
let covered = match &target.covers {
|
||
None => true,
|
||
Some(kinds) => kinds.iter().any(|k| k == &part.kind),
|
||
};
|
||
if covered {
|
||
continue;
|
||
}
|
||
let key = (part.kind.clone(), target.name.clone());
|
||
if seen.contains(&key) {
|
||
continue;
|
||
}
|
||
seen.push(key);
|
||
let mut d = Diagnostic::warning(
|
||
DiagCode::RenderIgnored,
|
||
format!(
|
||
"kind '{}' is not covered by target '{}'; it will be omitted from that export",
|
||
part.kind, target.name
|
||
),
|
||
)
|
||
.with_hint(format!(
|
||
"add '{}' to `covers` under target '{}', or remove the target",
|
||
part.kind, target.name
|
||
));
|
||
// Pin the severity via the named contract const. `Diagnostic::warning`
|
||
// already produces a warning, but assigning the const keeps the
|
||
// alignment to `renderIgnoredSeverity` load-bearing and greppable.
|
||
d.severity = RENDER_IGNORED_SEVERITY;
|
||
out.push(d);
|
||
}
|
||
}
|
||
out
|
||
}
|
||
|
||
/// Deduplicate diagnostics that are identical (same code+severity+message+span+
|
||
/// hint). The same typst error can surface once per compiled target, and the
|
||
/// same root cause can be reached by more than one phase; callers see each
|
||
/// distinct problem once.
|
||
fn dedup(diags: Vec<Diagnostic>) -> Vec<Diagnostic> {
|
||
let mut out: Vec<Diagnostic> = Vec::with_capacity(diags.len());
|
||
for d in diags {
|
||
if !out.contains(&d) {
|
||
out.push(d);
|
||
}
|
||
}
|
||
out
|
||
}
|