Skip to main content

algocline_app/service/
resolve.rs

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