Skip to main content

algocline_app/
service.rs

1use std::path::{Path, PathBuf};
2use std::sync::Arc;
3
4use algocline_core::{ExecutionMetrics, QueryId};
5use algocline_engine::{Executor, FeedResult, SessionRegistry};
6
7// ─── Transcript logging ─────────────────────────────────────────
8
9/// Controls transcript log output.
10///
11/// - `ALC_LOG_DIR`: Directory for log files. Default: `~/.algocline/logs`.
12/// - `ALC_LOG_LEVEL`: `full` (default) or `off`.
13#[derive(Clone, Debug)]
14pub struct TranscriptConfig {
15    pub dir: PathBuf,
16    pub enabled: bool,
17}
18
19impl TranscriptConfig {
20    /// Build from environment variables.
21    pub fn from_env() -> Self {
22        let dir = std::env::var("ALC_LOG_DIR")
23            .map(PathBuf::from)
24            .unwrap_or_else(|_| {
25                dirs::home_dir()
26                    .unwrap_or_else(|| PathBuf::from("."))
27                    .join(".algocline")
28                    .join("logs")
29            });
30
31        let enabled = std::env::var("ALC_LOG_LEVEL")
32            .map(|v| v.to_lowercase() != "off")
33            .unwrap_or(true);
34
35        Self { dir, enabled }
36    }
37}
38
39/// Write transcript log to `{dir}/{session_id}.json`.
40///
41/// Silently returns on I/O errors — logging must not break execution.
42fn write_transcript_log(config: &TranscriptConfig, session_id: &str, metrics: &ExecutionMetrics) {
43    if !config.enabled {
44        return;
45    }
46
47    let transcript = metrics.transcript_to_json();
48    if transcript.is_empty() {
49        return;
50    }
51
52    let stats = metrics.to_json();
53
54    // Extract task hint from first prompt (truncated to 100 chars)
55    let task_hint = transcript
56        .first()
57        .and_then(|e| e.get("prompt"))
58        .and_then(|p| p.as_str())
59        .map(|s| {
60            if s.len() <= 100 {
61                s.to_string()
62            } else {
63                // Find a char boundary at or before 100 bytes
64                let mut end = 100;
65                while end > 0 && !s.is_char_boundary(end) {
66                    end -= 1;
67                }
68                format!("{}...", &s[..end])
69            }
70        });
71
72    let auto_stats = &stats["auto"];
73
74    let log_entry = serde_json::json!({
75        "session_id": session_id,
76        "task_hint": task_hint,
77        "stats": auto_stats,
78        "transcript": transcript,
79    });
80
81    if std::fs::create_dir_all(&config.dir).is_err() {
82        return;
83    }
84
85    let path = match ContainedPath::child(&config.dir, &format!("{session_id}.json")) {
86        Ok(p) => p,
87        Err(_) => return,
88    };
89    let content = match serde_json::to_string_pretty(&log_entry) {
90        Ok(s) => s,
91        Err(_) => return,
92    };
93
94    let _ = std::fs::write(&path, content);
95
96    // Write lightweight meta file for log_list (avoids reading full transcript)
97    let meta = serde_json::json!({
98        "session_id": session_id,
99        "task_hint": task_hint,
100        "elapsed_ms": auto_stats.get("elapsed_ms"),
101        "rounds": auto_stats.get("rounds"),
102        "llm_calls": auto_stats.get("llm_calls"),
103        "notes_count": 0,
104    });
105    if let Ok(meta_path) = ContainedPath::child(&config.dir, &format!("{session_id}.meta.json")) {
106        let _ = serde_json::to_string(&meta).map(|s| std::fs::write(&meta_path, s));
107    }
108}
109
110/// Append a note to an existing log file.
111///
112/// Reads `{dir}/{session_id}.json`, adds the note to `"notes"` array, writes back.
113/// Returns Ok with the note count, or Err if the log file doesn't exist.
114fn append_note(
115    dir: &Path,
116    session_id: &str,
117    content: &str,
118    title: Option<&str>,
119) -> Result<usize, String> {
120    let path = ContainedPath::child(dir, &format!("{session_id}.json"))?;
121    if !path.as_ref().exists() {
122        return Err(format!("Log file not found for session '{session_id}'"));
123    }
124
125    let raw = std::fs::read_to_string(&path).map_err(|e| format!("Failed to read log: {e}"))?;
126    let mut doc: serde_json::Value =
127        serde_json::from_str(&raw).map_err(|e| format!("Failed to parse log: {e}"))?;
128
129    let timestamp = {
130        use std::time::{SystemTime, UNIX_EPOCH};
131        SystemTime::now()
132            .duration_since(UNIX_EPOCH)
133            .unwrap_or_default()
134            .as_secs()
135    };
136
137    let note = serde_json::json!({
138        "timestamp": timestamp,
139        "title": title,
140        "content": content,
141    });
142
143    let notes = doc
144        .as_object_mut()
145        .ok_or("Log file is not a JSON object")?
146        .entry("notes")
147        .or_insert_with(|| serde_json::json!([]));
148
149    let arr = notes
150        .as_array_mut()
151        .ok_or("'notes' field is not an array")?;
152    arr.push(note);
153    let count = arr.len();
154
155    let output =
156        serde_json::to_string_pretty(&doc).map_err(|e| format!("Failed to serialize: {e}"))?;
157    std::fs::write(path.as_ref(), output).map_err(|e| format!("Failed to write log: {e}"))?;
158
159    // Update notes_count in meta file (best-effort)
160    if let Ok(meta_path) = ContainedPath::child(dir, &format!("{session_id}.meta.json")) {
161        if meta_path.as_ref().exists() {
162            if let Ok(raw) = std::fs::read_to_string(&meta_path) {
163                if let Ok(mut meta) = serde_json::from_str::<serde_json::Value>(&raw) {
164                    meta["notes_count"] = serde_json::json!(count);
165                    if let Ok(s) = serde_json::to_string(&meta) {
166                        let _ = std::fs::write(&meta_path, s);
167                    }
168                }
169            }
170        }
171    }
172
173    Ok(count)
174}
175
176// ─── Helpers ────────────────────────────────────────────────────
177
178/// Recursively copy a directory tree (follows symlinks).
179fn copy_dir(src: &Path, dst: &Path) -> std::io::Result<()> {
180    std::fs::create_dir_all(dst)?;
181    for entry in std::fs::read_dir(src)? {
182        let entry = entry?;
183        // Use metadata() (follows symlinks) instead of file_type() (does not)
184        let meta = entry.metadata()?;
185        let dest_path = dst.join(entry.file_name());
186        if meta.is_dir() {
187            copy_dir(&entry.path(), &dest_path)?;
188        } else {
189            std::fs::copy(entry.path(), dest_path)?;
190        }
191    }
192    Ok(())
193}
194
195// ─── Path safety ────────────────────────────────────────────────
196
197/// A path verified to reside within a base directory.
198///
199/// Constructed via [`ContainedPath::child`], which rejects path traversal
200/// (`..`, absolute paths, symlink escapes). Once constructed, the inner path
201/// is safe for filesystem operations within the base directory.
202#[derive(Debug)]
203struct ContainedPath(PathBuf);
204
205impl ContainedPath {
206    /// Resolve `name` as a child of `base`, rejecting traversal attempts.
207    ///
208    /// Validates that every component in `name` is [`Component::Normal`].
209    /// If the resulting path already exists on disk, additionally verifies
210    /// via `canonicalize` that symlinks do not escape `base`.
211    fn child(base: &Path, name: &str) -> Result<Self, String> {
212        for comp in Path::new(name).components() {
213            if !matches!(comp, std::path::Component::Normal(_)) {
214                return Err(format!(
215                    "Invalid path component in '{name}': path traversal detected"
216                ));
217            }
218        }
219        let path = base.join(name);
220        if path.exists() {
221            let canonical = path
222                .canonicalize()
223                .map_err(|e| format!("Path resolution failed: {e}"))?;
224            let base_canonical = base
225                .canonicalize()
226                .map_err(|e| format!("Base path resolution failed: {e}"))?;
227            if !canonical.starts_with(&base_canonical) {
228                return Err(format!("Path '{name}' escapes base directory"));
229            }
230        }
231        Ok(Self(path))
232    }
233}
234
235impl std::ops::Deref for ContainedPath {
236    type Target = Path;
237    fn deref(&self) -> &Path {
238        &self.0
239    }
240}
241
242impl AsRef<Path> for ContainedPath {
243    fn as_ref(&self) -> &Path {
244        self
245    }
246}
247
248// ─── Parameter types (MCP-independent) ──────────────────────────
249
250/// A single query response in a batch feed.
251#[derive(Debug)]
252pub struct QueryResponse {
253    /// Query ID (e.g. "q-0", "q-1").
254    pub query_id: String,
255    /// The host LLM's response for this query.
256    pub response: String,
257}
258
259// ─── Code resolution ────────────────────────────────────────────
260
261pub(crate) fn resolve_code(
262    code: Option<String>,
263    code_file: Option<String>,
264) -> Result<String, String> {
265    match (code, code_file) {
266        (Some(c), None) => Ok(c),
267        (None, Some(path)) => std::fs::read_to_string(Path::new(&path))
268            .map_err(|e| format!("Failed to read {path}: {e}")),
269        (Some(_), Some(_)) => Err("Provide either `code` or `code_file`, not both.".into()),
270        (None, None) => Err("Either `code` or `code_file` must be provided.".into()),
271    }
272}
273
274/// Build Lua code that loads a package by name and calls `pkg.run(ctx)`.
275///
276/// # Security: `name` is not sanitized
277///
278/// `name` is interpolated directly into a Lua `require()` call without
279/// sanitization. This is intentional in the current architecture:
280///
281/// - algocline is a **local development/execution tool** that runs Lua in
282///   the user's own environment via mlua (not a multi-tenant service).
283/// - The same caller has access to `alc_run`, which executes **arbitrary
284///   Lua code**. Sanitizing `name` here would not reduce the attack surface.
285/// - The MCP trust boundary lies at the **host/client** level — the host
286///   decides whether to invoke `alc_advice` at all.
287///
288/// If algocline is extended to a shared backend (e.g. a package registry
289/// server accepting untrusted strategy names), `name` **must** be validated
290/// (allowlist of `[a-zA-Z0-9_-]` or equivalent) before interpolation.
291///
292/// References:
293/// - [MCP Security Best Practices — Local MCP Server Compromise](https://modelcontextprotocol.io/specification/draft/basic/security_best_practices)
294/// - [OWASP MCP Security Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/MCP_Security_Cheat_Sheet.html)
295pub(crate) fn make_require_code(name: &str) -> String {
296    format!(
297        r#"local pkg = require("{name}")
298return pkg.run(ctx)"#
299    )
300}
301
302pub(crate) fn packages_dir() -> Result<PathBuf, String> {
303    let home = dirs::home_dir().ok_or("Cannot determine home directory")?;
304    Ok(home.join(".algocline").join("packages"))
305}
306
307pub(crate) fn scenarios_dir() -> Result<PathBuf, String> {
308    let home = dirs::home_dir().ok_or("Cannot determine home directory")?;
309    Ok(home.join(".algocline").join("scenarios"))
310}
311
312/// Resolve scenario code from one of three mutually exclusive sources:
313/// inline code, file path, or scenario name (looked up in `~/.algocline/scenarios/`).
314pub(crate) fn resolve_scenario_code(
315    scenario: Option<String>,
316    scenario_file: Option<String>,
317    scenario_name: Option<String>,
318) -> Result<String, String> {
319    match (scenario, scenario_file, scenario_name) {
320        (Some(c), None, None) => Ok(c),
321        (None, Some(path), None) => std::fs::read_to_string(Path::new(&path))
322            .map_err(|e| format!("Failed to read {path}: {e}")),
323        (None, None, Some(name)) => {
324            let dir = scenarios_dir()?;
325            let path = ContainedPath::child(&dir, &format!("{name}.lua"))
326                .map_err(|e| format!("Invalid scenario name: {e}"))?;
327            if !path.as_ref().exists() {
328                return Err(format!(
329                    "Scenario '{name}' not found at {}",
330                    path.as_ref().display()
331                ));
332            }
333            std::fs::read_to_string(path.as_ref())
334                .map_err(|e| format!("Failed to read scenario '{name}': {e}"))
335        }
336        (None, None, None) => {
337            Err("Provide one of: scenario, scenario_file, or scenario_name.".into())
338        }
339        _ => Err(
340            "Provide only one of: scenario, scenario_file, or scenario_name (not multiple).".into(),
341        ),
342    }
343}
344
345/// Git URLs for auto-installation. Collection repos contain multiple packages
346/// as subdirectories; single repos have init.lua at root.
347const AUTO_INSTALL_SOURCES: &[&str] = &[
348    "https://github.com/ynishi/algocline-bundled-packages",
349    "https://github.com/ynishi/evalframe",
350];
351
352/// System packages: installed alongside user packages but not user-facing strategies.
353/// Excluded from `pkg_list` and not loaded via `require` for meta extraction.
354const SYSTEM_PACKAGES: &[&str] = &["evalframe"];
355
356/// Check whether a package is a system (non-user-facing) package.
357fn is_system_package(name: &str) -> bool {
358    SYSTEM_PACKAGES.contains(&name)
359}
360
361/// Check whether a package is installed (has `init.lua`).
362fn is_package_installed(name: &str) -> bool {
363    packages_dir()
364        .map(|dir| dir.join(name).join("init.lua").exists())
365        .unwrap_or(false)
366}
367
368/// Per-entry I/O failures collected during resilient batch operations.
369///
370/// **Resilience pattern:** Directory iteration and file operations may encounter
371/// per-entry I/O errors (permission denied, broken symlinks, etc.) that should
372/// not abort the entire operation. Failures are collected and returned alongside
373/// successful results so the caller has both the available data and diagnostics.
374///
375/// Included in JSON responses as `"failures": [...]`.
376type DirEntryFailures = Vec<String>;
377
378/// Extract a display name from a path: file_stem if available, otherwise file_name.
379fn display_name(path: &Path, file_name: &str) -> String {
380    path.file_stem()
381        .and_then(|s| s.to_str())
382        .map(String::from)
383        .unwrap_or_else(|| file_name.to_string())
384}
385
386/// Determine the scenario source directory within a cloned/downloaded tree.
387///
388/// Prefers a `scenarios/` subdirectory when present, falling back to the root.
389///
390/// # `.git` and other non-Lua entries
391///
392/// When falling back to the root, the directory may contain `.git/`, `README.md`,
393/// `LICENSE`, etc. This is safe because [`install_scenarios_from_dir`] applies two
394/// filters: `is_file()` (excludes `.git/` and other subdirectories) and
395/// `.lua` extension check (excludes non-Lua files). No explicit `.git` exclusion
396/// is needed.
397fn resolve_scenario_source(clone_root: &Path) -> PathBuf {
398    let subdir = clone_root.join("scenarios");
399    if subdir.is_dir() {
400        subdir
401    } else {
402        clone_root.to_path_buf()
403    }
404}
405
406/// Copy all `.lua` files from `source` directory into `dest` (scenarios dir).
407/// Skips files that already exist. Collects per-entry I/O errors as `failures`
408/// rather than aborting.
409fn install_scenarios_from_dir(source: &Path, dest: &Path) -> Result<String, String> {
410    let entries =
411        std::fs::read_dir(source).map_err(|e| format!("Failed to read source dir: {e}"))?;
412
413    let mut installed = Vec::new();
414    let mut skipped = Vec::new();
415    let mut failures: DirEntryFailures = Vec::new();
416
417    for entry_result in entries {
418        let entry = match entry_result {
419            Ok(e) => e,
420            Err(e) => {
421                failures.push(format!("readdir entry: {e}"));
422                continue;
423            }
424        };
425        let path = entry.path();
426        if !path.is_file() {
427            continue;
428        }
429        let ext = path.extension().and_then(|s| s.to_str());
430        if ext != Some("lua") {
431            continue;
432        }
433        let file_name = entry.file_name().to_string_lossy().to_string();
434        let dest_path = match ContainedPath::child(dest, &file_name) {
435            Ok(p) => p,
436            Err(_) => continue,
437        };
438        let name = display_name(&path, &file_name);
439        if dest_path.as_ref().exists() {
440            skipped.push(name);
441            continue;
442        }
443        match std::fs::copy(&path, dest_path.as_ref()) {
444            Ok(_) => installed.push(name),
445            Err(e) => failures.push(format!("{}: {e}", path.display())),
446        }
447    }
448
449    if installed.is_empty() && skipped.is_empty() && failures.is_empty() {
450        return Err("No .lua scenario files found in source.".into());
451    }
452
453    Ok(serde_json::json!({
454        "installed": installed,
455        "skipped": skipped,
456        "failures": failures,
457    })
458    .to_string())
459}
460
461// ─── Eval Result Store ──────────────────────────────────────────
462
463fn evals_dir() -> Result<PathBuf, String> {
464    let home = dirs::home_dir().ok_or("Cannot determine home directory")?;
465    Ok(home.join(".algocline").join("evals"))
466}
467
468/// Persist eval result to `~/.algocline/evals/{strategy}_{timestamp}.json`.
469///
470/// Silently returns on I/O errors — storage must not break eval execution.
471fn save_eval_result(strategy: &str, result_json: &str) {
472    let dir = match evals_dir() {
473        Ok(d) => d,
474        Err(_) => return,
475    };
476    if std::fs::create_dir_all(&dir).is_err() {
477        return;
478    }
479
480    let now = std::time::SystemTime::now()
481        .duration_since(std::time::UNIX_EPOCH)
482        .unwrap_or_default();
483    let timestamp = now.as_secs();
484    let eval_id = format!("{strategy}_{timestamp}");
485
486    // Parse result to extract summary fields for meta file
487    let parsed: serde_json::Value = match serde_json::from_str(result_json) {
488        Ok(v) => v,
489        Err(_) => return,
490    };
491
492    // Write full result
493    let path = match ContainedPath::child(&dir, &format!("{eval_id}.json")) {
494        Ok(p) => p,
495        Err(_) => return,
496    };
497    let _ = std::fs::write(&path, result_json);
498
499    // Write lightweight meta file for listing
500    let result_obj = parsed.get("result");
501    let stats_obj = parsed.get("stats");
502    let aggregated = result_obj.and_then(|r| r.get("aggregated"));
503
504    let meta = serde_json::json!({
505        "eval_id": eval_id,
506        "strategy": strategy,
507        "timestamp": timestamp,
508        "pass_rate": aggregated.and_then(|a| a.get("pass_rate")),
509        "mean_score": aggregated.and_then(|a| a.get("scores")).and_then(|s| s.get("mean")),
510        "total_cases": aggregated.and_then(|a| a.get("total")),
511        "passed": aggregated.and_then(|a| a.get("passed")),
512        "llm_calls": stats_obj.and_then(|s| s.get("auto")).and_then(|a| a.get("llm_calls")),
513        "elapsed_ms": stats_obj.and_then(|s| s.get("auto")).and_then(|a| a.get("elapsed_ms")),
514        "summary": result_obj.and_then(|r| r.get("summary")),
515    });
516
517    if let Ok(meta_path) = ContainedPath::child(&dir, &format!("{eval_id}.meta.json")) {
518        let _ = serde_json::to_string(&meta).map(|s| std::fs::write(&meta_path, s));
519    }
520}
521
522// ─── Eval Comparison Helpers ─────────────────────────────────────
523
524/// Escape a string for embedding in a Lua single-quoted string literal.
525///
526/// Handles backslash, single quote, newline, and carriage return —
527/// the characters that would break or alter a `'...'` Lua string.
528fn escape_for_lua_sq(s: &str) -> String {
529    s.replace('\\', "\\\\")
530        .replace('\'', "\\'")
531        .replace('\n', "\\n")
532        .replace('\r', "\\r")
533}
534
535/// Extract strategy name from eval_id (format: "{strategy}_{timestamp}").
536fn extract_strategy_from_id(eval_id: &str) -> Option<&str> {
537    eval_id.rsplit_once('_').map(|(prefix, _)| prefix)
538}
539
540/// Persist a comparison result to `~/.algocline/evals/`.
541fn save_compare_result(eval_id_a: &str, eval_id_b: &str, result_json: &str) {
542    let dir = match evals_dir() {
543        Ok(d) => d,
544        Err(_) => return,
545    };
546    let filename = format!("compare_{eval_id_a}_vs_{eval_id_b}.json");
547    if let Ok(path) = ContainedPath::child(&dir, &filename) {
548        let _ = std::fs::write(&path, result_json);
549    }
550}
551
552// ─── Application Service ────────────────────────────────────────
553
554/// Tracks which sessions are eval sessions and their strategy name.
555type EvalSessions = std::sync::Mutex<std::collections::HashMap<String, String>>;
556
557#[derive(Clone)]
558pub struct AppService {
559    executor: Arc<Executor>,
560    registry: Arc<SessionRegistry>,
561    log_config: TranscriptConfig,
562    /// session_id → strategy name for eval sessions (cleared on completion).
563    eval_sessions: Arc<EvalSessions>,
564}
565
566impl AppService {
567    pub fn new(executor: Arc<Executor>, log_config: TranscriptConfig) -> Self {
568        Self {
569            executor,
570            registry: Arc::new(SessionRegistry::new()),
571            log_config,
572            eval_sessions: Arc::new(std::sync::Mutex::new(std::collections::HashMap::new())),
573        }
574    }
575
576    /// Execute Lua code with optional JSON context.
577    pub async fn run(
578        &self,
579        code: Option<String>,
580        code_file: Option<String>,
581        ctx: Option<serde_json::Value>,
582    ) -> Result<String, String> {
583        let code = resolve_code(code, code_file)?;
584        let ctx = ctx.unwrap_or(serde_json::Value::Null);
585        self.start_and_tick(code, ctx).await
586    }
587
588    /// Apply a built-in strategy to a task.
589    ///
590    /// If the requested package is not installed, automatically installs the
591    /// bundled package collection from GitHub before executing.
592    pub async fn advice(
593        &self,
594        strategy: &str,
595        task: String,
596        opts: Option<serde_json::Value>,
597    ) -> Result<String, String> {
598        // Auto-install bundled packages if the requested strategy is missing
599        if !is_package_installed(strategy) {
600            self.auto_install_bundled_packages().await?;
601            if !is_package_installed(strategy) {
602                return Err(format!(
603                    "Package '{strategy}' not found after installing bundled collection. \
604                     Use alc_pkg_install to install it manually."
605                ));
606            }
607        }
608
609        let code = make_require_code(strategy);
610
611        let mut ctx_map = match opts {
612            Some(serde_json::Value::Object(m)) => m,
613            _ => serde_json::Map::new(),
614        };
615        ctx_map.insert("task".into(), serde_json::Value::String(task));
616        let ctx = serde_json::Value::Object(ctx_map);
617
618        self.start_and_tick(code, ctx).await
619    }
620
621    /// Run an evalframe evaluation suite.
622    ///
623    /// Accepts a scenario (bindings + cases) and a strategy name.
624    /// Automatically wires the strategy as the provider and executes
625    /// the evalframe suite, returning the report (summary, scores, failures).
626    ///
627    /// Injects a `std` global (mlua-batteries compatible shim) so evalframe's
628    /// `std.lua` can resolve json/fs/time from algocline's built-in primitives.
629    ///
630    /// # Security: `strategy` is not sanitized
631    ///
632    /// `strategy` is interpolated into a Lua string literal without escaping.
633    /// This is intentional — same rationale as [`make_require_code`]:
634    /// algocline runs Lua in the caller's own process with full ambient
635    /// authority, so Lua injection does not cross a trust boundary.
636    pub async fn eval(
637        &self,
638        scenario: Option<String>,
639        scenario_file: Option<String>,
640        scenario_name: Option<String>,
641        strategy: &str,
642        strategy_opts: Option<serde_json::Value>,
643    ) -> Result<String, String> {
644        // Auto-install bundled packages if evalframe is missing
645        if !is_package_installed("evalframe") {
646            self.auto_install_bundled_packages().await?;
647            if !is_package_installed("evalframe") {
648                return Err(
649                    "Package 'evalframe' not found after installing bundled collection. \
650                     Use alc_pkg_install to install it manually."
651                        .into(),
652                );
653            }
654        }
655
656        let scenario_code = resolve_scenario_code(scenario, scenario_file, scenario_name)?;
657
658        // Build strategy opts Lua table literal
659        let opts_lua = match &strategy_opts {
660            Some(v) if !v.is_null() => format!("alc.json_decode('{}')", v),
661            _ => "{}".to_string(),
662        };
663
664        // Inject `std` global as a mlua-batteries compatible shim.
665        //
666        // evalframe.std expects the host to provide a `std` global with:
667        //   std.json.decode/encode  — JSON serialization
668        //   std.fs.read/is_file     — filesystem access
669        //   std.time.now            — wall-clock time (epoch seconds, f64)
670        //
671        // We bridge these from algocline's alc.* primitives and Lua's io stdlib.
672        let wrapped = format!(
673            r#"
674std = {{
675  json = {{
676    decode = alc.json_decode,
677    encode = alc.json_encode,
678  }},
679  fs = {{
680    read = function(path)
681      local f, err = io.open(path, "r")
682      if not f then error("std.fs.read: " .. (err or path), 2) end
683      local content = f:read("*a")
684      f:close()
685      return content
686    end,
687    is_file = function(path)
688      local f = io.open(path, "r")
689      if f then f:close(); return true end
690      return false
691    end,
692  }},
693  time = {{
694    now = alc.time,
695  }},
696}}
697
698local ef = require("evalframe")
699
700-- Load scenario (bindings + cases, no provider)
701local spec = (function()
702{scenario_code}
703end)()
704
705-- Inject strategy as provider
706spec.provider = ef.providers.algocline {{
707  strategy = "{strategy}",
708  opts = {opts_lua},
709}}
710
711-- Build and run suite
712local s = ef.suite "eval" (spec)
713local report = s:run()
714return report:to_table()
715"#
716        );
717
718        let ctx = serde_json::Value::Null;
719        let result = self.start_and_tick(wrapped, ctx).await?;
720
721        // Register this session for eval result saving on completion.
722        // start_and_tick returns the first pause (needs_response) or completed.
723        // If completed immediately, save now. Otherwise, save when continue_* finishes.
724        if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(&result) {
725            match parsed.get("status").and_then(|s| s.as_str()) {
726                Some("completed") => {
727                    save_eval_result(strategy, &result);
728                }
729                Some("needs_response") => {
730                    if let Some(sid) = parsed.get("session_id").and_then(|s| s.as_str()) {
731                        if let Ok(mut map) = self.eval_sessions.lock() {
732                            map.insert(sid.to_string(), strategy.to_string());
733                        }
734                    }
735                }
736                _ => {}
737            }
738        }
739
740        Ok(result)
741    }
742
743    /// List eval history, optionally filtered by strategy.
744    pub fn eval_history(&self, strategy: Option<&str>, limit: usize) -> Result<String, String> {
745        let evals_dir = evals_dir()?;
746        if !evals_dir.exists() {
747            return Ok(serde_json::json!({ "evals": [] }).to_string());
748        }
749
750        let mut entries: Vec<serde_json::Value> = Vec::new();
751
752        let read_dir =
753            std::fs::read_dir(&evals_dir).map_err(|e| format!("Failed to read evals dir: {e}"))?;
754
755        for entry in read_dir.flatten() {
756            let path = entry.path();
757            if path.extension().and_then(|e| e.to_str()) != Some("json") {
758                continue;
759            }
760            // Skip meta files
761            if path
762                .file_name()
763                .and_then(|n| n.to_str())
764                .is_some_and(|n| n.contains(".meta."))
765            {
766                continue;
767            }
768
769            // Read meta file (lightweight) if it exists.
770            // Derive meta filename from the result filename to stay within evals_dir
771            // (ContainedPath ensures no traversal).
772            let stem = match path.file_stem().and_then(|s| s.to_str()) {
773                Some(s) => s,
774                None => continue,
775            };
776            let meta_path = match ContainedPath::child(&evals_dir, &format!("{stem}.meta.json")) {
777                Ok(p) => p,
778                Err(_) => continue,
779            };
780            let meta = if meta_path.exists() {
781                std::fs::read_to_string(&*meta_path)
782                    .ok()
783                    .and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok())
784            } else {
785                None
786            };
787
788            if let Some(meta) = meta {
789                // Filter by strategy if specified
790                if let Some(filter) = strategy {
791                    if meta.get("strategy").and_then(|s| s.as_str()) != Some(filter) {
792                        continue;
793                    }
794                }
795                entries.push(meta);
796            }
797        }
798
799        // Sort by timestamp descending (newest first)
800        entries.sort_by(|a, b| {
801            let ts_a = a
802                .get("timestamp")
803                .and_then(serde_json::Value::as_u64)
804                .unwrap_or(0);
805            let ts_b = b
806                .get("timestamp")
807                .and_then(serde_json::Value::as_u64)
808                .unwrap_or(0);
809            ts_b.cmp(&ts_a)
810        });
811        entries.truncate(limit);
812
813        Ok(serde_json::json!({ "evals": entries }).to_string())
814    }
815
816    /// View a specific eval result by ID.
817    pub fn eval_detail(&self, eval_id: &str) -> Result<String, String> {
818        let evals_dir = evals_dir()?;
819        let path = ContainedPath::child(&evals_dir, &format!("{eval_id}.json"))
820            .map_err(|e| format!("Invalid eval_id: {e}"))?;
821        if !path.exists() {
822            return Err(format!("Eval result not found: {eval_id}"));
823        }
824        std::fs::read_to_string(&*path).map_err(|e| format!("Failed to read eval: {e}"))
825    }
826
827    /// Compare two eval results with statistical significance testing.
828    ///
829    /// Delegates to evalframe's `stats.welch_t` (single source of truth for
830    /// t-distribution table and test logic). Reads persisted `aggregated.scores`
831    /// from each eval result — no re-computation of descriptive statistics.
832    ///
833    /// The comparison result is persisted to `~/.algocline/evals/` so repeated
834    /// lookups of the same pair are file reads only.
835    pub async fn eval_compare(&self, eval_id_a: &str, eval_id_b: &str) -> Result<String, String> {
836        // Check for cached comparison
837        let cache_filename = format!("compare_{eval_id_a}_vs_{eval_id_b}.json");
838        if let Ok(dir) = evals_dir() {
839            if let Ok(cached_path) = ContainedPath::child(&dir, &cache_filename) {
840                if cached_path.exists() {
841                    return std::fs::read_to_string(&*cached_path)
842                        .map_err(|e| format!("Failed to read cached comparison: {e}"));
843                }
844            }
845        }
846
847        // Auto-install bundled packages if evalframe is missing
848        if !is_package_installed("evalframe") {
849            self.auto_install_bundled_packages().await?;
850            if !is_package_installed("evalframe") {
851                return Err(
852                    "Package 'evalframe' not found after installing bundled collection. \
853                     Use alc_pkg_install to install it manually."
854                        .into(),
855                );
856            }
857        }
858
859        let result_a = self.eval_detail(eval_id_a)?;
860        let result_b = self.eval_detail(eval_id_b)?;
861
862        // Build Lua snippet that uses evalframe's stats module
863        // to compute welch_t from the persisted aggregated scores.
864        let lua_code = format!(
865            r#"
866std = {{
867  json = {{
868    decode = alc.json_decode,
869    encode = alc.json_encode,
870  }},
871  fs = {{ read = function() end, is_file = function() return false end }},
872  time = {{ now = alc.time }},
873}}
874
875local stats = require("evalframe.eval.stats")
876
877local result_a = alc.json_decode('{result_a_escaped}')
878local result_b = alc.json_decode('{result_b_escaped}')
879
880local agg_a = result_a.result and result_a.result.aggregated
881local agg_b = result_b.result and result_b.result.aggregated
882
883if not agg_a or not agg_a.scores then
884  error("No aggregated scores in {eval_id_a}")
885end
886if not agg_b or not agg_b.scores then
887  error("No aggregated scores in {eval_id_b}")
888end
889
890local welch = stats.welch_t(agg_a.scores, agg_b.scores)
891
892local strategy_a = (result_a.result and result_a.result.name) or "{strategy_a_fallback}"
893local strategy_b = (result_b.result and result_b.result.name) or "{strategy_b_fallback}"
894
895local delta = agg_a.scores.mean - agg_b.scores.mean
896local winner = "none"
897if welch.significant then
898  winner = delta > 0 and "a" or "b"
899end
900
901-- Build summary text
902local parts = {{}}
903if welch.significant then
904  local w, l, d = strategy_a, strategy_b, delta
905  if delta < 0 then w, l, d = strategy_b, strategy_a, -delta end
906  parts[#parts + 1] = string.format(
907    "%s outperforms %s by %.4f (mean score), statistically significant (t=%.3f, df=%.1f).",
908    w, l, d, math.abs(welch.t_stat), welch.df
909  )
910else
911  parts[#parts + 1] = string.format(
912    "No statistically significant difference between %s and %s (t=%.3f, df=%.1f).",
913    strategy_a, strategy_b, math.abs(welch.t_stat), welch.df
914  )
915end
916if agg_a.pass_rate and agg_b.pass_rate then
917  local dp = agg_a.pass_rate - agg_b.pass_rate
918  if math.abs(dp) > 1e-9 then
919    local h = dp > 0 and strategy_a or strategy_b
920    parts[#parts + 1] = string.format("Pass rate: %s +%.1fpp.", h, math.abs(dp) * 100)
921  else
922    parts[#parts + 1] = string.format("Pass rate: identical (%.1f%%).", agg_a.pass_rate * 100)
923  end
924end
925
926return {{
927  a = {{
928    eval_id = "{eval_id_a}",
929    strategy = strategy_a,
930    scores = agg_a.scores,
931    pass_rate = agg_a.pass_rate,
932    pass_at_1 = agg_a.pass_at_1,
933    ci_95 = agg_a.ci_95,
934  }},
935  b = {{
936    eval_id = "{eval_id_b}",
937    strategy = strategy_b,
938    scores = agg_b.scores,
939    pass_rate = agg_b.pass_rate,
940    pass_at_1 = agg_b.pass_at_1,
941    ci_95 = agg_b.ci_95,
942  }},
943  comparison = {{
944    delta_mean = delta,
945    welch_t = {{
946      t_stat = welch.t_stat,
947      df = welch.df,
948      significant = welch.significant,
949      direction = welch.direction,
950    }},
951    winner = winner,
952    summary = table.concat(parts, " "),
953  }},
954}}
955"#,
956            result_a_escaped = escape_for_lua_sq(&result_a),
957            result_b_escaped = escape_for_lua_sq(&result_b),
958            eval_id_a = eval_id_a,
959            eval_id_b = eval_id_b,
960            strategy_a_fallback = extract_strategy_from_id(eval_id_a).unwrap_or("A"),
961            strategy_b_fallback = extract_strategy_from_id(eval_id_b).unwrap_or("B"),
962        );
963
964        let ctx = serde_json::Value::Null;
965        let raw_result = self.start_and_tick(lua_code, ctx).await?;
966
967        // Persist comparison result
968        save_compare_result(eval_id_a, eval_id_b, &raw_result);
969
970        Ok(raw_result)
971    }
972
973    /// Continue a paused execution — batch feed.
974    pub async fn continue_batch(
975        &self,
976        session_id: &str,
977        responses: Vec<QueryResponse>,
978    ) -> Result<String, String> {
979        let mut last_result = None;
980        for qr in responses {
981            let qid = QueryId::parse(&qr.query_id);
982            let result = self
983                .registry
984                .feed_response(session_id, &qid, qr.response)
985                .await
986                .map_err(|e| format!("Continue failed: {e}"))?;
987            last_result = Some(result);
988        }
989        let result = last_result.ok_or("Empty responses array")?;
990        self.maybe_log_transcript(&result, session_id);
991        let json = result.to_json(session_id).to_string();
992        self.maybe_save_eval(&result, session_id, &json);
993        Ok(json)
994    }
995
996    /// Continue a paused execution — single response (with optional query_id).
997    pub async fn continue_single(
998        &self,
999        session_id: &str,
1000        response: String,
1001        query_id: Option<&str>,
1002    ) -> Result<String, String> {
1003        let query_id = match query_id {
1004            Some(qid) => QueryId::parse(qid),
1005            None => QueryId::single(),
1006        };
1007
1008        let result = self
1009            .registry
1010            .feed_response(session_id, &query_id, response)
1011            .await
1012            .map_err(|e| format!("Continue failed: {e}"))?;
1013
1014        self.maybe_log_transcript(&result, session_id);
1015        let json = result.to_json(session_id).to_string();
1016        self.maybe_save_eval(&result, session_id, &json);
1017        Ok(json)
1018    }
1019
1020    // ─── Package Management ─────────────────────────────────────
1021
1022    /// List installed packages with metadata.
1023    pub async fn pkg_list(&self) -> Result<String, String> {
1024        let pkg_dir = packages_dir()?;
1025        if !pkg_dir.is_dir() {
1026            return Ok(serde_json::json!({ "packages": [] }).to_string());
1027        }
1028
1029        let mut packages = Vec::new();
1030        let entries =
1031            std::fs::read_dir(&pkg_dir).map_err(|e| format!("Failed to read packages dir: {e}"))?;
1032
1033        for entry in entries.flatten() {
1034            let path = entry.path();
1035            if !path.is_dir() {
1036                continue;
1037            }
1038            let init_lua = path.join("init.lua");
1039            if !init_lua.exists() {
1040                continue;
1041            }
1042            let name = entry.file_name().to_string_lossy().to_string();
1043            // Skip system packages (not user-facing strategies)
1044            if is_system_package(&name) {
1045                continue;
1046            }
1047            let code = format!(
1048                r#"local pkg = require("{name}")
1049return pkg.meta or {{ name = "{name}" }}"#
1050            );
1051            match self.executor.eval_simple(code).await {
1052                Ok(meta) => packages.push(meta),
1053                Err(_) => {
1054                    packages
1055                        .push(serde_json::json!({ "name": name, "error": "failed to load meta" }));
1056                }
1057            }
1058        }
1059
1060        Ok(serde_json::json!({ "packages": packages }).to_string())
1061    }
1062
1063    /// Install a package from a Git URL or local path.
1064    pub async fn pkg_install(&self, url: String, name: Option<String>) -> Result<String, String> {
1065        let pkg_dir = packages_dir()?;
1066        let _ = std::fs::create_dir_all(&pkg_dir);
1067
1068        // Local path: copy directly (supports uncommitted/dirty working trees)
1069        let local_path = Path::new(&url);
1070        if local_path.is_absolute() && local_path.is_dir() {
1071            return self.install_from_local_path(local_path, &pkg_dir, name);
1072        }
1073
1074        // Normalize URL: add https:// only for bare domain-style URLs
1075        let git_url = if url.starts_with("http://")
1076            || url.starts_with("https://")
1077            || url.starts_with("file://")
1078            || url.starts_with("git@")
1079        {
1080            url.clone()
1081        } else {
1082            format!("https://{url}")
1083        };
1084
1085        // Clone to temp directory first to detect single vs collection
1086        let staging = tempfile::tempdir().map_err(|e| format!("Failed to create temp dir: {e}"))?;
1087
1088        let output = tokio::process::Command::new("git")
1089            .args([
1090                "clone",
1091                "--depth",
1092                "1",
1093                &git_url,
1094                &staging.path().to_string_lossy(),
1095            ])
1096            .output()
1097            .await
1098            .map_err(|e| format!("Failed to run git: {e}"))?;
1099
1100        if !output.status.success() {
1101            let stderr = String::from_utf8_lossy(&output.stderr);
1102            return Err(format!("git clone failed: {stderr}"));
1103        }
1104
1105        // Remove .git dir from staging
1106        let _ = std::fs::remove_dir_all(staging.path().join(".git"));
1107
1108        // Detect: single package (init.lua at root) vs collection (subdirs with init.lua)
1109        if staging.path().join("init.lua").exists() {
1110            // Single package mode
1111            let name = name.unwrap_or_else(|| {
1112                url.trim_end_matches('/')
1113                    .rsplit('/')
1114                    .next()
1115                    .unwrap_or("unknown")
1116                    .trim_end_matches(".git")
1117                    .to_string()
1118            });
1119
1120            let dest = ContainedPath::child(&pkg_dir, &name)?;
1121            if dest.as_ref().exists() {
1122                return Err(format!(
1123                    "Package '{name}' already exists at {}. Remove it first.",
1124                    dest.as_ref().display()
1125                ));
1126            }
1127
1128            copy_dir(staging.path(), dest.as_ref())
1129                .map_err(|e| format!("Failed to copy package: {e}"))?;
1130
1131            Ok(serde_json::json!({
1132                "installed": [name],
1133                "mode": "single",
1134            })
1135            .to_string())
1136        } else {
1137            // Collection mode: scan for subdirs containing init.lua
1138            if name.is_some() {
1139                // name parameter is only meaningful for single-package repos
1140                return Err(
1141                    "The 'name' parameter is only supported for single-package repos (init.lua at root). \
1142                     This repository is a collection (subdirs with init.lua)."
1143                        .to_string(),
1144                );
1145            }
1146
1147            let mut installed = Vec::new();
1148            let mut skipped = Vec::new();
1149
1150            let entries = std::fs::read_dir(staging.path())
1151                .map_err(|e| format!("Failed to read staging dir: {e}"))?;
1152
1153            for entry in entries {
1154                let entry = entry.map_err(|e| format!("Failed to read entry: {e}"))?;
1155                let path = entry.path();
1156                if !path.is_dir() {
1157                    continue;
1158                }
1159                if !path.join("init.lua").exists() {
1160                    continue;
1161                }
1162                let pkg_name = entry.file_name().to_string_lossy().to_string();
1163                let dest = pkg_dir.join(&pkg_name);
1164                if dest.exists() {
1165                    skipped.push(pkg_name);
1166                    continue;
1167                }
1168                copy_dir(&path, &dest)
1169                    .map_err(|e| format!("Failed to copy package '{pkg_name}': {e}"))?;
1170                installed.push(pkg_name);
1171            }
1172
1173            // Install bundled scenarios only when an explicit `scenarios/` subdir exists.
1174            // Unlike `scenario_install` (which falls back to root via `resolve_scenario_source`),
1175            // bundled scenarios are optional — we don't scan the package root for .lua files.
1176            let scenarios_subdir = staging.path().join("scenarios");
1177            let mut scenarios_installed: Vec<String> = Vec::new();
1178            let mut scenarios_failures: DirEntryFailures = Vec::new();
1179            if scenarios_subdir.is_dir() {
1180                if let Ok(sc_dir) = scenarios_dir() {
1181                    std::fs::create_dir_all(&sc_dir)
1182                        .map_err(|e| format!("Failed to create scenarios dir: {e}"))?;
1183                    if let Ok(result) = install_scenarios_from_dir(&scenarios_subdir, &sc_dir) {
1184                        if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(&result) {
1185                            if let Some(arr) = parsed.get("installed").and_then(|v| v.as_array()) {
1186                                scenarios_installed = arr
1187                                    .iter()
1188                                    .filter_map(|v| v.as_str().map(String::from))
1189                                    .collect();
1190                            }
1191                            if let Some(arr) = parsed.get("failures").and_then(|v| v.as_array()) {
1192                                scenarios_failures = arr
1193                                    .iter()
1194                                    .filter_map(|v| v.as_str().map(String::from))
1195                                    .collect();
1196                            }
1197                        }
1198                    }
1199                }
1200            }
1201
1202            if installed.is_empty() && skipped.is_empty() {
1203                return Err(
1204                    "No packages found. Expected init.lua at root (single) or */init.lua (collection)."
1205                        .to_string(),
1206                );
1207            }
1208
1209            Ok(serde_json::json!({
1210                "installed": installed,
1211                "skipped": skipped,
1212                "scenarios_installed": scenarios_installed,
1213                "scenarios_failures": scenarios_failures,
1214                "mode": "collection",
1215            })
1216            .to_string())
1217        }
1218    }
1219
1220    /// Install from a local directory path (supports dirty/uncommitted files).
1221    fn install_from_local_path(
1222        &self,
1223        source: &Path,
1224        pkg_dir: &Path,
1225        name: Option<String>,
1226    ) -> Result<String, String> {
1227        if source.join("init.lua").exists() {
1228            // Single package
1229            let name = name.unwrap_or_else(|| {
1230                source
1231                    .file_name()
1232                    .map(|n| n.to_string_lossy().to_string())
1233                    .unwrap_or_else(|| "unknown".to_string())
1234            });
1235
1236            let dest = ContainedPath::child(pkg_dir, &name)?;
1237            if dest.as_ref().exists() {
1238                // Overwrite for local installs (dev workflow)
1239                let _ = std::fs::remove_dir_all(&dest);
1240            }
1241
1242            copy_dir(source, dest.as_ref()).map_err(|e| format!("Failed to copy package: {e}"))?;
1243            // Remove .git if copied
1244            let _ = std::fs::remove_dir_all(dest.as_ref().join(".git"));
1245
1246            Ok(serde_json::json!({
1247                "installed": [name],
1248                "mode": "local_single",
1249            })
1250            .to_string())
1251        } else {
1252            // Collection mode
1253            if name.is_some() {
1254                return Err(
1255                    "The 'name' parameter is only supported for single-package dirs (init.lua at root)."
1256                        .to_string(),
1257                );
1258            }
1259
1260            let mut installed = Vec::new();
1261            let mut updated = Vec::new();
1262
1263            let entries =
1264                std::fs::read_dir(source).map_err(|e| format!("Failed to read source dir: {e}"))?;
1265
1266            for entry in entries {
1267                let entry = entry.map_err(|e| format!("Failed to read entry: {e}"))?;
1268                let path = entry.path();
1269                if !path.is_dir() || !path.join("init.lua").exists() {
1270                    continue;
1271                }
1272                let pkg_name = entry.file_name().to_string_lossy().to_string();
1273                let dest = pkg_dir.join(&pkg_name);
1274                let existed = dest.exists();
1275                if existed {
1276                    let _ = std::fs::remove_dir_all(&dest);
1277                }
1278                copy_dir(&path, &dest)
1279                    .map_err(|e| format!("Failed to copy package '{pkg_name}': {e}"))?;
1280                let _ = std::fs::remove_dir_all(dest.join(".git"));
1281                if existed {
1282                    updated.push(pkg_name);
1283                } else {
1284                    installed.push(pkg_name);
1285                }
1286            }
1287
1288            if installed.is_empty() && updated.is_empty() {
1289                return Err(
1290                    "No packages found. Expected init.lua at root (single) or */init.lua (collection)."
1291                        .to_string(),
1292                );
1293            }
1294
1295            Ok(serde_json::json!({
1296                "installed": installed,
1297                "updated": updated,
1298                "mode": "local_collection",
1299            })
1300            .to_string())
1301        }
1302    }
1303
1304    /// Remove an installed package.
1305    pub async fn pkg_remove(&self, name: &str) -> Result<String, String> {
1306        let pkg_dir = packages_dir()?;
1307        let dest = ContainedPath::child(&pkg_dir, name)?;
1308
1309        if !dest.as_ref().exists() {
1310            return Err(format!("Package '{name}' not found"));
1311        }
1312
1313        std::fs::remove_dir_all(&dest).map_err(|e| format!("Failed to remove '{name}': {e}"))?;
1314
1315        Ok(serde_json::json!({ "removed": name }).to_string())
1316    }
1317
1318    // ─── Logging ─────────────────────────────────────────────
1319
1320    /// Append a note to a session's log file.
1321    pub async fn add_note(
1322        &self,
1323        session_id: &str,
1324        content: &str,
1325        title: Option<&str>,
1326    ) -> Result<String, String> {
1327        let count = append_note(&self.log_config.dir, session_id, content, title)?;
1328        Ok(serde_json::json!({
1329            "session_id": session_id,
1330            "notes_count": count,
1331        })
1332        .to_string())
1333    }
1334
1335    /// View session logs.
1336    pub async fn log_view(
1337        &self,
1338        session_id: Option<&str>,
1339        limit: Option<usize>,
1340    ) -> Result<String, String> {
1341        match session_id {
1342            Some(sid) => self.log_read(sid),
1343            None => self.log_list(limit.unwrap_or(50)),
1344        }
1345    }
1346
1347    fn log_read(&self, session_id: &str) -> Result<String, String> {
1348        let path = ContainedPath::child(&self.log_config.dir, &format!("{session_id}.json"))?;
1349        if !path.as_ref().exists() {
1350            return Err(format!("Log file not found for session '{session_id}'"));
1351        }
1352        std::fs::read_to_string(&path).map_err(|e| format!("Failed to read log: {e}"))
1353    }
1354
1355    fn log_list(&self, limit: usize) -> Result<String, String> {
1356        let dir = &self.log_config.dir;
1357        if !dir.is_dir() {
1358            return Ok(serde_json::json!({ "sessions": [] }).to_string());
1359        }
1360
1361        let entries = std::fs::read_dir(dir).map_err(|e| format!("Failed to read log dir: {e}"))?;
1362
1363        // Collect .meta.json files first; fall back to .json for legacy logs
1364        let mut files: Vec<(std::path::PathBuf, std::time::SystemTime)> = entries
1365            .flatten()
1366            .filter_map(|entry| {
1367                let path = entry.path();
1368                let name = path.file_name()?.to_str()?;
1369                // Skip non-json and meta files in this pass
1370                if !name.ends_with(".json") || name.ends_with(".meta.json") {
1371                    return None;
1372                }
1373                let mtime = entry.metadata().ok()?.modified().ok()?;
1374                Some((path, mtime))
1375            })
1376            .collect();
1377
1378        // Sort by modification time descending (newest first), take limit
1379        files.sort_by(|a, b| b.1.cmp(&a.1));
1380        files.truncate(limit);
1381
1382        let mut sessions = Vec::new();
1383        for (path, _) in &files {
1384            // Try .meta.json first (lightweight), fall back to full log
1385            let meta_path = path.with_extension("meta.json");
1386            let doc: serde_json::Value = if meta_path.exists() {
1387                // Meta file: already flat summary (~200 bytes)
1388                match std::fs::read_to_string(&meta_path)
1389                    .ok()
1390                    .and_then(|r| serde_json::from_str(&r).ok())
1391                {
1392                    Some(d) => d,
1393                    None => continue,
1394                }
1395            } else {
1396                // Legacy fallback: read full log and extract fields
1397                let raw = match std::fs::read_to_string(path) {
1398                    Ok(r) => r,
1399                    Err(_) => continue,
1400                };
1401                match serde_json::from_str::<serde_json::Value>(&raw) {
1402                    Ok(d) => {
1403                        let stats = d.get("stats");
1404                        serde_json::json!({
1405                            "session_id": d.get("session_id").and_then(|v| v.as_str()).unwrap_or("unknown"),
1406                            "task_hint": d.get("task_hint").and_then(|v| v.as_str()),
1407                            "elapsed_ms": stats.and_then(|s| s.get("elapsed_ms")),
1408                            "rounds": stats.and_then(|s| s.get("rounds")),
1409                            "llm_calls": stats.and_then(|s| s.get("llm_calls")),
1410                            "notes_count": d.get("notes").and_then(|v| v.as_array()).map(|a| a.len()).unwrap_or(0),
1411                        })
1412                    }
1413                    Err(_) => continue,
1414                }
1415            };
1416
1417            sessions.push(doc);
1418        }
1419
1420        Ok(serde_json::json!({ "sessions": sessions }).to_string())
1421    }
1422
1423    // ─── Scenario Management ────────────────────────────────────
1424
1425    /// List available scenarios in `~/.algocline/scenarios/`.
1426    ///
1427    /// Per-entry I/O errors are collected in `"failures"` rather than aborting.
1428    pub fn scenario_list(&self) -> Result<String, String> {
1429        let dir = scenarios_dir()?;
1430        if !dir.exists() {
1431            return Ok(serde_json::json!({ "scenarios": [], "failures": [] }).to_string());
1432        }
1433
1434        let entries =
1435            std::fs::read_dir(&dir).map_err(|e| format!("Failed to read scenarios dir: {e}"))?;
1436
1437        let mut scenarios: Vec<serde_json::Value> = Vec::new();
1438        let mut failures: DirEntryFailures = Vec::new();
1439        for entry_result in entries {
1440            let entry = match entry_result {
1441                Ok(e) => e,
1442                Err(e) => {
1443                    failures.push(format!("readdir entry: {e}"));
1444                    continue;
1445                }
1446            };
1447            let path = entry.path();
1448            let name = match path.file_stem().and_then(|s| s.to_str()) {
1449                Some(s) => s.to_string(),
1450                None => continue,
1451            };
1452            let ext = path.extension().and_then(|s| s.to_str());
1453            if ext != Some("lua") {
1454                continue;
1455            }
1456            let metadata = std::fs::metadata(&path);
1457            let size_bytes = metadata.as_ref().map(|m| m.len()).unwrap_or(0);
1458            scenarios.push(serde_json::json!({
1459                "name": name,
1460                "path": path.to_string_lossy(),
1461                "size_bytes": size_bytes,
1462            }));
1463        }
1464
1465        scenarios.sort_by(|a, b| {
1466            a.get("name")
1467                .and_then(|v| v.as_str())
1468                .cmp(&b.get("name").and_then(|v| v.as_str()))
1469        });
1470
1471        Ok(serde_json::json!({
1472            "scenarios": scenarios,
1473            "failures": failures,
1474        })
1475        .to_string())
1476    }
1477
1478    /// Show the content of a named scenario.
1479    pub fn scenario_show(&self, name: &str) -> Result<String, String> {
1480        let dir = scenarios_dir()?;
1481        let path = ContainedPath::child(&dir, &format!("{name}.lua"))
1482            .map_err(|e| format!("Invalid scenario name: {e}"))?;
1483        if !path.as_ref().exists() {
1484            return Err(format!("Scenario '{name}' not found"));
1485        }
1486        let content = std::fs::read_to_string(path.as_ref())
1487            .map_err(|e| format!("Failed to read scenario '{name}': {e}"))?;
1488        Ok(serde_json::json!({
1489            "name": name,
1490            "path": path.as_ref().to_string_lossy(),
1491            "content": content,
1492        })
1493        .to_string())
1494    }
1495
1496    /// Install scenarios from a Git URL or local path into `~/.algocline/scenarios/`.
1497    ///
1498    /// Expects the source to contain `.lua` files (at root or in a `scenarios/` subdirectory).
1499    pub async fn scenario_install(&self, url: String) -> Result<String, String> {
1500        let dest_dir = scenarios_dir()?;
1501        std::fs::create_dir_all(&dest_dir)
1502            .map_err(|e| format!("Failed to create scenarios dir: {e}"))?;
1503
1504        // Local path: copy .lua files directly
1505        let local_path = Path::new(&url);
1506        if local_path.is_absolute() && local_path.is_dir() {
1507            return install_scenarios_from_dir(local_path, &dest_dir);
1508        }
1509
1510        // Normalize URL
1511        let git_url = if url.starts_with("http://")
1512            || url.starts_with("https://")
1513            || url.starts_with("file://")
1514            || url.starts_with("git@")
1515        {
1516            url.clone()
1517        } else {
1518            format!("https://{url}")
1519        };
1520
1521        let staging = tempfile::tempdir().map_err(|e| format!("Failed to create temp dir: {e}"))?;
1522
1523        let output = tokio::process::Command::new("git")
1524            .args([
1525                "clone",
1526                "--depth",
1527                "1",
1528                &git_url,
1529                &staging.path().to_string_lossy(),
1530            ])
1531            .output()
1532            .await
1533            .map_err(|e| format!("Failed to run git: {e}"))?;
1534
1535        if !output.status.success() {
1536            let stderr = String::from_utf8_lossy(&output.stderr);
1537            return Err(format!("git clone failed: {stderr}"));
1538        }
1539
1540        let source = resolve_scenario_source(staging.path());
1541        install_scenarios_from_dir(&source, &dest_dir)
1542    }
1543
1544    // ─── Internal ───────────────────────────────────────────────
1545
1546    /// Install all bundled sources (collections + single packages).
1547    async fn auto_install_bundled_packages(&self) -> Result<(), String> {
1548        let mut errors: Vec<String> = Vec::new();
1549        for url in AUTO_INSTALL_SOURCES {
1550            tracing::info!("auto-installing from {url}");
1551            if let Err(e) = self.pkg_install(url.to_string(), None).await {
1552                tracing::warn!("failed to auto-install from {url}: {e}");
1553                errors.push(format!("{url}: {e}"));
1554            }
1555        }
1556        // Fail only if ALL sources failed
1557        if errors.len() == AUTO_INSTALL_SOURCES.len() {
1558            return Err(format!(
1559                "Failed to auto-install bundled packages: {}",
1560                errors.join("; ")
1561            ));
1562        }
1563        Ok(())
1564    }
1565
1566    fn maybe_log_transcript(&self, result: &FeedResult, session_id: &str) {
1567        if let FeedResult::Finished(exec_result) = result {
1568            write_transcript_log(&self.log_config, session_id, &exec_result.metrics);
1569        }
1570    }
1571
1572    /// If this session was an eval, save the final result to the eval store.
1573    fn maybe_save_eval(&self, result: &FeedResult, session_id: &str, result_json: &str) {
1574        if !matches!(result, FeedResult::Finished(_)) {
1575            return;
1576        }
1577        let strategy = {
1578            let mut map = match self.eval_sessions.lock() {
1579                Ok(m) => m,
1580                Err(_) => return,
1581            };
1582            map.remove(session_id)
1583        };
1584        if let Some(strategy) = strategy {
1585            save_eval_result(&strategy, result_json);
1586        }
1587    }
1588
1589    async fn start_and_tick(&self, code: String, ctx: serde_json::Value) -> Result<String, String> {
1590        let session = self.executor.start_session(code, ctx).await?;
1591        let (session_id, result) = self
1592            .registry
1593            .start_execution(session)
1594            .await
1595            .map_err(|e| format!("Execution failed: {e}"))?;
1596        self.maybe_log_transcript(&result, &session_id);
1597        Ok(result.to_json(&session_id).to_string())
1598    }
1599}
1600
1601#[cfg(test)]
1602mod tests {
1603    use super::*;
1604    use algocline_core::ExecutionObserver;
1605    use std::io::Write;
1606
1607    // ─── resolve_code tests ───
1608
1609    #[test]
1610    fn resolve_code_inline() {
1611        let result = resolve_code(Some("return 1".into()), None);
1612        assert_eq!(result.unwrap(), "return 1");
1613    }
1614
1615    #[test]
1616    fn resolve_code_from_file() {
1617        let mut tmp = tempfile::NamedTempFile::new().unwrap();
1618        write!(tmp, "return 42").unwrap();
1619
1620        let result = resolve_code(None, Some(tmp.path().to_string_lossy().into()));
1621        assert_eq!(result.unwrap(), "return 42");
1622    }
1623
1624    #[test]
1625    fn resolve_code_both_provided_error() {
1626        let result = resolve_code(Some("code".into()), Some("file.lua".into()));
1627        let err = result.unwrap_err();
1628        assert!(err.contains("not both"), "error: {err}");
1629    }
1630
1631    #[test]
1632    fn resolve_code_neither_provided_error() {
1633        let result = resolve_code(None, None);
1634        let err = result.unwrap_err();
1635        assert!(err.contains("must be provided"), "error: {err}");
1636    }
1637
1638    #[test]
1639    fn resolve_code_nonexistent_file_error() {
1640        let result = resolve_code(
1641            None,
1642            Some("/tmp/algocline_nonexistent_test_file.lua".into()),
1643        );
1644        assert!(result.is_err());
1645    }
1646
1647    // ─── make_require_code tests ───
1648
1649    #[test]
1650    fn make_require_code_basic() {
1651        let code = make_require_code("ucb");
1652        assert!(code.contains(r#"require("ucb")"#), "code: {code}");
1653        assert!(code.contains("pkg.run(ctx)"), "code: {code}");
1654    }
1655
1656    #[test]
1657    fn make_require_code_different_names() {
1658        for name in &["panel", "cot", "sc", "cove", "reflect", "calibrate"] {
1659            let code = make_require_code(name);
1660            assert!(
1661                code.contains(&format!(r#"require("{name}")"#)),
1662                "code for {name}: {code}"
1663            );
1664        }
1665    }
1666
1667    // ─── packages_dir tests ───
1668
1669    #[test]
1670    fn packages_dir_ends_with_expected_path() {
1671        let dir = packages_dir().unwrap();
1672        assert!(
1673            dir.ends_with(".algocline/packages"),
1674            "dir: {}",
1675            dir.display()
1676        );
1677    }
1678
1679    // ─── append_note tests ───
1680
1681    #[test]
1682    fn append_note_to_existing_log() {
1683        let dir = tempfile::tempdir().unwrap();
1684        let session_id = "s-test-001";
1685        let log = serde_json::json!({
1686            "session_id": session_id,
1687            "stats": { "elapsed_ms": 100 },
1688            "transcript": [],
1689        });
1690        let path = dir.path().join(format!("{session_id}.json"));
1691        std::fs::write(&path, serde_json::to_string_pretty(&log).unwrap()).unwrap();
1692
1693        let count = append_note(dir.path(), session_id, "Step 2 was weak", Some("Step 2")).unwrap();
1694        assert_eq!(count, 1);
1695
1696        let count = append_note(dir.path(), session_id, "Overall good", None).unwrap();
1697        assert_eq!(count, 2);
1698
1699        let raw = std::fs::read_to_string(&path).unwrap();
1700        let doc: serde_json::Value = serde_json::from_str(&raw).unwrap();
1701        let notes = doc["notes"].as_array().unwrap();
1702        assert_eq!(notes.len(), 2);
1703        assert_eq!(notes[0]["content"], "Step 2 was weak");
1704        assert_eq!(notes[0]["title"], "Step 2");
1705        assert_eq!(notes[1]["content"], "Overall good");
1706        assert!(notes[1]["title"].is_null());
1707        assert!(notes[0]["timestamp"].is_number());
1708    }
1709
1710    #[test]
1711    fn append_note_missing_log_returns_error() {
1712        let dir = tempfile::tempdir().unwrap();
1713        let result = append_note(dir.path(), "s-nonexistent", "note", None);
1714        assert!(result.is_err());
1715        assert!(result.unwrap_err().contains("not found"));
1716    }
1717
1718    // ─── log_list / log_view tests ───
1719
1720    #[test]
1721    fn log_list_from_dir() {
1722        let dir = tempfile::tempdir().unwrap();
1723
1724        // Create two log files
1725        let log1 = serde_json::json!({
1726            "session_id": "s-001",
1727            "task_hint": "What is 2+2?",
1728            "stats": { "elapsed_ms": 100, "rounds": 1, "llm_calls": 1 },
1729            "transcript": [{ "prompt": "What is 2+2?", "response": "4" }],
1730        });
1731        let log2 = serde_json::json!({
1732            "session_id": "s-002",
1733            "task_hint": "Explain ownership",
1734            "stats": { "elapsed_ms": 5000, "rounds": 3, "llm_calls": 3 },
1735            "transcript": [],
1736            "notes": [{ "timestamp": 0, "content": "good" }],
1737        });
1738
1739        std::fs::write(
1740            dir.path().join("s-001.json"),
1741            serde_json::to_string(&log1).unwrap(),
1742        )
1743        .unwrap();
1744        std::fs::write(
1745            dir.path().join("s-002.json"),
1746            serde_json::to_string(&log2).unwrap(),
1747        )
1748        .unwrap();
1749        // Non-json file should be ignored
1750        std::fs::write(dir.path().join("README.txt"), "ignore me").unwrap();
1751
1752        let config = TranscriptConfig {
1753            dir: dir.path().to_path_buf(),
1754            enabled: true,
1755        };
1756
1757        // Use log_list directly via the free function path
1758        let entries = std::fs::read_dir(&config.dir).unwrap();
1759        let mut count = 0;
1760        for entry in entries.flatten() {
1761            if entry.path().extension().and_then(|e| e.to_str()) == Some("json") {
1762                count += 1;
1763            }
1764        }
1765        assert_eq!(count, 2);
1766    }
1767
1768    // ─── ContainedPath tests ───
1769
1770    #[test]
1771    fn contained_path_accepts_simple_name() {
1772        let dir = tempfile::tempdir().unwrap();
1773        let result = ContainedPath::child(dir.path(), "s-abc123.json");
1774        assert!(result.is_ok());
1775        assert!(result.unwrap().as_ref().ends_with("s-abc123.json"));
1776    }
1777
1778    #[test]
1779    fn contained_path_rejects_parent_traversal() {
1780        let dir = tempfile::tempdir().unwrap();
1781        let result = ContainedPath::child(dir.path(), "../../../etc/passwd");
1782        assert!(result.is_err());
1783        let err = result.unwrap_err();
1784        assert!(err.contains("path traversal"), "err: {err}");
1785    }
1786
1787    #[test]
1788    fn contained_path_rejects_absolute_path() {
1789        let dir = tempfile::tempdir().unwrap();
1790        let result = ContainedPath::child(dir.path(), "/etc/passwd");
1791        assert!(result.is_err());
1792        let err = result.unwrap_err();
1793        assert!(err.contains("path traversal"), "err: {err}");
1794    }
1795
1796    #[test]
1797    fn contained_path_rejects_dot_dot_in_middle() {
1798        let dir = tempfile::tempdir().unwrap();
1799        let result = ContainedPath::child(dir.path(), "foo/../bar");
1800        assert!(result.is_err());
1801    }
1802
1803    #[test]
1804    fn contained_path_accepts_nested_normal() {
1805        let dir = tempfile::tempdir().unwrap();
1806        let result = ContainedPath::child(dir.path(), "sub/file.json");
1807        assert!(result.is_ok());
1808    }
1809
1810    #[test]
1811    fn append_note_rejects_traversal_session_id() {
1812        let dir = tempfile::tempdir().unwrap();
1813        let result = append_note(dir.path(), "../../../etc/passwd", "evil", None);
1814        assert!(result.is_err());
1815        assert!(result.unwrap_err().contains("path traversal"));
1816    }
1817
1818    // ─── meta file tests ───
1819
1820    #[test]
1821    fn write_transcript_log_creates_meta_file() {
1822        let dir = tempfile::tempdir().unwrap();
1823        let config = TranscriptConfig {
1824            dir: dir.path().to_path_buf(),
1825            enabled: true,
1826        };
1827
1828        let metrics = algocline_core::ExecutionMetrics::new();
1829        let observer = metrics.create_observer();
1830        observer.on_paused(&[algocline_core::LlmQuery {
1831            id: algocline_core::QueryId::single(),
1832            prompt: "What is 2+2?".into(),
1833            system: None,
1834            max_tokens: 100,
1835            grounded: false,
1836            underspecified: false,
1837        }]);
1838        observer.on_response_fed(&algocline_core::QueryId::single(), "4");
1839        observer.on_resumed();
1840        observer.on_completed(&serde_json::json!(null));
1841
1842        write_transcript_log(&config, "s-meta-test", &metrics);
1843
1844        // Main log should exist
1845        assert!(dir.path().join("s-meta-test.json").exists());
1846
1847        // Meta file should exist
1848        let meta_path = dir.path().join("s-meta-test.meta.json");
1849        assert!(meta_path.exists());
1850
1851        let raw = std::fs::read_to_string(&meta_path).unwrap();
1852        let meta: serde_json::Value = serde_json::from_str(&raw).unwrap();
1853        assert_eq!(meta["session_id"], "s-meta-test");
1854        assert_eq!(meta["notes_count"], 0);
1855        assert!(meta.get("elapsed_ms").is_some());
1856        assert!(meta.get("rounds").is_some());
1857        assert!(meta.get("llm_calls").is_some());
1858        // Meta should NOT contain transcript
1859        assert!(meta.get("transcript").is_none());
1860    }
1861
1862    #[test]
1863    fn append_note_updates_meta_notes_count() {
1864        let dir = tempfile::tempdir().unwrap();
1865        let session_id = "s-meta-note";
1866
1867        // Create main log
1868        let log = serde_json::json!({
1869            "session_id": session_id,
1870            "stats": { "elapsed_ms": 100 },
1871            "transcript": [],
1872        });
1873        std::fs::write(
1874            dir.path().join(format!("{session_id}.json")),
1875            serde_json::to_string_pretty(&log).unwrap(),
1876        )
1877        .unwrap();
1878
1879        // Create meta file
1880        let meta = serde_json::json!({
1881            "session_id": session_id,
1882            "task_hint": "test",
1883            "elapsed_ms": 100,
1884            "rounds": 1,
1885            "llm_calls": 1,
1886            "notes_count": 0,
1887        });
1888        std::fs::write(
1889            dir.path().join(format!("{session_id}.meta.json")),
1890            serde_json::to_string(&meta).unwrap(),
1891        )
1892        .unwrap();
1893
1894        append_note(dir.path(), session_id, "first note", None).unwrap();
1895
1896        let raw =
1897            std::fs::read_to_string(dir.path().join(format!("{session_id}.meta.json"))).unwrap();
1898        let updated: serde_json::Value = serde_json::from_str(&raw).unwrap();
1899        assert_eq!(updated["notes_count"], 1);
1900
1901        append_note(dir.path(), session_id, "second note", None).unwrap();
1902
1903        let raw =
1904            std::fs::read_to_string(dir.path().join(format!("{session_id}.meta.json"))).unwrap();
1905        let updated: serde_json::Value = serde_json::from_str(&raw).unwrap();
1906        assert_eq!(updated["notes_count"], 2);
1907    }
1908
1909    // ─── TranscriptConfig tests ───
1910
1911    #[test]
1912    fn transcript_config_default_enabled() {
1913        // Without env vars, should default to enabled
1914        let config = TranscriptConfig {
1915            dir: PathBuf::from("/tmp/test"),
1916            enabled: true,
1917        };
1918        assert!(config.enabled);
1919    }
1920
1921    #[test]
1922    fn write_transcript_log_disabled_is_noop() {
1923        let dir = tempfile::tempdir().unwrap();
1924        let config = TranscriptConfig {
1925            dir: dir.path().to_path_buf(),
1926            enabled: false,
1927        };
1928        let metrics = algocline_core::ExecutionMetrics::new();
1929        let observer = metrics.create_observer();
1930        observer.on_paused(&[algocline_core::LlmQuery {
1931            id: algocline_core::QueryId::single(),
1932            prompt: "test".into(),
1933            system: None,
1934            max_tokens: 10,
1935            grounded: false,
1936            underspecified: false,
1937        }]);
1938        observer.on_response_fed(&algocline_core::QueryId::single(), "r");
1939        observer.on_resumed();
1940        observer.on_completed(&serde_json::json!(null));
1941
1942        write_transcript_log(&config, "s-disabled", &metrics);
1943
1944        // No file should be created
1945        assert!(!dir.path().join("s-disabled.json").exists());
1946        assert!(!dir.path().join("s-disabled.meta.json").exists());
1947    }
1948
1949    #[test]
1950    fn write_transcript_log_empty_transcript_is_noop() {
1951        let dir = tempfile::tempdir().unwrap();
1952        let config = TranscriptConfig {
1953            dir: dir.path().to_path_buf(),
1954            enabled: true,
1955        };
1956        // Metrics with no observer events → empty transcript
1957        let metrics = algocline_core::ExecutionMetrics::new();
1958        write_transcript_log(&config, "s-empty", &metrics);
1959        assert!(!dir.path().join("s-empty.json").exists());
1960    }
1961
1962    // ─── copy_dir tests ───
1963
1964    #[test]
1965    fn copy_dir_basic() {
1966        let src = tempfile::tempdir().unwrap();
1967        let dst = tempfile::tempdir().unwrap();
1968
1969        std::fs::write(src.path().join("a.txt"), "hello").unwrap();
1970        std::fs::create_dir(src.path().join("sub")).unwrap();
1971        std::fs::write(src.path().join("sub/b.txt"), "world").unwrap();
1972
1973        let dst_path = dst.path().join("copied");
1974        copy_dir(src.path(), &dst_path).unwrap();
1975
1976        assert_eq!(
1977            std::fs::read_to_string(dst_path.join("a.txt")).unwrap(),
1978            "hello"
1979        );
1980        assert_eq!(
1981            std::fs::read_to_string(dst_path.join("sub/b.txt")).unwrap(),
1982            "world"
1983        );
1984    }
1985
1986    #[test]
1987    fn copy_dir_empty() {
1988        let src = tempfile::tempdir().unwrap();
1989        let dst = tempfile::tempdir().unwrap();
1990        let dst_path = dst.path().join("empty_copy");
1991        copy_dir(src.path(), &dst_path).unwrap();
1992        assert!(dst_path.exists());
1993        assert!(dst_path.is_dir());
1994    }
1995
1996    // ─── task_hint truncation in write_transcript_log ───
1997
1998    #[test]
1999    fn write_transcript_log_truncates_long_prompt() {
2000        let dir = tempfile::tempdir().unwrap();
2001        let config = TranscriptConfig {
2002            dir: dir.path().to_path_buf(),
2003            enabled: true,
2004        };
2005        let metrics = algocline_core::ExecutionMetrics::new();
2006        let observer = metrics.create_observer();
2007        let long_prompt = "x".repeat(300);
2008        observer.on_paused(&[algocline_core::LlmQuery {
2009            id: algocline_core::QueryId::single(),
2010            prompt: long_prompt,
2011            system: None,
2012            max_tokens: 10,
2013            grounded: false,
2014            underspecified: false,
2015        }]);
2016        observer.on_response_fed(&algocline_core::QueryId::single(), "r");
2017        observer.on_resumed();
2018        observer.on_completed(&serde_json::json!(null));
2019
2020        write_transcript_log(&config, "s-long", &metrics);
2021
2022        let raw = std::fs::read_to_string(dir.path().join("s-long.json")).unwrap();
2023        let doc: serde_json::Value = serde_json::from_str(&raw).unwrap();
2024        let hint = doc["task_hint"].as_str().unwrap();
2025        // Should be truncated to ~100 chars + "..."
2026        assert!(hint.len() <= 104, "hint too long: {} chars", hint.len());
2027        assert!(hint.ends_with("..."));
2028    }
2029
2030    #[test]
2031    fn log_list_prefers_meta_file() {
2032        let dir = tempfile::tempdir().unwrap();
2033
2034        // Create a full log (large, with transcript)
2035        let log = serde_json::json!({
2036            "session_id": "s-big",
2037            "task_hint": "full log hint",
2038            "stats": { "elapsed_ms": 999, "rounds": 5, "llm_calls": 5 },
2039            "transcript": [{"prompt": "x".repeat(10000), "response": "y".repeat(10000)}],
2040        });
2041        std::fs::write(
2042            dir.path().join("s-big.json"),
2043            serde_json::to_string(&log).unwrap(),
2044        )
2045        .unwrap();
2046
2047        // Create corresponding meta
2048        let meta = serde_json::json!({
2049            "session_id": "s-big",
2050            "task_hint": "full log hint",
2051            "elapsed_ms": 999,
2052            "rounds": 5,
2053            "llm_calls": 5,
2054            "notes_count": 0,
2055        });
2056        std::fs::write(
2057            dir.path().join("s-big.meta.json"),
2058            serde_json::to_string(&meta).unwrap(),
2059        )
2060        .unwrap();
2061
2062        // Create a legacy log (no meta file)
2063        let legacy = serde_json::json!({
2064            "session_id": "s-legacy",
2065            "task_hint": "legacy hint",
2066            "stats": { "elapsed_ms": 100, "rounds": 1, "llm_calls": 1 },
2067            "transcript": [],
2068        });
2069        std::fs::write(
2070            dir.path().join("s-legacy.json"),
2071            serde_json::to_string(&legacy).unwrap(),
2072        )
2073        .unwrap();
2074
2075        let config = TranscriptConfig {
2076            dir: dir.path().to_path_buf(),
2077            enabled: true,
2078        };
2079        let app = AppService {
2080            executor: Arc::new(
2081                tokio::runtime::Builder::new_current_thread()
2082                    .build()
2083                    .unwrap()
2084                    .block_on(async { algocline_engine::Executor::new(vec![]).await.unwrap() }),
2085            ),
2086            registry: Arc::new(algocline_engine::SessionRegistry::new()),
2087            log_config: config,
2088            eval_sessions: Arc::new(std::sync::Mutex::new(std::collections::HashMap::new())),
2089        };
2090
2091        let result = app.log_list(50).unwrap();
2092        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
2093        let sessions = parsed["sessions"].as_array().unwrap();
2094
2095        assert_eq!(sessions.len(), 2);
2096
2097        // Both sessions should have session_id and task_hint
2098        let ids: Vec<&str> = sessions
2099            .iter()
2100            .map(|s| s["session_id"].as_str().unwrap())
2101            .collect();
2102        assert!(ids.contains(&"s-big"));
2103        assert!(ids.contains(&"s-legacy"));
2104    }
2105}
2106
2107#[cfg(test)]
2108mod proptests {
2109    use super::*;
2110    use proptest::prelude::*;
2111
2112    proptest! {
2113        /// resolve_code never panics.
2114        #[test]
2115        fn resolve_code_never_panics(
2116            code in proptest::option::of("[a-z]{0,50}"),
2117            file in proptest::option::of("[a-z]{0,50}"),
2118        ) {
2119            let _ = resolve_code(code, file);
2120        }
2121
2122        /// ContainedPath always rejects ".." components.
2123        #[test]
2124        fn contained_path_rejects_traversal(
2125            prefix in "[a-z]{0,5}",
2126            suffix in "[a-z]{0,5}",
2127        ) {
2128            let dir = tempfile::tempdir().unwrap();
2129            let name = format!("{prefix}/../{suffix}");
2130            let result = ContainedPath::child(dir.path(), &name);
2131            prop_assert!(result.is_err());
2132        }
2133
2134        /// ContainedPath accepts simple alphanumeric names.
2135        #[test]
2136        fn contained_path_accepts_simple_names(name in "[a-z][a-z0-9_-]{0,20}\\.json") {
2137            let dir = tempfile::tempdir().unwrap();
2138            let result = ContainedPath::child(dir.path(), &name);
2139            prop_assert!(result.is_ok());
2140        }
2141
2142        /// make_require_code always contains the strategy name in a require call.
2143        #[test]
2144        fn make_require_code_contains_name(name in "[a-z_]{1,20}") {
2145            let code = make_require_code(&name);
2146            let expected = format!("require(\"{}\")", name);
2147            prop_assert!(code.contains(&expected));
2148            prop_assert!(code.contains("pkg.run(ctx)"));
2149        }
2150
2151        /// copy_dir preserves file contents for arbitrary data.
2152        #[test]
2153        fn copy_dir_preserves_content(content in "[a-zA-Z0-9 ]{1,200}") {
2154            let src = tempfile::tempdir().unwrap();
2155            let dst = tempfile::tempdir().unwrap();
2156
2157            std::fs::write(src.path().join("test.txt"), &content).unwrap();
2158            let dst_path = dst.path().join("out");
2159            copy_dir(src.path(), &dst_path).unwrap();
2160
2161            let read = std::fs::read_to_string(dst_path.join("test.txt")).unwrap();
2162            prop_assert_eq!(&read, &content);
2163        }
2164    }
2165
2166    // ─── eval tests ───
2167
2168    #[test]
2169    fn eval_rejects_no_scenario() {
2170        let result = resolve_scenario_code(None, None, None);
2171        assert!(result.is_err());
2172    }
2173
2174    #[test]
2175    fn resolve_scenario_code_inline() {
2176        let result = resolve_scenario_code(Some("return 1".into()), None, None);
2177        assert_eq!(result.unwrap(), "return 1");
2178    }
2179
2180    #[test]
2181    fn resolve_scenario_code_from_file() {
2182        let mut tmp = tempfile::NamedTempFile::new().unwrap();
2183        std::io::Write::write_all(&mut tmp, b"return 42").unwrap();
2184        let result = resolve_scenario_code(None, Some(tmp.path().to_string_lossy().into()), None);
2185        assert_eq!(result.unwrap(), "return 42");
2186    }
2187
2188    #[test]
2189    fn resolve_scenario_code_rejects_multiple() {
2190        let result = resolve_scenario_code(Some("code".into()), Some("file".into()), None);
2191        assert!(result.is_err());
2192        assert!(result.unwrap_err().contains("only one"));
2193
2194        let result2 = resolve_scenario_code(Some("code".into()), None, Some("name".into()));
2195        assert!(result2.is_err());
2196    }
2197
2198    #[test]
2199    fn resolve_scenario_code_by_name_not_found() {
2200        // scenario_name resolves from ~/.algocline/scenarios/ which won't have this
2201        let result = resolve_scenario_code(None, None, Some("nonexistent_test_xyz".into()));
2202        assert!(result.is_err());
2203        assert!(result.unwrap_err().contains("not found"));
2204    }
2205
2206    // ─── scenario management tests ───
2207
2208    #[test]
2209    fn scenarios_dir_ends_with_expected_path() {
2210        let dir = scenarios_dir().unwrap();
2211        assert!(
2212            dir.ends_with(".algocline/scenarios"),
2213            "dir: {}",
2214            dir.display()
2215        );
2216    }
2217
2218    #[test]
2219    fn install_scenarios_from_dir_copies_lua_files() {
2220        let source = tempfile::tempdir().unwrap();
2221        let dest = tempfile::tempdir().unwrap();
2222
2223        // Create test .lua files
2224        std::fs::write(source.path().join("math_basic.lua"), "return {}").unwrap();
2225        std::fs::write(source.path().join("safety.lua"), "return {}").unwrap();
2226        // Non-lua file should be skipped
2227        std::fs::write(source.path().join("README.md"), "# docs").unwrap();
2228
2229        let result = install_scenarios_from_dir(source.path(), dest.path()).unwrap();
2230        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
2231        let installed = parsed["installed"].as_array().unwrap();
2232        assert_eq!(installed.len(), 2);
2233        assert!(dest.path().join("math_basic.lua").exists());
2234        assert!(dest.path().join("safety.lua").exists());
2235        assert!(!dest.path().join("README.md").exists());
2236        assert_eq!(parsed["failures"].as_array().unwrap().len(), 0);
2237    }
2238
2239    #[test]
2240    fn install_scenarios_from_dir_skips_existing() {
2241        let source = tempfile::tempdir().unwrap();
2242        let dest = tempfile::tempdir().unwrap();
2243
2244        std::fs::write(source.path().join("existing.lua"), "return {new=true}").unwrap();
2245        std::fs::write(dest.path().join("existing.lua"), "return {old=true}").unwrap();
2246
2247        let result = install_scenarios_from_dir(source.path(), dest.path()).unwrap();
2248        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
2249        assert_eq!(parsed["skipped"].as_array().unwrap().len(), 1);
2250        assert_eq!(parsed["installed"].as_array().unwrap().len(), 0);
2251        assert_eq!(parsed["failures"].as_array().unwrap().len(), 0);
2252
2253        // Original file should be preserved
2254        let content = std::fs::read_to_string(dest.path().join("existing.lua")).unwrap();
2255        assert!(content.contains("old=true"));
2256    }
2257
2258    #[test]
2259    fn install_scenarios_from_dir_empty_source_errors() {
2260        let source = tempfile::tempdir().unwrap();
2261        let dest = tempfile::tempdir().unwrap();
2262
2263        let result = install_scenarios_from_dir(source.path(), dest.path());
2264        assert!(result.is_err());
2265        assert!(result.unwrap_err().contains("No .lua"));
2266    }
2267
2268    #[test]
2269    fn install_scenarios_from_dir_collects_copy_failures() {
2270        let source = tempfile::tempdir().unwrap();
2271        // dest is a non-existent path inside a read-only dir to force copy failure
2272        let dest = tempfile::tempdir().unwrap();
2273        let bad_dest = dest.path().join("nonexistent_subdir");
2274        // Don't create bad_dest — copy will fail
2275
2276        std::fs::write(source.path().join("ok.lua"), "return 1").unwrap();
2277
2278        let result = install_scenarios_from_dir(source.path(), &bad_dest);
2279        // ContainedPath::child won't fail, but fs::copy to nonexistent dir will
2280        let parsed: serde_json::Value = serde_json::from_str(&result.unwrap()).unwrap();
2281        let failures = parsed["failures"].as_array().unwrap();
2282        assert_eq!(failures.len(), 1, "expected 1 copy failure");
2283        assert_eq!(parsed["installed"].as_array().unwrap().len(), 0);
2284    }
2285
2286    #[test]
2287    fn display_name_prefers_stem() {
2288        let path = Path::new("/tmp/math_basic.lua");
2289        assert_eq!(display_name(path, "math_basic.lua"), "math_basic");
2290    }
2291
2292    #[test]
2293    fn display_name_falls_back_to_file_name() {
2294        // file_stem returns None only for paths like "" or "/"
2295        let path = Path::new("");
2296        assert_eq!(display_name(path, "fallback"), "fallback");
2297    }
2298
2299    #[test]
2300    fn resolve_scenario_source_prefers_subdir() {
2301        let root = tempfile::tempdir().unwrap();
2302        std::fs::create_dir(root.path().join("scenarios")).unwrap();
2303        std::fs::write(root.path().join("scenarios").join("a.lua"), "").unwrap();
2304        std::fs::write(root.path().join("root.lua"), "").unwrap();
2305
2306        let source = resolve_scenario_source(root.path());
2307        assert_eq!(source, root.path().join("scenarios"));
2308    }
2309
2310    #[test]
2311    fn resolve_scenario_source_falls_back_to_root() {
2312        let root = tempfile::tempdir().unwrap();
2313        std::fs::write(root.path().join("a.lua"), "").unwrap();
2314
2315        let source = resolve_scenario_source(root.path());
2316        assert_eq!(source, root.path());
2317    }
2318
2319    #[test]
2320    fn eval_auto_installs_evalframe_on_missing() {
2321        // Skip if evalframe is already installed globally
2322        if is_package_installed("evalframe") {
2323            return;
2324        }
2325
2326        let rt = tokio::runtime::Builder::new_current_thread()
2327            .enable_all()
2328            .build()
2329            .unwrap();
2330
2331        let tmp = tempfile::tempdir().unwrap();
2332        let fake_pkg_dir = tmp.path().join("empty_packages");
2333        std::fs::create_dir_all(&fake_pkg_dir).unwrap();
2334
2335        let executor = Arc::new(rt.block_on(async {
2336            algocline_engine::Executor::new(vec![fake_pkg_dir])
2337                .await
2338                .unwrap()
2339        }));
2340        let config = TranscriptConfig {
2341            dir: tmp.path().join("logs"),
2342            enabled: false,
2343        };
2344        let svc = AppService::new(executor, config);
2345
2346        let scenario = r#"return { cases = {} }"#;
2347        let result = rt.block_on(svc.eval(Some(scenario.into()), None, None, "cove", None));
2348        assert!(result.is_err());
2349        // Auto-install is attempted first; error is about bundled install failure
2350        // (git clone) or evalframe still missing after install
2351        let err = result.unwrap_err();
2352        assert!(
2353            err.contains("bundled") || err.contains("evalframe"),
2354            "unexpected error: {err}"
2355        );
2356    }
2357
2358    // ─── comparison helper tests ───
2359
2360    #[test]
2361    fn extract_strategy_from_id_splits_correctly() {
2362        assert_eq!(extract_strategy_from_id("cove_1710672000"), Some("cove"));
2363        assert_eq!(
2364            extract_strategy_from_id("my_strat_1710672000"),
2365            Some("my_strat")
2366        );
2367        assert_eq!(extract_strategy_from_id("nostamp"), None);
2368    }
2369
2370    #[test]
2371    fn save_compare_result_persists_file() {
2372        let tmp = tempfile::tempdir().unwrap();
2373        let evals = tmp.path().join(".algocline").join("evals");
2374        std::fs::create_dir_all(&evals).unwrap();
2375
2376        // save_compare_result uses evals_dir() which reads HOME.
2377        // Test ContainedPath + write logic directly instead.
2378        let filename = "compare_a_1_vs_b_2.json";
2379        let path = ContainedPath::child(&evals, filename).unwrap();
2380        let data = r#"{"test": true}"#;
2381        std::fs::write(&*path, data).unwrap();
2382
2383        let read = std::fs::read_to_string(&*path).unwrap();
2384        assert_eq!(read, data);
2385    }
2386}