use std::{fs, path::Path, process::Command};
use anyhow::{Context, Result, bail};
use serde::{Deserialize, Serialize};
use crate::{cargo, cli::Options, config::Project};
#[path = "render_assets.rs"]
mod assets;
#[derive(Deserialize, Serialize)]
#[serde(default, deny_unknown_fields)]
struct RenderConfig {
mermaid: bool,
math: bool,
alerts: bool,
css: Vec<String>,
js: Vec<String>,
}
impl Default for RenderConfig {
fn default() -> Self {
Self {
mermaid: true,
math: true,
alerts: true,
css: Vec::new(),
js: Vec::new(),
}
}
}
pub fn build(
project: &Project,
options: &Options,
language: Option<&str>,
open: bool,
) -> Result<()> {
let mut config: RenderConfig = project
.metadata
.pointer("/textus/render")
.map(|value| serde_json::from_value(value.clone()))
.transpose()
.context("invalid [package.metadata.textus.render] configuration")?
.unwrap_or_default();
let target = project
.target_directory
.join("textus")
.join(&project.name)
.join(language.unwrap_or("default"));
let staging = target.join("textus-assets");
fs::create_dir_all(&staging).context("could not prepare rendering assets")?;
for (name, bytes) in assets::FILES {
let path = staging.join(name);
fs::create_dir_all(path.parent().unwrap())?;
fs::write(path, bytes)?;
}
if config.alerts {
fs::write(
staging.join("alerts.css"),
include_str!("render/alerts.css"),
)?;
}
let package_root = project
.manifest_path
.parent()
.context("manifest has no parent")?;
stage_custom(&mut config.css, "css", package_root, &staging)?;
stage_custom(&mut config.js, "js", package_root, &staging)?;
let runtime = include_str!("render/runtime.js")
.replace("__TEXTUS_CONFIG__", &serde_json::to_string(&config)?)
.replace("__TEXTUS_ALERTS__", include_str!("render/alerts.js"));
let header = target.join("textus-header.html");
fs::write(
&header,
format!("<script data-textus-render>\n{runtime}\n</script>\n"),
)?;
let status = cargo::command(options)
.args(["clean", "--doc", "--manifest-path"])
.arg(&project.manifest_path)
.arg("--target-dir")
.arg(&target)
.status()?;
if !status.success() {
bail!("could not clean rendering documentation ({status})");
}
let mut command = rustdoc_command(project, options, language, &target, &header, false);
let status = command
.status()
.context("could not run rendering rustdoc")?;
if !status.success() {
bail!("rendering rustdoc failed ({status})");
}
if install_assets(&target, &staging, 0)? == 0 {
bail!("could not locate rustdoc output for rendering assets");
}
if open {
let status = rustdoc_command(project, options, language, &target, &header, true)
.status()
.context("could not run cargo rustdoc --open")?;
if !status.success() {
bail!("could not open rendering documentation ({status})");
}
}
Ok(())
}
fn rustdoc_command(
project: &Project,
options: &Options,
language: Option<&str>,
target: &Path,
header: &Path,
open: bool,
) -> Command {
let mut command = cargo::command(options);
command
.args(["rustdoc", "--lib", "--manifest-path"])
.arg(&project.manifest_path)
.arg("--package")
.arg(&project.name)
.arg("--target-dir")
.arg(target);
if let Some(language) = language {
command.env("TEXTUS_LANG", language);
} else {
command.env_remove("TEXTUS_LANG");
}
if open {
command.arg("--open");
}
command.arg("--").arg("--html-in-header").arg(header);
command
}
fn stage_custom(paths: &mut [String], extension: &str, root: &Path, staging: &Path) -> Result<()> {
for (index, path) in paths.iter_mut().enumerate() {
if path.is_empty()
|| path.starts_with('/')
|| path.contains(['\\', ':'])
|| path.split('/').any(|part| part.is_empty() || part == "..")
{
bail!(
"render asset must be a nonempty package-relative path using /, without ..: {path:?}"
);
}
let source = root.join(&*path);
let name = format!("custom-{index}.{extension}");
fs::copy(&source, staging.join(&name))
.with_context(|| format!("could not read render asset {}", source.display()))?;
*path = name;
}
Ok(())
}
fn install_assets(target: &Path, staging: &Path, depth: usize) -> Result<usize> {
let mut count = 0;
for entry in fs::read_dir(target)? {
let entry = entry?;
if !entry.file_type()?.is_dir() {
continue;
}
if entry.file_name() == "doc" {
copy_tree(staging, &entry.path().join("textus-assets"))?;
count += 1;
} else if depth == 0
&& !matches!(
entry.file_name().to_str(),
Some("debug" | "release" | "textus-assets")
)
{
count += install_assets(&entry.path(), staging, depth + 1)?;
}
}
Ok(count)
}
fn copy_tree(source: &Path, destination: &Path) -> Result<()> {
fs::create_dir_all(destination)?;
for entry in fs::read_dir(source)? {
let entry = entry?;
if entry.file_type()?.is_dir() {
copy_tree(&entry.path(), &destination.join(entry.file_name()))?;
} else {
fs::copy(entry.path(), destination.join(entry.file_name()))?;
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn configuration_defaults_and_errors() {
let config: RenderConfig = serde_json::from_str("{}").unwrap();
assert!(config.mermaid && config.math && config.alerts);
let config: RenderConfig =
serde_json::from_str(r#"{"math":false,"alerts":false,"js":["a.js"]}"#).unwrap();
assert!(config.mermaid && !config.math && !config.alerts);
assert_eq!(config.js, ["a.js"]);
for json in [
r#"{"math":"yes"}"#,
r#"{"alerts":"true"}"#,
r#"{"unknown":true}"#,
r#"{"css":"a.css"}"#,
] {
assert!(serde_json::from_str::<RenderConfig>(json).is_err());
}
}
#[test]
fn custom_assets_reject_nonportable_paths_before_reading() {
for path in ["", "../a.js", "/a.js", "C:/a.js", "a//b.js", "a\\b.js"] {
assert!(
stage_custom(&mut [path.into()], "js", Path::new("."), Path::new(".")).is_err()
);
}
}
}