Skip to main content

algocline_app/
service.rs

1use std::path::{Path, PathBuf};
2use std::sync::Arc;
3
4use algocline_core::{ExecutionMetrics, QueryId};
5use algocline_engine::{Executor, FeedResult, SessionRegistry};
6
7// ─── Transcript logging ─────────────────────────────────────────
8
9/// Controls transcript log output.
10///
11/// - `ALC_LOG_DIR`: Directory for log files. Default: `~/.algocline/logs`.
12/// - `ALC_LOG_LEVEL`: `full` (default) or `off`.
13#[derive(Clone, Debug)]
14pub struct TranscriptConfig {
15    pub dir: PathBuf,
16    pub enabled: bool,
17}
18
19impl TranscriptConfig {
20    /// Build from environment variables.
21    pub fn from_env() -> Self {
22        let dir = std::env::var("ALC_LOG_DIR")
23            .map(PathBuf::from)
24            .unwrap_or_else(|_| {
25                dirs::home_dir()
26                    .unwrap_or_else(|| PathBuf::from("."))
27                    .join(".algocline")
28                    .join("logs")
29            });
30
31        let enabled = std::env::var("ALC_LOG_LEVEL")
32            .map(|v| v.to_lowercase() != "off")
33            .unwrap_or(true);
34
35        Self { dir, enabled }
36    }
37}
38
39/// Write transcript log to `{dir}/{session_id}.json`.
40///
41/// Silently returns on I/O errors — logging must not break execution.
42fn write_transcript_log(config: &TranscriptConfig, session_id: &str, metrics: &ExecutionMetrics) {
43    if !config.enabled {
44        return;
45    }
46
47    let transcript = metrics.transcript_to_json();
48    if transcript.is_empty() {
49        return;
50    }
51
52    let stats = metrics.to_json();
53
54    // Extract task hint from first prompt (truncated to 100 chars)
55    let task_hint = transcript
56        .first()
57        .and_then(|e| e.get("prompt"))
58        .and_then(|p| p.as_str())
59        .map(|s| {
60            if s.len() <= 100 {
61                s.to_string()
62            } else {
63                // Find a char boundary at or before 100 bytes
64                let mut end = 100;
65                while end > 0 && !s.is_char_boundary(end) {
66                    end -= 1;
67                }
68                format!("{}...", &s[..end])
69            }
70        });
71
72    let auto_stats = &stats["auto"];
73
74    let log_entry = serde_json::json!({
75        "session_id": session_id,
76        "task_hint": task_hint,
77        "stats": auto_stats,
78        "transcript": transcript,
79    });
80
81    if std::fs::create_dir_all(&config.dir).is_err() {
82        return;
83    }
84
85    let path = match ContainedPath::child(&config.dir, &format!("{session_id}.json")) {
86        Ok(p) => p,
87        Err(_) => return,
88    };
89    let content = match serde_json::to_string_pretty(&log_entry) {
90        Ok(s) => s,
91        Err(_) => return,
92    };
93
94    let _ = std::fs::write(&path, content);
95
96    // Write lightweight meta file for log_list (avoids reading full transcript)
97    let meta = serde_json::json!({
98        "session_id": session_id,
99        "task_hint": task_hint,
100        "elapsed_ms": auto_stats.get("elapsed_ms"),
101        "rounds": auto_stats.get("rounds"),
102        "llm_calls": auto_stats.get("llm_calls"),
103        "notes_count": 0,
104    });
105    if let Ok(meta_path) = ContainedPath::child(&config.dir, &format!("{session_id}.meta.json")) {
106        let _ = serde_json::to_string(&meta).map(|s| std::fs::write(&meta_path, s));
107    }
108}
109
110/// Append a note to an existing log file.
111///
112/// Reads `{dir}/{session_id}.json`, adds the note to `"notes"` array, writes back.
113/// Returns Ok with the note count, or Err if the log file doesn't exist.
114fn append_note(
115    dir: &Path,
116    session_id: &str,
117    content: &str,
118    title: Option<&str>,
119) -> Result<usize, String> {
120    let path = ContainedPath::child(dir, &format!("{session_id}.json"))?;
121    if !path.as_ref().exists() {
122        return Err(format!("Log file not found for session '{session_id}'"));
123    }
124
125    let raw = std::fs::read_to_string(&path).map_err(|e| format!("Failed to read log: {e}"))?;
126    let mut doc: serde_json::Value =
127        serde_json::from_str(&raw).map_err(|e| format!("Failed to parse log: {e}"))?;
128
129    let timestamp = {
130        use std::time::{SystemTime, UNIX_EPOCH};
131        SystemTime::now()
132            .duration_since(UNIX_EPOCH)
133            .unwrap_or_default()
134            .as_secs()
135    };
136
137    let note = serde_json::json!({
138        "timestamp": timestamp,
139        "title": title,
140        "content": content,
141    });
142
143    let notes = doc
144        .as_object_mut()
145        .ok_or("Log file is not a JSON object")?
146        .entry("notes")
147        .or_insert_with(|| serde_json::json!([]));
148
149    let arr = notes
150        .as_array_mut()
151        .ok_or("'notes' field is not an array")?;
152    arr.push(note);
153    let count = arr.len();
154
155    let output =
156        serde_json::to_string_pretty(&doc).map_err(|e| format!("Failed to serialize: {e}"))?;
157    std::fs::write(path.as_ref(), output).map_err(|e| format!("Failed to write log: {e}"))?;
158
159    // Update notes_count in meta file (best-effort)
160    if let Ok(meta_path) = ContainedPath::child(dir, &format!("{session_id}.meta.json")) {
161        if meta_path.as_ref().exists() {
162            if let Ok(raw) = std::fs::read_to_string(&meta_path) {
163                if let Ok(mut meta) = serde_json::from_str::<serde_json::Value>(&raw) {
164                    meta["notes_count"] = serde_json::json!(count);
165                    if let Ok(s) = serde_json::to_string(&meta) {
166                        let _ = std::fs::write(&meta_path, s);
167                    }
168                }
169            }
170        }
171    }
172
173    Ok(count)
174}
175
176// ─── Helpers ────────────────────────────────────────────────────
177
178/// Recursively copy a directory tree (follows symlinks).
179fn copy_dir(src: &Path, dst: &Path) -> std::io::Result<()> {
180    std::fs::create_dir_all(dst)?;
181    for entry in std::fs::read_dir(src)? {
182        let entry = entry?;
183        // Use metadata() (follows symlinks) instead of file_type() (does not)
184        let meta = entry.metadata()?;
185        let dest_path = dst.join(entry.file_name());
186        if meta.is_dir() {
187            copy_dir(&entry.path(), &dest_path)?;
188        } else {
189            std::fs::copy(entry.path(), dest_path)?;
190        }
191    }
192    Ok(())
193}
194
195// ─── Path safety ────────────────────────────────────────────────
196
197/// A path verified to reside within a base directory.
198///
199/// Constructed via [`ContainedPath::child`], which rejects path traversal
200/// (`..`, absolute paths, symlink escapes). Once constructed, the inner path
201/// is safe for filesystem operations within the base directory.
202#[derive(Debug)]
203struct ContainedPath(PathBuf);
204
205impl ContainedPath {
206    /// Resolve `name` as a child of `base`, rejecting traversal attempts.
207    ///
208    /// Validates that every component in `name` is [`Component::Normal`].
209    /// If the resulting path already exists on disk, additionally verifies
210    /// via `canonicalize` that symlinks do not escape `base`.
211    fn child(base: &Path, name: &str) -> Result<Self, String> {
212        for comp in Path::new(name).components() {
213            if !matches!(comp, std::path::Component::Normal(_)) {
214                return Err(format!(
215                    "Invalid path component in '{name}': path traversal detected"
216                ));
217            }
218        }
219        let path = base.join(name);
220        if path.exists() {
221            let canonical = path
222                .canonicalize()
223                .map_err(|e| format!("Path resolution failed: {e}"))?;
224            let base_canonical = base
225                .canonicalize()
226                .map_err(|e| format!("Base path resolution failed: {e}"))?;
227            if !canonical.starts_with(&base_canonical) {
228                return Err(format!("Path '{name}' escapes base directory"));
229            }
230        }
231        Ok(Self(path))
232    }
233}
234
235impl AsRef<Path> for ContainedPath {
236    fn as_ref(&self) -> &Path {
237        &self.0
238    }
239}
240
241// ─── Parameter types (MCP-independent) ──────────────────────────
242
243/// A single query response in a batch feed.
244#[derive(Debug)]
245pub struct QueryResponse {
246    /// Query ID (e.g. "q-0", "q-1").
247    pub query_id: String,
248    /// The host LLM's response for this query.
249    pub response: String,
250}
251
252// ─── Code resolution ────────────────────────────────────────────
253
254pub(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
267/// Build Lua code that loads a package by name and calls `pkg.run(ctx)`.
268///
269/// # Security: `name` is not sanitized
270///
271/// `name` is interpolated directly into a Lua `require()` call without
272/// sanitization. This is intentional in the current architecture:
273///
274/// - algocline is a **local development/execution tool** that runs Lua in
275///   the user's own environment via mlua (not a multi-tenant service).
276/// - The same caller has access to `alc_run`, which executes **arbitrary
277///   Lua code**. Sanitizing `name` here would not reduce the attack surface.
278/// - The MCP trust boundary lies at the **host/client** level — the host
279///   decides whether to invoke `alc_advice` at all.
280///
281/// If algocline is extended to a shared backend (e.g. a package registry
282/// server accepting untrusted strategy names), `name` **must** be validated
283/// (allowlist of `[a-zA-Z0-9_-]` or equivalent) before interpolation.
284///
285/// References:
286/// - [MCP Security Best Practices — Local MCP Server Compromise](https://modelcontextprotocol.io/specification/draft/basic/security_best_practices)
287/// - [OWASP MCP Security Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/MCP_Security_Cheat_Sheet.html)
288pub(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
300/// Default Git URL for the bundled package collection.
301const BUNDLED_PACKAGES_URL: &str = "https://github.com/ynishi/algocline-bundled-packages";
302
303/// Check whether a package is installed (has `init.lua`).
304fn 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// ─── Application Service ────────────────────────────────────────
311
312#[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    /// Execute Lua code with optional JSON context.
329    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    /// Apply a built-in strategy to a task.
341    ///
342    /// If the requested package is not installed, automatically installs the
343    /// bundled package collection from GitHub before executing.
344    pub async fn advice(
345        &self,
346        strategy: &str,
347        task: String,
348        opts: Option<serde_json::Value>,
349    ) -> Result<String, String> {
350        // Auto-install bundled packages if the requested strategy is missing
351        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    /// Continue a paused execution — batch feed.
374    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    /// Continue a paused execution — single response (with optional query_id).
395    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    // ─── Package Management ─────────────────────────────────────
417
418    /// List installed packages with metadata.
419    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    /// Install a package from a Git URL or local path.
456    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        // Local path: copy directly (supports uncommitted/dirty working trees)
461        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        // Normalize URL: add https:// only for bare domain-style URLs
467        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        // Clone to temp directory first to detect single vs collection
478        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        // Remove .git dir from staging
498        let _ = std::fs::remove_dir_all(staging.path().join(".git"));
499
500        // Detect: single package (init.lua at root) vs collection (subdirs with init.lua)
501        if staging.path().join("init.lua").exists() {
502            // Single package mode
503            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            // Collection mode: scan for subdirs containing init.lua
530            if name.is_some() {
531                // name parameter is only meaningful for single-package repos
532                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    /// Install from a local directory path (supports dirty/uncommitted files).
582    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            // Single package
590            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                // Overwrite for local installs (dev workflow)
600                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            // Remove .git if copied
605            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            // Collection mode
614            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    /// Remove an installed package.
666    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    // ─── Logging ─────────────────────────────────────────────
680
681    /// Append a note to a session's log file.
682    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    /// View session logs.
697    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        // Collect .meta.json files first; fall back to .json for legacy logs
725        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                // Skip non-json and meta files in this pass
731                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        // Sort by modification time descending (newest first), take limit
740        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            // Try .meta.json first (lightweight), fall back to full log
746            let meta_path = path.with_extension("meta.json");
747            let doc: serde_json::Value = if meta_path.exists() {
748                // Meta file: already flat summary (~200 bytes)
749                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                // Legacy fallback: read full log and extract fields
758                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    // ─── Internal ───────────────────────────────────────────────
785
786    /// Install the bundled package collection if the requested package is missing.
787    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    // ─── resolve_code tests ───
823
824    #[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    // ─── make_require_code tests ───
863
864    #[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    // ─── packages_dir tests ───
883
884    #[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    // ─── append_note tests ───
895
896    #[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    // ─── log_list / log_view tests ───
934
935    #[test]
936    fn log_list_from_dir() {
937        let dir = tempfile::tempdir().unwrap();
938
939        // Create two log files
940        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        // Non-json file should be ignored
965        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        // Use log_list directly via the free function path
973        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    // ─── ContainedPath tests ───
984
985    #[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    // ─── meta file tests ───
1034
1035    #[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        // Main log should exist
1059        assert!(dir.path().join("s-meta-test.json").exists());
1060
1061        // Meta file should exist
1062        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        // Meta should NOT contain transcript
1073        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        // Create main log
1082        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        // Create meta file
1094        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    // ─── TranscriptConfig tests ───
1124
1125    #[test]
1126    fn transcript_config_default_enabled() {
1127        // Without env vars, should default to enabled
1128        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        // No file should be created
1158        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        // Metrics with no observer events → empty transcript
1170        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    // ─── copy_dir tests ───
1176
1177    #[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    // ─── task_hint truncation in write_transcript_log ───
1210
1211    #[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        // Should be truncated to ~100 chars + "..."
1238        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        // Create a full log (large, with transcript)
1247        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        // Create corresponding meta
1260        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        // Create a legacy log (no meta file)
1275        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        // Both sessions should have session_id and task_hint
1309        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        /// resolve_code never panics.
1325        #[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        /// ContainedPath always rejects ".." components.
1334        #[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        /// ContainedPath accepts simple alphanumeric names.
1346        #[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        /// make_require_code always contains the strategy name in a require call.
1354        #[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        /// copy_dir preserves file contents for arbitrary data.
1363        #[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}