Skip to main content

algocline_app/service/
resolve.rs

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