use std::path::Path;
use anyhow::{Context, Result};
use crate::cron_nlp::{CronRequest, StepSpec};
use crate::skill_forge::ensure_tools;
fn workspace_dir() -> std::path::PathBuf {
std::path::PathBuf::from(
std::env::var("GARDEN_WORKSPACE").unwrap_or_else(|_| "/workspace".into()),
)
}
pub async fn register_cron_from_request(
request: &CronRequest,
garden_name: Option<&str>,
) -> Result<(String, String)> {
let workspace = workspace_dir();
let tool_names = ensure_tools(&workspace, &request.steps).await?;
let steps_json = build_workflow_steps(&tool_names, &request.steps);
let (garden_flag, garden_arg) = if let Some(g) = garden_name {
("-g", g)
} else {
("", "")
};
let job_name = if request.name.is_empty() {
&request.description
} else {
&request.name
};
let job_nameescaped = shell_escape(job_name);
let container_name = format!("garden-{}", garden_name.unwrap_or("default"));
let steps_escaped = steps_json.replace("'", "'\"'\"'");
let expr_escaped = shell_escape(&request.schedule);
let output = std::process::Command::new("docker")
.args([
"exec", &container_name,
"/app/clawgarden-cli",
"cron", "add",
"-e", &expr_escaped,
"-t", "workflow",
"-w", &steps_escaped,
"-n", &job_nameescaped,
])
.output()
.context("Failed to run docker exec for cron add")?;
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
if !output.status.success() {
anyhow::bail!(
"garden cron add failed (exit {}): {}\n{}",
output.status,
stdout,
stderr
);
}
let job_id = extract_job_id(&stdout).unwrap_or_else(|| "unknown".to_string());
let tools_str = tool_names.iter()
.enumerate()
.map(|(i, n)| format!(" {}. {}", i + 1, n))
.collect::<Vec<_>>()
.join("\n");
let confirmation = format!(
"✅ **Cron job 등록됨**\n\n\
**이름:** {}\n\
**스케줄:** {} (crontab)\n\
**설명:** {}\n\
**생성된 도구:**\n{}\n\
**Job ID:** `{}`\n\n\
매 {}에 실행됩니다.",
job_name,
request.schedule,
request.description,
tools_str,
job_id,
request.schedule
);
Ok((job_id, confirmation))
}
fn build_workflow_steps(tool_names: &[String], steps: &[StepSpec]) -> String {
use serde_json::json;
let steps: Vec<_> = tool_names.iter()
.zip(steps.iter())
.map(|(name, spec)| {
json!({
"tool_name": name,
"args": spec.params,
"timeout_ms": 30000
})
})
.collect();
serde_json::to_string(&steps).unwrap_or_else(|_| "[]".to_string())
}
fn extract_job_id(output: &str) -> Option<String> {
output.lines()
.find_map(|line| {
if line.contains("Cron job created:") {
line.split(':').last().map(|s| s.trim().to_string())
} else {
None
}
})
}
fn shell_escape(s: &str) -> String {
format!("'{}'", s.replace('\'', "'\"'\"'"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_shell_escape() {
assert_eq!(shell_escape("hello"), "'hello'");
assert_eq!(shell_escape("it's a test"), "'it'\"'\"'s a test'");
}
#[test]
fn test_extract_job_id() {
let out = "Cron job created: abc-123-def\nExpression: 0 2 * * *";
assert_eq!(extract_job_id(out), Some("abc-123-def".to_string()));
assert_eq!(extract_job_id("no match here"), None);
}
#[test]
fn test_build_workflow_steps() {
let names = vec!["hackernews-scrape".to_string(), "translate-ko".to_string()];
let specs = vec![
StepSpec { action: "hackernews".to_string(), params: "{}".to_string() },
StepSpec { action: "translate".to_string(), params: "to korean".to_string() },
];
let json = build_workflow_steps(&names, &specs);
assert!(json.contains("hackernews-scrape"));
assert!(json.contains("translate-ko"));
}
}