use std::path::{Component, Path, PathBuf};
use loftspec::codec;
use loftspec::index::IndexErrors;
use loftspec::render::openspec::{Finding, RENDER_MAP, RenderError, openspec};
use loftspec::render::render;
use loftspec_store::fs;
use loftspec_store::openspec::{WriteProjectionError, write_projection};
use serde_json::json;
use crate::finish::Finished;
use crate::json;
use crate::out::outln;
use crate::stop::{self, Stop};
const SCHEMA: &str = "loft-render/2";
const DEFAULT_MARKDOWN: &str = "docs/spec.md";
const DEFAULT_JSON: &str = "docs/spec.json";
pub struct Targets {
pub markdown: Option<PathBuf>,
pub json: Option<PathBuf>,
pub openspec: Option<PathBuf>,
pub force: bool,
}
pub fn run(root: &Path, targets: Targets, as_json: bool) -> Finished {
match write(root, targets) {
Ok(rendered) => {
let names = |paths: &[PathBuf]| -> Vec<String> {
paths
.iter()
.map(|path| path.display().to_string())
.collect()
};
if as_json {
let fields = json!({
"written": names(&rendered.written),
"unchanged": names(&rendered.unchanged),
"removed": names(&rendered.removed),
"findings": rendered.findings.iter().map(json::info_finding).collect::<Vec<_>>(),
});
json::print(&json::result(SCHEMA, "rendered", fields, Vec::new()));
} else {
for path in &rendered.written {
outln!("{}", path.display());
}
for path in &rendered.removed {
outln!("removed {}", path.display());
}
for finding in &rendered.findings {
outln!("{finding}");
}
}
Finished::success("rendered")
}
Err(stop) => stop.exit(SCHEMA, as_json),
}
}
#[derive(Default)]
struct Rendered {
written: Vec<PathBuf>,
unchanged: Vec<PathBuf>,
removed: Vec<PathBuf>,
findings: Vec<Finding>,
}
fn write(root: &Path, targets: Targets) -> Result<Rendered, Stop> {
let (markdown, json_path) = match (targets.markdown, targets.json, &targets.openspec) {
(None, None, None) => (Some(DEFAULT_MARKDOWN.into()), Some(DEFAULT_JSON.into())),
(markdown, json_path, _) => (markdown, json_path),
};
let paths = [&markdown, &json_path, &targets.openspec];
refuse_under_spec(root, paths.into_iter().flatten())?;
let files = stop::read_files(root)?;
let document = codec::read(&files)?;
let mut outputs: Vec<(PathBuf, Vec<u8>)> = Vec::new();
if markdown.is_some() || json_path.is_some() {
let projections = render(&document).map_err(|errors| integrity(&errors))?;
outputs.extend(markdown.map(|path| (path, projections.markdown.into_bytes())));
outputs.extend(json_path.map(|path| (path, projections.json)));
}
let mut rendered = Rendered::default();
if let Some(dir) = &targets.openspec {
let mut projection = openspec(&document).map_err(|error| match error {
RenderError::Index(errors) => integrity(&errors),
RenderError::Collision { .. } => Stop::one("refused", error.to_string()),
})?;
let written_to = projection
.roots
.iter()
.map(String::as_str)
.chain([RENDER_MAP]);
refuse_under_spec(root, written_to.map(|path| dir.join(path)))?;
rendered.findings = std::mem::take(&mut projection.findings)
.into_iter()
.map(|finding| finding.under(dir))
.collect();
let written = write_projection(&tidy(&root.join(dir)), projection, targets.force).map_err(
|error| match error {
WriteProjectionError::NotEmpty(_) | WriteProjectionError::Link(_) => {
Stop::one("refused", error.to_string())
}
WriteProjectionError::Store(error) => Stop::one("store", error.to_string()),
},
)?;
let here = |paths: Vec<PathBuf>| paths.iter().map(|path| tidy(&dir.join(path))).collect();
rendered.written = here(written.written);
rendered.unchanged = here(written.unchanged);
rendered.removed = here(written.removed);
}
let written =
fs::write_outputs(root, &outputs).map_err(|error| Stop::one("store", error.to_string()))?;
rendered.written.extend(written.written);
rendered.unchanged.extend(written.unchanged);
Ok(rendered)
}
fn tidy(path: &Path) -> PathBuf {
let tidy: PathBuf = path
.components()
.filter(|part| *part != Component::CurDir)
.collect();
if tidy.as_os_str().is_empty() {
PathBuf::from(".")
} else {
tidy
}
}
fn refuse_under_spec<P: AsRef<Path>>(
root: &Path,
paths: impl IntoIterator<Item = P>,
) -> Result<(), Stop> {
for path in paths {
let path = path.as_ref();
if fs::is_under_spec(root, path) {
let message = format!(
"{}: a projection is never written under `spec/`",
path.display()
);
return Err(Stop::one("refused", message));
}
}
Ok(())
}
fn integrity(errors: &IndexErrors) -> Stop {
let integrity = errors.integrity.iter().map(ToString::to_string);
let cycles = errors.cycles.iter().map(ToString::to_string);
Stop::all("integrity", integrity.chain(cycles).collect())
}