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 AsRef<Path> for ContainedPath {
236 fn as_ref(&self) -> &Path {
237 &self.0
238 }
239}
240
241#[derive(Debug)]
245pub struct QueryResponse {
246 pub query_id: String,
248 pub response: String,
250}
251
252pub(crate) fn resolve_code(
255 code: Option<String>,
256 code_file: Option<String>,
257) -> Result<String, String> {
258 match (code, code_file) {
259 (Some(c), None) => Ok(c),
260 (None, Some(path)) => std::fs::read_to_string(Path::new(&path))
261 .map_err(|e| format!("Failed to read {path}: {e}")),
262 (Some(_), Some(_)) => Err("Provide either `code` or `code_file`, not both.".into()),
263 (None, None) => Err("Either `code` or `code_file` must be provided.".into()),
264 }
265}
266
267pub(crate) fn make_require_code(name: &str) -> String {
289 format!(
290 r#"local pkg = require("{name}")
291return pkg.run(ctx)"#
292 )
293}
294
295pub(crate) fn packages_dir() -> Result<PathBuf, String> {
296 let home = dirs::home_dir().ok_or("Cannot determine home directory")?;
297 Ok(home.join(".algocline").join("packages"))
298}
299
300const BUNDLED_PACKAGES_URL: &str = "https://github.com/ynishi/algocline-bundled-packages";
302
303fn is_package_installed(name: &str) -> bool {
305 packages_dir()
306 .map(|dir| dir.join(name).join("init.lua").exists())
307 .unwrap_or(false)
308}
309
310#[derive(Clone)]
313pub struct AppService {
314 executor: Arc<Executor>,
315 registry: Arc<SessionRegistry>,
316 log_config: TranscriptConfig,
317}
318
319impl AppService {
320 pub fn new(executor: Arc<Executor>, log_config: TranscriptConfig) -> Self {
321 Self {
322 executor,
323 registry: Arc::new(SessionRegistry::new()),
324 log_config,
325 }
326 }
327
328 pub async fn run(
330 &self,
331 code: Option<String>,
332 code_file: Option<String>,
333 ctx: Option<serde_json::Value>,
334 ) -> Result<String, String> {
335 let code = resolve_code(code, code_file)?;
336 let ctx = ctx.unwrap_or(serde_json::Value::Null);
337 self.start_and_tick(code, ctx).await
338 }
339
340 pub async fn advice(
345 &self,
346 strategy: &str,
347 task: String,
348 opts: Option<serde_json::Value>,
349 ) -> Result<String, String> {
350 if !is_package_installed(strategy) {
352 self.auto_install_bundled_packages().await?;
353 if !is_package_installed(strategy) {
354 return Err(format!(
355 "Package '{strategy}' not found after installing bundled collection. \
356 Use alc_pkg_install to install it manually."
357 ));
358 }
359 }
360
361 let code = make_require_code(strategy);
362
363 let mut ctx_map = match opts {
364 Some(serde_json::Value::Object(m)) => m,
365 _ => serde_json::Map::new(),
366 };
367 ctx_map.insert("task".into(), serde_json::Value::String(task));
368 let ctx = serde_json::Value::Object(ctx_map);
369
370 self.start_and_tick(code, ctx).await
371 }
372
373 pub async fn continue_batch(
375 &self,
376 session_id: &str,
377 responses: Vec<QueryResponse>,
378 ) -> Result<String, String> {
379 let mut last_result = None;
380 for qr in responses {
381 let qid = QueryId::parse(&qr.query_id);
382 let result = self
383 .registry
384 .feed_response(session_id, &qid, qr.response)
385 .await
386 .map_err(|e| format!("Continue failed: {e}"))?;
387 last_result = Some(result);
388 }
389 let result = last_result.ok_or("Empty responses array")?;
390 self.maybe_log_transcript(&result, session_id);
391 Ok(result.to_json(session_id).to_string())
392 }
393
394 pub async fn continue_single(
396 &self,
397 session_id: &str,
398 response: String,
399 query_id: Option<&str>,
400 ) -> Result<String, String> {
401 let query_id = match query_id {
402 Some(qid) => QueryId::parse(qid),
403 None => QueryId::single(),
404 };
405
406 let result = self
407 .registry
408 .feed_response(session_id, &query_id, response)
409 .await
410 .map_err(|e| format!("Continue failed: {e}"))?;
411
412 self.maybe_log_transcript(&result, session_id);
413 Ok(result.to_json(session_id).to_string())
414 }
415
416 pub async fn pkg_list(&self) -> Result<String, String> {
420 let pkg_dir = packages_dir()?;
421 if !pkg_dir.is_dir() {
422 return Ok(serde_json::json!({ "packages": [] }).to_string());
423 }
424
425 let mut packages = Vec::new();
426 let entries =
427 std::fs::read_dir(&pkg_dir).map_err(|e| format!("Failed to read packages dir: {e}"))?;
428
429 for entry in entries.flatten() {
430 let path = entry.path();
431 if !path.is_dir() {
432 continue;
433 }
434 let init_lua = path.join("init.lua");
435 if !init_lua.exists() {
436 continue;
437 }
438 let name = entry.file_name().to_string_lossy().to_string();
439 let code = format!(
440 r#"local pkg = require("{name}")
441return pkg.meta or {{ name = "{name}" }}"#
442 );
443 match self.executor.eval_simple(code).await {
444 Ok(meta) => packages.push(meta),
445 Err(_) => {
446 packages
447 .push(serde_json::json!({ "name": name, "error": "failed to load meta" }));
448 }
449 }
450 }
451
452 Ok(serde_json::json!({ "packages": packages }).to_string())
453 }
454
455 pub async fn pkg_install(&self, url: String, name: Option<String>) -> Result<String, String> {
457 let pkg_dir = packages_dir()?;
458 let _ = std::fs::create_dir_all(&pkg_dir);
459
460 let local_path = Path::new(&url);
462 if local_path.is_absolute() && local_path.is_dir() {
463 return self.install_from_local_path(local_path, &pkg_dir, name);
464 }
465
466 let git_url = if url.starts_with("http://")
468 || url.starts_with("https://")
469 || url.starts_with("file://")
470 || url.starts_with("git@")
471 {
472 url.clone()
473 } else {
474 format!("https://{url}")
475 };
476
477 let staging = tempfile::tempdir().map_err(|e| format!("Failed to create temp dir: {e}"))?;
479
480 let output = tokio::process::Command::new("git")
481 .args([
482 "clone",
483 "--depth",
484 "1",
485 &git_url,
486 &staging.path().to_string_lossy(),
487 ])
488 .output()
489 .await
490 .map_err(|e| format!("Failed to run git: {e}"))?;
491
492 if !output.status.success() {
493 let stderr = String::from_utf8_lossy(&output.stderr);
494 return Err(format!("git clone failed: {stderr}"));
495 }
496
497 let _ = std::fs::remove_dir_all(staging.path().join(".git"));
499
500 if staging.path().join("init.lua").exists() {
502 let name = name.unwrap_or_else(|| {
504 url.trim_end_matches('/')
505 .rsplit('/')
506 .next()
507 .unwrap_or("unknown")
508 .trim_end_matches(".git")
509 .to_string()
510 });
511
512 let dest = ContainedPath::child(&pkg_dir, &name)?;
513 if dest.as_ref().exists() {
514 return Err(format!(
515 "Package '{name}' already exists at {}. Remove it first.",
516 dest.as_ref().display()
517 ));
518 }
519
520 copy_dir(staging.path(), dest.as_ref())
521 .map_err(|e| format!("Failed to copy package: {e}"))?;
522
523 Ok(serde_json::json!({
524 "installed": [name],
525 "mode": "single",
526 })
527 .to_string())
528 } else {
529 if name.is_some() {
531 return Err(
533 "The 'name' parameter is only supported for single-package repos (init.lua at root). \
534 This repository is a collection (subdirs with init.lua)."
535 .to_string(),
536 );
537 }
538
539 let mut installed = Vec::new();
540 let mut skipped = Vec::new();
541
542 let entries = std::fs::read_dir(staging.path())
543 .map_err(|e| format!("Failed to read staging dir: {e}"))?;
544
545 for entry in entries {
546 let entry = entry.map_err(|e| format!("Failed to read entry: {e}"))?;
547 let path = entry.path();
548 if !path.is_dir() {
549 continue;
550 }
551 if !path.join("init.lua").exists() {
552 continue;
553 }
554 let pkg_name = entry.file_name().to_string_lossy().to_string();
555 let dest = pkg_dir.join(&pkg_name);
556 if dest.exists() {
557 skipped.push(pkg_name);
558 continue;
559 }
560 copy_dir(&path, &dest)
561 .map_err(|e| format!("Failed to copy package '{pkg_name}': {e}"))?;
562 installed.push(pkg_name);
563 }
564
565 if installed.is_empty() && skipped.is_empty() {
566 return Err(
567 "No packages found. Expected init.lua at root (single) or */init.lua (collection)."
568 .to_string(),
569 );
570 }
571
572 Ok(serde_json::json!({
573 "installed": installed,
574 "skipped": skipped,
575 "mode": "collection",
576 })
577 .to_string())
578 }
579 }
580
581 fn install_from_local_path(
583 &self,
584 source: &Path,
585 pkg_dir: &Path,
586 name: Option<String>,
587 ) -> Result<String, String> {
588 if source.join("init.lua").exists() {
589 let name = name.unwrap_or_else(|| {
591 source
592 .file_name()
593 .map(|n| n.to_string_lossy().to_string())
594 .unwrap_or_else(|| "unknown".to_string())
595 });
596
597 let dest = ContainedPath::child(pkg_dir, &name)?;
598 if dest.as_ref().exists() {
599 let _ = std::fs::remove_dir_all(&dest);
601 }
602
603 copy_dir(source, dest.as_ref()).map_err(|e| format!("Failed to copy package: {e}"))?;
604 let _ = std::fs::remove_dir_all(dest.as_ref().join(".git"));
606
607 Ok(serde_json::json!({
608 "installed": [name],
609 "mode": "local_single",
610 })
611 .to_string())
612 } else {
613 if name.is_some() {
615 return Err(
616 "The 'name' parameter is only supported for single-package dirs (init.lua at root)."
617 .to_string(),
618 );
619 }
620
621 let mut installed = Vec::new();
622 let mut updated = Vec::new();
623
624 let entries =
625 std::fs::read_dir(source).map_err(|e| format!("Failed to read source dir: {e}"))?;
626
627 for entry in entries {
628 let entry = entry.map_err(|e| format!("Failed to read entry: {e}"))?;
629 let path = entry.path();
630 if !path.is_dir() || !path.join("init.lua").exists() {
631 continue;
632 }
633 let pkg_name = entry.file_name().to_string_lossy().to_string();
634 let dest = pkg_dir.join(&pkg_name);
635 let existed = dest.exists();
636 if existed {
637 let _ = std::fs::remove_dir_all(&dest);
638 }
639 copy_dir(&path, &dest)
640 .map_err(|e| format!("Failed to copy package '{pkg_name}': {e}"))?;
641 let _ = std::fs::remove_dir_all(dest.join(".git"));
642 if existed {
643 updated.push(pkg_name);
644 } else {
645 installed.push(pkg_name);
646 }
647 }
648
649 if installed.is_empty() && updated.is_empty() {
650 return Err(
651 "No packages found. Expected init.lua at root (single) or */init.lua (collection)."
652 .to_string(),
653 );
654 }
655
656 Ok(serde_json::json!({
657 "installed": installed,
658 "updated": updated,
659 "mode": "local_collection",
660 })
661 .to_string())
662 }
663 }
664
665 pub async fn pkg_remove(&self, name: &str) -> Result<String, String> {
667 let pkg_dir = packages_dir()?;
668 let dest = ContainedPath::child(&pkg_dir, name)?;
669
670 if !dest.as_ref().exists() {
671 return Err(format!("Package '{name}' not found"));
672 }
673
674 std::fs::remove_dir_all(&dest).map_err(|e| format!("Failed to remove '{name}': {e}"))?;
675
676 Ok(serde_json::json!({ "removed": name }).to_string())
677 }
678
679 pub async fn add_note(
683 &self,
684 session_id: &str,
685 content: &str,
686 title: Option<&str>,
687 ) -> Result<String, String> {
688 let count = append_note(&self.log_config.dir, session_id, content, title)?;
689 Ok(serde_json::json!({
690 "session_id": session_id,
691 "notes_count": count,
692 })
693 .to_string())
694 }
695
696 pub async fn log_view(
698 &self,
699 session_id: Option<&str>,
700 limit: Option<usize>,
701 ) -> Result<String, String> {
702 match session_id {
703 Some(sid) => self.log_read(sid),
704 None => self.log_list(limit.unwrap_or(50)),
705 }
706 }
707
708 fn log_read(&self, session_id: &str) -> Result<String, String> {
709 let path = ContainedPath::child(&self.log_config.dir, &format!("{session_id}.json"))?;
710 if !path.as_ref().exists() {
711 return Err(format!("Log file not found for session '{session_id}'"));
712 }
713 std::fs::read_to_string(&path).map_err(|e| format!("Failed to read log: {e}"))
714 }
715
716 fn log_list(&self, limit: usize) -> Result<String, String> {
717 let dir = &self.log_config.dir;
718 if !dir.is_dir() {
719 return Ok(serde_json::json!({ "sessions": [] }).to_string());
720 }
721
722 let entries = std::fs::read_dir(dir).map_err(|e| format!("Failed to read log dir: {e}"))?;
723
724 let mut files: Vec<(std::path::PathBuf, std::time::SystemTime)> = entries
726 .flatten()
727 .filter_map(|entry| {
728 let path = entry.path();
729 let name = path.file_name()?.to_str()?;
730 if !name.ends_with(".json") || name.ends_with(".meta.json") {
732 return None;
733 }
734 let mtime = entry.metadata().ok()?.modified().ok()?;
735 Some((path, mtime))
736 })
737 .collect();
738
739 files.sort_by(|a, b| b.1.cmp(&a.1));
741 files.truncate(limit);
742
743 let mut sessions = Vec::new();
744 for (path, _) in &files {
745 let meta_path = path.with_extension("meta.json");
747 let doc: serde_json::Value = if meta_path.exists() {
748 match std::fs::read_to_string(&meta_path)
750 .ok()
751 .and_then(|r| serde_json::from_str(&r).ok())
752 {
753 Some(d) => d,
754 None => continue,
755 }
756 } else {
757 let raw = match std::fs::read_to_string(path) {
759 Ok(r) => r,
760 Err(_) => continue,
761 };
762 match serde_json::from_str::<serde_json::Value>(&raw) {
763 Ok(d) => {
764 let stats = d.get("stats");
765 serde_json::json!({
766 "session_id": d.get("session_id").and_then(|v| v.as_str()).unwrap_or("unknown"),
767 "task_hint": d.get("task_hint").and_then(|v| v.as_str()),
768 "elapsed_ms": stats.and_then(|s| s.get("elapsed_ms")),
769 "rounds": stats.and_then(|s| s.get("rounds")),
770 "llm_calls": stats.and_then(|s| s.get("llm_calls")),
771 "notes_count": d.get("notes").and_then(|v| v.as_array()).map(|a| a.len()).unwrap_or(0),
772 })
773 }
774 Err(_) => continue,
775 }
776 };
777
778 sessions.push(doc);
779 }
780
781 Ok(serde_json::json!({ "sessions": sessions }).to_string())
782 }
783
784 async fn auto_install_bundled_packages(&self) -> Result<(), String> {
788 tracing::info!(
789 "auto-installing bundled packages from {}",
790 BUNDLED_PACKAGES_URL
791 );
792 self.pkg_install(BUNDLED_PACKAGES_URL.to_string(), None)
793 .await
794 .map_err(|e| format!("Failed to auto-install bundled packages: {e}"))?;
795 Ok(())
796 }
797
798 fn maybe_log_transcript(&self, result: &FeedResult, session_id: &str) {
799 if let FeedResult::Finished(exec_result) = result {
800 write_transcript_log(&self.log_config, session_id, &exec_result.metrics);
801 }
802 }
803
804 async fn start_and_tick(&self, code: String, ctx: serde_json::Value) -> Result<String, String> {
805 let session = self.executor.start_session(code, ctx).await?;
806 let (session_id, result) = self
807 .registry
808 .start_execution(session)
809 .await
810 .map_err(|e| format!("Execution failed: {e}"))?;
811 self.maybe_log_transcript(&result, &session_id);
812 Ok(result.to_json(&session_id).to_string())
813 }
814}
815
816#[cfg(test)]
817mod tests {
818 use super::*;
819 use algocline_core::ExecutionObserver;
820 use std::io::Write;
821
822 #[test]
825 fn resolve_code_inline() {
826 let result = resolve_code(Some("return 1".into()), None);
827 assert_eq!(result.unwrap(), "return 1");
828 }
829
830 #[test]
831 fn resolve_code_from_file() {
832 let mut tmp = tempfile::NamedTempFile::new().unwrap();
833 write!(tmp, "return 42").unwrap();
834
835 let result = resolve_code(None, Some(tmp.path().to_string_lossy().into()));
836 assert_eq!(result.unwrap(), "return 42");
837 }
838
839 #[test]
840 fn resolve_code_both_provided_error() {
841 let result = resolve_code(Some("code".into()), Some("file.lua".into()));
842 let err = result.unwrap_err();
843 assert!(err.contains("not both"), "error: {err}");
844 }
845
846 #[test]
847 fn resolve_code_neither_provided_error() {
848 let result = resolve_code(None, None);
849 let err = result.unwrap_err();
850 assert!(err.contains("must be provided"), "error: {err}");
851 }
852
853 #[test]
854 fn resolve_code_nonexistent_file_error() {
855 let result = resolve_code(
856 None,
857 Some("/tmp/algocline_nonexistent_test_file.lua".into()),
858 );
859 assert!(result.is_err());
860 }
861
862 #[test]
865 fn make_require_code_basic() {
866 let code = make_require_code("ucb");
867 assert!(code.contains(r#"require("ucb")"#), "code: {code}");
868 assert!(code.contains("pkg.run(ctx)"), "code: {code}");
869 }
870
871 #[test]
872 fn make_require_code_different_names() {
873 for name in &["panel", "cot", "sc", "cove", "reflect", "calibrate"] {
874 let code = make_require_code(name);
875 assert!(
876 code.contains(&format!(r#"require("{name}")"#)),
877 "code for {name}: {code}"
878 );
879 }
880 }
881
882 #[test]
885 fn packages_dir_ends_with_expected_path() {
886 let dir = packages_dir().unwrap();
887 assert!(
888 dir.ends_with(".algocline/packages"),
889 "dir: {}",
890 dir.display()
891 );
892 }
893
894 #[test]
897 fn append_note_to_existing_log() {
898 let dir = tempfile::tempdir().unwrap();
899 let session_id = "s-test-001";
900 let log = serde_json::json!({
901 "session_id": session_id,
902 "stats": { "elapsed_ms": 100 },
903 "transcript": [],
904 });
905 let path = dir.path().join(format!("{session_id}.json"));
906 std::fs::write(&path, serde_json::to_string_pretty(&log).unwrap()).unwrap();
907
908 let count = append_note(dir.path(), session_id, "Step 2 was weak", Some("Step 2")).unwrap();
909 assert_eq!(count, 1);
910
911 let count = append_note(dir.path(), session_id, "Overall good", None).unwrap();
912 assert_eq!(count, 2);
913
914 let raw = std::fs::read_to_string(&path).unwrap();
915 let doc: serde_json::Value = serde_json::from_str(&raw).unwrap();
916 let notes = doc["notes"].as_array().unwrap();
917 assert_eq!(notes.len(), 2);
918 assert_eq!(notes[0]["content"], "Step 2 was weak");
919 assert_eq!(notes[0]["title"], "Step 2");
920 assert_eq!(notes[1]["content"], "Overall good");
921 assert!(notes[1]["title"].is_null());
922 assert!(notes[0]["timestamp"].is_number());
923 }
924
925 #[test]
926 fn append_note_missing_log_returns_error() {
927 let dir = tempfile::tempdir().unwrap();
928 let result = append_note(dir.path(), "s-nonexistent", "note", None);
929 assert!(result.is_err());
930 assert!(result.unwrap_err().contains("not found"));
931 }
932
933 #[test]
936 fn log_list_from_dir() {
937 let dir = tempfile::tempdir().unwrap();
938
939 let log1 = serde_json::json!({
941 "session_id": "s-001",
942 "task_hint": "What is 2+2?",
943 "stats": { "elapsed_ms": 100, "rounds": 1, "llm_calls": 1 },
944 "transcript": [{ "prompt": "What is 2+2?", "response": "4" }],
945 });
946 let log2 = serde_json::json!({
947 "session_id": "s-002",
948 "task_hint": "Explain ownership",
949 "stats": { "elapsed_ms": 5000, "rounds": 3, "llm_calls": 3 },
950 "transcript": [],
951 "notes": [{ "timestamp": 0, "content": "good" }],
952 });
953
954 std::fs::write(
955 dir.path().join("s-001.json"),
956 serde_json::to_string(&log1).unwrap(),
957 )
958 .unwrap();
959 std::fs::write(
960 dir.path().join("s-002.json"),
961 serde_json::to_string(&log2).unwrap(),
962 )
963 .unwrap();
964 std::fs::write(dir.path().join("README.txt"), "ignore me").unwrap();
966
967 let config = TranscriptConfig {
968 dir: dir.path().to_path_buf(),
969 enabled: true,
970 };
971
972 let entries = std::fs::read_dir(&config.dir).unwrap();
974 let mut count = 0;
975 for entry in entries.flatten() {
976 if entry.path().extension().and_then(|e| e.to_str()) == Some("json") {
977 count += 1;
978 }
979 }
980 assert_eq!(count, 2);
981 }
982
983 #[test]
986 fn contained_path_accepts_simple_name() {
987 let dir = tempfile::tempdir().unwrap();
988 let result = ContainedPath::child(dir.path(), "s-abc123.json");
989 assert!(result.is_ok());
990 assert!(result.unwrap().as_ref().ends_with("s-abc123.json"));
991 }
992
993 #[test]
994 fn contained_path_rejects_parent_traversal() {
995 let dir = tempfile::tempdir().unwrap();
996 let result = ContainedPath::child(dir.path(), "../../../etc/passwd");
997 assert!(result.is_err());
998 let err = result.unwrap_err();
999 assert!(err.contains("path traversal"), "err: {err}");
1000 }
1001
1002 #[test]
1003 fn contained_path_rejects_absolute_path() {
1004 let dir = tempfile::tempdir().unwrap();
1005 let result = ContainedPath::child(dir.path(), "/etc/passwd");
1006 assert!(result.is_err());
1007 let err = result.unwrap_err();
1008 assert!(err.contains("path traversal"), "err: {err}");
1009 }
1010
1011 #[test]
1012 fn contained_path_rejects_dot_dot_in_middle() {
1013 let dir = tempfile::tempdir().unwrap();
1014 let result = ContainedPath::child(dir.path(), "foo/../bar");
1015 assert!(result.is_err());
1016 }
1017
1018 #[test]
1019 fn contained_path_accepts_nested_normal() {
1020 let dir = tempfile::tempdir().unwrap();
1021 let result = ContainedPath::child(dir.path(), "sub/file.json");
1022 assert!(result.is_ok());
1023 }
1024
1025 #[test]
1026 fn append_note_rejects_traversal_session_id() {
1027 let dir = tempfile::tempdir().unwrap();
1028 let result = append_note(dir.path(), "../../../etc/passwd", "evil", None);
1029 assert!(result.is_err());
1030 assert!(result.unwrap_err().contains("path traversal"));
1031 }
1032
1033 #[test]
1036 fn write_transcript_log_creates_meta_file() {
1037 let dir = tempfile::tempdir().unwrap();
1038 let config = TranscriptConfig {
1039 dir: dir.path().to_path_buf(),
1040 enabled: true,
1041 };
1042
1043 let metrics = algocline_core::ExecutionMetrics::new();
1044 let observer = metrics.create_observer();
1045 observer.on_paused(&[algocline_core::LlmQuery {
1046 id: algocline_core::QueryId::single(),
1047 prompt: "What is 2+2?".into(),
1048 system: None,
1049 max_tokens: 100,
1050 grounded: false,
1051 }]);
1052 observer.on_response_fed(&algocline_core::QueryId::single(), "4");
1053 observer.on_resumed();
1054 observer.on_completed(&serde_json::json!(null));
1055
1056 write_transcript_log(&config, "s-meta-test", &metrics);
1057
1058 assert!(dir.path().join("s-meta-test.json").exists());
1060
1061 let meta_path = dir.path().join("s-meta-test.meta.json");
1063 assert!(meta_path.exists());
1064
1065 let raw = std::fs::read_to_string(&meta_path).unwrap();
1066 let meta: serde_json::Value = serde_json::from_str(&raw).unwrap();
1067 assert_eq!(meta["session_id"], "s-meta-test");
1068 assert_eq!(meta["notes_count"], 0);
1069 assert!(meta.get("elapsed_ms").is_some());
1070 assert!(meta.get("rounds").is_some());
1071 assert!(meta.get("llm_calls").is_some());
1072 assert!(meta.get("transcript").is_none());
1074 }
1075
1076 #[test]
1077 fn append_note_updates_meta_notes_count() {
1078 let dir = tempfile::tempdir().unwrap();
1079 let session_id = "s-meta-note";
1080
1081 let log = serde_json::json!({
1083 "session_id": session_id,
1084 "stats": { "elapsed_ms": 100 },
1085 "transcript": [],
1086 });
1087 std::fs::write(
1088 dir.path().join(format!("{session_id}.json")),
1089 serde_json::to_string_pretty(&log).unwrap(),
1090 )
1091 .unwrap();
1092
1093 let meta = serde_json::json!({
1095 "session_id": session_id,
1096 "task_hint": "test",
1097 "elapsed_ms": 100,
1098 "rounds": 1,
1099 "llm_calls": 1,
1100 "notes_count": 0,
1101 });
1102 std::fs::write(
1103 dir.path().join(format!("{session_id}.meta.json")),
1104 serde_json::to_string(&meta).unwrap(),
1105 )
1106 .unwrap();
1107
1108 append_note(dir.path(), session_id, "first note", None).unwrap();
1109
1110 let raw =
1111 std::fs::read_to_string(dir.path().join(format!("{session_id}.meta.json"))).unwrap();
1112 let updated: serde_json::Value = serde_json::from_str(&raw).unwrap();
1113 assert_eq!(updated["notes_count"], 1);
1114
1115 append_note(dir.path(), session_id, "second note", None).unwrap();
1116
1117 let raw =
1118 std::fs::read_to_string(dir.path().join(format!("{session_id}.meta.json"))).unwrap();
1119 let updated: serde_json::Value = serde_json::from_str(&raw).unwrap();
1120 assert_eq!(updated["notes_count"], 2);
1121 }
1122
1123 #[test]
1126 fn transcript_config_default_enabled() {
1127 let config = TranscriptConfig {
1129 dir: PathBuf::from("/tmp/test"),
1130 enabled: true,
1131 };
1132 assert!(config.enabled);
1133 }
1134
1135 #[test]
1136 fn write_transcript_log_disabled_is_noop() {
1137 let dir = tempfile::tempdir().unwrap();
1138 let config = TranscriptConfig {
1139 dir: dir.path().to_path_buf(),
1140 enabled: false,
1141 };
1142 let metrics = algocline_core::ExecutionMetrics::new();
1143 let observer = metrics.create_observer();
1144 observer.on_paused(&[algocline_core::LlmQuery {
1145 id: algocline_core::QueryId::single(),
1146 prompt: "test".into(),
1147 system: None,
1148 max_tokens: 10,
1149 grounded: false,
1150 }]);
1151 observer.on_response_fed(&algocline_core::QueryId::single(), "r");
1152 observer.on_resumed();
1153 observer.on_completed(&serde_json::json!(null));
1154
1155 write_transcript_log(&config, "s-disabled", &metrics);
1156
1157 assert!(!dir.path().join("s-disabled.json").exists());
1159 assert!(!dir.path().join("s-disabled.meta.json").exists());
1160 }
1161
1162 #[test]
1163 fn write_transcript_log_empty_transcript_is_noop() {
1164 let dir = tempfile::tempdir().unwrap();
1165 let config = TranscriptConfig {
1166 dir: dir.path().to_path_buf(),
1167 enabled: true,
1168 };
1169 let metrics = algocline_core::ExecutionMetrics::new();
1171 write_transcript_log(&config, "s-empty", &metrics);
1172 assert!(!dir.path().join("s-empty.json").exists());
1173 }
1174
1175 #[test]
1178 fn copy_dir_basic() {
1179 let src = tempfile::tempdir().unwrap();
1180 let dst = tempfile::tempdir().unwrap();
1181
1182 std::fs::write(src.path().join("a.txt"), "hello").unwrap();
1183 std::fs::create_dir(src.path().join("sub")).unwrap();
1184 std::fs::write(src.path().join("sub/b.txt"), "world").unwrap();
1185
1186 let dst_path = dst.path().join("copied");
1187 copy_dir(src.path(), &dst_path).unwrap();
1188
1189 assert_eq!(
1190 std::fs::read_to_string(dst_path.join("a.txt")).unwrap(),
1191 "hello"
1192 );
1193 assert_eq!(
1194 std::fs::read_to_string(dst_path.join("sub/b.txt")).unwrap(),
1195 "world"
1196 );
1197 }
1198
1199 #[test]
1200 fn copy_dir_empty() {
1201 let src = tempfile::tempdir().unwrap();
1202 let dst = tempfile::tempdir().unwrap();
1203 let dst_path = dst.path().join("empty_copy");
1204 copy_dir(src.path(), &dst_path).unwrap();
1205 assert!(dst_path.exists());
1206 assert!(dst_path.is_dir());
1207 }
1208
1209 #[test]
1212 fn write_transcript_log_truncates_long_prompt() {
1213 let dir = tempfile::tempdir().unwrap();
1214 let config = TranscriptConfig {
1215 dir: dir.path().to_path_buf(),
1216 enabled: true,
1217 };
1218 let metrics = algocline_core::ExecutionMetrics::new();
1219 let observer = metrics.create_observer();
1220 let long_prompt = "x".repeat(300);
1221 observer.on_paused(&[algocline_core::LlmQuery {
1222 id: algocline_core::QueryId::single(),
1223 prompt: long_prompt,
1224 system: None,
1225 max_tokens: 10,
1226 grounded: false,
1227 }]);
1228 observer.on_response_fed(&algocline_core::QueryId::single(), "r");
1229 observer.on_resumed();
1230 observer.on_completed(&serde_json::json!(null));
1231
1232 write_transcript_log(&config, "s-long", &metrics);
1233
1234 let raw = std::fs::read_to_string(dir.path().join("s-long.json")).unwrap();
1235 let doc: serde_json::Value = serde_json::from_str(&raw).unwrap();
1236 let hint = doc["task_hint"].as_str().unwrap();
1237 assert!(hint.len() <= 104, "hint too long: {} chars", hint.len());
1239 assert!(hint.ends_with("..."));
1240 }
1241
1242 #[test]
1243 fn log_list_prefers_meta_file() {
1244 let dir = tempfile::tempdir().unwrap();
1245
1246 let log = serde_json::json!({
1248 "session_id": "s-big",
1249 "task_hint": "full log hint",
1250 "stats": { "elapsed_ms": 999, "rounds": 5, "llm_calls": 5 },
1251 "transcript": [{"prompt": "x".repeat(10000), "response": "y".repeat(10000)}],
1252 });
1253 std::fs::write(
1254 dir.path().join("s-big.json"),
1255 serde_json::to_string(&log).unwrap(),
1256 )
1257 .unwrap();
1258
1259 let meta = serde_json::json!({
1261 "session_id": "s-big",
1262 "task_hint": "full log hint",
1263 "elapsed_ms": 999,
1264 "rounds": 5,
1265 "llm_calls": 5,
1266 "notes_count": 0,
1267 });
1268 std::fs::write(
1269 dir.path().join("s-big.meta.json"),
1270 serde_json::to_string(&meta).unwrap(),
1271 )
1272 .unwrap();
1273
1274 let legacy = serde_json::json!({
1276 "session_id": "s-legacy",
1277 "task_hint": "legacy hint",
1278 "stats": { "elapsed_ms": 100, "rounds": 1, "llm_calls": 1 },
1279 "transcript": [],
1280 });
1281 std::fs::write(
1282 dir.path().join("s-legacy.json"),
1283 serde_json::to_string(&legacy).unwrap(),
1284 )
1285 .unwrap();
1286
1287 let config = TranscriptConfig {
1288 dir: dir.path().to_path_buf(),
1289 enabled: true,
1290 };
1291 let app = AppService {
1292 executor: Arc::new(
1293 tokio::runtime::Builder::new_current_thread()
1294 .build()
1295 .unwrap()
1296 .block_on(async { algocline_engine::Executor::new(vec![]).await.unwrap() }),
1297 ),
1298 registry: Arc::new(algocline_engine::SessionRegistry::new()),
1299 log_config: config,
1300 };
1301
1302 let result = app.log_list(50).unwrap();
1303 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
1304 let sessions = parsed["sessions"].as_array().unwrap();
1305
1306 assert_eq!(sessions.len(), 2);
1307
1308 let ids: Vec<&str> = sessions
1310 .iter()
1311 .map(|s| s["session_id"].as_str().unwrap())
1312 .collect();
1313 assert!(ids.contains(&"s-big"));
1314 assert!(ids.contains(&"s-legacy"));
1315 }
1316}
1317
1318#[cfg(test)]
1319mod proptests {
1320 use super::*;
1321 use proptest::prelude::*;
1322
1323 proptest! {
1324 #[test]
1326 fn resolve_code_never_panics(
1327 code in proptest::option::of("[a-z]{0,50}"),
1328 file in proptest::option::of("[a-z]{0,50}"),
1329 ) {
1330 let _ = resolve_code(code, file);
1331 }
1332
1333 #[test]
1335 fn contained_path_rejects_traversal(
1336 prefix in "[a-z]{0,5}",
1337 suffix in "[a-z]{0,5}",
1338 ) {
1339 let dir = tempfile::tempdir().unwrap();
1340 let name = format!("{prefix}/../{suffix}");
1341 let result = ContainedPath::child(dir.path(), &name);
1342 prop_assert!(result.is_err());
1343 }
1344
1345 #[test]
1347 fn contained_path_accepts_simple_names(name in "[a-z][a-z0-9_-]{0,20}\\.json") {
1348 let dir = tempfile::tempdir().unwrap();
1349 let result = ContainedPath::child(dir.path(), &name);
1350 prop_assert!(result.is_ok());
1351 }
1352
1353 #[test]
1355 fn make_require_code_contains_name(name in "[a-z_]{1,20}") {
1356 let code = make_require_code(&name);
1357 let expected = format!("require(\"{}\")", name);
1358 prop_assert!(code.contains(&expected));
1359 prop_assert!(code.contains("pkg.run(ctx)"));
1360 }
1361
1362 #[test]
1364 fn copy_dir_preserves_content(content in "[a-zA-Z0-9 ]{1,200}") {
1365 let src = tempfile::tempdir().unwrap();
1366 let dst = tempfile::tempdir().unwrap();
1367
1368 std::fs::write(src.path().join("test.txt"), &content).unwrap();
1369 let dst_path = dst.path().join("out");
1370 copy_dir(src.path(), &dst_path).unwrap();
1371
1372 let read = std::fs::read_to_string(dst_path.join("test.txt")).unwrap();
1373 prop_assert_eq!(&read, &content);
1374 }
1375 }
1376}