1use std::path::{Path, PathBuf};
2use std::sync::Arc;
3
4use algocline_core::QueryId;
5use algocline_engine::{Executor, SessionRegistry};
6
7#[derive(Debug)]
11pub struct QueryResponse {
12 pub query_id: String,
14 pub response: String,
16}
17
18pub(crate) fn resolve_code(
21 code: Option<String>,
22 code_file: Option<String>,
23) -> Result<String, String> {
24 match (code, code_file) {
25 (Some(c), None) => Ok(c),
26 (None, Some(path)) => std::fs::read_to_string(Path::new(&path))
27 .map_err(|e| format!("Failed to read {path}: {e}")),
28 (Some(_), Some(_)) => Err("Provide either `code` or `code_file`, not both.".into()),
29 (None, None) => Err("Either `code` or `code_file` must be provided.".into()),
30 }
31}
32
33pub(crate) fn make_require_code(name: &str) -> String {
55 format!(
56 r#"local pkg = require("{name}")
57return pkg.run(ctx)"#
58 )
59}
60
61pub(crate) fn packages_dir() -> Result<PathBuf, String> {
62 let home = dirs::home_dir().ok_or("Cannot determine home directory")?;
63 Ok(home.join(".algocline").join("packages"))
64}
65
66#[derive(Clone)]
69pub struct AppService {
70 executor: Arc<Executor>,
71 registry: Arc<SessionRegistry>,
72}
73
74impl AppService {
75 pub fn new(executor: Arc<Executor>) -> Self {
76 Self {
77 executor,
78 registry: Arc::new(SessionRegistry::new()),
79 }
80 }
81
82 pub async fn run(
84 &self,
85 code: Option<String>,
86 code_file: Option<String>,
87 ctx: Option<serde_json::Value>,
88 ) -> Result<String, String> {
89 let code = resolve_code(code, code_file)?;
90 let ctx = ctx.unwrap_or(serde_json::Value::Null);
91 self.start_and_tick(code, ctx).await
92 }
93
94 pub async fn advice(
96 &self,
97 strategy: &str,
98 task: String,
99 opts: Option<serde_json::Value>,
100 ) -> Result<String, String> {
101 let code = make_require_code(strategy);
102
103 let mut ctx_map = match opts {
104 Some(serde_json::Value::Object(m)) => m,
105 _ => serde_json::Map::new(),
106 };
107 ctx_map.insert("task".into(), serde_json::Value::String(task));
108 let ctx = serde_json::Value::Object(ctx_map);
109
110 self.start_and_tick(code, ctx).await
111 }
112
113 pub async fn continue_batch(
115 &self,
116 session_id: &str,
117 responses: Vec<QueryResponse>,
118 ) -> Result<String, String> {
119 let mut last_result = None;
120 for qr in responses {
121 let qid = QueryId::parse(&qr.query_id);
122 let result = self
123 .registry
124 .feed_response(session_id, &qid, qr.response)
125 .await
126 .map_err(|e| format!("Continue failed: {e}"))?;
127 last_result = Some(result);
128 }
129 let result = last_result.ok_or("Empty responses array")?;
130 Ok(result.to_json(session_id).to_string())
131 }
132
133 pub async fn continue_single(
135 &self,
136 session_id: &str,
137 response: String,
138 query_id: Option<&str>,
139 ) -> Result<String, String> {
140 let query_id = match query_id {
141 Some(qid) => QueryId::parse(qid),
142 None => QueryId::single(),
143 };
144
145 let result = self
146 .registry
147 .feed_response(session_id, &query_id, response)
148 .await
149 .map_err(|e| format!("Continue failed: {e}"))?;
150
151 Ok(result.to_json(session_id).to_string())
152 }
153
154 pub async fn pkg_list(&self) -> Result<String, String> {
158 let pkg_dir = packages_dir()?;
159 if !pkg_dir.is_dir() {
160 return Ok(serde_json::json!({ "packages": [] }).to_string());
161 }
162
163 let mut packages = Vec::new();
164 let entries =
165 std::fs::read_dir(&pkg_dir).map_err(|e| format!("Failed to read packages dir: {e}"))?;
166
167 for entry in entries.flatten() {
168 let path = entry.path();
169 if !path.is_dir() {
170 continue;
171 }
172 let init_lua = path.join("init.lua");
173 if !init_lua.exists() {
174 continue;
175 }
176 let name = entry.file_name().to_string_lossy().to_string();
177 let code = format!(
178 r#"local pkg = require("{name}")
179return pkg.meta or {{ name = "{name}" }}"#
180 );
181 match self.executor.eval_simple(code).await {
182 Ok(meta) => packages.push(meta),
183 Err(_) => {
184 packages
185 .push(serde_json::json!({ "name": name, "error": "failed to load meta" }));
186 }
187 }
188 }
189
190 Ok(serde_json::json!({ "packages": packages }).to_string())
191 }
192
193 pub async fn pkg_install(&self, url: String, name: Option<String>) -> Result<String, String> {
195 let pkg_dir = packages_dir()?;
196 let _ = std::fs::create_dir_all(&pkg_dir);
197
198 let name = name.unwrap_or_else(|| {
199 url.trim_end_matches('/')
200 .rsplit('/')
201 .next()
202 .unwrap_or("unknown")
203 .trim_end_matches(".git")
204 .to_string()
205 });
206
207 let dest = pkg_dir.join(&name);
208 if dest.exists() {
209 return Err(format!(
210 "Package '{name}' already exists at {}. Remove it first.",
211 dest.display()
212 ));
213 }
214
215 let git_url = if url.starts_with("http://")
217 || url.starts_with("https://")
218 || url.starts_with("file://")
219 || url.starts_with("git@")
220 || url.starts_with('/')
221 {
222 url.clone()
223 } else {
224 format!("https://{url}")
225 };
226
227 let output = tokio::process::Command::new("git")
228 .args(["clone", "--depth", "1", &git_url, &dest.to_string_lossy()])
229 .output()
230 .await
231 .map_err(|e| format!("Failed to run git: {e}"))?;
232
233 if !output.status.success() {
234 let stderr = String::from_utf8_lossy(&output.stderr);
235 return Err(format!("git clone failed: {stderr}"));
236 }
237
238 if !dest.join("init.lua").exists() {
240 let _ = std::fs::remove_dir_all(&dest);
241 return Err(format!(
242 "Package '{name}' has no init.lua at root. Not a valid algocline package."
243 ));
244 }
245
246 let _ = std::fs::remove_dir_all(dest.join(".git"));
248
249 Ok(serde_json::json!({
250 "installed": name,
251 "path": dest.to_string_lossy(),
252 })
253 .to_string())
254 }
255
256 pub async fn pkg_remove(&self, name: &str) -> Result<String, String> {
258 let pkg_dir = packages_dir()?;
259 let dest = pkg_dir.join(name);
260
261 if !dest.exists() {
262 return Err(format!("Package '{name}' not found"));
263 }
264
265 let canonical = dest
267 .canonicalize()
268 .map_err(|e| format!("Path error: {e}"))?;
269 let pkg_canonical = pkg_dir
270 .canonicalize()
271 .map_err(|e| format!("Path error: {e}"))?;
272 if !canonical.starts_with(&pkg_canonical) {
273 return Err("Path traversal detected".to_string());
274 }
275
276 std::fs::remove_dir_all(&dest).map_err(|e| format!("Failed to remove '{name}': {e}"))?;
277
278 Ok(serde_json::json!({ "removed": name }).to_string())
279 }
280
281 async fn start_and_tick(&self, code: String, ctx: serde_json::Value) -> Result<String, String> {
284 let session = self.executor.start_session(code, ctx).await?;
285 let (session_id, result) = self
286 .registry
287 .start_execution(session)
288 .await
289 .map_err(|e| format!("Execution failed: {e}"))?;
290 Ok(result.to_json(&session_id).to_string())
291 }
292}
293
294#[cfg(test)]
295mod tests {
296 use super::*;
297 use std::io::Write;
298
299 #[test]
302 fn resolve_code_inline() {
303 let result = resolve_code(Some("return 1".into()), None);
304 assert_eq!(result.unwrap(), "return 1");
305 }
306
307 #[test]
308 fn resolve_code_from_file() {
309 let mut tmp = tempfile::NamedTempFile::new().unwrap();
310 write!(tmp, "return 42").unwrap();
311
312 let result = resolve_code(None, Some(tmp.path().to_string_lossy().into()));
313 assert_eq!(result.unwrap(), "return 42");
314 }
315
316 #[test]
317 fn resolve_code_both_provided_error() {
318 let result = resolve_code(Some("code".into()), Some("file.lua".into()));
319 let err = result.unwrap_err();
320 assert!(err.contains("not both"), "error: {err}");
321 }
322
323 #[test]
324 fn resolve_code_neither_provided_error() {
325 let result = resolve_code(None, None);
326 let err = result.unwrap_err();
327 assert!(err.contains("must be provided"), "error: {err}");
328 }
329
330 #[test]
331 fn resolve_code_nonexistent_file_error() {
332 let result = resolve_code(
333 None,
334 Some("/tmp/algocline_nonexistent_test_file.lua".into()),
335 );
336 assert!(result.is_err());
337 }
338
339 #[test]
342 fn make_require_code_basic() {
343 let code = make_require_code("explore");
344 assert!(code.contains(r#"require("explore")"#), "code: {code}");
345 assert!(code.contains("pkg.run(ctx)"), "code: {code}");
346 }
347
348 #[test]
349 fn make_require_code_different_names() {
350 for name in &["panel", "chain", "ensemble", "verify"] {
351 let code = make_require_code(name);
352 assert!(
353 code.contains(&format!(r#"require("{name}")"#)),
354 "code for {name}: {code}"
355 );
356 }
357 }
358
359 #[test]
362 fn packages_dir_ends_with_expected_path() {
363 let dir = packages_dir().unwrap();
364 assert!(
365 dir.ends_with(".algocline/packages"),
366 "dir: {}",
367 dir.display()
368 );
369 }
370}