gestura_core_tasks/
workflows.rs

1//! Workflow management system for prompt templates and automation
2//!
3//! This module provides a workflow management system that discovers, loads, and executes
4//! workflow files (markdown-based prompt templates) from the `.gestura/workflows/` directory.
5//!
6//! # Architecture
7//!
8//! ```text
9//! .gestura/workflows/
10//! ├── code-review.md         # Workflow for code review
11//! ├── bug-fix.md             # Workflow for bug fixing
12//! └── feature-planning.md    # Workflow for feature planning
13//! ```
14//!
15//! # Workflow File Format
16//!
17//! Workflows are markdown files with optional YAML frontmatter:
18//!
19//! ```markdown
20//! ---
21//! description: Review code for best practices and potential issues
22//! tags: [code, review, quality]
23//! ---
24//!
25//! Please review the following code for:
26//! - Code quality and best practices
27//! - Potential bugs or edge cases
28//! - Performance optimizations
29//! ```
30//!
31//! # Usage
32//!
33//! ```rust,ignore
34//! use gestura_core::workflows::WorkflowManager;
35//!
36//! let manager = WorkflowManager::new();
37//! let workflows = manager.list_workflows()?;
38//! let content = manager.load_workflow("code-review")?;
39//! ```
40
41use serde::{Deserialize, Serialize};
42use std::fs;
43use std::path::{Path, PathBuf};
44
45/// A workflow represents a reusable prompt template
46#[derive(Debug, Clone, Serialize, Deserialize)]
47pub struct Workflow {
48    /// Workflow name (derived from filename without extension)
49    pub name: String,
50    /// Human-readable description (from frontmatter or default)
51    pub description: String,
52    /// Optional tags for categorization
53    #[serde(default, skip_serializing_if = "Vec::is_empty")]
54    pub tags: Vec<String>,
55    /// The actual prompt content (frontmatter stripped)
56    pub content: String,
57}
58
59/// Workflow metadata for listing (without loading full content)
60#[derive(Debug, Clone, Serialize, Deserialize)]
61pub struct WorkflowInfo {
62    /// Workflow name
63    pub name: String,
64    /// Description
65    pub description: String,
66    /// Tags
67    #[serde(default, skip_serializing_if = "Vec::is_empty")]
68    pub tags: Vec<String>,
69}
70
71/// Error type for workflow operations
72#[derive(Debug, thiserror::Error)]
73pub enum WorkflowError {
74    /// Workflow not found
75    #[error("Workflow not found: {0}")]
76    NotFound(String),
77    /// Invalid workflow format
78    #[error("Invalid workflow format: {0}")]
79    InvalidFormat(String),
80    /// I/O error
81    #[error("I/O error: {0}")]
82    Io(#[from] std::io::Error),
83    /// YAML parsing error
84    #[error("YAML parsing error: {0}")]
85    Yaml(#[from] serde_yaml::Error),
86}
87
88/// Workflow manager for discovering and loading workflow files
89pub struct WorkflowManager {
90    /// Base directory for workflow files
91    workflows_dir: PathBuf,
92}
93
94impl WorkflowManager {
95    /// Create a new workflow manager
96    ///
97    /// This will use the default workflows directory:
98    /// 1. `.gestura/workflows/` in current directory (if exists)
99    /// 2. `~/.local/share/gestura/workflows/` (fallback)
100    pub fn new() -> Self {
101        Self {
102            workflows_dir: Self::default_workflows_dir(),
103        }
104    }
105
106    /// Create a workflow manager with a custom directory
107    pub fn with_dir(dir: impl Into<PathBuf>) -> Self {
108        Self {
109            workflows_dir: dir.into(),
110        }
111    }
112
113    /// Get the default workflows directory
114    ///
115    /// Precedence:
116    /// 1. `.gestura/workflows/` in current directory (if exists)
117    /// 2. `~/.local/share/gestura/workflows/` (fallback)
118    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    /// Get the workflows directory path
131    pub fn workflows_dir(&self) -> &Path {
132        &self.workflows_dir
133    }
134
135    /// List all available workflows (metadata only, no content)
136    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            // Only process .md files
148            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            // Read file to extract frontmatter
159            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        // Sort by name for consistent ordering
170        workflows.sort_by(|a, b| a.name.cmp(&b.name));
171
172        Ok(workflows)
173    }
174
175    /// Load a workflow by name
176    ///
177    /// The name should be the filename without the `.md` extension.
178    /// For example, to load `code-review.md`, use `load_workflow("code-review")`.
179    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    /// Parse frontmatter from workflow content
204    ///
205    /// Returns (description, tags) tuple
206    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                // Parse YAML frontmatter
215                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            // No frontmatter, try to extract description from first line
229            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    /// Strip frontmatter from workflow content
243    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    /// Check if a workflow exists
253    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    /// Create the workflows directory if it doesn't exist
263    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        // Create a test workflow
298        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}