Skip to main content

ito_core/
task_repository.rs

1//! Filesystem-backed task repository implementation.
2
3use std::path::Path;
4
5use ito_common::fs::{FileSystem, StdFs};
6use ito_config::ConfigContext;
7use ito_domain::errors::{DomainError, DomainResult};
8use ito_domain::tasks::{
9    TaskRepository as DomainTaskRepository, TasksParseResult, is_safe_tracking_filename,
10    parse_tasks_tracking_file, tasks_path_checked, tracking_path_checked,
11};
12
13use crate::templates::{default_schema_name, read_change_schema, resolve_schema};
14
15/// Filesystem-backed implementation of the domain `TaskRepository` port.
16pub struct FsTaskRepository<'a, F: FileSystem = StdFs> {
17    ito_path: &'a Path,
18    fs: F,
19}
20
21impl<'a> FsTaskRepository<'a, StdFs> {
22    /// Create a filesystem-backed task repository using the standard filesystem.
23    pub fn new(ito_path: &'a Path) -> Self {
24        Self::with_fs(ito_path, StdFs)
25    }
26}
27
28impl<'a, F: FileSystem> FsTaskRepository<'a, F> {
29    /// Create a filesystem-backed task repository with a custom filesystem.
30    pub fn with_fs(ito_path: &'a Path, fs: F) -> Self {
31        Self { ito_path, fs }
32    }
33
34    /// Load tasks from an explicit change directory.
35    pub(crate) fn load_tasks_from_dir(&self, change_dir: &Path) -> DomainResult<TasksParseResult> {
36        let schema_name = read_schema_from_dir(&self.fs, change_dir);
37        let mut ctx = ConfigContext::from_process_env();
38        ctx.project_dir = self.ito_path.parent().map(|p| p.to_path_buf());
39
40        let mut tracking_file = "tasks.md".to_string();
41        if let Ok(resolved) = resolve_schema(Some(&schema_name), &ctx)
42            && let Some(apply) = resolved.schema.apply.as_ref()
43            && let Some(tracks) = apply.tracks.as_deref()
44            && is_safe_tracking_filename(tracks)
45        {
46            tracking_file = tracks.to_string();
47        }
48
49        if !is_safe_tracking_filename(&tracking_file) {
50            return Ok(TasksParseResult::empty());
51        }
52
53        let path = change_dir.join(&tracking_file);
54        if !self.fs.is_file(&path) {
55            return Ok(TasksParseResult::empty());
56        }
57
58        let contents = self
59            .fs
60            .read_to_string(&path)
61            .map_err(|source| DomainError::io("reading tasks file", source))?;
62        Ok(parse_tasks_tracking_file(&contents))
63    }
64}
65
66fn read_schema_from_dir<F: FileSystem>(fs: &F, change_dir: &Path) -> String {
67    let meta = crate::change_meta::read_change_meta_from_dir(fs, change_dir);
68    meta.schema
69        .unwrap_or_else(|| default_schema_name().to_string())
70}
71
72impl<F: FileSystem> DomainTaskRepository for FsTaskRepository<'_, F> {
73    fn load_tasks(&self, change_id: &str) -> DomainResult<TasksParseResult> {
74        // `read_change_schema` uses `change_id` as a path segment; reject traversal.
75        if tasks_path_checked(self.ito_path, change_id).is_none() {
76            return Ok(TasksParseResult::empty());
77        }
78
79        let schema_name = read_change_schema(self.ito_path, change_id);
80        let mut ctx = ConfigContext::from_process_env();
81        ctx.project_dir = self.ito_path.parent().map(|p| p.to_path_buf());
82
83        let mut tracking_file = "tasks.md".to_string();
84        if let Ok(resolved) = resolve_schema(Some(&schema_name), &ctx)
85            && let Some(apply) = resolved.schema.apply.as_ref()
86            && let Some(tracks) = apply.tracks.as_deref()
87        {
88            tracking_file = tracks.to_string();
89        }
90
91        let Some(path) = tracking_path_checked(self.ito_path, change_id, &tracking_file) else {
92            return Ok(TasksParseResult::empty());
93        };
94        if !self.fs.is_file(&path) {
95            return Ok(TasksParseResult::empty());
96        }
97        let contents = self
98            .fs
99            .read_to_string(&path)
100            .map_err(|source| DomainError::io("reading tasks file", source))?;
101        Ok(parse_tasks_tracking_file(&contents))
102    }
103}
104
105/// Backward-compatible alias for the default filesystem-backed task repository.
106pub type TaskRepository<'a> = FsTaskRepository<'a, StdFs>;
107
108#[cfg(test)]
109#[path = "task_repository_tests.rs"]
110mod task_repository_tests;