1use std::path::{Path, PathBuf};
2use std::sync::Arc;
3
4use algocline_core::{ExecutionMetrics, QueryId};
5use algocline_engine::{Executor, FeedResult, SessionRegistry};
6
7#[derive(Clone, Debug)]
14pub struct TranscriptConfig {
15 pub dir: PathBuf,
16 pub enabled: bool,
17}
18
19impl TranscriptConfig {
20 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
39fn 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 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 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 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
110fn 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 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
176fn 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 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#[derive(Debug)]
203struct ContainedPath(PathBuf);
204
205impl ContainedPath {
206 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#[derive(Debug)]
252pub struct QueryResponse {
253 pub query_id: String,
255 pub response: String,
257}
258
259pub(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
274pub(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
312pub(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
345const AUTO_INSTALL_SOURCES: &[&str] = &[
348 "https://github.com/ynishi/algocline-bundled-packages",
349 "https://github.com/ynishi/evalframe",
350];
351
352const SYSTEM_PACKAGES: &[&str] = &["evalframe"];
355
356fn is_system_package(name: &str) -> bool {
358 SYSTEM_PACKAGES.contains(&name)
359}
360
361fn 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
368type DirEntryFailures = Vec<String>;
377
378fn 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
386fn 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
406fn 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
461fn 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
468fn 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 let parsed: serde_json::Value = match serde_json::from_str(result_json) {
488 Ok(v) => v,
489 Err(_) => return,
490 };
491
492 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 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
522fn escape_for_lua_sq(s: &str) -> String {
529 s.replace('\\', "\\\\")
530 .replace('\'', "\\'")
531 .replace('\n', "\\n")
532 .replace('\r', "\\r")
533}
534
535fn extract_strategy_from_id(eval_id: &str) -> Option<&str> {
537 eval_id.rsplit_once('_').map(|(prefix, _)| prefix)
538}
539
540fn 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
552type 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 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 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 pub async fn advice(
593 &self,
594 strategy: &str,
595 task: String,
596 opts: Option<serde_json::Value>,
597 ) -> Result<String, String> {
598 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 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 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 let opts_lua = match &strategy_opts {
660 Some(v) if !v.is_null() => format!("alc.json_decode('{}')", v),
661 _ => "{}".to_string(),
662 };
663
664 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 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 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 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 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 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 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 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 pub async fn eval_compare(&self, eval_id_a: &str, eval_id_b: &str) -> Result<String, String> {
836 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 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 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 save_compare_result(eval_id_a, eval_id_b, &raw_result);
969
970 Ok(raw_result)
971 }
972
973 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 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 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 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 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 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 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 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 let _ = std::fs::remove_dir_all(staging.path().join(".git"));
1107
1108 if staging.path().join("init.lua").exists() {
1110 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 if name.is_some() {
1139 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 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 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 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 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 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 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 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 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 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 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 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 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 let meta_path = path.with_extension("meta.json");
1386 let doc: serde_json::Value = if meta_path.exists() {
1387 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 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 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 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 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 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 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 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 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 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 #[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 #[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 #[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 #[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 #[test]
1721 fn log_list_from_dir() {
1722 let dir = tempfile::tempdir().unwrap();
1723
1724 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 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 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 #[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 #[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 assert!(dir.path().join("s-meta-test.json").exists());
1846
1847 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 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 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 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 #[test]
1912 fn transcript_config_default_enabled() {
1913 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 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 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 #[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 #[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 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 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 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 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 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 #[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 #[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 #[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 #[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 #[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 #[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 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 #[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 std::fs::write(source.path().join("math_basic.lua"), "return {}").unwrap();
2225 std::fs::write(source.path().join("safety.lua"), "return {}").unwrap();
2226 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 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 let dest = tempfile::tempdir().unwrap();
2273 let bad_dest = dest.path().join("nonexistent_subdir");
2274 std::fs::write(source.path().join("ok.lua"), "return 1").unwrap();
2277
2278 let result = install_scenarios_from_dir(source.path(), &bad_dest);
2279 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 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 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 let err = result.unwrap_err();
2352 assert!(
2353 err.contains("bundled") || err.contains("evalframe"),
2354 "unexpected error: {err}"
2355 );
2356 }
2357
2358 #[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 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}