use std::path::PathBuf;
use semver::Version;
use thiserror::Error;
use super::gendoc::EMBEDDED_ALC_SHAPES_VERSION;
use super::AppService;
#[derive(Debug, Error)]
pub enum PkgScaffoldError {
#[error("invalid package name {name:?}: {reason}")]
NameInvalid { name: String, reason: &'static str },
#[error("package skeleton already exists at {}", path.display())]
AlreadyExists { path: PathBuf },
#[error("I/O error at {}: {cause}", path.display())]
IoError { path: PathBuf, cause: String },
}
#[derive(Debug)]
pub struct ScaffoldResult {
pub path: PathBuf,
pub bytes_written: usize,
}
const TEMPLATE: &str = r#"--- {{NAME}} — {{HEADER_LINE}}.
local S = require("alc_shapes")
local T = S.T
local M = {
meta = {
name = "{{NAME}}",
version = "0.1.0",
alc_shapes_compat = "{{COMPAT}}",
{{CATEGORY_LINE}}{{DESCRIPTION_LINE}} },
spec = {
entries = {
run = {
-- TODO: declare input / result via alc_shapes.t combinators.
-- input = T.shape({ ... }),
-- result = T.shape({ ... }),
},
},
},
}
function M.run(ctx)
-- TODO: implement. Use alc.llm(prompt) for LLM calls
-- (pauses execution; host resumes via alc_continue).
local answer = alc.llm("example prompt for " .. tostring(ctx.task))
return { answer = answer }
end
return M
"#;
fn validate_name(name: &str) -> Result<(), PkgScaffoldError> {
if name.is_empty() {
return Err(PkgScaffoldError::NameInvalid {
name: name.to_string(),
reason: "name must not be empty",
});
}
if name.len() > 64 {
return Err(PkgScaffoldError::NameInvalid {
name: name.to_string(),
reason: "name must be 64 characters or fewer",
});
}
let mut chars = name.chars();
let first = chars.next().expect("non-empty checked above");
if !first.is_ascii_lowercase() {
return Err(PkgScaffoldError::NameInvalid {
name: name.to_string(),
reason: "name must start with a lowercase ASCII letter (a-z)",
});
}
for ch in chars {
if !ch.is_ascii_lowercase() && !ch.is_ascii_digit() && ch != '_' {
return Err(PkgScaffoldError::NameInvalid {
name: name.to_string(),
reason: "name may only contain lowercase ASCII letters, digits, and underscores",
});
}
}
Ok(())
}
fn default_compat_range() -> String {
match Version::parse(EMBEDDED_ALC_SHAPES_VERSION) {
Ok(v) => {
let major = v.major;
let minor = v.minor;
format!(">={major}.{minor}.0, <{major}.{}", minor + 1)
}
Err(e) => {
tracing::warn!(
embedded = EMBEDDED_ALC_SHAPES_VERSION,
error = %e,
"pkg_scaffold: failed to parse EMBEDDED_ALC_SHAPES_VERSION; \
falling back to hardcoded compat range"
);
">=0.25.0, <0.26".to_string()
}
}
}
fn escape_lua_string(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for ch in s.chars() {
match ch {
'\\' => out.push_str("\\\\"),
'"' => out.push_str("\\\""),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\0' => out.push_str("\\0"),
c => out.push(c),
}
}
out
}
fn sanitize_header_line(s: &str) -> String {
s.replace(['\r', '\n'], " ")
}
fn render_template(
name: &str,
compat: &str,
category: Option<&str>,
description: Option<&str>,
) -> String {
let header_line = match description {
Some(d) => sanitize_header_line(d),
None => "TODO: one-line description".to_string(),
};
let category_line = match category {
Some(cat) => format!(" category = \"{}\",\n", escape_lua_string(cat)),
None => {
" -- category = \"<category>\", -- uncomment if provided\n".to_string()
}
};
let description_line = match description {
Some(desc) => format!(" description = \"{}\",\n", escape_lua_string(desc)),
None => {
" -- description = \"<description>\", -- uncomment if provided\n".to_string()
}
};
TEMPLATE
.replace("{{NAME}}", &escape_lua_string(name))
.replace("{{COMPAT}}", &escape_lua_string(compat))
.replace("{{HEADER_LINE}}", &header_line)
.replace("{{CATEGORY_LINE}}", &category_line)
.replace("{{DESCRIPTION_LINE}}", &description_line)
}
pub fn scaffold_pkg(
name: &str,
target_dir: &str,
category: Option<&str>,
description: Option<&str>,
) -> Result<ScaffoldResult, PkgScaffoldError> {
validate_name(name)?;
let pkg_dir = std::path::Path::new(target_dir).join(name);
let init_lua = pkg_dir.join("init.lua");
if init_lua.exists() {
return Err(PkgScaffoldError::AlreadyExists { path: init_lua });
}
std::fs::create_dir_all(&pkg_dir).map_err(|e| PkgScaffoldError::IoError {
path: pkg_dir.clone(),
cause: e.to_string(),
})?;
let compat = default_compat_range();
let content = render_template(name, &compat, category, description);
let bytes_written = content.len();
std::fs::write(&init_lua, &content).map_err(|e| PkgScaffoldError::IoError {
path: init_lua.clone(),
cause: e.to_string(),
})?;
Ok(ScaffoldResult {
path: init_lua,
bytes_written,
})
}
impl AppService {
pub fn pkg_scaffold(
&self,
name: &str,
target_dir: Option<&str>,
category: Option<&str>,
description: Option<&str>,
) -> Result<String, String> {
let dir = target_dir.unwrap_or(".");
let result = scaffold_pkg(name, dir, category, description).map_err(|e| e.to_string())?;
serde_json::to_string(&serde_json::json!({
"status": "ok",
"path": result.path.to_string_lossy(),
"bytes_written": result.bytes_written,
}))
.map_err(|e| format!("pkg_scaffold: JSON serialization error: {e}"))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_validate_name_ok() {
assert!(validate_name("my_pkg").is_ok());
assert!(validate_name("a").is_ok());
assert!(validate_name("pkg123").is_ok());
assert!(validate_name("a_b_c").is_ok());
let long = "a".repeat(64);
assert!(validate_name(&long).is_ok());
}
#[test]
fn test_validate_name_empty() {
let err = validate_name("").unwrap_err();
assert!(matches!(err, PkgScaffoldError::NameInvalid { .. }));
assert!(err.to_string().contains("not be empty"));
}
#[test]
fn test_validate_name_too_long() {
let name = "a".repeat(65);
let err = validate_name(&name).unwrap_err();
assert!(matches!(err, PkgScaffoldError::NameInvalid { .. }));
assert!(err.to_string().contains("64 characters"));
}
#[test]
fn test_validate_name_starts_with_digit() {
let err = validate_name("1bad").unwrap_err();
assert!(matches!(err, PkgScaffoldError::NameInvalid { .. }));
assert!(err.to_string().contains("start with a lowercase"));
}
#[test]
fn test_validate_name_starts_with_upper() {
let err = validate_name("Bad").unwrap_err();
assert!(matches!(err, PkgScaffoldError::NameInvalid { .. }));
}
#[test]
fn test_validate_name_contains_slash() {
let err = validate_name("has/slash").unwrap_err();
assert!(matches!(err, PkgScaffoldError::NameInvalid { .. }));
assert!(err.to_string().contains("only contain"));
}
#[test]
fn test_validate_name_contains_hyphen() {
let err = validate_name("with-hyphen").unwrap_err();
assert!(matches!(err, PkgScaffoldError::NameInvalid { .. }));
}
#[test]
fn test_validate_name_uppercase_mid() {
let err = validate_name("myPkg").unwrap_err();
assert!(matches!(err, PkgScaffoldError::NameInvalid { .. }));
}
#[test]
fn test_default_compat_range_format() {
let range = default_compat_range();
assert!(
range.starts_with(">="),
"expected range to start with '>=' got: {range}"
);
assert!(
range.contains(", <"),
"expected range to contain ', <' got: {range}"
);
}
#[test]
fn test_default_compat_range_current_version() {
let range = default_compat_range();
assert_eq!(range, ">=0.25.0, <0.26");
}
#[test]
fn test_escape_lua_string_passes_through_plain() {
assert_eq!(escape_lua_string("plain text"), "plain text");
}
#[test]
fn test_escape_lua_string_escapes_quote_and_backslash() {
assert_eq!(
escape_lua_string(r#"he said "hi" \n"#),
r#"he said \"hi\" \\n"#
);
}
#[test]
fn test_escape_lua_string_escapes_newline_cr_nul() {
assert_eq!(escape_lua_string("a\nb\rc\0d"), "a\\nb\\rc\\0d");
}
#[test]
fn test_render_template_escapes_injection_payload() {
let payload = r#"x",injected=os.execute("rm -rf /"),y=""#;
let out = render_template("my_pkg", ">=0.25.0, <0.26", Some(payload), Some(payload));
let expected_escaped = r#"x\",injected=os.execute(\"rm -rf /\"),y=\""#;
assert!(
out.contains(expected_escaped),
"payload must be fully escaped; render was:\n{out}"
);
for line in out.lines() {
let trimmed = line.trim_start();
if trimmed.starts_with("category = \"") || trimmed.starts_with("description = \"") {
assert!(
line.contains(expected_escaped),
"field line must contain escaped payload: {line}"
);
assert!(
!line.contains(payload),
"field line must not contain raw payload: {line}"
);
}
}
}
#[test]
fn test_render_template_basic() {
let out = render_template("my_pkg", ">=0.25.0, <0.26", None, None);
assert!(out.contains(r#"name = "my_pkg""#));
assert!(out.contains(r#"alc_shapes_compat = ">=0.25.0, <0.26""#));
assert!(out.contains("-- category = \"<category>\","));
assert!(out.contains("-- description = \"<description>\","));
assert!(out.contains("TODO: one-line description"));
assert!(out.contains("function M.run(ctx)"));
assert!(out.contains("T.shape"));
assert!(out.contains("return M"));
}
#[test]
fn test_render_template_with_category_and_description() {
let out = render_template(
"my_pkg",
">=0.25.0, <0.26",
Some("selection"),
Some("test pkg"),
);
assert!(out.contains(r#"category = "selection""#));
assert!(out.contains(r#"description = "test pkg""#));
assert!(!out.contains("-- category ="));
assert!(!out.contains("-- description ="));
assert!(out.contains("test pkg"));
}
#[test]
fn test_render_template_with_category_only() {
let out = render_template("my_pkg", ">=0.25.0, <0.26", Some("reasoning"), None);
assert!(out.contains(r#"category = "reasoning""#));
assert!(out.contains("-- description ="));
}
#[test]
fn test_scaffold_pkg_creates_file() {
let tmp = tempfile::tempdir().expect("tempdir");
let result =
scaffold_pkg("my_pkg", tmp.path().to_str().unwrap(), None, None).expect("scaffold ok");
let expected_path = tmp.path().join("my_pkg").join("init.lua");
assert_eq!(result.path, expected_path);
assert!(expected_path.exists(), "init.lua must exist");
let content = std::fs::read_to_string(&expected_path).expect("read init.lua");
assert!(content.contains(r#"name = "my_pkg""#));
assert!(content.contains("alc_shapes_compat"));
assert!(result.bytes_written > 0);
assert_eq!(result.bytes_written, content.len());
}
#[test]
fn test_scaffold_pkg_already_exists() {
let tmp = tempfile::tempdir().expect("tempdir");
let pkg_dir = tmp.path().join("my_pkg");
std::fs::create_dir_all(&pkg_dir).expect("create dir");
std::fs::write(pkg_dir.join("init.lua"), "-- existing").expect("write existing");
let err = scaffold_pkg("my_pkg", tmp.path().to_str().unwrap(), None, None).unwrap_err();
assert!(matches!(err, PkgScaffoldError::AlreadyExists { .. }));
}
#[test]
fn test_scaffold_pkg_invalid_name() {
let tmp = tempfile::tempdir().expect("tempdir");
let err = scaffold_pkg("1bad", tmp.path().to_str().unwrap(), None, None).unwrap_err();
assert!(matches!(err, PkgScaffoldError::NameInvalid { .. }));
}
#[test]
fn test_scaffold_pkg_with_category_and_description() {
let tmp = tempfile::tempdir().expect("tempdir");
scaffold_pkg(
"my_pkg",
tmp.path().to_str().unwrap(),
Some("selection"),
Some("test pkg"),
)
.expect("scaffold ok");
let content = std::fs::read_to_string(tmp.path().join("my_pkg").join("init.lua")).unwrap();
assert!(content.contains(r#"category = "selection""#));
assert!(content.contains(r#"description = "test pkg""#));
assert!(!content.contains("-- category ="));
assert!(!content.contains("-- description ="));
}
}