forked from EduCraft/curriculum-project-hub
feat(checker+typst): 嵌入 render 使 cph 可 cargo install;实现 shell step 执行器(ADR-0013)
三件事,服务于"把恒一小奥 KenKen 课作者化为合法 native 工程文件"这条主线
(工程文件本身在 repo 外的 sibling playground,不入此提交):
1. cph-typst: 把 render/ 编译期嵌入二进制,`cargo install` 后开箱即用
- build.rs 把 render/ 拷进 OUT_DIR,剔除 dev 自指符号链接 vendor/local-packages
(否则 include_dir! 无限递归)
- embedded.rs 用 include_dir! 嵌入,运行时按版本解压到 per-user cache dir
(CPH_RENDER_DIR 仍优先作 dev override)
- default_render_dir 不再用编译期 CARGO_MANIFEST_DIR 写死路径
2. cph-check + cph-cli: 实现 Step::Shell 执行器(此前仅占位)
- run_shell_target: 跑 check 门控 → 以工程根为 cwd 执行 shell step,首非零退出即停
- target_is_shell + CLI run_shell_build 路由;`check` 不跑 shell(结构校验只看 lesson)
- compile_targets 只保留含 TypstCompile 的 target,纯 shell target 不走 typst 引擎
- 语义:失败属 build-过程错误,不入 6 类诊断(taxonomy 保持 6,显式拒绝加 ShellStep 码)
3. spec + ADR: 在 Export/Render.lean 钉 shell step 执行语义 prose;
ADR-0013(已实现)、ADR-0014(md/HTML 导出后端方向,Proposed/设计先行,未实现)
测试:全 workspace 42 passed(新增 4 个 shell 执行器测试);lake build 25 jobs 绿。
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -262,6 +262,118 @@ template = "exports/handout.typ"
|
||||
cleanup(&tmp);
|
||||
}
|
||||
|
||||
/// Write a one-segment lesson with a `file-tree` shell target whose `run` is the
|
||||
/// given command. Returns nothing; the caller runs `run_shell_target`.
|
||||
fn write_shell_target_lesson(tmp: &PathBuf, run: &str) {
|
||||
std::fs::write(
|
||||
tmp.join("manifest.toml"),
|
||||
format!(
|
||||
r#"
|
||||
[project]
|
||||
id = "sh"
|
||||
name = "sh"
|
||||
|
||||
[info]
|
||||
title = "sh"
|
||||
|
||||
[[parts]]
|
||||
kind = "segment"
|
||||
path = "segments/intro"
|
||||
|
||||
[targets.assets]
|
||||
artifact = {{ type = "file-tree", root = "build/assets", outputs = "**/*" }}
|
||||
[[targets.assets.steps]]
|
||||
type = "shell"
|
||||
run = "{run}"
|
||||
"#
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
let part_dir = tmp.join("segments").join("intro");
|
||||
std::fs::create_dir_all(&part_dir).unwrap();
|
||||
std::fs::write(part_dir.join("element.toml"), "kind = \"segment\"\n").unwrap();
|
||||
std::fs::write(part_dir.join("textbook.typ"), "Hi.\n").unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn target_is_shell_detects_shape() {
|
||||
let tmp = tempdir();
|
||||
write_shell_target_lesson(&tmp, "true");
|
||||
assert!(cph_check::target_is_shell(&tmp, "assets"));
|
||||
assert!(!cph_check::target_is_shell(&tmp, "nonexistent"));
|
||||
cleanup(&tmp);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_shell_target_executes_in_engineering_root() {
|
||||
// The command writes a file under the engineering root; cwd must be the root.
|
||||
let tmp = tempdir();
|
||||
write_shell_target_lesson(
|
||||
&tmp,
|
||||
"mkdir -p build/assets && printf ok > build/assets/marker.txt",
|
||||
);
|
||||
|
||||
let report = cph_check::run_shell_target(&tmp, &engine(), "assets");
|
||||
assert!(report.ok, "shell target should succeed: {:#?}", report);
|
||||
assert_eq!(report.outcomes.len(), 1);
|
||||
assert!(report.outcomes[0].ok());
|
||||
|
||||
// The side effect landed in the engineering root.
|
||||
let marker = tmp.join("build").join("assets").join("marker.txt");
|
||||
assert_eq!(std::fs::read_to_string(&marker).unwrap(), "ok");
|
||||
cleanup(&tmp);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_shell_target_reports_nonzero_exit() {
|
||||
let tmp = tempdir();
|
||||
write_shell_target_lesson(&tmp, "exit 3");
|
||||
|
||||
let report = cph_check::run_shell_target(&tmp, &engine(), "assets");
|
||||
assert!(!report.ok, "non-zero exit must fail the run");
|
||||
assert_eq!(report.outcomes.len(), 1);
|
||||
assert_eq!(report.outcomes[0].status, Some(3));
|
||||
cleanup(&tmp);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_shell_target_refuses_broken_lesson() {
|
||||
// A part path that doesn't exist → check error → shell steps never run.
|
||||
let tmp = tempdir();
|
||||
std::fs::write(
|
||||
tmp.join("manifest.toml"),
|
||||
r#"
|
||||
[project]
|
||||
id = "sh"
|
||||
name = "sh"
|
||||
|
||||
[info]
|
||||
title = "sh"
|
||||
|
||||
[[parts]]
|
||||
kind = "segment"
|
||||
path = "segments/missing"
|
||||
|
||||
[targets.assets]
|
||||
artifact = { type = "file-tree", root = "build/assets", outputs = "**/*" }
|
||||
[[targets.assets.steps]]
|
||||
type = "shell"
|
||||
run = "printf SHOULD_NOT_RUN > build/ran.txt"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let report = cph_check::run_shell_target(&tmp, &engine(), "assets");
|
||||
assert!(!report.ok);
|
||||
assert!(report.outcomes.is_empty(), "no command should run on a broken lesson");
|
||||
assert!(report.check.has_errors());
|
||||
assert!(
|
||||
!tmp.join("build").join("ran.txt").exists(),
|
||||
"the command must not have executed"
|
||||
);
|
||||
cleanup(&tmp);
|
||||
}
|
||||
|
||||
// --- tiny temp-dir helpers (no external dev-dep) ------------------------------
|
||||
|
||||
fn tempdir() -> PathBuf {
|
||||
|
||||
Reference in New Issue
Block a user