metis_core/application/services/workspace/
reassignment.rs1use 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
8pub struct ReassignmentService {
10 workspace_dir: PathBuf,
11}
12
13#[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#[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 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 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 let source_doc = self.find_task_by_short_code(short_code, db_service)?;
75
76 let parent_doc = self.find_and_validate_parent(new_parent_id, db_service)?;
78
79 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 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 pub async fn reassign_to_backlog(
96 &self,
97 short_code: &str,
98 category: BacklogCategory,
99 db_service: &mut DatabaseService,
100 ) -> Result<ReassignmentResult> {
101 let source_doc = self.find_task_by_short_code(short_code, db_service)?;
103
104 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 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 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 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 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 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 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 Ok(self.workspace_dir.join(initiative_dir).join("tasks").join(filename))
214 }
215
216 fn move_file(&self, source: &Path, dest: &Path) -> Result<()> {
218 if !source.exists() {
220 return Err(MetisError::NotFound(format!(
221 "Source file not found: {}",
222 source.display()
223 )));
224 }
225
226 if dest.exists() {
228 return Err(MetisError::ValidationFailed {
229 message: format!(
230 "Destination already exists: {}",
231 dest.display()
232 ),
233 });
234 }
235
236 if source == dest {
238 return Err(MetisError::ValidationFailed {
239 message: "Task is already at the target location".to_string(),
240 });
241 }
242
243 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 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}