use std::fs;
use std::panic::AssertUnwindSafe;
use std::panic::catch_unwind;
use std::path::Path;
use std::path::PathBuf;
use anyhow::Result;
use fnv::FnvHashSet;
use serde::Deserialize;
use serde::Serialize;
use walkdir::WalkDir;
use super::Test;
use crate::tooling::render_artifacts::RENDERED_MODEL_NAME;
const ALLOWED_FILETYPES: [&str; 3] = ["kcl", "stp", "step"];
lazy_static::lazy_static! {
static ref INPUTS_DIR: PathBuf = Path::new("../../public/kcl-samples").to_path_buf();
static ref OUTPUTS_DIR: PathBuf = Path::new("tests/kcl_samples").to_path_buf();
}
#[kcl_directory_test_macro::test_all_dirs("../public/kcl-samples")]
fn parse(dir_name: &str, dir_path: &Path) {
let t = test(dir_name, dir_path.join("main.kcl"));
let write_new = matches!(
std::env::var("INSTA_UPDATE").as_deref(),
Ok("auto" | "always" | "new" | "unseen")
);
if write_new {
std::fs::create_dir_all(t.output_dir.clone()).unwrap();
}
super::parse_test(&t);
}
#[kcl_directory_test_macro::test_all_dirs("../public/kcl-samples")]
async fn unparse(dir_name: &str, dir_path: &Path) {
let t = test(dir_name, dir_path.join("main.kcl"));
unparse_test(&t).await;
}
async fn unparse_test(test: &Test) {
let kcl_files = crate::unparser::walk_dir(&test.input_dir).await.unwrap();
let futures = kcl_files
.into_iter()
.filter(|file| file.extension().is_some_and(|ext| ext == "kcl")) .map(|file| {
tokio::spawn(async move {
let contents = tokio::fs::read_to_string(&file).await.unwrap();
eprintln!("{}", file.display());
let program = crate::Program::parse_no_errs(&contents).unwrap();
let recast = program.recast_with_options(&Default::default());
catch_unwind(AssertUnwindSafe(|| {
expectorate::assert_contents(&file, &recast.to_string());
}))
})
})
.collect::<Vec<_>>();
for future in futures {
future.await.unwrap().unwrap();
}
}
#[kcl_directory_test_macro::test_all_dirs("../public/kcl-samples")]
async fn kcl_test_execute(dir_name: &str, dir_path: &Path) {
let t = test(dir_name, dir_path.join("main.kcl"));
super::execute_test(&t, true, true).await;
}
#[test]
fn test_after_engine_ensure_kcl_samples_manifest_etc() {
let tests = kcl_samples_inputs();
let expected_outputs = kcl_samples_outputs();
let input_names = FnvHashSet::from_iter(tests.iter().map(|t| t.name.clone()));
let missing = expected_outputs
.into_iter()
.filter(|name| !input_names.contains(name))
.collect::<Vec<_>>();
assert!(
missing.is_empty(),
"Expected input kcl-samples for the following. If these are no longer tests, delete the expected output directories for them in {}: {missing:?}",
OUTPUTS_DIR.to_string_lossy()
);
let public_screenshot_dir = INPUTS_DIR.join("screenshots");
for dir in [&public_screenshot_dir] {
if !dir.exists() {
std::fs::create_dir_all(dir).unwrap();
}
}
for tests in &tests {
let screenshot_file = OUTPUTS_DIR.join(&tests.name).join(RENDERED_MODEL_NAME);
if !screenshot_file.exists() {
panic!("Missing screenshot for test: {}", tests.name);
}
std::fs::copy(
screenshot_file,
public_screenshot_dir.join(format!("{}.png", &tests.name)),
)
.unwrap();
}
let mut new_content = String::new();
for test in tests {
new_content.push_str(&format!(
r#"#### [{}]({}/main.kcl) ([screenshot](screenshots/{}.png))
[]({}/main.kcl)
"#,
test.name, test.name, test.name, test.name, test.name, test.name,
));
}
update_readme(&INPUTS_DIR, &new_content).unwrap();
}
#[test]
fn test_after_engine_generate_manifest() {
generate_kcl_manifest(&INPUTS_DIR).unwrap();
let manifest_path = INPUTS_DIR.join(MANIFEST_FILE);
let _manifest: Vec<KclMetadata> = serde_json::from_str(&fs::read_to_string(&manifest_path).unwrap()).unwrap();
}
fn test(test_name: &str, entry_point: std::path::PathBuf) -> Test {
let parent = std::fs::canonicalize(entry_point.parent().unwrap()).unwrap();
let inputs_dir = std::fs::canonicalize(INPUTS_DIR.as_path()).unwrap();
let relative_path = parent.strip_prefix(inputs_dir).unwrap();
let output_dir = std::fs::canonicalize(OUTPUTS_DIR.as_path()).unwrap();
let relative_output_dir = output_dir.join(relative_path);
if !relative_output_dir.exists() {
std::fs::create_dir_all(&relative_output_dir).unwrap();
}
Test {
name: test_name.to_owned(),
entry_point,
input_dir: parent.to_path_buf(),
output_dir: relative_output_dir,
skip_assert_artifact_graph: true,
}
}
fn kcl_samples_inputs() -> Vec<Test> {
let mut tests = Vec::new();
let mut entries: Vec<_> = INPUTS_DIR
.read_dir()
.unwrap()
.filter_map(Result::ok)
.filter(|e| e.path().is_dir())
.collect();
entries.sort_by_key(|a| a.file_name());
for entry in entries {
let path = entry.path();
if !path.is_dir() {
continue;
}
let Some(dir_name) = path.file_name() else {
continue;
};
let dir_name_str = dir_name.to_string_lossy();
if dir_name_str.starts_with('.') {
continue;
}
if matches!(dir_name_str.as_ref(), "screenshots") {
continue;
}
let sub_dir = INPUTS_DIR.join(dir_name);
let main_kcl_path = sub_dir.join("main.kcl");
let entry_point = if main_kcl_path.exists() {
main_kcl_path
} else {
panic!("No main.kcl found in {sub_dir:?}");
};
tests.push(test(&dir_name_str, entry_point));
}
tests
}
fn kcl_samples_outputs() -> Vec<String> {
let mut outputs = Vec::new();
for entry in OUTPUTS_DIR.read_dir().unwrap() {
let entry = entry.unwrap();
let path = entry.path();
if !path.is_dir() {
continue;
}
let Some(dir_name) = path.file_name() else {
continue;
};
let dir_name_str = dir_name.to_string_lossy();
if dir_name_str.starts_with('.') {
continue;
}
outputs.push(dir_name_str.into_owned());
}
outputs
}
const MANIFEST_FILE: &str = "manifest.json";
const COMMENT_PREFIX: &str = "//";
#[derive(Serialize, Deserialize, Debug)]
#[serde(rename_all = "camelCase")]
struct KclMetadata {
file: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
categories: Vec<String>,
path_from_project_directory_to_first_file: String,
multiple_files: bool,
title: String,
description: String,
files: Vec<String>,
}
fn get_kcl_metadata(project_path: &Path, files: &[String]) -> Option<KclMetadata> {
let primary_kcl_file = files.iter().find(|file| file.contains("main.kcl"))?;
let full_path_to_primary_kcl = project_path.join(primary_kcl_file);
let content = match fs::read_to_string(&full_path_to_primary_kcl) {
Ok(content) => content,
Err(_) => return None,
};
let lines: Vec<&str> = content.lines().collect();
if lines.len() < 2 {
return None;
}
let title = lines[0].trim_start_matches(COMMENT_PREFIX).trim().to_string();
let description = lines[1].trim_start_matches(COMMENT_PREFIX).trim().to_string();
let categories = if let Some(third_line) = lines.get(2)
&& let Some(categories_line) = third_line
.trim_start_matches(COMMENT_PREFIX)
.trim()
.strip_prefix("Categories: ")
{
categories_line.split(',').map(|s| s.trim().to_string()).collect()
} else {
Vec::new()
};
let path_from_project_dir = full_path_to_primary_kcl
.strip_prefix(INPUTS_DIR.as_path())
.unwrap_or(&full_path_to_primary_kcl)
.to_string_lossy()
.to_string();
let mut files = files.to_vec();
files.sort();
Some(KclMetadata {
file: primary_kcl_file.to_owned(),
path_from_project_directory_to_first_file: path_from_project_dir,
multiple_files: files.len() > 1,
title,
description,
files,
categories,
})
}
fn generate_kcl_manifest(kcl_samples_root_dir: &Path) -> Result<()> {
let mut manifest = Vec::new();
let mut entries: Vec<_> = kcl_samples_root_dir.read_dir()?.filter_map(|e| e.ok()).collect();
entries.sort_by_key(|a| a.file_name().to_string_lossy().to_string());
for entry in entries {
let path = entry.path();
if path.is_dir() {
let files: Vec<String> = WalkDir::new(&path)
.into_iter()
.filter_map(Result::ok)
.filter(|e| {
if let Some(ext) = e.path().extension() {
let ext = ext.to_string_lossy().to_lowercase();
ALLOWED_FILETYPES.contains(&ext.as_str())
} else {
false
}
})
.map(|e| {
e.path()
.strip_prefix(&path)
.unwrap_or(e.path())
.to_string_lossy()
.replace('\\', "/")
})
.collect();
if files.is_empty() {
continue;
}
if let Some(metadata) = get_kcl_metadata(&path, &files) {
manifest.push(metadata);
}
}
}
let output_path = kcl_samples_root_dir.join(MANIFEST_FILE);
expectorate::assert_contents(&output_path, &serde_json::to_string_pretty(&manifest).unwrap());
println!(
"Manifest of {} items written to {}",
manifest.len(),
output_path.display()
);
Ok(())
}
fn update_readme(dir: &Path, new_content: &str) -> Result<()> {
let search_str = "---\n";
let readme_path = dir.join("README.md");
let content = fs::read_to_string(&readme_path)?;
let Some(index) = content.find(search_str) else {
anyhow::bail!(
"Search string '{}' not found in `{}`",
search_str,
readme_path.display()
);
};
let position = index + search_str.len();
let updated_content = format!("{}{}\n", &content[..position], new_content);
expectorate::assert_contents(&readme_path, &updated_content);
Ok(())
}