Skip to main content

algocline_app/service/
run.rs

1use std::collections::HashMap;
2use std::sync::Arc;
3
4use algocline_core::pkg::{PkgType, TypeSource};
5use algocline_core::QueryId;
6use algocline_engine::{FeedResult, VariantPkg};
7
8use super::alc_toml::load_alc_toml;
9use super::eval_store::{splice_response_string, splice_response_warnings};
10use super::resolve::{is_package_installed, make_require_code, resolve_code, QueryResponse};
11use super::transcript::write_transcript_log;
12use super::AppService;
13use crate::pool::dispatch::{continue_via_pool, run_via_pool};
14
15/// Recover from MCP clients that JSON-stringify an object-typed field
16/// before sending. The MCP `inputSchema` for `ctx` / `opts` /
17/// `strategy_opts` declares `type: object`, but some clients send a
18/// JSON-encoded string instead. Without this normalisation the value
19/// reaches Lua as a string and breaks pkgs that require a table
20/// (issue 1778656404-63015).
21///
22/// Only `Value::String` payloads that parse into a JSON object or
23/// array are replaced; ordinary strings and primitive scalars pass
24/// through untouched.
25pub(crate) fn normalize_stringified_json_object(v: serde_json::Value) -> serde_json::Value {
26    match v {
27        serde_json::Value::String(ref s) => match serde_json::from_str::<serde_json::Value>(s) {
28            Ok(parsed @ serde_json::Value::Object(_)) => parsed,
29            Ok(parsed @ serde_json::Value::Array(_)) => parsed,
30            _ => v,
31        },
32        other => other,
33    }
34}
35
36/// Splice `save_warning` into the JSON `result` when the optional
37/// warning is `Some(_)`. Returns the original string unchanged when
38/// there is no warning.
39fn splice_save_warning(result_json: &str, warning: Option<String>) -> String {
40    match warning {
41        Some(msg) => splice_response_string(result_json, "save_warning", &msg),
42        None => result_json.to_string(),
43    }
44}
45
46/// Splice `transcript_warning` into the JSON `result` when the optional
47/// warning is `Some(_)`. Returns the original string unchanged when
48/// there is no warning.
49fn splice_transcript_warning(result_json: &str, warning: Option<String>) -> String {
50    match warning {
51        Some(msg) => splice_response_string(result_json, "transcript_warning", &msg),
52        None => result_json.to_string(),
53    }
54}
55
56/// Build a frozen env snapshot from up to three sources and an optional allowlist.
57///
58/// # Sources (applied in priority order, lower overwritten by higher)
59///
60/// 1. **OS environment** (`ctx.env.allow_os = true` only) — `std::env::vars()` snapshot.
61/// 2. **dotenv file** (`ctx.env.dotenv`) — parsed via `dotenvy`; keys overwrite OS layer.
62/// 3. **inject** (`ctx.env.inject`) — explicit key/value map; highest priority, overwrites all.
63///
64/// # Arguments
65///
66/// - `ctx` — the full `alc_run` context value; `ctx["env"]` is extracted here.
67/// - `project_root` — required when `ctx.env.dotenv` is a relative path.
68///   `None` with a relative dotenv path is an error (avoids CWD ambiguity).
69/// - `alc_toml_allow` — optional allowlist from `alc.toml [env].allow`.
70///   When `Some`, the merged map is filtered to only keys present in the list.
71///   `None` means no filtering (all resolved keys are kept).
72///
73/// # Errors
74///
75/// Returns `Err(String)` for:
76/// - `ctx.env.inject` values that are not JSON strings
77/// - `ctx.env.dotenv` is a relative path but `project_root` is `None`
78/// - `dotenvy` I/O error opening the dotenv file
79/// - `dotenvy` parse error for any entry in the dotenv file
80pub(super) fn resolve_env(
81    ctx: &serde_json::Value,
82    project_root: Option<&std::path::Path>,
83    alc_toml_allow: Option<&[String]>,
84) -> Result<Arc<HashMap<String, String>>, String> {
85    let env_obj = ctx.get("env").and_then(|v| v.as_object());
86
87    // ── Layer 3 (highest): inject ──────────────────────────────────────────────
88    let inject: HashMap<String, String> = if let Some(obj) = env_obj {
89        if let Some(inject_val) = obj.get("inject") {
90            match inject_val.as_object() {
91                Some(m) => {
92                    let mut map = HashMap::new();
93                    for (k, v) in m {
94                        match v.as_str() {
95                            Some(s) => {
96                                map.insert(k.clone(), s.to_string());
97                            }
98                            None => {
99                                return Err(format!(
100                                    "ctx.env.inject: value for key '{k}' must be a string, got {v}"
101                                ));
102                            }
103                        }
104                    }
105                    map
106                }
107                None => {
108                    return Err(format!(
109                        "ctx.env.inject must be an object, got {}",
110                        inject_val
111                    ));
112                }
113            }
114        } else {
115            HashMap::new()
116        }
117    } else {
118        HashMap::new()
119    };
120
121    // Resolved dotenv path (if any)
122    let dotenv_path: Option<std::path::PathBuf> = if let Some(p) = env_obj
123        .and_then(|o| o.get("dotenv"))
124        .and_then(|v| v.as_str())
125    {
126        let path = std::path::Path::new(p);
127        if path.is_absolute() {
128            Some(path.to_path_buf())
129        } else {
130            match project_root {
131                Some(root) => Some(root.join(p)),
132                None => {
133                    return Err(format!(
134                        "ctx.env.dotenv: relative path '{p}' requires project_root to be set"
135                    ));
136                }
137            }
138        }
139    } else {
140        None
141    };
142
143    let allow_os = env_obj
144        .and_then(|o| o.get("allow_os"))
145        .and_then(|v| v.as_bool())
146        .unwrap_or(false);
147
148    let mut merged: HashMap<String, String> = HashMap::new();
149
150    // ── Layer 1 (lowest): OS environment ──────────────────────────────────────
151    if allow_os {
152        for (k, v) in std::env::vars() {
153            merged.insert(k, v);
154        }
155    }
156
157    // ── Layer 2: dotenv file ───────────────────────────────────────────────────
158    if let Some(ref full) = dotenv_path {
159        let iter = dotenvy::from_path_iter(full)
160            .map_err(|e| format!("ctx.env.dotenv: failed to open '{}': {e}", full.display()))?;
161        for item in iter {
162            let (k, v) = item
163                .map_err(|e| format!("ctx.env.dotenv: parse error in '{}': {e}", full.display()))?;
164            merged.insert(k, v);
165        }
166    }
167
168    // ── Layer 3: inject (overwrite, highest priority) ──────────────────────────
169    for (k, v) in inject {
170        merged.insert(k, v);
171    }
172
173    // ── Optional allowlist filter (alc.toml [env].allow) ──────────────────────
174    if let Some(allow) = alc_toml_allow {
175        if !allow.is_empty() {
176            let allowset: std::collections::HashSet<&String> = allow.iter().collect();
177            merged.retain(|k, _| allowset.contains(k));
178        }
179    }
180
181    Ok(Arc::new(merged))
182}
183
184impl AppService {
185    /// Execute Lua code with optional JSON context.
186    ///
187    /// When `host_mode: Some(true)` is passed, the call is proxied via
188    /// `PoolClient` to a long-lived worker subprocess over a Unix domain socket.
189    /// When `host_mode` is `None` or `Some(false)` the existing in-process
190    /// `Executor::start_session` path is used unchanged.
191    ///
192    /// # Concurrency
193    ///
194    /// **host_mode=false (default)**: No additional locking beyond `SessionRegistry`
195    /// lock C. `AppService` itself holds no long-lived lock during this call.
196    ///
197    /// **host_mode=true**: Acquires `RwLock<PoolRegistry>` (write) and advisory
198    /// `fs4::FileExt::lock_exclusive` to update `registry.json`. These locks are
199    /// **not** held across the UDS round-trip await.
200    ///
201    /// **Cancel safety**: cancelling this `.await` mid-UDS-request leaves the
202    /// worker subprocess running. The registry entry persists; callers can
203    /// reconnect via `alc_continue` after MCP restart.
204    pub async fn run(
205        &self,
206        code: Option<String>,
207        code_file: Option<String>,
208        ctx: Option<serde_json::Value>,
209        project_root: Option<String>,
210        host_mode: Option<bool>,
211    ) -> Result<String, String> {
212        let code = resolve_code(code, code_file)?;
213        let ctx = normalize_stringified_json_object(ctx.unwrap_or(serde_json::Value::Null));
214        let (extra, extra_warnings) = self.resolve_extra_lib_paths(project_root.as_deref());
215        let (variants, variant_warnings) = self.resolve_variant_pkgs(project_root.as_deref());
216        let mut warnings: Vec<String> = extra_warnings;
217        warnings.extend(variant_warnings);
218
219        if host_mode == Some(true) {
220            // ── Pool path (Crux: MCP thin proxy IPC boundary) ─────────────────
221            // Worker subprocess is spawned and communicated via UDS.
222            // SessionRegistry (in-memory) is NOT touched on this path.
223            let (session_id, json, pool_save_error) = run_via_pool(
224                &self.pool_dir,
225                &self.pool_reg_path,
226                &self.pool_lock_path,
227                extra,
228                code,
229                ctx,
230            )
231            .await
232            .map_err(|e| e.to_string())?;
233
234            // session_id is stored in the JSON by the worker; update the
235            // in-memory registry so this MCP instance can route continues
236            // without another disk read.
237            // Load the just-persisted entry from disk to keep in-memory
238            // registry in sync.  This is a best-effort convenience cache;
239            // the disk state is authoritative.  Failure is surfaced to the
240            // MCP wire response as `pool_cache_reload_warning` so the caller
241            // can observe stale-cache conditions; tracing::warn! is kept for
242            // operator visibility in logs.
243            let cache_reload_warning: Option<String> =
244                match crate::pool::PoolRegistry::load_or_default(&self.pool_reg_path) {
245                    Ok(reg) => {
246                        let mut guard = self.pool_registry.write().await;
247                        *guard = reg;
248                        None
249                    }
250                    Err(e) => {
251                        tracing::warn!(
252                            error = %e,
253                            "failed to reload pool registry after run; in-memory cache may be stale"
254                        );
255                        Some(e.to_string())
256                    }
257                };
258
259            let json = splice_response_warnings(&json, "lib_path_warnings", &warnings);
260            let json = match pool_save_error {
261                Some(msg) => splice_response_string(&json, "pool_save_error", &msg),
262                None => json,
263            };
264            let json = match cache_reload_warning {
265                Some(msg) => splice_response_string(&json, "pool_cache_reload_warning", &msg),
266                None => json,
267            };
268            let _ = session_id; // session_id is embedded in the JSON response
269            return Ok(json);
270        }
271
272        // ── In-process path (default) ──────────────────────────────────────────
273
274        // Build the frozen env snapshot at alc_run invocation time (TIME boundary).
275        // Load alc.toml to get the optional env.allow allowlist.
276        let alc_toml_allow_list: Vec<String> = if let Some(root) = project_root.as_deref() {
277            let root_path = std::path::Path::new(root);
278            match load_alc_toml(root_path) {
279                Ok(Some(t)) => t.env.map(|e| e.allow).unwrap_or_default(),
280                Ok(None) => Vec::new(),
281                Err(e) => return Err(format!("alc.toml load error: {e}")),
282            }
283        } else {
284            Vec::new()
285        };
286        let alc_toml_allow = if alc_toml_allow_list.is_empty() {
287            None
288        } else {
289            Some(alc_toml_allow_list.as_slice())
290        };
291
292        let project_root_path = project_root.as_deref().map(std::path::Path::new);
293        let env_map = resolve_env(&ctx, project_root_path, alc_toml_allow)?;
294
295        let json = self
296            .start_and_tick(env_map, code, ctx, None, extra, variants)
297            .await?;
298        Ok(splice_response_warnings(
299            &json,
300            "lib_path_warnings",
301            &warnings,
302        ))
303    }
304
305    /// Apply a built-in strategy to a task.
306    ///
307    /// If the requested package is not installed, automatically installs the
308    /// bundled package collection from GitHub before executing.
309    ///
310    /// `project_root` — optional absolute path to the project root containing
311    /// `alc.lock`. Falls back to `ALC_PROJECT_ROOT` env or ancestor walk.
312    pub async fn advice(
313        &self,
314        strategy: &str,
315        task: Option<String>,
316        opts: Option<serde_json::Value>,
317        project_root: Option<String>,
318    ) -> Result<String, String> {
319        // Hoist variant-scope resolution before install check so alc.local.toml linked
320        // packages short-circuit auto_install / not-found error.
321        let app_dir = self.log_config.app_dir();
322        let (variants, variant_warnings) = self.resolve_variant_pkgs(project_root.as_deref());
323        let strategy_in_variant = variants.iter().any(|v| v.name == strategy);
324
325        // Auto-install bundled packages if the requested strategy is missing in all tiers
326        if !strategy_in_variant && !is_package_installed(&app_dir, strategy) {
327            self.auto_install_bundled_packages().await?;
328            if !is_package_installed(&app_dir, strategy) {
329                return Err(format!(
330                    "Package '{strategy}' not found after installing bundled collection. \
331                     Use alc_pkg_install to install it manually."
332                ));
333            }
334        }
335
336        // Guard: reject library packages before make_require_code (= M.run invocation)
337        if let Some((PkgType::Library, _)) = self.resolve_pkg_type_lua(strategy, &variants).await? {
338            return Err(format!(
339                "Package '{strategy}' is a library package (type = \"library\"). \
340                 Library packages provide reusable modules and do not have a run() entry point. \
341                 Use alc_run with custom code to import this library."
342            ));
343        }
344
345        let code = make_require_code(strategy);
346
347        let opts = opts.map(normalize_stringified_json_object);
348        let mut ctx_map = match opts {
349            Some(serde_json::Value::Object(m)) => m,
350            _ => serde_json::Map::new(),
351        };
352        if let Some(task) = task {
353            ctx_map.insert("task".into(), serde_json::Value::String(task));
354        }
355        let ctx = serde_json::Value::Object(ctx_map);
356
357        let (extra, extra_warnings) = self.resolve_extra_lib_paths(project_root.as_deref());
358        let mut warnings: Vec<String> = extra_warnings;
359        warnings.extend(variant_warnings);
360        // advice() does not accept ctx.env; pass an empty map so AlcEnv is
361        // present but empty (no env vars visible to advice strategies).
362        let env_map = Arc::new(HashMap::new());
363        let json = self
364            .start_and_tick(env_map, code, ctx, Some(strategy), extra, variants)
365            .await?;
366        Ok(splice_response_warnings(
367            &json,
368            "lib_path_warnings",
369            &warnings,
370        ))
371    }
372
373    /// Resolve the package type and provenance via a dedicated Lua VM.
374    ///
375    /// Runs `LUA_TYPE_AUTODETECT` in a VM that has variant-scope packages
376    /// registered so that packages linked via `alc.local.toml` are reachable.
377    ///
378    /// # Arguments
379    ///
380    /// * `name` - The package name to probe (used in `require`).
381    /// * `variants` - Variant-scope packages to register into the VM. Pass
382    ///   `&[]` when the caller has no variant context (e.g. the `eval` path).
383    ///
384    /// # Returns
385    ///
386    /// - `Ok(Some((PkgType, TypeSource)))` — type and provenance both parsed
387    ///   successfully.
388    /// - `Ok(None)` — eval succeeded but either the `type` or `type_source`
389    ///   field could not be parsed; the caller treats this as a legacy
390    ///   passthrough and does not apply the library guard.
391    /// - `Err(String)` — the Lua VM returned an error; propagated to the
392    ///   caller.
393    ///
394    /// # Provenance
395    ///
396    /// The Lua snippet (`LUA_TYPE_AUTODETECT`) sets `meta.type_source` to one
397    /// of `"auto_detected_runnable"` / `"auto_detected_library"` before
398    /// control returns to Rust, so both fields are always populated when the
399    /// snippet runs without error.
400    pub(crate) async fn resolve_pkg_type_lua(
401        &self,
402        name: &str,
403        variants: &[VariantPkg],
404    ) -> Result<Option<(PkgType, TypeSource)>, String> {
405        let auto = super::resolve::LUA_TYPE_AUTODETECT;
406        let code = format!(
407            r#"package.loaded["{name}"] = nil; local pkg = require("{name}"); local meta = pkg.meta or {{}}; {auto}; return {{ type = meta.type, type_source = meta.type_source }}"#,
408            name = name,
409            auto = auto,
410        );
411        let val = self
412            .executor
413            .eval_simple_with_paths(code, vec![], variants.to_vec())
414            .await?;
415        let result = val.as_object().and_then(|obj| {
416            let pkg_type =
417                obj.get("type")
418                    .and_then(|v| v.as_str())
419                    .and_then(|s| match s.parse::<PkgType>() {
420                        Ok(t) => Some(t),
421                        Err(e) => {
422                            tracing::warn!(
423                                package = name,
424                                raw_type = s,
425                                error = %e,
426                                "unknown pkg type string; treating as legacy passthrough"
427                            );
428                            None
429                        }
430                    });
431            let type_source = obj
432                .get("type_source")
433                .and_then(|v| v.as_str())
434                .and_then(|s| s.parse::<TypeSource>().ok());
435            match (pkg_type, type_source) {
436                (Some(t), Some(src)) => Some((t, src)),
437                _ => None,
438            }
439        });
440        Ok(result)
441    }
442
443    /// Continue a paused execution — batch feed.
444    ///
445    /// For pool sessions (`session_id` found in registry.json), each response
446    /// in the batch is forwarded to the worker via `PoolClient::send_request`.
447    /// For in-MCP sessions, the existing `SessionRegistry::feed_response` path
448    /// is used unchanged.
449    pub async fn continue_batch(
450        &self,
451        session_id: &str,
452        responses: Vec<QueryResponse>,
453    ) -> Result<String, String> {
454        // ── Pool path check (same registry lookup as continue_single) ─────────
455        let pool_entry = {
456            let reg = self.pool_registry.read().await;
457            reg.find(session_id).cloned()
458        };
459
460        let pool_entry = if pool_entry.is_some() {
461            pool_entry
462        } else {
463            match crate::pool::PoolRegistry::load_or_default(&self.pool_reg_path) {
464                Ok(reg) => {
465                    let entry = reg.find(session_id).cloned();
466                    if entry.is_some() {
467                        let mut guard = self.pool_registry.write().await;
468                        *guard = reg;
469                    }
470                    entry
471                }
472                Err(e) => {
473                    return Err(format!("Continue failed: {e}"));
474                }
475            }
476        };
477
478        if let Some(entry) = pool_entry {
479            // ── Pool routing ────────────────────────────────────────────────────
480            let mut last_json = None;
481            for qr in responses {
482                let json =
483                    continue_via_pool(&entry, session_id, qr.response, Some(qr.query_id), qr.usage)
484                        .await
485                        .map_err(|e| format!("Continue failed: {e}"))?;
486                last_json = Some(json);
487            }
488            return last_json.ok_or_else(|| "Empty responses array".to_string());
489        }
490
491        // ── In-MCP path ────────────────────────────────────────────────────────
492        let mut last_result = None;
493        for qr in responses {
494            let qid = QueryId::parse(&qr.query_id);
495            let result = self
496                .registry
497                .feed_response(session_id, &qid, qr.response, qr.usage.as_ref())
498                .await
499                .map_err(|e| format!("Continue failed: {e}"))?;
500            last_result = Some(result);
501        }
502        let result = last_result.ok_or("Empty responses array")?;
503        let transcript_warning = self.maybe_log_transcript(&result, session_id);
504        let json = result.to_json(session_id).to_string();
505        let json = splice_transcript_warning(&json, transcript_warning);
506        let save_warning = self.maybe_save_eval(&result, session_id, &json);
507        Ok(splice_save_warning(&json, save_warning))
508    }
509
510    /// Continue a paused execution — single response (with optional query_id).
511    ///
512    /// Routing is automatic: if `session_id` is found in `registry.json`
513    /// (pool path), the call is proxied via `PoolClient` over UDS. If not
514    /// found (in-MCP path), the existing `SessionRegistry::feed_response`
515    /// is used. Both paths never coexist for the same `session_id`.
516    ///
517    /// # Concurrency
518    ///
519    /// **Pool path**: acquires `RwLock<PoolRegistry>` (read) to look up the
520    /// session entry, then acquires `tokio::sync::Mutex` inside `PoolClient`
521    /// to serialize the UDS write. Neither lock is held across the UDS await.
522    ///
523    /// **In-MCP path**: acquires lock C in the two-phase pattern documented on
524    /// `SessionRegistry::feed_response`.
525    ///
526    /// **Cancel safety**: cancelling mid-await on the pool path leaves the
527    /// worker subprocess running (UDS send may have been partially written;
528    /// `read_line` is not cancel-safe — a partial line in the buffer renders
529    /// the connection unusable and `PoolClient` must reconnect).
530    pub async fn continue_single(
531        &self,
532        session_id: &str,
533        response: String,
534        query_id: Option<&str>,
535        usage: Option<algocline_core::TokenUsage>,
536    ) -> Result<String, String> {
537        // ── Pool path: check in-memory registry, then disk registry ───────────
538        // K-4: acquire read lock, clone the entry, release lock BEFORE await.
539        let pool_entry = {
540            let reg = self.pool_registry.read().await;
541            reg.find(session_id).cloned()
542        }; // read lock released here
543
544        // If in-memory cache missed, check disk (e.g. after MCP restart).
545        let pool_entry = if pool_entry.is_some() {
546            pool_entry
547        } else {
548            match crate::pool::PoolRegistry::load_or_default(&self.pool_reg_path) {
549                Ok(reg) => {
550                    let entry = reg.find(session_id).cloned();
551                    if entry.is_some() {
552                        // Warm the in-memory cache.
553                        let mut guard = self.pool_registry.write().await;
554                        *guard = reg;
555                    }
556                    entry
557                }
558                Err(e) => {
559                    // Corrupt registry: propagate to MCP wire per §Error 伝播規律.
560                    return Err(format!("Continue failed: {e}"));
561                }
562            }
563        };
564
565        if let Some(entry) = pool_entry {
566            // ── Pool routing (Crux: MCP thin proxy IPC boundary) ──────────────
567            let json = continue_via_pool(
568                &entry,
569                session_id,
570                response,
571                query_id.map(str::to_string),
572                usage,
573            )
574            .await
575            .map_err(|e| format!("Continue failed: {e}"))?;
576            return Ok(json);
577        }
578
579        // ── In-MCP path ────────────────────────────────────────────────────────
580        let query_id = match query_id {
581            Some(qid) => QueryId::parse(qid),
582            None => self
583                .registry
584                .resolve_sole_pending_id(session_id)
585                .await
586                .map_err(|e| format!("Continue failed: {e}"))?,
587        };
588
589        let result = self
590            .registry
591            .feed_response(session_id, &query_id, response, usage.as_ref())
592            .await
593            .map_err(|e| format!("Continue failed: {e}"))?;
594
595        let transcript_warning = self.maybe_log_transcript(&result, session_id);
596        let json = result.to_json(session_id).to_string();
597        let json = splice_transcript_warning(&json, transcript_warning);
598        let save_warning = self.maybe_save_eval(&result, session_id, &json);
599        Ok(splice_save_warning(&json, save_warning))
600    }
601
602    // ─── Internal ───────────────────────────────────────────────
603
604    pub(super) fn maybe_log_transcript(
605        &self,
606        result: &FeedResult,
607        session_id: &str,
608    ) -> Option<String> {
609        if let FeedResult::Finished(exec_result) = result {
610            // Mutex poison means a previous thread panicked while holding the lock.
611            // Strategy name is non-critical for correctness, but the failure must be
612            // surfaced to MCP callers so it is observable, not silently dropped.
613            // See CLAUDE.md §Service 層の Error 伝播規律.
614            let strategy = match self.session_strategies.lock() {
615                Ok(mut map) => map.remove(session_id),
616                Err(e) => {
617                    tracing::warn!(
618                        "session_strategies mutex poisoned for '{}': {}",
619                        session_id,
620                        e
621                    );
622                    // Return warning immediately; transcript cannot be written
623                    // without strategy context being reliably recoverable.
624                    return Some(format!(
625                        "session_strategies mutex poisoned for '{session_id}': {e}"
626                    ));
627                }
628            };
629            // write_transcript_log returns Ok(Some(warning)) when meta write
630            // failed but the main log succeeded, so both Err and meta warning
631            // are surfaced as transcript_warning on the wire response.
632            match write_transcript_log(
633                &self.log_config,
634                session_id,
635                &exec_result.metrics,
636                strategy.as_deref(),
637            ) {
638                Err(e) => Some(e.to_string()),
639                Ok(meta_warning) => meta_warning,
640            }
641        } else {
642            None
643        }
644    }
645
646    /// Persist eval result for a finished session, returning any storage
647    /// failure as `Some(msg)` so the caller can surface it on the wire
648    /// response. `None` covers both "not an eval session" and
649    /// "successfully saved" — they are indistinguishable to the caller
650    /// because both produce the same wire shape.
651    pub(super) fn maybe_save_eval(
652        &self,
653        result: &FeedResult,
654        session_id: &str,
655        result_json: &str,
656    ) -> Option<String> {
657        if !matches!(result, FeedResult::Finished(_)) {
658            return None;
659        }
660        let strategy = {
661            let mut map = self.eval_sessions.lock().unwrap_or_else(|e| e.into_inner());
662            map.remove(session_id)
663        };
664        strategy.and_then(|s| {
665            super::eval_store::save_eval_result(&self.log_config.app_dir(), &s, result_json).err()
666        })
667    }
668
669    /// Start a Lua session with the given env snapshot and tick until the first
670    /// pause or completion.
671    ///
672    /// # Arguments
673    ///
674    /// - `env_map` — frozen env snapshot built by `resolve_env`; passed to
675    ///   `executor.start_session_with_env` so `alc.env` is populated before any
676    ///   Lua code runs (TIME boundary: snapshot is taken before this call).
677    /// - `code` — Lua source to execute.
678    /// - `ctx` — JSON context accessible as `ctx` global in the Lua VM.
679    /// - `strategy` — optional strategy name (used to correlate eval sessions).
680    /// - `extra_lib_paths` — additional `require` search paths.
681    /// - `variant_pkgs` — variant package overrides.
682    ///
683    /// # Errors
684    ///
685    /// Returns `Err(String)` if session spawn or initial execution fails.
686    pub(super) async fn start_and_tick(
687        &self,
688        env_map: Arc<HashMap<String, String>>,
689        code: String,
690        ctx: serde_json::Value,
691        strategy: Option<&str>,
692        extra_lib_paths: Vec<std::path::PathBuf>,
693        variant_pkgs: Vec<VariantPkg>,
694    ) -> Result<String, String> {
695        let app_dir = self.log_config.app_dir();
696        let dirs = algocline_engine::SessionDirs {
697            state_store: Arc::clone(&self.state_store),
698            card_store: Arc::clone(&self.card_store),
699            scenarios_dir: app_dir.scenarios_dir(),
700            nn_dir: app_dir.nn_dir(),
701        };
702        // Legacy start_and_tick paths (alc_run / advice / eval) do not
703        // resolve the Phase 1-B `[setting.card].run` gate; existing pkgs
704        // never populate a top-level `run` field on their Cards so the
705        // gate defaults to OFF here without behavioural change.  The v2
706        // ExecutionService path resolves the setting from the caller's
707        // project_root and threads it through spawn_v2.
708        let card_run_enabled = false;
709        let session = self
710            .executor
711            .start_session_with_env(
712                env_map,
713                code,
714                ctx,
715                extra_lib_paths,
716                variant_pkgs,
717                dirs,
718                card_run_enabled,
719            )
720            .await?;
721        let (session_id, result) = self
722            .registry
723            .start_execution(session)
724            .await
725            .map_err(|e| format!("Execution failed: {e}"))?;
726        if let Some(s) = strategy {
727            if let Ok(mut map) = self.session_strategies.lock() {
728                map.insert(session_id.clone(), s.to_string());
729            }
730        }
731        let transcript_warning = self.maybe_log_transcript(&result, &session_id);
732        let json = result.to_json(&session_id).to_string();
733        Ok(splice_transcript_warning(&json, transcript_warning))
734    }
735}
736
737#[cfg(test)]
738mod tests {
739    use std::path::PathBuf;
740    use std::sync::Arc;
741
742    use algocline_core::{
743        AppDir, ExecutionMetrics, ExecutionObserver, LlmQuery, QueryId, TerminalState,
744    };
745    use algocline_engine::{ExecutionResult, FeedResult};
746
747    use super::super::config::{AppConfig, LogDirSource};
748    use super::{splice_transcript_warning, AppService};
749
750    fn make_metrics_with_transcript() -> ExecutionMetrics {
751        let metrics = ExecutionMetrics::new();
752        let observer = metrics.create_observer();
753        observer.on_paused(&[LlmQuery {
754            id: QueryId::single(),
755            prompt: "test prompt".into(),
756            system: None,
757            max_tokens: 100,
758            grounded: false,
759            underspecified: false,
760            cache_breakpoint: None,
761            role: None,
762        }]);
763        metrics
764    }
765
766    fn make_finished_result(metrics: ExecutionMetrics) -> FeedResult {
767        FeedResult::Finished(ExecutionResult {
768            state: TerminalState::Completed {
769                result: serde_json::json!({"ok": true}),
770            },
771            metrics,
772        })
773    }
774
775    /// Build a minimal AppService with log_enabled and a custom log_dir.
776    async fn make_app_service_with_log_dir(log_dir: PathBuf) -> AppService {
777        let executor = Arc::new(
778            algocline_engine::Executor::new(vec![])
779                .await
780                .expect("executor"),
781        );
782        let tmp_app = tempfile::tempdir().expect("test tempdir");
783        let log_config = AppConfig {
784            log_dir: Some(log_dir),
785            log_dir_source: LogDirSource::EnvVar,
786            log_enabled: true,
787            prompt_preview_chars: 200,
788            app_dir: Arc::new(AppDir::new(tmp_app.path().to_path_buf())),
789        };
790        std::mem::forget(tmp_app);
791        AppService::new(executor, log_config, vec![])
792    }
793
794    // ── (b) maybe_log_transcript returns Some when write fails ──────────
795
796    #[tokio::test]
797    async fn maybe_log_transcript_returns_some_on_write_failure() {
798        let tmp = tempfile::tempdir().expect("test tempdir");
799        let log_dir = tmp.path().to_path_buf();
800        // Block write by creating a directory at the session file path.
801        std::fs::create_dir_all(log_dir.join("fail-session.json"))
802            .expect("pre-create dir to block write");
803        let svc = make_app_service_with_log_dir(log_dir).await;
804        let metrics = make_metrics_with_transcript();
805        let result = make_finished_result(metrics);
806        let warning = svc.maybe_log_transcript(&result, "fail-session");
807        assert!(warning.is_some(), "expected Some warning on write failure");
808        let msg = warning.unwrap();
809        assert!(
810            msg.contains("transcript"),
811            "warning should mention 'transcript', got: {msg}"
812        );
813    }
814
815    #[tokio::test]
816    async fn maybe_log_transcript_returns_none_on_non_finished() {
817        let tmp = tempfile::tempdir().expect("test tempdir");
818        let svc = make_app_service_with_log_dir(tmp.path().to_path_buf()).await;
819        let result = FeedResult::Accepted { remaining: 1 };
820        let warning = svc.maybe_log_transcript(&result, "any-session");
821        assert!(warning.is_none(), "Accepted result should return None");
822    }
823
824    // ── (c) splice_transcript_warning inserts field into JSON ───────────
825
826    #[test]
827    fn splice_transcript_warning_injects_field_when_some() {
828        let json = r#"{"status":"finished","result":{}}"#;
829        let out = splice_transcript_warning(json, Some("write failed".to_string()));
830        let v: serde_json::Value = serde_json::from_str(&out).expect("valid JSON");
831        assert_eq!(
832            v["transcript_warning"].as_str(),
833            Some("write failed"),
834            "transcript_warning field should be present"
835        );
836        // Original fields are preserved.
837        assert_eq!(v["status"].as_str(), Some("finished"));
838    }
839
840    #[test]
841    fn splice_transcript_warning_passthrough_when_none() {
842        let json = r#"{"status":"finished"}"#;
843        let out = splice_transcript_warning(json, None);
844        let v: serde_json::Value = serde_json::from_str(&out).expect("valid JSON");
845        assert!(
846            v.get("transcript_warning").is_none(),
847            "transcript_warning must be absent when warning is None"
848        );
849    }
850
851    // ── pool registry routing tests ─────────────────────────────────────────
852
853    use crate::pool::PoolSessionEntry;
854
855    /// T1: continue_single falls through to in-MCP path when session is not in pool registry.
856    ///
857    /// An unknown session ID should not be found in the pool registry and
858    /// should reach the `SessionRegistry::feed_response` path, which returns
859    /// an error because no session exists in the in-memory registry either.
860    #[tokio::test]
861    async fn continue_single_in_mcp_path_on_registry_miss() {
862        let tmp = tempfile::tempdir().expect("test tempdir");
863        let svc = make_app_service_with_log_dir(tmp.path().to_path_buf()).await;
864
865        // The pool registry on disk and in-memory is empty (no workers registered).
866        // continue_single should fall through to the in-MCP path and return
867        // "not found" because the in-memory session registry also has nothing.
868        let result = svc
869            .continue_single(
870                "nonexistent-session-id",
871                "some response".to_string(),
872                None,
873                None,
874            )
875            .await;
876        assert!(
877            result.is_err(),
878            "unknown session must return Err on in-MCP path"
879        );
880        let msg = result.unwrap_err();
881        assert!(
882            msg.contains("not found") || msg.contains("Continue failed"),
883            "error must indicate session not found, got: {msg}"
884        );
885    }
886
887    /// T2: AppService::new initialises pool_registry as empty when pool dir absent.
888    ///
889    /// Verifies that startup GC with a missing registry.json (normal first-run)
890    /// produces an empty PoolRegistry (not an error).
891    #[tokio::test]
892    async fn app_service_new_initialises_empty_pool_registry() {
893        let tmp = tempfile::tempdir().expect("test tempdir");
894        let svc = make_app_service_with_log_dir(tmp.path().to_path_buf()).await;
895
896        let reg = svc.pool_registry.read().await;
897        assert!(
898            reg.sessions.is_empty(),
899            "pool registry must be empty on first-run (no registry.json)"
900        );
901    }
902
903    /// T2b: AppService correctly stores pool registry paths derived from app_dir.
904    ///
905    /// Verifies that pool_dir / pool_reg_path / pool_lock_path are
906    /// non-empty paths derived from state_dir/pool/*.
907    #[tokio::test]
908    async fn app_service_pool_paths_correctly_derived() {
909        let tmp = tempfile::tempdir().expect("test tempdir");
910        let svc = make_app_service_with_log_dir(tmp.path().to_path_buf()).await;
911
912        assert!(
913            svc.pool_dir.ends_with("pool"),
914            "pool_dir must end in 'pool', got: {}",
915            svc.pool_dir.display()
916        );
917        assert!(
918            svc.pool_reg_path.ends_with("pool/registry.json"),
919            "pool_reg_path must end in 'pool/registry.json', got: {}",
920            svc.pool_reg_path.display()
921        );
922        assert!(
923            svc.pool_lock_path.ends_with("pool/registry.lock"),
924            "pool_lock_path must end in 'pool/registry.lock', got: {}",
925            svc.pool_lock_path.display()
926        );
927    }
928
929    /// T3: continue_single propagates PoolError::RegistryCorrupted to MCP wire.
930    ///
931    /// When registry.json is corrupt and there is a cache miss, continue_single
932    /// must return Err (not silently proceed with empty registry). This verifies
933    /// the CLAUDE.md §Error 伝播規律 invariant — no unwrap_or_default() swallowing.
934    #[tokio::test]
935    async fn continue_single_propagates_corrupted_registry_error() {
936        let tmp = tempfile::tempdir().expect("test tempdir");
937        let svc = make_app_service_with_log_dir(tmp.path().to_path_buf()).await;
938
939        // Write a corrupt registry.json to the pool directory.
940        let pool_dir = svc.pool_dir.clone();
941        std::fs::create_dir_all(&pool_dir).expect("create pool dir");
942        std::fs::write(pool_dir.join("registry.json"), b"{ not valid json !!!")
943            .expect("write corrupt registry");
944
945        // The in-memory cache is empty (startup GC failed on the corrupt file,
946        // so pool_registry is empty default).  The disk read in continue_single
947        // will hit the corrupt file and must propagate the error.
948        let result = svc
949            .continue_single("any-session-id", "response".to_string(), None, None)
950            .await;
951        assert!(
952            result.is_err(),
953            "corrupted registry must cause Err, not silent empty fallback"
954        );
955        let msg = result.unwrap_err();
956        assert!(
957            msg.contains("corrupted") || msg.contains("parse") || msg.contains("Continue failed"),
958            "error must mention registry problem, got: {msg}"
959        );
960    }
961
962    // ── pool_cache_reload_warning splice tests ───────────────────────────────
963
964    use super::super::eval_store::splice_response_string;
965
966    /// T1 (happy path): splice_response_string inserts pool_cache_reload_warning
967    /// into a valid JSON object response.
968    ///
969    /// Verifies the invariant: cache-reload failure must surface on the MCP
970    /// wire as an additive field, not remain warn!-only.
971    #[test]
972    fn splice_response_string_injects_cache_reload_warning() {
973        let json = r#"{"status":"finished","result":{"ok":true}}"#;
974        let msg = "failed to reload pool registry: No such file or directory";
975        let out = splice_response_string(json, "pool_cache_reload_warning", msg);
976        let v: serde_json::Value = serde_json::from_str(&out).expect("valid JSON");
977        assert_eq!(
978            v["pool_cache_reload_warning"].as_str(),
979            Some(msg),
980            "pool_cache_reload_warning must be present in response"
981        );
982        // Original fields preserved (additive, not destructive).
983        assert_eq!(v["status"].as_str(), Some("finished"));
984    }
985
986    /// T2 (edge case): splice_response_string is a no-op when input is not a
987    /// JSON object (e.g. bare string or array).
988    ///
989    /// Guards against panics when strategy output is malformed.
990    #[test]
991    fn splice_response_string_passthrough_on_non_object_json() {
992        let non_object = r#""just a string""#;
993        let out = splice_response_string(non_object, "pool_cache_reload_warning", "err");
994        // Must return original unchanged.
995        assert_eq!(out, non_object);
996    }
997
998    /// T3 (error path / None branch): when cache_reload_warning is None, the
999    /// pool_cache_reload_warning field must NOT appear in the response JSON.
1000    ///
1001    /// Verifies the None arm of the new match block in run.rs leaves the JSON
1002    /// untouched, consistent with the pool_save_error pattern.
1003    #[test]
1004    fn splice_response_string_not_called_when_none() {
1005        let json = r#"{"status":"finished"}"#;
1006        // Simulate the None branch: we simply do not call splice_response_string.
1007        let v: serde_json::Value = serde_json::from_str(json).expect("valid JSON");
1008        assert!(
1009            v.get("pool_cache_reload_warning").is_none(),
1010            "pool_cache_reload_warning must be absent when no cache-reload error occurred"
1011        );
1012    }
1013
1014    /// T1b: in-memory pool registry lookup finds an entry and routes to pool path.
1015    ///
1016    /// Inserts a live entry (current process PID) into the in-memory registry
1017    /// and verifies that continue_single attempts the pool path (fails with
1018    /// connection error because no real worker socket exists, not "not found").
1019    #[tokio::test]
1020    async fn continue_single_routes_to_pool_on_registry_hit() {
1021        let tmp = tempfile::tempdir().expect("test tempdir");
1022        let svc = make_app_service_with_log_dir(tmp.path().to_path_buf()).await;
1023
1024        // Insert a fake entry pointing to a non-existent socket.
1025        // This simulates the case where a pool session was started.
1026        let fake_sock = tmp.path().join("nonexistent.sock");
1027        let entry = PoolSessionEntry::new(
1028            "test-pool-session",
1029            std::process::id(), // live PID — survives GC
1030            fake_sock.clone(),
1031            env!("CARGO_PKG_VERSION"),
1032        );
1033        {
1034            let mut reg = svc.pool_registry.write().await;
1035            reg.add(entry);
1036        }
1037
1038        // continue_single should find the entry and attempt pool path.
1039        // The UDS connect will fail (no socket file) → PoolError::Connect → Err.
1040        // Importantly, the error is a connection error, NOT a "session not found" error.
1041        let result = svc
1042            .continue_single("test-pool-session", "response".to_string(), None, None)
1043            .await;
1044        assert!(
1045            result.is_err(),
1046            "pool path must fail with connect error (no real worker)"
1047        );
1048        let msg = result.unwrap_err();
1049        // The error must come from pool path (UDS connect), not from SessionRegistry.
1050        // "not found" would indicate the in-MCP path was taken instead.
1051        assert!(
1052            !msg.contains("session not found") || msg.contains("Continue failed"),
1053            "error must be from pool path (UDS connect), got: {msg}"
1054        );
1055    }
1056
1057    // ── resolve_env unit tests ──────────────────────────────────────────────────
1058
1059    use super::resolve_env;
1060
1061    /// T1 (happy path): inject keys are accessible in the resolved map.
1062    ///
1063    /// Verifies the SPACE boundary inject path: keys supplied via
1064    /// `ctx.env.inject` appear in the frozen snapshot.
1065    #[test]
1066    fn resolve_env_inject_keys_readable() {
1067        let ctx = serde_json::json!({
1068            "env": {
1069                "inject": { "FOO": "bar", "BAZ": "qux" }
1070            }
1071        });
1072        let map = resolve_env(&ctx, None, None).expect("resolve_env should succeed");
1073        assert_eq!(map.get("FOO").map(String::as_str), Some("bar"));
1074        assert_eq!(map.get("BAZ").map(String::as_str), Some("qux"));
1075    }
1076
1077    /// T1b (happy path): empty ctx produces an empty env map.
1078    ///
1079    /// Verifies that `alc.env` is always present (empty map is valid) even
1080    /// when no env configuration is supplied in the invocation context.
1081    #[test]
1082    fn resolve_env_empty_ctx_produces_empty_map() {
1083        let ctx = serde_json::Value::Null;
1084        let map = resolve_env(&ctx, None, None).expect("resolve_env with null ctx should succeed");
1085        assert!(map.is_empty(), "empty ctx must produce an empty env map");
1086    }
1087
1088    /// T1c (happy path): inject priority over dotenv file for same key.
1089    ///
1090    /// Verifies the SOURCE boundary: inject (Layer 3) overwrites dotenv (Layer 2).
1091    #[test]
1092    fn resolve_env_inject_overwrites_dotenv() {
1093        let tmp = tempfile::tempdir().expect("test tempdir");
1094        let env_file = tmp.path().join(".env");
1095        // The .env file declares PRIORITY=from_dotenv.
1096        std::fs::write(&env_file, b"PRIORITY=from_dotenv\n").expect("write .env");
1097
1098        let ctx = serde_json::json!({
1099            "env": {
1100                "dotenv": env_file.to_str().expect("valid path"),
1101                "inject": { "PRIORITY": "from_inject" }
1102            }
1103        });
1104        let map = resolve_env(&ctx, None, None).expect("resolve_env should succeed");
1105        // inject (higher priority) must win over dotenv.
1106        assert_eq!(
1107            map.get("PRIORITY").map(String::as_str),
1108            Some("from_inject"),
1109            "inject must shadow dotenv for the same key"
1110        );
1111    }
1112
1113    /// T1d (happy path): dotenv file keys are loaded when path is absolute.
1114    ///
1115    /// Verifies Layer 2 (dotenv) of the SOURCE boundary merge chain.
1116    #[test]
1117    fn resolve_env_dotenv_absolute_path_loaded() {
1118        let tmp = tempfile::tempdir().expect("test tempdir");
1119        let env_file = tmp.path().join(".env");
1120        std::fs::write(&env_file, b"DOTENV_KEY=dotenv_val\n").expect("write .env");
1121
1122        let ctx = serde_json::json!({
1123            "env": {
1124                "dotenv": env_file.to_str().expect("valid path")
1125            }
1126        });
1127        let map = resolve_env(&ctx, None, None).expect("resolve_env should succeed");
1128        assert_eq!(
1129            map.get("DOTENV_KEY").map(String::as_str),
1130            Some("dotenv_val"),
1131            "key from dotenv file must be accessible"
1132        );
1133    }
1134
1135    /// T1e (happy path): allowlist filter retains only listed keys.
1136    ///
1137    /// Verifies alc.toml [env].allow filtering is applied after 3-source merge.
1138    #[test]
1139    fn resolve_env_allowlist_filters_inject_keys() {
1140        let ctx = serde_json::json!({
1141            "env": {
1142                "inject": { "ALLOWED": "yes", "BLOCKED": "no" }
1143            }
1144        });
1145        let allow = vec!["ALLOWED".to_string()];
1146        let map =
1147            resolve_env(&ctx, None, Some(allow.as_slice())).expect("resolve_env should succeed");
1148        assert_eq!(map.get("ALLOWED").map(String::as_str), Some("yes"));
1149        assert!(
1150            map.get("BLOCKED").is_none(),
1151            "BLOCKED key must be excluded by allowlist"
1152        );
1153    }
1154
1155    /// T2 (boundary): allow_os=false (default) must not include any OS env vars.
1156    ///
1157    /// Verifies the SOURCE boundary: OS env is excluded unless explicitly opted in.
1158    #[test]
1159    fn resolve_env_allow_os_false_excludes_os_vars() {
1160        // PATH is nearly always set in the test environment.
1161        let ctx = serde_json::json!({ "env": { "allow_os": false } });
1162        let map = resolve_env(&ctx, None, None).expect("resolve_env should succeed");
1163        // Even if PATH is set in the OS, it must not appear in the snapshot.
1164        assert!(
1165            map.get("PATH").is_none(),
1166            "OS env must not leak when allow_os is false"
1167        );
1168    }
1169
1170    /// T2b (boundary): relative dotenv path without project_root returns Err.
1171    ///
1172    /// Verifies the plan Risks #3 decision: relative path + None project_root = Err.
1173    #[test]
1174    fn resolve_env_relative_dotenv_without_project_root_errors() {
1175        let ctx = serde_json::json!({
1176            "env": { "dotenv": ".env" }
1177        });
1178        let result = resolve_env(&ctx, None, None);
1179        assert!(
1180            result.is_err(),
1181            "relative dotenv path without project_root must return Err"
1182        );
1183        let msg = result.unwrap_err();
1184        assert!(
1185            msg.contains("project_root"),
1186            "error must mention project_root, got: {msg}"
1187        );
1188    }
1189
1190    /// T2c (boundary): relative dotenv path with project_root is resolved correctly.
1191    ///
1192    /// Verifies that a relative path is joined against project_root.
1193    #[test]
1194    fn resolve_env_relative_dotenv_with_project_root_resolved() {
1195        let tmp = tempfile::tempdir().expect("test tempdir");
1196        std::fs::write(tmp.path().join(".env"), b"REL_KEY=rel_val\n").expect("write .env");
1197
1198        let ctx = serde_json::json!({ "env": { "dotenv": ".env" } });
1199        let map = resolve_env(&ctx, Some(tmp.path()), None).expect("resolve_env should succeed");
1200        assert_eq!(
1201            map.get("REL_KEY").map(String::as_str),
1202            Some("rel_val"),
1203            "relative dotenv path must be resolved against project_root"
1204        );
1205    }
1206
1207    /// T2d (boundary): empty allowlist (None) means no filtering — all keys pass through.
1208    #[test]
1209    fn resolve_env_none_allowlist_keeps_all_inject_keys() {
1210        let ctx = serde_json::json!({
1211            "env": { "inject": { "A": "1", "B": "2" } }
1212        });
1213        let map = resolve_env(&ctx, None, None).expect("resolve_env should succeed");
1214        assert_eq!(
1215            map.len(),
1216            2,
1217            "all inject keys must be retained when allowlist is None"
1218        );
1219    }
1220
1221    /// T3 (error path): inject value that is not a string returns Err.
1222    ///
1223    /// Verifies that non-string inject values propagate as Result::Err (no silent drop).
1224    #[test]
1225    fn resolve_env_inject_non_string_value_errors() {
1226        let ctx = serde_json::json!({
1227            "env": { "inject": { "BAD": 42 } }
1228        });
1229        let result = resolve_env(&ctx, None, None);
1230        assert!(result.is_err(), "non-string inject value must return Err");
1231        let msg = result.unwrap_err();
1232        assert!(
1233            msg.contains("BAD"),
1234            "error must mention the offending key, got: {msg}"
1235        );
1236    }
1237
1238    /// T3b (error path): inject object that is not an object returns Err.
1239    #[test]
1240    fn resolve_env_inject_not_an_object_errors() {
1241        let ctx = serde_json::json!({
1242            "env": { "inject": ["not", "an", "object"] }
1243        });
1244        let result = resolve_env(&ctx, None, None);
1245        assert!(result.is_err(), "non-object inject value must return Err");
1246    }
1247
1248    /// T3c (error path): missing dotenv file propagates as Err (no silent skip).
1249    ///
1250    /// Verifies SOURCE boundary: dotenv I/O errors are surfaced, not swallowed.
1251    #[test]
1252    fn resolve_env_missing_dotenv_file_errors() {
1253        let ctx = serde_json::json!({
1254            "env": { "dotenv": "/nonexistent/path/to/.env" }
1255        });
1256        let result = resolve_env(&ctx, None, None);
1257        assert!(
1258            result.is_err(),
1259            "missing dotenv file must return Err, not empty map"
1260        );
1261        let msg = result.unwrap_err();
1262        assert!(
1263            msg.contains("dotenv"),
1264            "error must mention dotenv, got: {msg}"
1265        );
1266    }
1267
1268    // ── resolve_pkg_type_lua unit tests ─────────────────────────────────────
1269
1270    use algocline_core::pkg::{PkgType, TypeSource};
1271
1272    /// Build a temporary package directory with the given `init.lua` content
1273    /// and return the parent directory (the one to pass as a lib_path to
1274    /// `Executor::new`).
1275    fn make_temp_pkg(parent: &std::path::Path, pkg_name: &str, init_lua: &str) -> PathBuf {
1276        let pkg_dir = parent.join(pkg_name);
1277        std::fs::create_dir_all(&pkg_dir).expect("create pkg dir");
1278        std::fs::write(pkg_dir.join("init.lua"), init_lua).expect("write init.lua");
1279        parent.to_path_buf()
1280    }
1281
1282    /// Build a minimal `AppService` whose executor can `require` packages from
1283    /// `pkg_root` (the parent directory that contains `<pkg_name>/init.lua`).
1284    async fn make_svc_with_pkg_root(pkg_root: PathBuf) -> AppService {
1285        let executor = Arc::new(
1286            algocline_engine::Executor::new(vec![pkg_root])
1287                .await
1288                .expect("executor"),
1289        );
1290        // `AppConfig::default()` (test-only) creates a fresh leaked tempdir and
1291        // sets log_enabled = false — suitable for unit tests.
1292        let log_config = super::super::config::AppConfig::default();
1293        AppService::new(executor, log_config, vec![])
1294    }
1295
1296    /// T1 (boundary): `M.run` defined + no `meta.type` → `Some((Runnable, AutoDetectedRunnable))`.
1297    ///
1298    /// When `meta.type` is absent and `pkg.run` is a function, the snippet
1299    /// sets `meta.type = "runnable"` and `meta.type_source = "auto_detected_runnable"`.
1300    #[tokio::test]
1301    async fn resolve_pkg_type_lua_returns_auto_runnable() {
1302        let tmp = tempfile::tempdir().expect("test tempdir");
1303        let pkg_root = make_temp_pkg(
1304            tmp.path(),
1305            "auto_runnable",
1306            r#"local M = {}
1307M.meta = { name = "auto_runnable" }
1308M.run = function(ctx) return "ok" end
1309return M
1310"#,
1311        );
1312        let svc = make_svc_with_pkg_root(pkg_root).await;
1313        let result = svc
1314            .resolve_pkg_type_lua("auto_runnable", &[])
1315            .await
1316            .expect("eval must succeed");
1317        assert_eq!(
1318            result,
1319            Some((PkgType::Runnable, TypeSource::AutoDetectedRunnable)),
1320            "M.run present + no meta.type must produce (Runnable, AutoDetectedRunnable)"
1321        );
1322    }
1323
1324    /// T3 (error path): no `M.run` + no `meta.type` → `Some((Library, AutoDetectedLibrary))`.
1325    ///
1326    /// When `meta.type` is absent and `pkg.run` is not a function, the snippet
1327    /// sets `meta.type = "library"` and `meta.type_source = "auto_detected_library"`.
1328    /// This is the warn-eligible case — the crux constraint requires this specific
1329    /// `TypeSource` variant so `alc_pkg_doctor` / `alc_pkg_list` can fire the
1330    /// unmarked-library warning only for `AutoDetectedLibrary`, not for `None`.
1331    #[tokio::test]
1332    async fn resolve_pkg_type_lua_returns_auto_library() {
1333        let tmp = tempfile::tempdir().expect("test tempdir");
1334        let pkg_root = make_temp_pkg(
1335            tmp.path(),
1336            "auto_library",
1337            r#"local M = {}
1338M.meta = { name = "auto_library" }
1339-- no M.run defined
1340return M
1341"#,
1342        );
1343        let svc = make_svc_with_pkg_root(pkg_root).await;
1344        let result = svc
1345            .resolve_pkg_type_lua("auto_library", &[])
1346            .await
1347            .expect("eval must succeed");
1348        assert_eq!(
1349            result,
1350            Some((PkgType::Library, TypeSource::AutoDetectedLibrary)),
1351            "no M.run + no meta.type must produce (Library, AutoDetectedLibrary)"
1352        );
1353    }
1354}