use crate::compat::{format, String};
use crate::designer::generator::{self, GenerationRequest, TargetProfile};
pub const GENERATED_MARKER: &str =
"// Generated by the rust_widgets designer. Do not edit by hand.";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ArtifactPaths;
impl ArtifactPaths {
pub const DIR: &'static str = "src/generated";
pub fn file_for(target: TargetProfile) -> &'static str {
match target {
TargetProfile::Default => "ui_default.rs",
TargetProfile::Stripped => "ui_stripped.rs",
}
}
pub fn module_for(target: TargetProfile) -> &'static str {
match target {
TargetProfile::Default => "ui_default",
TargetProfile::Stripped => "ui_stripped",
}
}
pub fn path_for(target: TargetProfile) -> String {
format!("{}/{}.rs", Self::DIR, Self::module_for(target))
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ArtifactOutcome {
Created(String),
Updated(String),
Unchanged(String),
}
impl ArtifactOutcome {
pub fn path(&self) -> &str {
match self {
Self::Created(path) | Self::Updated(path) | Self::Unchanged(path) => path,
}
}
pub fn wrote(&self) -> bool {
!matches!(self, Self::Unchanged(_))
}
pub fn describe(&self) -> String {
match self {
Self::Created(path) => format!("created {path}"),
Self::Updated(path) => format!("updated {path}"),
Self::Unchanged(path) => format!("unchanged {path}"),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Artifact {
pub path: String,
pub source: String,
pub report: crate::designer::generator::GenerationReport,
}
pub fn plan_artifacts(json: &str, width: u32, height: u32) -> Result<Vec<Artifact>, String> {
let mut artifacts = Vec::with_capacity(2);
for target in [TargetProfile::Default, TargetProfile::Stripped] {
let request = GenerationRequest {
json: String::from(json),
target,
width,
height,
function_name: String::from("build_ui"),
};
let generated = generator::generate(&request)?;
artifacts.push(Artifact {
path: ArtifactPaths::path_for(target),
source: generated.source,
report: generated.report,
});
}
Ok(artifacts)
}
pub fn is_generated(text: &str) -> bool {
text.lines()
.find(|line| !line.trim().is_empty())
.is_some_and(|line| line.trim_start().starts_with(GENERATED_MARKER))
}
fn write_one(artifact: &Artifact) -> Result<ArtifactOutcome, String> {
let path = std::path::Path::new(&artifact.path);
if !artifact.source.contains(GENERATED_MARKER) {
return Err(format!(
"refusing to write {} without the generated marker; every artifact must carry it or \
the drift gate cannot recognise the file as generated",
artifact.path
));
}
match std::fs::read_to_string(path) {
Ok(existing) if existing == artifact.source => {
return Ok(ArtifactOutcome::Unchanged(artifact.path.clone()))
}
Ok(_) => {}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.map_err(|error| format!("could not create {}: {error}", parent.display()))?;
}
std::fs::write(path, &artifact.source)
.map_err(|error| format!("could not write {}: {error}", artifact.path))?;
return Ok(ArtifactOutcome::Created(artifact.path.clone()));
}
Err(error) => {
return Err(format!("could not read {}: {error}", artifact.path));
}
}
std::fs::write(path, &artifact.source)
.map_err(|error| format!("could not write {}: {error}", artifact.path))?;
Ok(ArtifactOutcome::Updated(artifact.path.clone()))
}
pub fn regenerate_into(
root: &std::path::Path,
json: &str,
width: u32,
height: u32,
) -> Result<Vec<ArtifactOutcome>, String> {
let artifacts = plan_artifacts(json, width, height)?;
let mut outcomes = Vec::with_capacity(artifacts.len());
for artifact in &artifacts {
let absolute = root.join(&artifact.path);
let mut relocated = artifact.clone();
relocated.path = absolute.to_string_lossy().into_owned();
outcomes.push(write_one(&relocated)?);
}
Ok(outcomes)
}
pub fn artifact_source(
json: &str,
target: TargetProfile,
width: u32,
height: u32,
) -> Result<String, String> {
let artifacts = plan_artifacts(json, width, height)?;
let wanted = ArtifactPaths::path_for(target);
artifacts
.into_iter()
.find(|artifact| artifact.path == wanted)
.map(|artifact| artifact.source)
.ok_or_else(|| format!("no artifact is produced for {}", target.feature_hint()))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::compat::Vec;
const PROJECT: &str = r#"{"window":{"id":"w","title":"T","width":640,"height":480,
"layout":{"type":"vbox","children":[{"label":{"text":"Hi"}}]}}}"#;
fn temp_root(name: &str) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!("rw_artifact_{name}"));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("create the temp root");
dir
}
#[test]
fn the_marker_is_the_first_line_of_every_artifact() {
for target in [TargetProfile::Default, TargetProfile::Stripped] {
let source = artifact_source(PROJECT, target, 640, 480).expect("generation");
assert!(
is_generated(&source),
"a `{:?}` artifact must be recognisable as generated",
target
);
}
}
#[test]
fn a_file_that_merely_mentions_the_marker_is_not_generated() {
let quoted =
format!("// this doc explains the line `{GENERATED_MARKER}`\nfn main() {{}}\n");
assert!(!is_generated("ed));
assert!(!is_generated("// my hand-written module\nfn main() {}\n"));
assert!(!is_generated(""));
}
#[test]
fn leading_blank_lines_do_not_hide_the_marker() {
let with_blank = format!("\n\n{GENERATED_MARKER}\npub fn build_ui() {{}}\n");
assert!(is_generated(&with_blank));
}
#[test]
fn both_targets_are_produced_together() {
let artifacts = plan_artifacts(PROJECT, 640, 480).expect("generation");
assert_eq!(artifacts.len(), 2, "one project has one committed state per target");
let paths: Vec<&str> = artifacts.iter().map(|a| a.path.as_str()).collect();
assert!(paths.contains(&"src/generated/ui_default.rs"));
assert!(paths.contains(&"src/generated/ui_stripped.rs"));
}
#[test]
fn the_two_targets_never_produce_the_same_file() {
assert_ne!(
ArtifactPaths::path_for(TargetProfile::Default),
ArtifactPaths::path_for(TargetProfile::Stripped),
"the templates emit mutually un-compilable code, so they cannot share a file"
);
}
#[test]
fn a_first_write_creates_and_a_second_reports_unchanged() {
let root = temp_root("first_then_unchanged");
let first = regenerate_into(&root, PROJECT, 640, 480).expect("first generation");
assert_eq!(first.len(), 2);
assert!(
first.iter().all(|outcome| matches!(outcome, ArtifactOutcome::Created(_))),
"nothing existed, so both files are created: {first:?}"
);
let second = regenerate_into(&root, PROJECT, 640, 480).expect("second generation");
assert!(
second.iter().all(|outcome| matches!(outcome, ArtifactOutcome::Unchanged(_))),
"the same document must produce the same bytes, so nothing is rewritten: {second:?}"
);
assert!(!second.iter().any(ArtifactOutcome::wrote), "no write may happen");
}
#[test]
fn a_changed_document_reports_updated() {
let root = temp_root("updated");
regenerate_into(&root, PROJECT, 640, 480).expect("first generation");
let changed = PROJECT.replace("\"Hi\"", "\"Changed\"");
let outcomes = regenerate_into(&root, &changed, 640, 480).expect("second generation");
assert!(
outcomes.iter().any(|outcome| matches!(outcome, ArtifactOutcome::Updated(_))),
"a changed document must report an update: {outcomes:?}"
);
let written = std::fs::read_to_string(root.join("src/generated/ui_default.rs"))
.expect("the file exists");
assert!(written.contains("Changed"), "the new text must be on disk");
}
#[test]
fn the_missing_directory_is_created() {
let root = temp_root("mkdir");
assert!(!root.join(ArtifactPaths::DIR).exists());
regenerate_into(&root, PROJECT, 640, 480).expect("generation must create its directory");
assert!(root.join(ArtifactPaths::DIR).is_dir());
}
#[test]
fn a_source_without_the_marker_is_refused_rather_than_written() {
let root = temp_root("no_marker");
let artifact = Artifact {
path: root.join("src/generated/bad.rs").to_string_lossy().into_owned(),
source: String::from("pub fn build_ui() {}\n"),
report: Default::default(),
};
let error = write_one(&artifact).unwrap_err();
assert!(error.contains("without the generated marker"), "got: {error}");
assert!(
!root.join("src/generated/bad.rs").exists(),
"a refused write must leave nothing behind"
);
}
#[test]
fn a_nonexistent_project_root_is_created_rather_than_reported_as_a_read_failure() {
let root = temp_root("nested").join("deep").join("project");
regenerate_into(&root, PROJECT, 640, 480).expect("generation must create the whole path");
assert!(root.join("src/generated/ui_default.rs").is_file());
}
#[test]
fn a_malformed_document_reports_the_parse_error_and_writes_nothing() {
let root = temp_root("malformed");
let error = regenerate_into(&root, "not json", 640, 480).unwrap_err();
assert!(error.contains("could not be parsed"), "got: {error}");
assert!(
!root.join(ArtifactPaths::DIR).exists(),
"a parse failure must not leave a half-written layout behind"
);
}
#[test]
fn outcomes_describe_themselves_for_a_status_line() {
assert_eq!(ArtifactOutcome::Created(String::from("a.rs")).describe(), "created a.rs");
assert_eq!(ArtifactOutcome::Updated(String::from("a.rs")).describe(), "updated a.rs");
assert_eq!(ArtifactOutcome::Unchanged(String::from("a.rs")).describe(), "unchanged a.rs");
assert!(ArtifactOutcome::Created(String::from("a.rs")).wrote());
assert!(!ArtifactOutcome::Unchanged(String::from("a.rs")).wrote());
assert_eq!(ArtifactOutcome::Updated(String::from("a.rs")).path(), "a.rs");
}
#[test]
fn the_generated_files_are_not_interchangeable_between_targets() {
let default = artifact_source(PROJECT, TargetProfile::Default, 640, 480).expect("default");
let stripped =
artifact_source(PROJECT, TargetProfile::Stripped, 640, 480).expect("stripped");
assert_ne!(default, stripped);
assert!(default.contains("Node::new"), "the default template builds a tree value");
assert!(stripped.contains("try_add_child"), "the stripped template adds imperatively");
}
#[test]
fn the_artifact_paths_are_directory_scoped() {
assert!(ArtifactPaths::path_for(TargetProfile::Default).starts_with(ArtifactPaths::DIR));
assert!(ArtifactPaths::path_for(TargetProfile::Stripped).starts_with(ArtifactPaths::DIR));
assert_eq!(ArtifactPaths::module_for(TargetProfile::Default), "ui_default");
assert_eq!(ArtifactPaths::module_for(TargetProfile::Stripped), "ui_stripped");
}
}