Skip to main content

metis_core/application/services/workspace/
reassignment.rs

1use crate::application::services::DatabaseService;
2use crate::dal::database::models::Document;
3use crate::Result;
4use crate::MetisError;
5use std::fs;
6use std::path::{Path, PathBuf};
7
8/// Service for reassigning tasks to different parent initiatives or the backlog
9pub struct ReassignmentService {
10    workspace_dir: PathBuf,
11}
12
13/// Result of reassignment operation
14#[derive(Debug)]
15pub struct ReassignmentResult {
16    pub short_code: String,
17    pub old_path: PathBuf,
18    pub new_path: PathBuf,
19    pub new_parent: Option<String>,
20}
21
22/// Backlog category for standalone tasks
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub enum BacklogCategory {
25    Bug,
26    Feature,
27    TechDebt,
28}
29
30impl BacklogCategory {
31    pub fn from_str(s: &str) -> Option<Self> {
32        match s.to_lowercase().as_str() {
33            "bug" => Some(Self::Bug),
34            "feature" => Some(Self::Feature),
35            "tech-debt" | "techdebt" | "tech_debt" => Some(Self::TechDebt),
36            _ => None,
37        }
38    }
39
40    pub fn directory_name(&self) -> &'static str {
41        match self {
42            Self::Bug => "bugs",
43            Self::Feature => "features",
44            Self::TechDebt => "tech-debt",
45        }
46    }
47}
48
49impl ReassignmentService {
50    /// Create a new reassignment service for a workspace
51    pub fn new<P: AsRef<Path>>(workspace_dir: P) -> Self {
52        let path = workspace_dir.as_ref();
53        let absolute_path = if path.is_absolute() {
54            path.to_path_buf()
55        } else {
56            std::env::current_dir()
57                .unwrap_or_default()
58                .join(path)
59        };
60
61        Self {
62            workspace_dir: absolute_path,
63        }
64    }
65
66    /// Reassign a task to a new parent initiative
67    pub async fn reassign_to_initiative(
68        &self,
69        short_code: &str,
70        new_parent_id: &str,
71        db_service: &mut DatabaseService,
72    ) -> Result<ReassignmentResult> {
73        // Find source document
74        let source_doc = self.find_task_by_short_code(short_code, db_service)?;
75
76        // Find and validate parent initiative
77        let parent_doc = self.find_and_validate_parent(new_parent_id, db_service)?;
78
79        // Determine paths
80        let source_path = self.workspace_dir.join(&source_doc.filepath);
81        let dest_path = self.compute_initiative_task_path(&parent_doc, &source_doc)?;
82
83        // Perform the move
84        self.move_file(&source_path, &dest_path)?;
85
86        Ok(ReassignmentResult {
87            short_code: short_code.to_string(),
88            old_path: source_path,
89            new_path: dest_path,
90            new_parent: Some(new_parent_id.to_string()),
91        })
92    }
93
94    /// Move a task to the backlog
95    pub async fn reassign_to_backlog(
96        &self,
97        short_code: &str,
98        category: BacklogCategory,
99        db_service: &mut DatabaseService,
100    ) -> Result<ReassignmentResult> {
101        // Find source document
102        let source_doc = self.find_task_by_short_code(short_code, db_service)?;
103
104        // Determine paths
105        let source_path = self.workspace_dir.join(&source_doc.filepath);
106        let filename = source_path
107            .file_name()
108            .and_then(|n| n.to_str())
109            .ok_or_else(|| MetisError::ValidationFailed {
110                message: "Could not determine filename".to_string(),
111            })?;
112
113        let dest_path = self.workspace_dir
114            .join("backlog")
115            .join(category.directory_name())
116            .join(filename);
117
118        // Perform the move
119        self.move_file(&source_path, &dest_path)?;
120
121        Ok(ReassignmentResult {
122            short_code: short_code.to_string(),
123            old_path: source_path,
124            new_path: dest_path,
125            new_parent: None,
126        })
127    }
128
129    /// Find a task by short code and validate it's a task
130    fn find_task_by_short_code(
131        &self,
132        short_code: &str,
133        db_service: &mut DatabaseService,
134    ) -> Result<Document> {
135        let doc = db_service
136            .find_by_short_code(short_code)?
137            .ok_or_else(|| MetisError::NotFound(format!(
138                "Document '{}' not found",
139                short_code
140            )))?;
141
142        if doc.document_type != "task" {
143            return Err(MetisError::ValidationFailed {
144                message: format!(
145                    "Only tasks can be reassigned. '{}' is a {}.",
146                    short_code, doc.document_type
147                ),
148            });
149        }
150
151        Ok(doc)
152    }
153
154    /// Find and validate a parent initiative
155    fn find_and_validate_parent(
156        &self,
157        parent_id: &str,
158        db_service: &mut DatabaseService,
159    ) -> Result<Document> {
160        let parent = db_service
161            .find_by_short_code(parent_id)?
162            .ok_or_else(|| MetisError::NotFound(format!(
163                "Parent initiative '{}' not found",
164                parent_id
165            )))?;
166
167        if parent.document_type != "initiative" {
168            return Err(MetisError::ValidationFailed {
169                message: format!(
170                    "Parent must be an initiative. '{}' is a {}.",
171                    parent_id, parent.document_type
172                ),
173            });
174        }
175
176        // Validate phase
177        let phase = parent.phase.to_lowercase();
178        if phase != "decompose" && phase != "active" {
179            return Err(MetisError::ValidationFailed {
180                message: format!(
181                    "Initiative '{}' is in '{}' phase. Tasks can only be assigned to initiatives in 'decompose' or 'active' phase.",
182                    parent_id, parent.phase
183                ),
184            });
185        }
186
187        Ok(parent)
188    }
189
190    /// Compute the destination path for a task under an initiative
191    fn compute_initiative_task_path(
192        &self,
193        parent_doc: &Document,
194        source_doc: &Document,
195    ) -> Result<PathBuf> {
196        let source_path = self.workspace_dir.join(&source_doc.filepath);
197        let filename = source_path
198            .file_name()
199            .and_then(|n| n.to_str())
200            .ok_or_else(|| MetisError::ValidationFailed {
201                message: "Could not determine source filename".to_string(),
202            })?;
203
204        // Get initiative directory from its filepath
205        let parent_path = Path::new(&parent_doc.filepath);
206        let initiative_dir = parent_path.parent().ok_or_else(|| {
207            MetisError::ValidationFailed {
208                message: "Could not determine initiative directory".to_string(),
209            }
210        })?;
211
212        // Tasks go in {initiative_dir}/tasks/
213        Ok(self.workspace_dir.join(initiative_dir).join("tasks").join(filename))
214    }
215
216    /// Move a file from source to destination
217    fn move_file(&self, source: &Path, dest: &Path) -> Result<()> {
218        // Validate source exists
219        if !source.exists() {
220            return Err(MetisError::NotFound(format!(
221                "Source file not found: {}",
222                source.display()
223            )));
224        }
225
226        // Check destination doesn't exist
227        if dest.exists() {
228            return Err(MetisError::ValidationFailed {
229                message: format!(
230                    "Destination already exists: {}",
231                    dest.display()
232                ),
233            });
234        }
235
236        // Same location check
237        if source == dest {
238            return Err(MetisError::ValidationFailed {
239                message: "Task is already at the target location".to_string(),
240            });
241        }
242
243        // Create destination directory if needed
244        if let Some(parent_dir) = dest.parent() {
245            if !parent_dir.exists() {
246                fs::create_dir_all(parent_dir).map_err(|e| {
247                    MetisError::FileSystem(format!(
248                        "Failed to create destination directory: {}",
249                        e
250                    ))
251                })?;
252            }
253        }
254
255        // Move the file
256        fs::rename(source, dest).map_err(|e| {
257            MetisError::FileSystem(format!("Failed to move file: {}", e))
258        })?;
259
260        Ok(())
261    }
262}
263
264#[cfg(test)]
265mod tests {
266    use super::*;
267
268    #[test]
269    fn test_backlog_category_parsing() {
270        assert_eq!(BacklogCategory::from_str("bug"), Some(BacklogCategory::Bug));
271        assert_eq!(BacklogCategory::from_str("feature"), Some(BacklogCategory::Feature));
272        assert_eq!(BacklogCategory::from_str("tech-debt"), Some(BacklogCategory::TechDebt));
273        assert_eq!(BacklogCategory::from_str("techdebt"), Some(BacklogCategory::TechDebt));
274        assert_eq!(BacklogCategory::from_str("tech_debt"), Some(BacklogCategory::TechDebt));
275        assert_eq!(BacklogCategory::from_str("invalid"), None);
276    }
277
278    #[test]
279    fn test_backlog_category_directory() {
280        assert_eq!(BacklogCategory::Bug.directory_name(), "bugs");
281        assert_eq!(BacklogCategory::Feature.directory_name(), "features");
282        assert_eq!(BacklogCategory::TechDebt.directory_name(), "tech-debt");
283    }
284}