use anyhow::{anyhow, Context, Result};
use colored::*;
use serde_json::Value;
use std::fs;
use std::path::{Path, PathBuf};
const GENERATED_BEGIN: &str = "// autocore-codegen:generated-start";
const GENERATED_END: &str = "// autocore-codegen:generated-end";
const TAGS_PATH: &str = "www/src/AutoCoreTags.ts";
pub fn cmd_codegen_tags(force: bool) -> Result<()> {
let project_json_path = PathBuf::from("project.json");
if !project_json_path.exists() {
return Err(anyhow!(
"project.json not found in current directory — run from an AutoCore project root"
));
}
let project_text = fs::read_to_string(&project_json_path).context("reading project.json")?;
let project: Value =
serde_json::from_str(&project_text).context("parsing project.json")?;
let tags = collect_tags(&project);
println!(
"{} from project.json variables with {}",
"Collected".bold(),
"\"ux\": true".cyan(),
);
println!(" {} tag{}", tags.len(), if tags.len() == 1 { "" } else { "s" });
if tags.is_empty() {
println!(
" {}",
"(set \"ux\": true on variables you want exposed to the web UI)".dimmed(),
);
}
let tags_path = PathBuf::from(TAGS_PATH);
let generated_block = render_generated_block(&tags);
let action = decide_action(&tags_path, force)?;
match action {
Action::ReplaceInPlace { existing } => {
let new_contents = splice_generated_block(&existing, &generated_block)?;
fs::write(&tags_path, new_contents)
.with_context(|| format!("writing {}", tags_path.display()))?;
println!(
" {} {} (acTagSpecCustom preserved)",
"updated".green(),
tags_path.display(),
);
}
Action::FullRegen { backed_up } => {
if let Some(bak) = backed_up {
println!(
" {} {}",
"backed up".yellow(),
bak.display(),
);
}
let full = render_full_file(&generated_block);
if let Some(parent) = tags_path.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("creating {}", parent.display()))?;
}
fs::write(&tags_path, full)
.with_context(|| format!("writing {}", tags_path.display()))?;
println!(" {} {}", "wrote".green(), tags_path.display());
}
}
Ok(())
}
struct Tag {
tag_name: String,
fqdn: String,
value_type: &'static str,
}
fn collect_tags(project: &Value) -> Vec<Tag> {
let empty = serde_json::Map::new();
let vars = project
.get("variables")
.and_then(|v| v.as_object())
.unwrap_or(&empty);
let mut tags: Vec<Tag> = Vec::new();
for (name, cfg) in vars {
let ux = cfg.get("ux").and_then(|v| v.as_bool()).unwrap_or(false);
if !ux {
continue;
}
let ty = cfg.get("type").and_then(|v| v.as_str()).unwrap_or("");
let value_type = match var_type_to_ts(ty) {
Some(t) => t,
None => {
eprintln!(
"{}: skipping variable '{}': unsupported type '{}' for UI generation",
"warning".yellow(),
name,
ty,
);
continue;
}
};
tags.push(Tag {
tag_name: snake_to_camel(name),
fqdn: format!("gm.{}", name),
value_type,
});
}
tags.sort_by(|a, b| a.tag_name.cmp(&b.tag_name));
tags
}
fn var_type_to_ts(t: &str) -> Option<&'static str> {
match t {
"bool" => Some("boolean"),
"u8" | "i8" | "u16" | "i16" | "u32" | "i32" | "u64" | "i64" | "f32" | "f64" => Some("number"),
"string" => Some("string"),
_ => None,
}
}
fn snake_to_camel(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut capitalize_next = false;
let mut first = true;
for c in s.chars() {
if c == '_' {
capitalize_next = true;
continue;
}
if first {
out.extend(c.to_lowercase());
first = false;
} else if capitalize_next {
out.extend(c.to_uppercase());
capitalize_next = false;
} else {
out.push(c);
}
}
out
}
fn render_generated_block(tags: &[Tag]) -> String {
let mut out = String::new();
out.push_str(GENERATED_BEGIN);
out.push('\n');
out.push_str("// DO NOT EDIT: this block is regenerated by `acctl codegen-tags`.\n");
out.push_str("export const acTagSpecGenerated = [\n");
for t in tags {
out.push_str(&format!(
" {{ \"tagName\": \"{}\", \"fqdn\": \"{}\", \"valueType\": \"{}\" }},\n",
t.tag_name, t.fqdn, t.value_type,
));
}
out.push_str("] as const satisfies readonly TagConfig[];\n");
out.push_str(GENERATED_END);
out
}
fn render_full_file(generated_block: &str) -> String {
const HEADER: &str = r#"/**
* AutoCore Tag Definitions
*
* The exported `acTagSpec` is the union of two arrays:
* - `acTagSpecGenerated` — produced by `acctl codegen-tags` from every
* variable in `project.json` with `"ux": true`. Do NOT edit this block
* by hand; it will be overwritten on the next codegen run.
* - `acTagSpecCustom` — hand-written tags and per-tag overrides
* (subscription cycle times, scales, etc.). Safe to edit; preserved
* across codegen runs as long as the sentinel comments stay intact.
*/
import type { TagConfig } from "@adcops/autocore-react/core/AutoCoreTagTypes";
"#;
const FOOTER: &str = r#"
// Hand-written tags and overrides. Safe to edit.
//
// Example:
// {
// tagName: "liftPosition",
// fqdn: "gm.lift_axis_position",
// valueType: "number",
// subscriptionOptions: { sampling_interval_ms: 300 },
// scale: "position",
// },
export const acTagSpecCustom = [
] as const satisfies readonly TagConfig[];
export const acTagSpec = [
...acTagSpecGenerated,
...acTagSpecCustom,
] as const satisfies readonly TagConfig[];
export default acTagSpec;
"#;
let mut out = String::with_capacity(HEADER.len() + generated_block.len() + FOOTER.len());
out.push_str(HEADER);
out.push_str(generated_block);
out.push_str(FOOTER);
out
}
enum Action {
ReplaceInPlace { existing: String },
FullRegen { backed_up: Option<PathBuf> },
}
fn decide_action(tags_path: &Path, force: bool) -> Result<Action> {
if !tags_path.exists() {
return Ok(Action::FullRegen { backed_up: None });
}
let existing =
fs::read_to_string(tags_path).with_context(|| format!("reading {}", tags_path.display()))?;
let has_sentinels = existing.contains(GENERATED_BEGIN) && existing.contains(GENERATED_END);
let has_custom = existing.contains("acTagSpecCustom");
if !force && has_sentinels && has_custom {
return Ok(Action::ReplaceInPlace { existing });
}
let bak = tags_path.with_extension("ts.bak");
fs::copy(tags_path, &bak)
.with_context(|| format!("backing up {} → {}", tags_path.display(), bak.display()))?;
Ok(Action::FullRegen { backed_up: Some(bak) })
}
fn splice_generated_block(existing: &str, generated_block: &str) -> Result<String> {
let begin_idx = existing
.find(GENERATED_BEGIN)
.ok_or_else(|| anyhow!("sentinel '{}' not found", GENERATED_BEGIN))?;
let end_idx = existing
.find(GENERATED_END)
.ok_or_else(|| anyhow!("sentinel '{}' not found", GENERATED_END))?;
if end_idx <= begin_idx {
return Err(anyhow!(
"sentinels are out of order — end appears before begin"
));
}
let after_end = end_idx + GENERATED_END.len();
let mut out = String::with_capacity(existing.len() + generated_block.len());
out.push_str(&existing[..begin_idx]);
out.push_str(generated_block);
out.push_str(&existing[after_end..]);
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn snake_to_camel_basic() {
assert_eq!(snake_to_camel("all_axes_homed"), "allAxesHomed");
assert_eq!(snake_to_camel("req_start_auto"), "reqStartAuto");
assert_eq!(snake_to_camel("x"), "x");
assert_eq!(snake_to_camel(""), "");
assert_eq!(snake_to_camel("already_camelcase_ish"), "alreadyCamelcaseIsh");
}
#[test]
fn snake_to_camel_preserves_digits_and_leading_caps() {
assert_eq!(snake_to_camel("axis_1_position"), "axis1Position");
assert_eq!(snake_to_camel("Foo_bar"), "fooBar");
}
#[test]
fn var_type_mapping() {
assert_eq!(var_type_to_ts("bool"), Some("boolean"));
assert_eq!(var_type_to_ts("f32"), Some("number"));
assert_eq!(var_type_to_ts("i64"), Some("number"));
assert_eq!(var_type_to_ts("string"), Some("string"));
assert_eq!(var_type_to_ts("struct_something"), None);
}
#[test]
fn collect_tags_filters_and_sorts() {
let project = serde_json::json!({
"variables": {
"all_axes_homed": { "type": "bool", "ux": true },
"hidden_internal": { "type": "u32" }, "z_explicit_false": { "type": "bool", "ux": false },
"jog_distance_mm": { "type": "f64", "ux": true },
"info_test_id": { "type": "string", "ux": true },
}
});
let tags = collect_tags(&project);
let names: Vec<_> = tags.iter().map(|t| t.tag_name.as_str()).collect();
assert_eq!(names, vec!["allAxesHomed", "infoTestId", "jogDistanceMm"]);
assert_eq!(tags[0].fqdn, "gm.all_axes_homed");
assert_eq!(tags[0].value_type, "boolean");
assert_eq!(tags[2].value_type, "number");
}
#[test]
fn splice_replaces_only_generated_block() {
let existing = "header\n\
// autocore-codegen:generated-start\n\
OLD CONTENT\n\
// autocore-codegen:generated-end\n\
custom kept\n\
export const acTagSpecCustom = [];\n";
let new_block = "// autocore-codegen:generated-start\n\
NEW CONTENT\n\
// autocore-codegen:generated-end";
let spliced = splice_generated_block(existing, new_block).unwrap();
assert!(spliced.contains("NEW CONTENT"));
assert!(!spliced.contains("OLD CONTENT"));
assert!(spliced.starts_with("header\n"));
assert!(spliced.contains("custom kept"));
assert!(spliced.contains("export const acTagSpecCustom = [];"));
}
#[test]
fn splice_errors_on_missing_sentinel() {
let existing = "no sentinels here";
let new_block = "irrelevant";
assert!(splice_generated_block(existing, new_block).is_err());
}
#[test]
fn render_generated_block_emits_sorted_records() {
let tags = vec![
Tag { tag_name: "aFirst".into(), fqdn: "gm.a_first".into(), value_type: "boolean" },
Tag { tag_name: "bSecond".into(), fqdn: "gm.b_second".into(), value_type: "number" },
];
let block = render_generated_block(&tags);
assert!(block.starts_with(GENERATED_BEGIN));
assert!(block.trim_end().ends_with(GENERATED_END));
assert!(block.contains("\"tagName\": \"aFirst\""));
assert!(block.contains("\"fqdn\": \"gm.b_second\""));
assert!(block.contains("as const satisfies readonly TagConfig[]"));
}
}