gestura_core_tasks/
workflows.rs1use serde::{Deserialize, Serialize};
42use std::fs;
43use std::path::{Path, PathBuf};
44
45#[derive(Debug, Clone, Serialize, Deserialize)]
47pub struct Workflow {
48 pub name: String,
50 pub description: String,
52 #[serde(default, skip_serializing_if = "Vec::is_empty")]
54 pub tags: Vec<String>,
55 pub content: String,
57}
58
59#[derive(Debug, Clone, Serialize, Deserialize)]
61pub struct WorkflowInfo {
62 pub name: String,
64 pub description: String,
66 #[serde(default, skip_serializing_if = "Vec::is_empty")]
68 pub tags: Vec<String>,
69}
70
71#[derive(Debug, thiserror::Error)]
73pub enum WorkflowError {
74 #[error("Workflow not found: {0}")]
76 NotFound(String),
77 #[error("Invalid workflow format: {0}")]
79 InvalidFormat(String),
80 #[error("I/O error: {0}")]
82 Io(#[from] std::io::Error),
83 #[error("YAML parsing error: {0}")]
85 Yaml(#[from] serde_yaml::Error),
86}
87
88pub struct WorkflowManager {
90 workflows_dir: PathBuf,
92}
93
94impl WorkflowManager {
95 pub fn new() -> Self {
101 Self {
102 workflows_dir: Self::default_workflows_dir(),
103 }
104 }
105
106 pub fn with_dir(dir: impl Into<PathBuf>) -> Self {
108 Self {
109 workflows_dir: dir.into(),
110 }
111 }
112
113 pub fn default_workflows_dir() -> PathBuf {
119 let current = PathBuf::from(".gestura/workflows");
120 if current.exists() {
121 return current;
122 }
123
124 dirs::data_local_dir()
125 .unwrap_or_else(|| PathBuf::from("."))
126 .join("gestura")
127 .join("workflows")
128 }
129
130 pub fn workflows_dir(&self) -> &Path {
132 &self.workflows_dir
133 }
134
135 pub fn list_workflows(&self) -> Result<Vec<WorkflowInfo>, WorkflowError> {
137 let mut workflows = Vec::new();
138
139 if !self.workflows_dir.exists() {
140 return Ok(workflows);
141 }
142
143 for entry in fs::read_dir(&self.workflows_dir)? {
144 let entry = entry?;
145 let path = entry.path();
146
147 if path.extension().is_none_or(|ext| ext != "md") {
149 continue;
150 }
151
152 let name = path
153 .file_stem()
154 .and_then(|s| s.to_str())
155 .ok_or_else(|| WorkflowError::InvalidFormat("Invalid filename".to_string()))?
156 .to_string();
157
158 let content = fs::read_to_string(&path)?;
160 let (description, tags) = Self::parse_frontmatter(&content);
161
162 workflows.push(WorkflowInfo {
163 name,
164 description,
165 tags,
166 });
167 }
168
169 workflows.sort_by(|a, b| a.name.cmp(&b.name));
171
172 Ok(workflows)
173 }
174
175 pub fn load_workflow(&self, name: &str) -> Result<Workflow, WorkflowError> {
180 let filename = if name.ends_with(".md") {
181 name.to_string()
182 } else {
183 format!("{}.md", name)
184 };
185
186 let path = self.workflows_dir.join(&filename);
187 if !path.exists() {
188 return Err(WorkflowError::NotFound(name.to_string()));
189 }
190
191 let content = fs::read_to_string(&path)?;
192 let (description, tags) = Self::parse_frontmatter(&content);
193 let content = Self::strip_frontmatter(&content);
194
195 Ok(Workflow {
196 name: name.trim_end_matches(".md").to_string(),
197 description,
198 tags,
199 content,
200 })
201 }
202
203 fn parse_frontmatter(content: &str) -> (String, Vec<String>) {
207 let mut description = "No description".to_string();
208 let mut tags = Vec::new();
209
210 if let Some(stripped) = content.strip_prefix("---") {
211 if let Some(end_idx) = stripped.find("---") {
212 let frontmatter = &stripped[..end_idx];
213
214 if let Ok(yaml) = serde_yaml::from_str::<serde_yaml::Value>(frontmatter) {
216 if let Some(desc) = yaml.get("description").and_then(|v| v.as_str()) {
217 description = desc.to_string();
218 }
219 if let Some(tag_list) = yaml.get("tags").and_then(|v| v.as_sequence()) {
220 tags = tag_list
221 .iter()
222 .filter_map(|v| v.as_str().map(|s| s.to_string()))
223 .collect();
224 }
225 }
226 }
227 } else {
228 if let Some(first_line) = content.lines().find(|l| !l.trim().is_empty())
230 && first_line.starts_with("description:")
231 {
232 description = first_line
233 .trim_start_matches("description:")
234 .trim()
235 .to_string();
236 }
237 }
238
239 (description, tags)
240 }
241
242 fn strip_frontmatter(content: &str) -> String {
244 if let Some(stripped) = content.strip_prefix("---")
245 && let Some(end_idx) = stripped.find("---")
246 {
247 return stripped[end_idx + 3..].trim().to_string();
248 }
249 content.to_string()
250 }
251
252 pub fn workflow_exists(&self, name: &str) -> bool {
254 let filename = if name.ends_with(".md") {
255 name.to_string()
256 } else {
257 format!("{}.md", name)
258 };
259 self.workflows_dir.join(filename).exists()
260 }
261
262 pub fn ensure_workflows_dir(&self) -> Result<(), WorkflowError> {
264 if !self.workflows_dir.exists() {
265 fs::create_dir_all(&self.workflows_dir)?;
266 }
267 Ok(())
268 }
269}
270
271impl Default for WorkflowManager {
272 fn default() -> Self {
273 Self::new()
274 }
275}
276
277#[cfg(test)]
278mod tests {
279 use super::*;
280 use std::fs;
281 use tempfile::TempDir;
282
283 #[test]
284 fn test_workflow_manager_list_empty() {
285 let temp_dir = TempDir::new().unwrap();
286 let manager = WorkflowManager::with_dir(temp_dir.path());
287
288 let workflows = manager.list_workflows().unwrap();
289 assert_eq!(workflows.len(), 0);
290 }
291
292 #[test]
293 fn test_workflow_manager_load_workflow() {
294 let temp_dir = TempDir::new().unwrap();
295 let manager = WorkflowManager::with_dir(temp_dir.path());
296
297 manager.ensure_workflows_dir().unwrap();
299 let workflow_path = temp_dir.path().join("test.md");
300 fs::write(
301 &workflow_path,
302 "---\ndescription: Test workflow\ntags: [test, example]\n---\n\nTest content",
303 )
304 .unwrap();
305
306 let workflow = manager.load_workflow("test").unwrap();
307 assert_eq!(workflow.name, "test");
308 assert_eq!(workflow.description, "Test workflow");
309 assert_eq!(workflow.tags, vec!["test", "example"]);
310 assert_eq!(workflow.content, "Test content");
311 }
312
313 #[test]
314 fn test_workflow_manager_list_workflows() {
315 let temp_dir = TempDir::new().unwrap();
316 let manager = WorkflowManager::with_dir(temp_dir.path());
317
318 manager.ensure_workflows_dir().unwrap();
319 fs::write(
320 temp_dir.path().join("workflow1.md"),
321 "---\ndescription: First workflow\n---\n\nContent 1",
322 )
323 .unwrap();
324 fs::write(
325 temp_dir.path().join("workflow2.md"),
326 "---\ndescription: Second workflow\n---\n\nContent 2",
327 )
328 .unwrap();
329
330 let workflows = manager.list_workflows().unwrap();
331 assert_eq!(workflows.len(), 2);
332 assert_eq!(workflows[0].name, "workflow1");
333 assert_eq!(workflows[1].name, "workflow2");
334 }
335}