feat(checker): cph-model — structured export-target build config (WU-B, ADR-0009)

manifest.toml [targets.*] upgraded from bare names to structured build configs.
Lesson.targets: Vec<String> → Vec<TargetConfig> { name, artifact, numbering }.
ArtifactKind { SingleFile, FileTree } mirrors Spec.Courseware.Artifact.
NumberingConfig.heading: Option<Vec<String>> — None means "use framework
default" (render package owns it), so absence is distinguishable from a value.
Empty [targets.x] body = all defaults, no diagnostics. Malformed config (bad
artifact value, non-array numbering.heading) → non-fatal SchemaViolation, target
kept with defaults. Enabled toml `preserve_order` so target declaration order is
real (the old Vec<String> never actually preserved order). target_names() helper
for call sites that only want names. 8 tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-22 08:42:15 +08:00
parent 09b026f598
commit b5bf9d60d1
6 changed files with 372 additions and 11 deletions
+235 -6
View File
@@ -34,13 +34,93 @@ pub struct Lesson {
pub info: Info,
/// The ordered parts — the lesson's element sequence.
pub parts: Vec<Part>,
/// Declared export target names, e.g. `["student", "teacher"]`, collected
/// from the `[targets.<name>]` tables.
pub targets: Vec<String>,
/// Declared export targets, collected from the `[targets.<name>]` tables.
///
/// Per ADR-0009 an export target is a *build* producing a typed artifact,
/// with a file-resident, overridable presentation config. This carries the
/// file-resident slice of that (artifact choice + presentation overrides),
/// in declared (TOML document) order. See [`TargetConfig`].
pub targets: Vec<TargetConfig>,
/// Engineering-file root (absolute), for resolving part paths.
pub root: PathBuf,
}
impl Lesson {
/// The declared export-target names, in declared order.
///
/// Convenience for callers that only need the names (the shape this crate
/// exposed before ADR-0009 turned `targets` into structured
/// [`TargetConfig`]s).
pub fn target_names(&self) -> Vec<&str> {
self.targets.iter().map(|t| t.name.as_str()).collect()
}
}
/// One declared export target's *file-resident* build config (ADR-0009).
///
/// An export target is a build producing a typed [`ArtifactKind`]; its defaults
/// are seeded at project creation and **overridable in the engineering file**.
/// This struct models the slice of that build config that lives in the file:
/// the artifact choice and presentation overrides (currently heading
/// numbering). The per-kind render *map* itself stays in the render package
/// (`Spec.Courseware.TargetSpec.renders`), not here.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct TargetConfig {
/// Target name, e.g. `"student"` (the `[targets.<name>]` table key).
pub name: String,
/// The artifact shape this target produces. Defaults to
/// [`ArtifactKind::SingleFile`] when the `artifact` field is absent.
pub artifact: ArtifactKind,
/// Presentation override for this target. `None` when no presentation
/// config is given in the file (the render layer applies its defaults).
pub numbering: Option<NumberingConfig>,
}
/// The shape of the artifact an export target produces (ADR-0009).
///
/// **Mirrors `Spec.Courseware.Artifact`** in the Lean semantic master
/// (`spec/Spec/Courseware/Artifact.lean`), whose definition is exactly:
///
/// ```text
/// inductive Artifact where
/// | singleFile
/// | fileTree
/// ```
///
/// `SingleFile` is one bundled document (a 讲义 / 教案 PDF); `FileTree` is a
/// tree of files plus an index (a third-party-platform archive). The on-disk
/// `artifact` string maps `"single-file"` → [`ArtifactKind::SingleFile`] and
/// `"file-tree"` → [`ArtifactKind::FileTree`].
///
/// As with `cph-diag`'s `Severity`, there is no CI gate enforcing this
/// alignment (see the repo constitution) — it is maintained by review, which is
/// why the correspondence is documented here.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
pub enum ArtifactKind {
/// One bundled document. Mirrors Lean `Artifact.singleFile`. Default.
SingleFile,
/// A tree of files plus an index. Mirrors Lean `Artifact.fileTree`.
FileTree,
}
impl ArtifactKind {
/// The default artifact shape when `artifact` is omitted: `SingleFile`.
const DEFAULT: ArtifactKind = ArtifactKind::SingleFile;
}
/// File-resident presentation overrides for a target (ADR-0009).
///
/// Each field is `Option` so **absence is distinguishable from a value**: when
/// a field is `None`, the render package applies its framework default; only an
/// explicitly-given value overrides it. This crate deliberately invents no
/// presentation defaults — that ownership stays in the render layer.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct NumberingConfig {
/// Per-heading-level numbering patterns (index 0 = level 1, etc.), e.g.
/// `["{1:一}、", "{1:1}.{2:1}"]`. `None` when `numbering.heading` is absent.
pub heading: Option<Vec<String>>,
}
/// `[project]` table.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct Project {
@@ -215,9 +295,15 @@ pub fn load(root: &Path) -> (Option<Lesson>, Vec<Diagnostic>) {
}
};
// Target names are just the keys of the [targets.*] table; order is not
// semantically meaningful, but we preserve the TOML document order.
let targets: Vec<String> = raw.targets.keys().cloned().collect();
// Parse each [targets.<name>] table into a structured build config
// (ADR-0009). Order is the TOML document order, as before. Malformed
// target config is non-fatal: it is reported and the target is kept with
// defaults so loading continues.
let targets: Vec<TargetConfig> = raw
.targets
.into_iter()
.map(|(name, value)| parse_target(name, value, &mut diags))
.collect();
// Load each part. Problems are collected, not fatal: we still build the
// Part (with a best-effort descriptor) so order/membership is observable.
@@ -399,6 +485,149 @@ fn load_descriptor(
}
}
/// Parse one `[targets.<name>]` table into a [`TargetConfig`] (ADR-0009),
/// collecting [`DiagCode::SchemaViolation`] diagnostics for malformed fields.
///
/// Malformed config is **non-fatal**: a bad `artifact` value falls back to the
/// default ([`ArtifactKind::SingleFile`]); a malformed `numbering.heading`
/// leaves the override absent — both reported, never dropping the target. An
/// empty `[targets.<name>]` body is valid (all defaults, no diagnostics).
fn parse_target(name: String, value: toml::Value, diags: &mut Vec<Diagnostic>) -> TargetConfig {
// A target body must be a table; anything else (`student = 1`) is malformed.
let mut table = match value {
toml::Value::Table(t) => t,
_ => {
diags.push(
Diagnostic::error(
DiagCode::SchemaViolation,
format!("target '{name}' must be a table, e.g. `[targets.{name}]`"),
)
.with_hint("declare a target as a `[targets.<name>]` table"),
);
return TargetConfig {
name,
artifact: ArtifactKind::DEFAULT,
numbering: None,
};
}
};
let artifact = match table.remove("artifact") {
None => ArtifactKind::DEFAULT,
Some(toml::Value::String(s)) => match s.as_str() {
"single-file" => ArtifactKind::SingleFile,
"file-tree" => ArtifactKind::FileTree,
other => {
diags.push(
Diagnostic::error(
DiagCode::SchemaViolation,
format!(
"target '{name}' has an invalid artifact '{other}'; \
valid values are \"single-file\", \"file-tree\""
),
)
.with_hint("set `artifact` to \"single-file\" or \"file-tree\""),
);
ArtifactKind::DEFAULT
}
},
Some(_) => {
diags.push(
Diagnostic::error(
DiagCode::SchemaViolation,
format!(
"target '{name}' has a non-string `artifact`; \
valid values are \"single-file\", \"file-tree\""
),
)
.with_hint("set `artifact` to \"single-file\" or \"file-tree\""),
);
ArtifactKind::DEFAULT
}
};
let numbering = parse_numbering(&name, &mut table, diags);
TargetConfig {
name,
artifact,
numbering,
}
}
/// Parse the optional `[targets.<name>.numbering]` sub-table into a
/// [`NumberingConfig`]. Returns `None` when no `numbering` table is present.
/// A malformed `heading` (not an array of strings) is reported as a
/// [`DiagCode::SchemaViolation`] and left absent (`None`).
fn parse_numbering(
name: &str,
table: &mut toml::Table,
diags: &mut Vec<Diagnostic>,
) -> Option<NumberingConfig> {
let numbering = table.remove("numbering")?;
let mut numbering_table = match numbering {
toml::Value::Table(t) => t,
_ => {
diags.push(
Diagnostic::error(
DiagCode::SchemaViolation,
format!("target '{name}' has a non-table `numbering`"),
)
.with_hint(format!("declare numbering as `[targets.{name}.numbering]`")),
);
return Some(NumberingConfig { heading: None });
}
};
let heading = match numbering_table.remove("heading") {
None => None,
Some(toml::Value::Array(items)) => {
let mut patterns = Vec::with_capacity(items.len());
let mut malformed = false;
for item in items {
match item {
toml::Value::String(s) => patterns.push(s),
_ => {
malformed = true;
break;
}
}
}
if malformed {
diags.push(
Diagnostic::error(
DiagCode::SchemaViolation,
format!(
"target '{name}' has a `numbering.heading` that is not an array of strings"
),
)
.with_hint(
"`numbering.heading` must be an array of per-level pattern strings",
),
);
None
} else {
Some(patterns)
}
}
Some(_) => {
diags.push(
Diagnostic::error(
DiagCode::SchemaViolation,
format!(
"target '{name}' has a `numbering.heading` that is not an array of strings"
),
)
.with_hint("`numbering.heading` must be an array of per-level pattern strings"),
);
None
}
};
Some(NumberingConfig { heading })
}
/// True if `path` contains any `..` component (parent-dir traversal).
fn has_parent_traversal(path: &Path) -> bool {
path.components().any(|c| matches!(c, Component::ParentDir))