Skip to main content

algocline_app/service/
pkg_scaffold.rs

1//! `alc_pkg_scaffold` — generate a minimal package skeleton.
2//!
3//! Writes a single `init.lua` file to `<target_dir>/<name>/init.lua` with
4//! `M.meta` / `M.spec.entries.run` / `M.run` template.  The
5//! `alc_shapes_compat` range is derived automatically from
6//! [`EMBEDDED_ALC_SHAPES_VERSION`].
7//!
8//! Per the project-level Error propagation rule
9//! (`CLAUDE.md §Service 層 Error 伝播規律`), every error variant is
10//! propagated via `?` to the MCP wire layer — no `warn!` drops, no
11//! `unwrap_or_default`, no silent `Err(_) =>` branches.
12
13use std::path::PathBuf;
14
15use semver::Version;
16use thiserror::Error;
17
18use super::gendoc::EMBEDDED_ALC_SHAPES_VERSION;
19use super::AppService;
20
21// ── Error type ───────────────────────────────────────────────────────────────
22
23#[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// ── Result type ──────────────────────────────────────────────────────────────
36
37/// Successful scaffold result.
38#[derive(Debug)]
39pub struct ScaffoldResult {
40    pub path: PathBuf,
41    pub bytes_written: usize,
42}
43
44// ── Template ─────────────────────────────────────────────────────────────────
45
46/// Base template — markers `{{NAME}}`, `{{COMPAT}}`, `{{HEADER_LINE}}`,
47/// `{{CATEGORY_LINE}}`, `{{DESCRIPTION_LINE}}` are substituted at render time.
48const 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
80// ── Name validation ───────────────────────────────────────────────────────────
81
82/// Validate a package name.
83///
84/// Rules (hand-rolled, no regex):
85/// - Non-empty, length ≤ 64.
86/// - First character: `a-z`.
87/// - Remaining characters: `a-z`, `0-9`, `_`.
88fn 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
120// ── Default compat range ─────────────────────────────────────────────────────
121
122/// Compute the default `alc_shapes_compat` range from [`EMBEDDED_ALC_SHAPES_VERSION`].
123///
124/// `"0.25.1"` → `">=0.25.0, <0.26"`.
125/// `"1.2.3"`  → `">=1.2.0, <1.3"`.
126///
127/// On parse failure (should never happen — the constant is well-formed) falls
128/// back to a literal and emits `tracing::warn!`.
129fn 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
148// ── Template rendering ────────────────────────────────────────────────────────
149
150/// Escape a Rust `&str` for safe embedding inside a Lua double-quoted
151/// string literal.
152///
153/// Without this, a `category` / `description` value containing a bare
154/// `"` or `\n` would break out of the string literal in the generated
155/// `init.lua` and, in the worst case, let a caller inject arbitrary
156/// Lua code. We escape `\`, `"`, `\n`, `\r`, and `\0` — the minimal
157/// set required by Lua's lexer.
158///
159/// The `header_line` substitution lands in a `---` comment rather than
160/// a string literal, but may still contain newlines; callers should
161/// collapse newlines before passing a description used as a header.
162fn 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
177/// Collapse any CR/LF in a header substitution (lands in a `---` comment).
178/// Comment lines terminate at `\n`, so an unescaped newline in the header
179/// would silently break the doc comment structure.
180fn 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    // Category line: uncommented when provided, commented-out placeholder otherwise.
196    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    // Description line: uncommented when provided, commented-out placeholder otherwise.
204    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    // `name` and `compat` are validated separately (name: strict charset;
212    // compat: produced internally from EMBEDDED_ALC_SHAPES_VERSION), so
213    // they cannot contain characters requiring escaping. Still escape
214    // defensively to keep the template rendering layer self-contained.
215    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
223// ── Core function ─────────────────────────────────────────────────────────────
224
225/// Generate a minimal package skeleton at `<target_dir>/<name>/init.lua`.
226///
227/// Errors:
228/// - [`PkgScaffoldError::NameInvalid`] — name fails validation.
229/// - [`PkgScaffoldError::AlreadyExists`] — `init.lua` already present.
230/// - [`PkgScaffoldError::IoError`] — filesystem operation failed.
231pub 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
266// ── AppService method ─────────────────────────────────────────────────────────
267
268impl AppService {
269    /// Generate a minimal package skeleton at `<target_dir>/<name>/init.lua`.
270    ///
271    /// Returns a JSON string `{ "status": "ok", "path": "...", "bytes_written": N }`.
272    /// Typed errors are forwarded as `Err(String)` to the MCP wire layer.
273    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// ── Unit tests ────────────────────────────────────────────────────────────────
294
295#[cfg(test)]
296mod tests {
297    use super::*;
298
299    // ── validate_name ────────────────────────────────────────────────────────
300
301    #[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        // exactly 64 chars
308        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    // ── default_compat_range ─────────────────────────────────────────────────
360
361    #[test]
362    fn test_default_compat_range_format() {
363        let range = default_compat_range();
364        // Must contain the expected format for current version.
365        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        // "0.25.1" → ">=0.25.0, <0.26"
378        let range = default_compat_range();
379        assert_eq!(range, ">=0.25.0, <0.26");
380    }
381
382    // ── escape_lua_string ────────────────────────────────────────────────────
383
384    #[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        // Attempted breakout via closing quote + injected Lua code.
405        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        // Every `"` in the payload must be emitted as `\"` in the
408        // rendered Lua string literal. The payload contains 4 quotes;
409        // they must all be preceded by a backslash.
410        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        // The `category` / `description` Lua string literals must
416        // contain the escaped form only. Scan the rendered lines that
417        // begin the field declaration.
418        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    // ── render_template ──────────────────────────────────────────────────────
434
435    #[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        // Commented-out placeholders must NOT appear.
459        assert!(!out.contains("-- category ="));
460        assert!(!out.contains("-- description ="));
461        // Header line uses description value.
462        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        // description placeholder is commented out.
470        assert!(out.contains("-- description ="));
471    }
472
473    // ── scaffold_pkg ─────────────────────────────────────────────────────────
474
475    #[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}