Skip to main content

ito_core/
task_mutations.rs

1//! Task mutation services for filesystem-backed persistence.
2
3use std::path::PathBuf;
4
5use crate::errors::CoreError;
6use ito_domain::errors::DomainError;
7use ito_domain::tasks::{
8    TaskInitResult, TaskMutationError, TaskMutationResult, TaskMutationService,
9    TaskMutationServiceResult,
10};
11
12/// Filesystem-backed task mutation service.
13#[derive(Debug, Clone)]
14pub struct FsTaskMutationService {
15    ito_path: PathBuf,
16}
17
18impl FsTaskMutationService {
19    /// Create a filesystem-backed task mutation service for a `.ito/` path.
20    pub fn new(ito_path: impl Into<PathBuf>) -> Self {
21        Self {
22            ito_path: ito_path.into(),
23        }
24    }
25
26    /// Return a `NotFound` error when the tasks file is absent, with a helpful init hint.
27    fn missing_tasks_error(change_id: &str) -> TaskMutationError {
28        TaskMutationError::not_found(format!(
29            "No backend tasks found for \"{change_id}\". Run \"ito tasks init {change_id}\" first."
30        ))
31    }
32
33    /// Resolve the tracking file path and verify it exists.
34    ///
35    /// Returns `Err(NotFound)` when the file is absent so callers get a consistent
36    /// 404-class error rather than an opaque IO failure.
37    fn require_tasks_path(&self, change_id: &str) -> TaskMutationServiceResult<std::path::PathBuf> {
38        let path = crate::tasks::tracking_file_path(&self.ito_path, change_id)
39            .map_err(task_mutation_error_from_core)?;
40        if !path.exists() {
41            return Err(Self::missing_tasks_error(change_id));
42        }
43        Ok(path)
44    }
45}
46
47impl TaskMutationService for FsTaskMutationService {
48    fn load_tasks_markdown(&self, change_id: &str) -> TaskMutationServiceResult<Option<String>> {
49        let path = crate::tasks::tracking_file_path(&self.ito_path, change_id)
50            .map_err(task_mutation_error_from_core)?;
51        if !path.exists() {
52            return Ok(None);
53        }
54        let contents = ito_common::io::read_to_string_std(&path)
55            .map_err(|e| TaskMutationError::io("reading tasks markdown", e))?;
56        Ok(Some(contents))
57    }
58
59    fn init_tasks(&self, change_id: &str) -> TaskMutationServiceResult<TaskInitResult> {
60        let (path, existed) = crate::tasks::init_tasks(&self.ito_path, change_id)
61            .map_err(task_mutation_error_from_core)?;
62        Ok(TaskInitResult {
63            change_id: change_id.to_string(),
64            path: Some(path),
65            existed,
66            revision: None,
67        })
68    }
69
70    fn start_task(
71        &self,
72        change_id: &str,
73        task_id: &str,
74    ) -> TaskMutationServiceResult<TaskMutationResult> {
75        // Verify tasks file exists before delegating to core (gives a 404 instead of IO error).
76        let _ = self.require_tasks_path(change_id)?;
77        let task = crate::tasks::start_task(&self.ito_path, change_id, task_id)
78            .map_err(task_mutation_error_from_core)?;
79        Ok(TaskMutationResult {
80            change_id: change_id.to_string(),
81            task,
82            revision: None,
83        })
84    }
85
86    fn complete_task(
87        &self,
88        change_id: &str,
89        task_id: &str,
90        note: Option<String>,
91    ) -> TaskMutationServiceResult<TaskMutationResult> {
92        let _ = self.require_tasks_path(change_id)?;
93        let task = crate::tasks::complete_task(&self.ito_path, change_id, task_id, note)
94            .map_err(task_mutation_error_from_core)?;
95        Ok(TaskMutationResult {
96            change_id: change_id.to_string(),
97            task,
98            revision: None,
99        })
100    }
101
102    fn shelve_task(
103        &self,
104        change_id: &str,
105        task_id: &str,
106        reason: Option<String>,
107    ) -> TaskMutationServiceResult<TaskMutationResult> {
108        let _ = self.require_tasks_path(change_id)?;
109        let task = crate::tasks::shelve_task(&self.ito_path, change_id, task_id, reason)
110            .map_err(task_mutation_error_from_core)?;
111        Ok(TaskMutationResult {
112            change_id: change_id.to_string(),
113            task,
114            revision: None,
115        })
116    }
117
118    fn unshelve_task(
119        &self,
120        change_id: &str,
121        task_id: &str,
122    ) -> TaskMutationServiceResult<TaskMutationResult> {
123        let _ = self.require_tasks_path(change_id)?;
124        let task = crate::tasks::unshelve_task(&self.ito_path, change_id, task_id)
125            .map_err(task_mutation_error_from_core)?;
126        Ok(TaskMutationResult {
127            change_id: change_id.to_string(),
128            task,
129            revision: None,
130        })
131    }
132
133    fn add_task(
134        &self,
135        change_id: &str,
136        title: &str,
137        wave: Option<u32>,
138    ) -> TaskMutationServiceResult<TaskMutationResult> {
139        let _ = self.require_tasks_path(change_id)?;
140        let task = crate::tasks::add_task(&self.ito_path, change_id, title, wave)
141            .map_err(task_mutation_error_from_core)?;
142        Ok(TaskMutationResult {
143            change_id: change_id.to_string(),
144            task,
145            revision: None,
146        })
147    }
148}
149
150pub(crate) fn boxed_fs_task_mutation_service(
151    ito_path: PathBuf,
152) -> Box<dyn TaskMutationService + Send> {
153    Box::new(FsTaskMutationService::new(ito_path))
154}
155
156pub(crate) fn task_mutation_error_from_core(err: CoreError) -> TaskMutationError {
157    match err {
158        CoreError::Domain(domain) => match domain {
159            DomainError::Io { context, source } => TaskMutationError::io(context, source),
160            DomainError::NotFound { entity, id } => {
161                TaskMutationError::not_found(format!("{entity} not found: {id}"))
162            }
163            DomainError::AmbiguousTarget {
164                entity,
165                input,
166                matches,
167            } => TaskMutationError::validation(format!(
168                "Ambiguous {entity} target '{input}'. Matches: {matches}"
169            )),
170        },
171        CoreError::Io { context, source } => TaskMutationError::io(context, source),
172        CoreError::Validation(message) => TaskMutationError::validation(message),
173        CoreError::Parse(message) => TaskMutationError::validation(message),
174        CoreError::Process(message) => TaskMutationError::other(message),
175        CoreError::Sqlite(message) => TaskMutationError::other(format!("sqlite error: {message}")),
176        CoreError::NotFound(message) => TaskMutationError::not_found(message),
177        CoreError::Serde { context, message } => {
178            TaskMutationError::other(format!("{context}: {message}"))
179        }
180    }
181}