Skip to main content

algocline_app/service/
resolve.rs

1use std::path::{Path, PathBuf};
2
3use super::path::ContainedPath;
4
5// ─── Parameter types (MCP-independent) ──────────────────────────
6
7/// A single query response in a batch feed.
8#[derive(Debug)]
9pub struct QueryResponse {
10    /// Query ID (e.g. "q-0", "q-1").
11    pub query_id: String,
12    /// The host LLM's response for this query.
13    pub response: String,
14}
15
16// ─── Code resolution ────────────────────────────────────────────
17
18pub(crate) fn resolve_code(
19    code: Option<String>,
20    code_file: Option<String>,
21) -> Result<String, String> {
22    match (code, code_file) {
23        (Some(c), None) => Ok(c),
24        (None, Some(path)) => std::fs::read_to_string(Path::new(&path))
25            .map_err(|e| format!("Failed to read {path}: {e}")),
26        (Some(_), Some(_)) => Err("Provide either `code` or `code_file`, not both.".into()),
27        (None, None) => Err("Either `code` or `code_file` must be provided.".into()),
28    }
29}
30
31/// Build Lua code that loads a package by name and calls `pkg.run(ctx)`.
32///
33/// # Security: `name` is not sanitized
34///
35/// `name` is interpolated directly into a Lua `require()` call without
36/// sanitization. This is intentional in the current architecture:
37///
38/// - algocline is a **local development/execution tool** that runs Lua in
39///   the user's own environment via mlua (not a multi-tenant service).
40/// - The same caller has access to `alc_run`, which executes **arbitrary
41///   Lua code**. Sanitizing `name` here would not reduce the attack surface.
42/// - The MCP trust boundary lies at the **host/client** level — the host
43///   decides whether to invoke `alc_advice` at all.
44///
45/// If algocline is extended to a shared backend (e.g. a package registry
46/// server accepting untrusted strategy names), `name` **must** be validated
47/// (allowlist of `[a-zA-Z0-9_-]` or equivalent) before interpolation.
48///
49/// References:
50/// - [MCP Security Best Practices — Local MCP Server Compromise](https://modelcontextprotocol.io/specification/draft/basic/security_best_practices)
51/// - [OWASP MCP Security Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/MCP_Security_Cheat_Sheet.html)
52pub(crate) fn make_require_code(name: &str) -> String {
53    format!(
54        r#"local pkg = require("{name}")
55return pkg.run(ctx)"#
56    )
57}
58
59pub(crate) fn packages_dir() -> Result<PathBuf, String> {
60    let home = dirs::home_dir().ok_or("Cannot determine home directory")?;
61    Ok(home.join(".algocline").join("packages"))
62}
63
64pub(crate) fn scenarios_dir() -> Result<PathBuf, String> {
65    let home = dirs::home_dir().ok_or("Cannot determine home directory")?;
66    Ok(home.join(".algocline").join("scenarios"))
67}
68
69/// Resolve scenario code from one of three mutually exclusive sources:
70/// inline code, file path, or scenario name (looked up in `~/.algocline/scenarios/`).
71pub(crate) fn resolve_scenario_code(
72    scenario: Option<String>,
73    scenario_file: Option<String>,
74    scenario_name: Option<String>,
75) -> Result<String, String> {
76    match (scenario, scenario_file, scenario_name) {
77        (Some(c), None, None) => Ok(c),
78        (None, Some(path), None) => std::fs::read_to_string(Path::new(&path))
79            .map_err(|e| format!("Failed to read {path}: {e}")),
80        (None, None, Some(name)) => {
81            let dir = scenarios_dir()?;
82            let path = ContainedPath::child(&dir, &format!("{name}.lua"))
83                .map_err(|e| format!("Invalid scenario name: {e}"))?;
84            if !path.as_ref().exists() {
85                return Err(format!(
86                    "Scenario '{name}' not found at {}",
87                    path.as_ref().display()
88                ));
89            }
90            std::fs::read_to_string(path.as_ref())
91                .map_err(|e| format!("Failed to read scenario '{name}': {e}"))
92        }
93        (None, None, None) => {
94            Err("Provide one of: scenario, scenario_file, or scenario_name.".into())
95        }
96        _ => Err(
97            "Provide only one of: scenario, scenario_file, or scenario_name (not multiple).".into(),
98        ),
99    }
100}
101
102/// Git URLs for auto-installation. Collection repos contain multiple packages
103/// as subdirectories; single repos have init.lua at root.
104pub(super) const AUTO_INSTALL_SOURCES: &[&str] = &[
105    "https://github.com/ynishi/algocline-bundled-packages",
106    "https://github.com/ynishi/evalframe",
107];
108
109/// System packages: installed alongside user packages but not user-facing strategies.
110/// Excluded from `pkg_list` and not loaded via `require` for meta extraction.
111const SYSTEM_PACKAGES: &[&str] = &["evalframe"];
112
113/// Check whether a package is a system (non-user-facing) package.
114pub(super) fn is_system_package(name: &str) -> bool {
115    SYSTEM_PACKAGES.contains(&name)
116}
117
118/// Check whether a package is installed (has `init.lua`).
119pub(super) fn is_package_installed(name: &str) -> bool {
120    packages_dir()
121        .map(|dir| dir.join(name).join("init.lua").exists())
122        .unwrap_or(false)
123}
124
125/// Per-entry I/O failures collected during resilient batch operations.
126///
127/// **Resilience pattern:** Directory iteration and file operations may encounter
128/// per-entry I/O errors (permission denied, broken symlinks, etc.) that should
129/// not abort the entire operation. Failures are collected and returned alongside
130/// successful results so the caller has both the available data and diagnostics.
131///
132/// Included in JSON responses as `"failures": [...]`.
133pub(super) type DirEntryFailures = Vec<String>;
134
135/// Extract a display name from a path: file_stem if available, otherwise file_name.
136pub(super) fn display_name(path: &Path, file_name: &str) -> String {
137    path.file_stem()
138        .and_then(|s| s.to_str())
139        .map(String::from)
140        .unwrap_or_else(|| file_name.to_string())
141}
142
143/// Determine the scenario source directory within a cloned/downloaded tree.
144///
145/// Prefers a `scenarios/` subdirectory when present, falling back to the root.
146///
147/// # `.git` and other non-Lua entries
148///
149/// When falling back to the root, the directory may contain `.git/`, `README.md`,
150/// `LICENSE`, etc. This is safe because [`install_scenarios_from_dir`] applies two
151/// filters: `is_file()` (excludes `.git/` and other subdirectories) and
152/// `.lua` extension check (excludes non-Lua files). No explicit `.git` exclusion
153/// is needed.
154pub(super) fn resolve_scenario_source(clone_root: &Path) -> PathBuf {
155    let subdir = clone_root.join("scenarios");
156    if subdir.is_dir() {
157        subdir
158    } else {
159        clone_root.to_path_buf()
160    }
161}
162
163/// Copy all `.lua` files from `source` directory into `dest` (scenarios dir).
164/// Skips files that already exist. Collects per-entry I/O errors as `failures`
165/// rather than aborting.
166pub(super) fn install_scenarios_from_dir(source: &Path, dest: &Path) -> Result<String, String> {
167    let entries =
168        std::fs::read_dir(source).map_err(|e| format!("Failed to read source dir: {e}"))?;
169
170    let mut installed = Vec::new();
171    let mut skipped = Vec::new();
172    let mut failures: DirEntryFailures = Vec::new();
173
174    for entry_result in entries {
175        let entry = match entry_result {
176            Ok(e) => e,
177            Err(e) => {
178                failures.push(format!("readdir entry: {e}"));
179                continue;
180            }
181        };
182        let path = entry.path();
183        if !path.is_file() {
184            continue;
185        }
186        let ext = path.extension().and_then(|s| s.to_str());
187        if ext != Some("lua") {
188            continue;
189        }
190        let file_name = entry.file_name().to_string_lossy().to_string();
191        let dest_path = match ContainedPath::child(dest, &file_name) {
192            Ok(p) => p,
193            Err(_) => continue,
194        };
195        let name = display_name(&path, &file_name);
196        if dest_path.as_ref().exists() {
197            skipped.push(name);
198            continue;
199        }
200        match std::fs::copy(&path, dest_path.as_ref()) {
201            Ok(_) => installed.push(name),
202            Err(e) => failures.push(format!("{}: {e}", path.display())),
203        }
204    }
205
206    if installed.is_empty() && skipped.is_empty() && failures.is_empty() {
207        return Err("No .lua scenario files found in source.".into());
208    }
209
210    Ok(serde_json::json!({
211        "installed": installed,
212        "skipped": skipped,
213        "failures": failures,
214    })
215    .to_string())
216}