1use std::path::PathBuf;
14
15use semver::Version;
16use thiserror::Error;
17
18use super::gendoc::EMBEDDED_ALC_SHAPES_VERSION;
19use super::AppService;
20
21#[derive(Debug, Error)]
24pub enum PkgScaffoldError {
25 #[error("invalid package name {name:?}: {reason}")]
26 NameInvalid { name: String, reason: &'static str },
27
28 #[error("package skeleton already exists at {}", path.display())]
29 AlreadyExists { path: PathBuf },
30
31 #[error("I/O error at {}: {cause}", path.display())]
32 IoError { path: PathBuf, cause: String },
33}
34
35#[derive(Debug)]
39pub struct ScaffoldResult {
40 pub path: PathBuf,
41 pub bytes_written: usize,
42}
43
44const TEMPLATE: &str = r#"--- {{NAME}} — {{HEADER_LINE}}.
49
50local S = require("alc_shapes")
51local T = S.T
52
53local M = {
54 meta = {
55 name = "{{NAME}}",
56 version = "0.1.0",
57 alc_shapes_compat = "{{COMPAT}}",
58{{CATEGORY_LINE}}{{DESCRIPTION_LINE}} },
59 spec = {
60 entries = {
61 run = {
62 -- TODO: declare input / result via alc_shapes.t combinators.
63 -- input = T.shape({ ... }),
64 -- result = T.shape({ ... }),
65 },
66 },
67 },
68}
69
70function M.run(ctx)
71 -- TODO: implement. Use alc.llm(prompt) for LLM calls
72 -- (pauses execution; host resumes via alc_continue).
73 local answer = alc.llm("example prompt for " .. tostring(ctx.task))
74 return { answer = answer }
75end
76
77return M
78"#;
79
80fn validate_name(name: &str) -> Result<(), PkgScaffoldError> {
89 if name.is_empty() {
90 return Err(PkgScaffoldError::NameInvalid {
91 name: name.to_string(),
92 reason: "name must not be empty",
93 });
94 }
95 if name.len() > 64 {
96 return Err(PkgScaffoldError::NameInvalid {
97 name: name.to_string(),
98 reason: "name must be 64 characters or fewer",
99 });
100 }
101 let mut chars = name.chars();
102 let first = chars.next().expect("non-empty checked above");
103 if !first.is_ascii_lowercase() {
104 return Err(PkgScaffoldError::NameInvalid {
105 name: name.to_string(),
106 reason: "name must start with a lowercase ASCII letter (a-z)",
107 });
108 }
109 for ch in chars {
110 if !ch.is_ascii_lowercase() && !ch.is_ascii_digit() && ch != '_' {
111 return Err(PkgScaffoldError::NameInvalid {
112 name: name.to_string(),
113 reason: "name may only contain lowercase ASCII letters, digits, and underscores",
114 });
115 }
116 }
117 Ok(())
118}
119
120fn default_compat_range() -> String {
130 match Version::parse(EMBEDDED_ALC_SHAPES_VERSION) {
131 Ok(v) => {
132 let major = v.major;
133 let minor = v.minor;
134 format!(">={major}.{minor}.0, <{major}.{}", minor + 1)
135 }
136 Err(e) => {
137 tracing::warn!(
138 embedded = EMBEDDED_ALC_SHAPES_VERSION,
139 error = %e,
140 "pkg_scaffold: failed to parse EMBEDDED_ALC_SHAPES_VERSION; \
141 falling back to hardcoded compat range"
142 );
143 ">=0.25.0, <0.26".to_string()
144 }
145 }
146}
147
148fn escape_lua_string(s: &str) -> String {
163 let mut out = String::with_capacity(s.len());
164 for ch in s.chars() {
165 match ch {
166 '\\' => out.push_str("\\\\"),
167 '"' => out.push_str("\\\""),
168 '\n' => out.push_str("\\n"),
169 '\r' => out.push_str("\\r"),
170 '\0' => out.push_str("\\0"),
171 c => out.push(c),
172 }
173 }
174 out
175}
176
177fn sanitize_header_line(s: &str) -> String {
181 s.replace(['\r', '\n'], " ")
182}
183
184fn render_template(
185 name: &str,
186 compat: &str,
187 category: Option<&str>,
188 description: Option<&str>,
189) -> String {
190 let header_line = match description {
191 Some(d) => sanitize_header_line(d),
192 None => "TODO: one-line description".to_string(),
193 };
194
195 let category_line = match category {
197 Some(cat) => format!(" category = \"{}\",\n", escape_lua_string(cat)),
198 None => {
199 " -- category = \"<category>\", -- uncomment if provided\n".to_string()
200 }
201 };
202
203 let description_line = match description {
205 Some(desc) => format!(" description = \"{}\",\n", escape_lua_string(desc)),
206 None => {
207 " -- description = \"<description>\", -- uncomment if provided\n".to_string()
208 }
209 };
210
211 TEMPLATE
216 .replace("{{NAME}}", &escape_lua_string(name))
217 .replace("{{COMPAT}}", &escape_lua_string(compat))
218 .replace("{{HEADER_LINE}}", &header_line)
219 .replace("{{CATEGORY_LINE}}", &category_line)
220 .replace("{{DESCRIPTION_LINE}}", &description_line)
221}
222
223pub fn scaffold_pkg(
232 name: &str,
233 target_dir: &str,
234 category: Option<&str>,
235 description: Option<&str>,
236) -> Result<ScaffoldResult, PkgScaffoldError> {
237 validate_name(name)?;
238
239 let pkg_dir = std::path::Path::new(target_dir).join(name);
240 let init_lua = pkg_dir.join("init.lua");
241
242 if init_lua.exists() {
243 return Err(PkgScaffoldError::AlreadyExists { path: init_lua });
244 }
245
246 std::fs::create_dir_all(&pkg_dir).map_err(|e| PkgScaffoldError::IoError {
247 path: pkg_dir.clone(),
248 cause: e.to_string(),
249 })?;
250
251 let compat = default_compat_range();
252 let content = render_template(name, &compat, category, description);
253 let bytes_written = content.len();
254
255 std::fs::write(&init_lua, &content).map_err(|e| PkgScaffoldError::IoError {
256 path: init_lua.clone(),
257 cause: e.to_string(),
258 })?;
259
260 Ok(ScaffoldResult {
261 path: init_lua,
262 bytes_written,
263 })
264}
265
266impl AppService {
269 pub fn pkg_scaffold(
274 &self,
275 name: &str,
276 target_dir: Option<&str>,
277 category: Option<&str>,
278 description: Option<&str>,
279 ) -> Result<String, String> {
280 let dir = target_dir.unwrap_or(".");
281
282 let result = scaffold_pkg(name, dir, category, description).map_err(|e| e.to_string())?;
283
284 serde_json::to_string(&serde_json::json!({
285 "status": "ok",
286 "path": result.path.to_string_lossy(),
287 "bytes_written": result.bytes_written,
288 }))
289 .map_err(|e| format!("pkg_scaffold: JSON serialization error: {e}"))
290 }
291}
292
293#[cfg(test)]
296mod tests {
297 use super::*;
298
299 #[test]
302 fn test_validate_name_ok() {
303 assert!(validate_name("my_pkg").is_ok());
304 assert!(validate_name("a").is_ok());
305 assert!(validate_name("pkg123").is_ok());
306 assert!(validate_name("a_b_c").is_ok());
307 let long = "a".repeat(64);
309 assert!(validate_name(&long).is_ok());
310 }
311
312 #[test]
313 fn test_validate_name_empty() {
314 let err = validate_name("").unwrap_err();
315 assert!(matches!(err, PkgScaffoldError::NameInvalid { .. }));
316 assert!(err.to_string().contains("not be empty"));
317 }
318
319 #[test]
320 fn test_validate_name_too_long() {
321 let name = "a".repeat(65);
322 let err = validate_name(&name).unwrap_err();
323 assert!(matches!(err, PkgScaffoldError::NameInvalid { .. }));
324 assert!(err.to_string().contains("64 characters"));
325 }
326
327 #[test]
328 fn test_validate_name_starts_with_digit() {
329 let err = validate_name("1bad").unwrap_err();
330 assert!(matches!(err, PkgScaffoldError::NameInvalid { .. }));
331 assert!(err.to_string().contains("start with a lowercase"));
332 }
333
334 #[test]
335 fn test_validate_name_starts_with_upper() {
336 let err = validate_name("Bad").unwrap_err();
337 assert!(matches!(err, PkgScaffoldError::NameInvalid { .. }));
338 }
339
340 #[test]
341 fn test_validate_name_contains_slash() {
342 let err = validate_name("has/slash").unwrap_err();
343 assert!(matches!(err, PkgScaffoldError::NameInvalid { .. }));
344 assert!(err.to_string().contains("only contain"));
345 }
346
347 #[test]
348 fn test_validate_name_contains_hyphen() {
349 let err = validate_name("with-hyphen").unwrap_err();
350 assert!(matches!(err, PkgScaffoldError::NameInvalid { .. }));
351 }
352
353 #[test]
354 fn test_validate_name_uppercase_mid() {
355 let err = validate_name("myPkg").unwrap_err();
356 assert!(matches!(err, PkgScaffoldError::NameInvalid { .. }));
357 }
358
359 #[test]
362 fn test_default_compat_range_format() {
363 let range = default_compat_range();
364 assert!(
366 range.starts_with(">="),
367 "expected range to start with '>=' got: {range}"
368 );
369 assert!(
370 range.contains(", <"),
371 "expected range to contain ', <' got: {range}"
372 );
373 }
374
375 #[test]
376 fn test_default_compat_range_current_version() {
377 let range = default_compat_range();
379 assert_eq!(range, ">=0.25.0, <0.26");
380 }
381
382 #[test]
385 fn test_escape_lua_string_passes_through_plain() {
386 assert_eq!(escape_lua_string("plain text"), "plain text");
387 }
388
389 #[test]
390 fn test_escape_lua_string_escapes_quote_and_backslash() {
391 assert_eq!(
392 escape_lua_string(r#"he said "hi" \n"#),
393 r#"he said \"hi\" \\n"#
394 );
395 }
396
397 #[test]
398 fn test_escape_lua_string_escapes_newline_cr_nul() {
399 assert_eq!(escape_lua_string("a\nb\rc\0d"), "a\\nb\\rc\\0d");
400 }
401
402 #[test]
403 fn test_render_template_escapes_injection_payload() {
404 let payload = r#"x",injected=os.execute("rm -rf /"),y=""#;
406 let out = render_template("my_pkg", ">=0.25.0, <0.26", Some(payload), Some(payload));
407 let expected_escaped = r#"x\",injected=os.execute(\"rm -rf /\"),y=\""#;
411 assert!(
412 out.contains(expected_escaped),
413 "payload must be fully escaped; render was:\n{out}"
414 );
415 for line in out.lines() {
419 let trimmed = line.trim_start();
420 if trimmed.starts_with("category = \"") || trimmed.starts_with("description = \"") {
421 assert!(
422 line.contains(expected_escaped),
423 "field line must contain escaped payload: {line}"
424 );
425 assert!(
426 !line.contains(payload),
427 "field line must not contain raw payload: {line}"
428 );
429 }
430 }
431 }
432
433 #[test]
436 fn test_render_template_basic() {
437 let out = render_template("my_pkg", ">=0.25.0, <0.26", None, None);
438 assert!(out.contains(r#"name = "my_pkg""#));
439 assert!(out.contains(r#"alc_shapes_compat = ">=0.25.0, <0.26""#));
440 assert!(out.contains("-- category = \"<category>\","));
441 assert!(out.contains("-- description = \"<description>\","));
442 assert!(out.contains("TODO: one-line description"));
443 assert!(out.contains("function M.run(ctx)"));
444 assert!(out.contains("T.shape"));
445 assert!(out.contains("return M"));
446 }
447
448 #[test]
449 fn test_render_template_with_category_and_description() {
450 let out = render_template(
451 "my_pkg",
452 ">=0.25.0, <0.26",
453 Some("selection"),
454 Some("test pkg"),
455 );
456 assert!(out.contains(r#"category = "selection""#));
457 assert!(out.contains(r#"description = "test pkg""#));
458 assert!(!out.contains("-- category ="));
460 assert!(!out.contains("-- description ="));
461 assert!(out.contains("test pkg"));
463 }
464
465 #[test]
466 fn test_render_template_with_category_only() {
467 let out = render_template("my_pkg", ">=0.25.0, <0.26", Some("reasoning"), None);
468 assert!(out.contains(r#"category = "reasoning""#));
469 assert!(out.contains("-- description ="));
471 }
472
473 #[test]
476 fn test_scaffold_pkg_creates_file() {
477 let tmp = tempfile::tempdir().expect("tempdir");
478 let result =
479 scaffold_pkg("my_pkg", tmp.path().to_str().unwrap(), None, None).expect("scaffold ok");
480
481 let expected_path = tmp.path().join("my_pkg").join("init.lua");
482 assert_eq!(result.path, expected_path);
483 assert!(expected_path.exists(), "init.lua must exist");
484
485 let content = std::fs::read_to_string(&expected_path).expect("read init.lua");
486 assert!(content.contains(r#"name = "my_pkg""#));
487 assert!(content.contains("alc_shapes_compat"));
488 assert!(result.bytes_written > 0);
489 assert_eq!(result.bytes_written, content.len());
490 }
491
492 #[test]
493 fn test_scaffold_pkg_already_exists() {
494 let tmp = tempfile::tempdir().expect("tempdir");
495 let pkg_dir = tmp.path().join("my_pkg");
496 std::fs::create_dir_all(&pkg_dir).expect("create dir");
497 std::fs::write(pkg_dir.join("init.lua"), "-- existing").expect("write existing");
498
499 let err = scaffold_pkg("my_pkg", tmp.path().to_str().unwrap(), None, None).unwrap_err();
500 assert!(matches!(err, PkgScaffoldError::AlreadyExists { .. }));
501 }
502
503 #[test]
504 fn test_scaffold_pkg_invalid_name() {
505 let tmp = tempfile::tempdir().expect("tempdir");
506 let err = scaffold_pkg("1bad", tmp.path().to_str().unwrap(), None, None).unwrap_err();
507 assert!(matches!(err, PkgScaffoldError::NameInvalid { .. }));
508 }
509
510 #[test]
511 fn test_scaffold_pkg_with_category_and_description() {
512 let tmp = tempfile::tempdir().expect("tempdir");
513 scaffold_pkg(
514 "my_pkg",
515 tmp.path().to_str().unwrap(),
516 Some("selection"),
517 Some("test pkg"),
518 )
519 .expect("scaffold ok");
520
521 let content = std::fs::read_to_string(tmp.path().join("my_pkg").join("init.lua")).unwrap();
522 assert!(content.contains(r#"category = "selection""#));
523 assert!(content.contains(r#"description = "test pkg""#));
524 assert!(!content.contains("-- category ="));
525 assert!(!content.contains("-- description ="));
526 }
527}