ito_test_support/
mock_repos.rs

1//! In-memory mock implementations of domain repository traits for unit testing.
2//!
3//! These mocks store data in `Vec`s and `HashMap`s, requiring no filesystem access.
4//! They implement the domain traits from `ito-domain`, allowing tests to exercise
5//! business logic without touching disk.
6
7use std::collections::HashMap;
8
9use chrono::Utc;
10use ito_domain::changes::{
11    Change, ChangeRepository, ChangeStatus, ChangeSummary, ChangeTargetResolution,
12    ResolveTargetOptions,
13};
14use ito_domain::errors::{DomainError, DomainResult};
15use ito_domain::modules::{Module, ModuleRepository, ModuleSummary};
16use ito_domain::tasks::{ProgressInfo, TaskRepository, TasksFormat, TasksParseResult};
17
18// ---------------------------------------------------------------------------
19// MockTaskRepository
20// ---------------------------------------------------------------------------
21
22/// In-memory task repository that returns pre-configured task parse results.
23#[derive(Debug, Clone, Default)]
24pub struct MockTaskRepository {
25    tasks: HashMap<String, TasksParseResult>,
26}
27
28impl MockTaskRepository {
29    /// Create a new empty mock task repository.
30    pub fn new() -> Self {
31        Self::default()
32    }
33
34    /// Insert a pre-built `TasksParseResult` for a given change ID.
35    pub fn with_tasks(mut self, change_id: &str, result: TasksParseResult) -> Self {
36        self.tasks.insert(change_id.to_string(), result);
37        self
38    }
39}
40
41impl TaskRepository for MockTaskRepository {
42    fn load_tasks(&self, change_id: &str) -> DomainResult<TasksParseResult> {
43        Ok(self
44            .tasks
45            .get(change_id)
46            .cloned()
47            .unwrap_or_else(TasksParseResult::empty))
48    }
49}
50
51// ---------------------------------------------------------------------------
52// MockChangeRepository
53// ---------------------------------------------------------------------------
54
55/// In-memory change repository backed by `Vec<Change>` and `Vec<ChangeSummary>`.
56///
57/// Configure via builder methods, then pass as `&impl ChangeRepository` to
58/// business-logic functions under test.
59#[derive(Debug, Clone, Default)]
60pub struct MockChangeRepository {
61    changes: HashMap<String, Change>,
62    summaries: Vec<ChangeSummary>,
63}
64
65impl MockChangeRepository {
66    /// Create a new empty mock change repository.
67    pub fn new() -> Self {
68        Self::default()
69    }
70
71    /// Register a full `Change` (also generates a `ChangeSummary` entry).
72    pub fn with_change(mut self, change: Change) -> Self {
73        let progress = &change.tasks.progress;
74        let summary = ChangeSummary {
75            id: change.id.clone(),
76            module_id: change.module_id.clone(),
77            completed_tasks: progress.complete as u32,
78            shelved_tasks: progress.shelved as u32,
79            in_progress_tasks: progress.in_progress as u32,
80            pending_tasks: progress.pending as u32,
81            total_tasks: progress.total as u32,
82            last_modified: change.last_modified,
83            has_proposal: change.proposal.is_some(),
84            has_design: change.design.is_some(),
85            has_specs: !change.specs.is_empty(),
86            has_tasks: progress.total > 0,
87        };
88        self.summaries.push(summary);
89        self.changes.insert(change.id.clone(), change);
90        self
91    }
92
93    /// Register a standalone `ChangeSummary` without a full `Change`.
94    pub fn with_summary(mut self, summary: ChangeSummary) -> Self {
95        self.summaries.push(summary);
96        self
97    }
98}
99
100impl ChangeRepository for MockChangeRepository {
101    fn resolve_target_with_options(
102        &self,
103        input: &str,
104        _options: ResolveTargetOptions,
105    ) -> ChangeTargetResolution {
106        let matches: Vec<&String> = self.changes.keys().filter(|k| k.contains(input)).collect();
107        match matches.len() {
108            0 => ChangeTargetResolution::NotFound,
109            1 => ChangeTargetResolution::Unique(matches[0].clone()),
110            _ => ChangeTargetResolution::Ambiguous(matches.into_iter().cloned().collect()),
111        }
112    }
113
114    fn suggest_targets(&self, input: &str, max: usize) -> Vec<String> {
115        self.changes
116            .keys()
117            .filter(|k| k.contains(input))
118            .take(max)
119            .cloned()
120            .collect()
121    }
122
123    fn exists(&self, id: &str) -> bool {
124        self.changes.contains_key(id)
125    }
126
127    fn get(&self, id: &str) -> DomainResult<Change> {
128        self.changes
129            .get(id)
130            .cloned()
131            .ok_or_else(|| DomainError::not_found("change", id))
132    }
133
134    fn list(&self) -> DomainResult<Vec<ChangeSummary>> {
135        Ok(self.summaries.clone())
136    }
137
138    fn list_by_module(&self, module_id: &str) -> DomainResult<Vec<ChangeSummary>> {
139        Ok(self
140            .summaries
141            .iter()
142            .filter(|s| s.module_id.as_deref() == Some(module_id))
143            .cloned()
144            .collect())
145    }
146
147    fn list_incomplete(&self) -> DomainResult<Vec<ChangeSummary>> {
148        Ok(self
149            .summaries
150            .iter()
151            .filter(|s| {
152                let status = s.status();
153                match status {
154                    ChangeStatus::Complete => false,
155                    ChangeStatus::NoTasks => true,
156                    ChangeStatus::InProgress => true,
157                }
158            })
159            .cloned()
160            .collect())
161    }
162
163    fn list_complete(&self) -> DomainResult<Vec<ChangeSummary>> {
164        Ok(self
165            .summaries
166            .iter()
167            .filter(|s| {
168                let status = s.status();
169                match status {
170                    ChangeStatus::Complete => true,
171                    ChangeStatus::NoTasks => false,
172                    ChangeStatus::InProgress => false,
173                }
174            })
175            .cloned()
176            .collect())
177    }
178
179    fn get_summary(&self, id: &str) -> DomainResult<ChangeSummary> {
180        self.summaries
181            .iter()
182            .find(|s| s.id == id)
183            .cloned()
184            .ok_or_else(|| DomainError::not_found("change", id))
185    }
186}
187
188// ---------------------------------------------------------------------------
189// MockModuleRepository
190// ---------------------------------------------------------------------------
191
192/// In-memory module repository.
193#[derive(Debug, Clone, Default)]
194pub struct MockModuleRepository {
195    modules: HashMap<String, Module>,
196    summaries: Vec<ModuleSummary>,
197}
198
199impl MockModuleRepository {
200    /// Create a new empty mock module repository.
201    pub fn new() -> Self {
202        Self::default()
203    }
204
205    /// Register a `Module` (also generates a `ModuleSummary` with zero changes).
206    pub fn with_module(mut self, module: Module) -> Self {
207        let summary = ModuleSummary {
208            id: module.id.clone(),
209            name: module.name.clone(),
210            change_count: 0,
211        };
212        self.summaries.push(summary);
213        self.modules.insert(module.id.clone(), module);
214        self
215    }
216
217    /// Register a `Module` with a specific change count.
218    pub fn with_module_and_count(mut self, module: Module, change_count: u32) -> Self {
219        let summary = ModuleSummary {
220            id: module.id.clone(),
221            name: module.name.clone(),
222            change_count,
223        };
224        self.summaries.push(summary);
225        self.modules.insert(module.id.clone(), module);
226        self
227    }
228}
229
230impl ModuleRepository for MockModuleRepository {
231    fn exists(&self, id: &str) -> bool {
232        self.modules.contains_key(id)
233    }
234
235    fn get(&self, id_or_name: &str) -> DomainResult<Module> {
236        // Try by ID first, then by name
237        if let Some(m) = self.modules.get(id_or_name) {
238            return Ok(m.clone());
239        }
240        for m in self.modules.values() {
241            if m.name == id_or_name {
242                return Ok(m.clone());
243            }
244        }
245        Err(DomainError::not_found("module", id_or_name))
246    }
247
248    fn list(&self) -> DomainResult<Vec<ModuleSummary>> {
249        Ok(self.summaries.clone())
250    }
251}
252
253// ---------------------------------------------------------------------------
254// Helpers for creating test domain objects
255// ---------------------------------------------------------------------------
256
257/// Create a minimal `Change` with sensible defaults for testing.
258pub fn make_change(id: &str) -> Change {
259    Change {
260        id: id.to_string(),
261        module_id: None,
262        path: std::path::PathBuf::from(format!("/tmp/test/{id}")),
263        proposal: None,
264        design: None,
265        specs: Vec::new(),
266        tasks: TasksParseResult::empty(),
267        last_modified: Utc::now(),
268    }
269}
270
271/// Create a `Change` with a specific module and task progress.
272pub fn make_change_with_progress(
273    id: &str,
274    module_id: Option<&str>,
275    total: usize,
276    complete: usize,
277) -> Change {
278    let mut change = make_change(id);
279    change.module_id = module_id.map(String::from);
280    change.tasks = TasksParseResult {
281        format: TasksFormat::Checkbox,
282        tasks: Vec::new(),
283        waves: Vec::new(),
284        diagnostics: Vec::new(),
285        progress: ProgressInfo {
286            total,
287            complete,
288            shelved: 0,
289            in_progress: 0,
290            pending: total.saturating_sub(complete),
291            remaining: total.saturating_sub(complete),
292        },
293    };
294    change
295}
296
297/// Create a minimal `ChangeSummary` for testing.
298pub fn make_change_summary(id: &str) -> ChangeSummary {
299    ChangeSummary {
300        id: id.to_string(),
301        module_id: None,
302        completed_tasks: 0,
303        shelved_tasks: 0,
304        in_progress_tasks: 0,
305        pending_tasks: 0,
306        total_tasks: 0,
307        last_modified: Utc::now(),
308        has_proposal: false,
309        has_design: false,
310        has_specs: false,
311        has_tasks: false,
312    }
313}
314
315/// Create a minimal `Module` for testing.
316pub fn make_module(id: &str, name: &str) -> Module {
317    Module {
318        id: id.to_string(),
319        name: name.to_string(),
320        description: None,
321        path: std::path::PathBuf::from(format!("/tmp/test/modules/{id}")),
322    }
323}
324
325/// Create a minimal `TasksParseResult` with given progress.
326pub fn make_tasks_result(total: usize, complete: usize) -> TasksParseResult {
327    TasksParseResult {
328        format: TasksFormat::Checkbox,
329        tasks: Vec::new(),
330        waves: Vec::new(),
331        diagnostics: Vec::new(),
332        progress: ProgressInfo {
333            total,
334            complete,
335            shelved: 0,
336            in_progress: 0,
337            pending: total.saturating_sub(complete),
338            remaining: total.saturating_sub(complete),
339        },
340    }
341}