Skip to main content

pinto/storage/
file_repository.rs

1use super::atomic_write;
2use super::issued_ids::{max_number, record};
3use super::markdown::{from_markdown, sprint_from_markdown, sprint_to_markdown, to_markdown};
4use super::repository::{BacklogItemRepository, SprintRepository};
5use crate::backlog::{BacklogItem, ItemId};
6use crate::error::Error;
7use crate::error::Result;
8use crate::sprint::{Sprint, SprintId};
9use rayon::prelude::*;
10use std::collections::HashMap;
11use std::io;
12use std::path::{Path, PathBuf};
13use tokio::fs;
14use tokio::task::JoinSet;
15
16/// [`BacklogItemRepository`] and [`SprintRepository`] implementation backed by the `.pinto/` directory.
17#[derive(Debug, Clone)]
18pub struct FileRepository {
19    root: PathBuf,
20}
21
22type ItemRecord = (PathBuf, BacklogItem);
23type SprintRecord = (PathBuf, Sprint);
24
25impl FileRepository {
26    /// Build by specifying the board root (`.pinto/`). No file I/O is performed.
27    pub fn new(root: impl Into<PathBuf>) -> Self {
28        Self { root: root.into() }
29    }
30
31    /// Directory to place task files (`<root>/tasks`).
32    #[must_use]
33    pub fn tasks_dir(&self) -> PathBuf {
34        self.root.join("tasks")
35    }
36
37    /// Directory to put sprint files (`<root>/sprints`).
38    #[must_use]
39    pub fn sprints_dir(&self) -> PathBuf {
40        self.root.join("sprints")
41    }
42
43    /// The sprint file path for the specified ID (`<root>/sprints/<id>.md`).
44    pub(crate) fn sprint_path_for(&self, id: &SprintId) -> PathBuf {
45        self.sprints_dir().join(format!("{id}.md"))
46    }
47
48    /// Task file path for the specified ID (`<root>/tasks/<id>.md`).
49    pub(crate) fn path_for(&self, id: &ItemId) -> Result<PathBuf> {
50        self.safe_item_path(&self.tasks_dir(), id)
51    }
52
53    /// Destination directory (`<root>/archive`).
54    fn archive_dir(&self) -> PathBuf {
55        self.root.join("archive")
56    }
57
58    /// Archive file path for the specified ID (`<root>/archive/<id>.md`).
59    fn archive_path_for(&self, id: &ItemId) -> Result<PathBuf> {
60        self.safe_item_path(&self.archive_dir(), id)
61    }
62
63    /// Build one item path while checking that the ID contributes exactly one filename component.
64    fn safe_item_path(&self, directory: &Path, id: &ItemId) -> Result<PathBuf> {
65        let path = directory.join(format!("{id}.md"));
66        if path.parent() != Some(directory) || !path.starts_with(directory) {
67            return Err(Error::InvalidItemId(id.to_string()));
68        }
69        Ok(path)
70    }
71}
72
73impl BacklogItemRepository for FileRepository {
74    async fn save(&self, item: &BacklogItem) -> Result<()> {
75        let (_, archived) = self.read_all_item_records().await?;
76        let dir = self.tasks_dir();
77        fs::create_dir_all(&dir)
78            .await
79            .map_err(|e| Error::io(&dir, &e))?;
80        let path = self.path_for(&item.id)?;
81        if let Some((archive_path, _)) =
82            archived.iter().find(|(_, existing)| existing.id == item.id)
83        {
84            return Err(Error::parse(
85                &path,
86                format!(
87                    "cannot save item `{}`: archive file {} already exists; remove the archived copy before restoring the item",
88                    item.id,
89                    archive_path.display()
90                ),
91            ));
92        }
93        record(&self.root, &item.id).await?;
94        let text = to_markdown(item)?;
95        atomic_write(&path, &text).await
96    }
97
98    async fn load(&self, id: &ItemId) -> Result<BacklogItem> {
99        let (active, _) = self.read_all_item_records().await?;
100        active
101            .into_iter()
102            .find_map(|(_, item)| (item.id == *id).then_some(item))
103            .ok_or_else(|| Error::NotFound(id.clone()))
104    }
105
106    async fn list(&self) -> Result<Vec<BacklogItem>> {
107        let (active, _) = self.read_all_item_records().await?;
108        let mut items = active.into_iter().map(|(_, item)| item).collect::<Vec<_>>();
109
110        // Canonical backlog order (rank asc, ID tie-break) shared with every view.
111        items.sort_by(BacklogItem::backlog_cmp);
112        Ok(items)
113    }
114
115    async fn delete(&self, id: &ItemId) -> Result<()> {
116        self.read_all_item_records().await?;
117        let path = self.path_for(id)?;
118        match fs::remove_file(&path).await {
119            Ok(()) => record(&self.root, id).await,
120            Err(e) if e.kind() == io::ErrorKind::NotFound => Err(Error::NotFound(id.clone())),
121            Err(e) => Err(Error::io(&path, &e)),
122        }
123    }
124
125    async fn archive(&self, id: &ItemId) -> Result<PathBuf> {
126        let src = self.path_for(id)?;
127        let source_exists = fs::try_exists(&src)
128            .await
129            .map_err(|e| Error::io(&src, &e))?;
130        let dest = self.archive_path_for(id)?;
131        let destination_exists = fs::try_exists(&dest)
132            .await
133            .map_err(|e| Error::io(&dest, &e))?;
134        if source_exists && destination_exists {
135            return Err(Error::parse(
136                &dest,
137                format!(
138                    "cannot archive `{id}`: destination already exists at {}; remove the archived copy before retrying",
139                    dest.display()
140                ),
141            ));
142        }
143        self.read_all_item_records().await?;
144        if !source_exists {
145            return Err(Error::NotFound(id.clone()));
146        }
147        let archive_dir = self.archive_dir();
148        fs::create_dir_all(&archive_dir)
149            .await
150            .map_err(|e| Error::io(&archive_dir, &e))?;
151
152        // Rename after validation; map a source that disappears between validation and the move
153        // to `NotFound` without allowing an existing destination to be replaced.
154        match fs::rename(&src, &dest).await {
155            Ok(()) => {
156                record(&self.root, id).await?;
157                Ok(dest)
158            }
159            Err(e) if e.kind() == io::ErrorKind::NotFound => Err(Error::NotFound(id.clone())),
160            Err(e) => Err(Error::io(&src, &e)),
161        }
162    }
163
164    async fn next_id(&self, prefix: &str) -> Result<ItemId> {
165        // IDs are never reused after issuance. The history file also covers physically deleted IDs
166        // and backend migrations; scanning both directories keeps older boards safe as well.
167        let mut max = max_number(&self.root, prefix).await?;
168        let (active, archived) = self.read_all_item_records().await?;
169        let existing_max = active
170            .iter()
171            .chain(archived.iter())
172            .map(|(_, item)| item.id.clone())
173            .filter(|id| id.prefix() == prefix)
174            .map(|id| id.number())
175            .max()
176            .unwrap_or(0);
177        max = max.max(existing_max);
178        let next = max
179            .checked_add(1)
180            .ok_or_else(|| Error::InvalidItemId(format!("{prefix}-{max}")))?;
181        ItemId::try_new(prefix, next)
182    }
183}
184
185impl SprintRepository for FileRepository {
186    async fn save(&self, sprint: &Sprint) -> Result<()> {
187        self.read_sprint_records().await?;
188        let dir = self.sprints_dir();
189        fs::create_dir_all(&dir)
190            .await
191            .map_err(|e| Error::io(&dir, &e))?;
192        let path = self.sprint_path_for(&sprint.id);
193        let text = sprint_to_markdown(sprint)?;
194        atomic_write(&path, &text).await
195    }
196
197    async fn load(&self, id: &SprintId) -> Result<Sprint> {
198        self.read_sprint_records()
199            .await?
200            .into_iter()
201            .find_map(|(_, sprint)| (sprint.id == *id).then_some(sprint))
202            .ok_or_else(|| Error::SprintNotFound(id.clone()))
203    }
204
205    async fn list(&self) -> Result<Vec<Sprint>> {
206        let mut sprints = self
207            .read_sprint_records()
208            .await?
209            .into_iter()
210            .map(|(_, sprint)| sprint)
211            .collect::<Vec<_>>();
212
213        // Sort by creation time, using the ID as a deterministic tie-breaker.
214        sprints.sort_by(|a, b| {
215            a.created
216                .cmp(&b.created)
217                .then_with(|| a.id.as_str().cmp(b.id.as_str()))
218        });
219        Ok(sprints)
220    }
221
222    async fn delete(&self, id: &SprintId) -> Result<()> {
223        self.read_sprint_records().await?;
224        let path = self.sprint_path_for(id);
225        match fs::remove_file(&path).await {
226            Ok(()) => Ok(()),
227            Err(e) if e.kind() == io::ErrorKind::NotFound => Err(Error::SprintNotFound(id.clone())),
228            Err(e) => Err(Error::io(&path, &e)),
229        }
230    }
231}
232
233impl FileRepository {
234    /// Read and validate every active and archived item before exposing the active set.
235    async fn read_all_item_records(&self) -> Result<(Vec<ItemRecord>, Vec<ItemRecord>)> {
236        let active = self.read_item_records(&self.tasks_dir()).await?;
237        let archived = self.read_item_records(&self.archive_dir()).await?;
238        Self::ensure_unique_item_ids(active.iter().chain(archived.iter()))?;
239        Ok((active, archived))
240    }
241
242    /// Read item files from one directory, validate their logical IDs, and retain their paths for
243    /// actionable corruption diagnostics and cross-directory collision checks.
244    async fn read_item_records(&self, dir: &Path) -> Result<Vec<ItemRecord>> {
245        let Some(paths) = self.markdown_paths(dir).await? else {
246            return Ok(Vec::new());
247        };
248
249        // Read all files concurrently; this is I/O-bound work.
250        let mut reads = JoinSet::new();
251        for path in paths {
252            reads.spawn(async move {
253                fs::read_to_string(&path)
254                    .await
255                    .map_err(|e| Error::io(&path, &e))
256                    .map(|text| (path, text))
257            });
258        }
259        let mut contents = Vec::new();
260        while let Some(joined) = reads.join_next().await {
261            contents.push(joined.map_err(Error::task)??);
262        }
263
264        // Parse frontmatter in parallel; this is CPU-bound work.
265        let records = contents
266            .into_par_iter()
267            .map(|(path, text)| from_markdown(&text, &path).map(|item| (path, item)))
268            .collect::<Result<Vec<_>>>()?;
269
270        // Report duplicate logical IDs before filename mismatches so the error names both records
271        // when a copied file creates an ambiguous lookup key.
272        Self::ensure_unique_item_ids(records.iter())?;
273        for (path, item) in &records {
274            Self::validate_item_filename(path, item)?;
275        }
276        Ok(records)
277    }
278
279    /// Reject two files that resolve to the same logical item ID.
280    fn ensure_unique_item_ids<'a>(records: impl IntoIterator<Item = &'a ItemRecord>) -> Result<()> {
281        let mut seen = HashMap::new();
282        for (path, item) in records {
283            if let Some(previous) = seen.insert(item.id.clone(), path.clone()) {
284                return Err(Error::parse(
285                    path,
286                    format!(
287                        "duplicate item ID `{}` in {} and {}; fix one frontmatter ID or rename one file",
288                        item.id,
289                        previous.display(),
290                        path.display()
291                    ),
292                ));
293            }
294        }
295        Ok(())
296    }
297
298    /// Ensure an item's filename stem and frontmatter ID describe the same record.
299    fn validate_item_filename(path: &Path, item: &BacklogItem) -> Result<()> {
300        let stem = path
301            .file_stem()
302            .and_then(|value| value.to_str())
303            .ok_or_else(|| {
304                Error::parse(
305                    path,
306                    "item filename must be a UTF-8 `<PREFIX>-<NUMBER>.md`; rename the file",
307                )
308            })?;
309        let filename_id = stem.parse::<ItemId>().map_err(|error| {
310            Error::parse(
311                path,
312                format!("invalid item filename `{stem}.md`: {error}; rename the file to `<ID>.md`"),
313            )
314        })?;
315        if filename_id != item.id {
316            return Err(Error::parse(
317                path,
318                format!(
319                    "filename ID `{filename_id}` does not match frontmatter ID `{}`; rename the file or fix its frontmatter",
320                    item.id
321                ),
322            ));
323        }
324        Ok(())
325    }
326
327    /// Read and validate every sprint file, retaining paths for collision diagnostics.
328    async fn read_sprint_records(&self) -> Result<Vec<SprintRecord>> {
329        let dir = self.sprints_dir();
330        let Some(paths) = self.markdown_paths(&dir).await? else {
331            return Ok(Vec::new());
332        };
333
334        let mut reads = JoinSet::new();
335        for path in paths {
336            reads.spawn(async move {
337                fs::read_to_string(&path)
338                    .await
339                    .map_err(|e| Error::io(&path, &e))
340                    .map(|text| (path, text))
341            });
342        }
343        let mut contents = Vec::new();
344        while let Some(joined) = reads.join_next().await {
345            contents.push(joined.map_err(Error::task)??);
346        }
347
348        let records = contents
349            .into_iter()
350            .map(|(path, text)| sprint_from_markdown(&text, &path).map(|sprint| (path, sprint)))
351            .collect::<Result<Vec<_>>>()?;
352        Self::ensure_unique_sprint_ids(&records)?;
353        for (path, sprint) in &records {
354            Self::validate_sprint_filename(path, sprint)?;
355        }
356        Ok(records)
357    }
358
359    /// Reject two sprint files that resolve to the same logical sprint ID.
360    fn ensure_unique_sprint_ids(records: &[SprintRecord]) -> Result<()> {
361        let mut seen = HashMap::new();
362        for (path, sprint) in records {
363            if let Some(previous) = seen.insert(sprint.id.clone(), path.clone()) {
364                return Err(Error::parse(
365                    path,
366                    format!(
367                        "duplicate sprint ID `{}` in {} and {}; fix one frontmatter ID or rename one file",
368                        sprint.id,
369                        previous.display(),
370                        path.display()
371                    ),
372                ));
373            }
374        }
375        Ok(())
376    }
377
378    /// Ensure a sprint filename stem and frontmatter ID describe the same record.
379    fn validate_sprint_filename(path: &Path, sprint: &Sprint) -> Result<()> {
380        let stem = path
381            .file_stem()
382            .and_then(|value| value.to_str())
383            .ok_or_else(|| {
384                Error::parse(
385                    path,
386                    "sprint filename must be a UTF-8 `<ID>.md`; rename the file",
387                )
388            })?;
389        let filename_id = stem.parse::<SprintId>().map_err(|error| {
390            Error::parse(
391                path,
392                format!(
393                    "invalid sprint filename `{stem}.md`: {error}; rename the file to `<ID>.md`"
394                ),
395            )
396        })?;
397        if filename_id != sprint.id {
398            return Err(Error::parse(
399                path,
400                format!(
401                    "filename ID `{filename_id}` does not match frontmatter ID `{}`; rename the file or fix its frontmatter",
402                    sprint.id
403                ),
404            ));
405        }
406        Ok(())
407    }
408
409    /// Collect the `.md` file paths directly under `tasks/` (directory scanning is asynchronous).
410    ///
411    /// If the directory does not exist, return `None` to indicate that it has no items yet.
412    async fn markdown_paths(&self, dir: &Path) -> Result<Option<Vec<PathBuf>>> {
413        let mut read_dir = match fs::read_dir(dir).await {
414            Ok(rd) => rd,
415            Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(None),
416            Err(e) => return Err(Error::io(dir, &e)),
417        };
418
419        let mut paths = Vec::new();
420        while let Some(entry) = read_dir
421            .next_entry()
422            .await
423            .map_err(|e| Error::io(dir, &e))?
424        {
425            let path = entry.path();
426            if Self::is_markdown(&path) {
427                paths.push(path);
428            }
429        }
430        Ok(Some(paths))
431    }
432
433    /// Return whether `path` has the `.md` extension.
434    fn is_markdown(path: &Path) -> bool {
435        path.extension().and_then(|s| s.to_str()) == Some("md")
436    }
437}
438
439#[cfg(test)]
440mod tests {
441    //! Persistence tests for `FileRepository`.
442    //!
443    //! Each test uses a `tempfile` directory so it cannot modify the real file system. The I/O
444    //! layer is asynchronous (`tokio`), so tests use `#[tokio::test]`.
445
446    use super::{BacklogItemRepository, FileRepository, SprintRepository};
447    use crate::backlog::{BacklogItem, ItemId, Status};
448    use crate::error::Error;
449    use crate::rank::Rank;
450    use crate::sprint::{Sprint, SprintId};
451    use chrono::{DateTime, TimeZone, Utc};
452    use tempfile::TempDir;
453    use tokio::fs;
454    fn ts(secs: i64) -> DateTime<Utc> {
455        Utc.timestamp_opt(secs, 0)
456            .single()
457            .expect("valid timestamp")
458    }
459
460    /// Create a temporary directory and a repository rooted at `.pinto` inside it.
461    fn repo() -> (TempDir, FileRepository) {
462        let dir = TempDir::new().expect("create temp dir");
463        let repo = FileRepository::new(dir.path().join(".pinto"));
464        (dir, repo)
465    }
466
467    /// Generate `count` monotonically increasing ranks in the order of insertion.
468    fn ranks(count: usize) -> Vec<Rank> {
469        let mut out = Vec::with_capacity(count);
470        let mut prev: Option<Rank> = None;
471        for _ in 0..count {
472            let next = Rank::after(prev.as_ref());
473            prev = Some(next.clone());
474            out.push(next);
475        }
476        out
477    }
478
479    /// Create a minimal item with the specified ID and rank.
480    fn item(n: u32, rank: Rank) -> BacklogItem {
481        BacklogItem::new(
482            ItemId::new("T", n),
483            format!("Item {n}"),
484            Status::new("todo"),
485            rank,
486            ts(1_000),
487        )
488        .expect("valid item")
489    }
490
491    fn sample_item() -> BacklogItem {
492        let mut item = BacklogItem::new(
493            ItemId::new("T", 1),
494            "Implement storage layer",
495            Status::new("todo"),
496            Rank::after(None),
497            ts(1_000),
498        )
499        .expect("valid item");
500        item.points = Some(5);
501        item.labels = vec!["storage".to_string(), "cli".to_string()];
502        item.assignee = Some("alice".to_string());
503        item.sprint = Some("S-1".to_string());
504        item.parent = Some(ItemId::new("T", 0));
505        item.depends_on = vec![ItemId::new("T", 2), ItemId::new("T", 3)];
506        item.updated = ts(2_000);
507        item.body = "## 説明\n本文の Markdown。\n\n- [ ] 受け入れ条件".to_string();
508        item
509    }
510
511    #[tokio::test]
512    async fn save_then_load_roundtrips_all_fields() {
513        let (_dir, repo) = repo();
514        let item = sample_item();
515
516        BacklogItemRepository::save(&repo, &item)
517            .await
518            .expect("save succeeds");
519        let loaded = BacklogItemRepository::load(&repo, &item.id)
520            .await
521            .expect("load succeeds");
522
523        assert_eq!(loaded, item);
524    }
525
526    #[tokio::test]
527    async fn save_writes_toml_frontmatter_file() {
528        let (_dir, repo) = repo();
529        let item = sample_item();
530
531        BacklogItemRepository::save(&repo, &item)
532            .await
533            .expect("save succeeds");
534
535        let path = repo.tasks_dir().join("T-1.md");
536        let text = fs::read_to_string(&path).await.expect("file exists");
537        assert!(text.starts_with("+++\n"), "should open with TOML delimiter");
538        assert!(text.contains("id = \"T-1\""), "frontmatter carries id");
539        assert!(
540            text.contains("status = \"todo\""),
541            "frontmatter carries status"
542        );
543        assert!(text.contains("rank = "), "frontmatter carries rank");
544        assert!(
545            text.contains("depends_on = [\"T-2\", \"T-3\"]"),
546            "frontmatter carries dependencies"
547        );
548        assert!(
549            text.contains("## 説明"),
550            "body is preserved after frontmatter"
551        );
552    }
553
554    #[tokio::test]
555    async fn load_missing_item_returns_not_found() {
556        let (_dir, repo) = repo();
557
558        let err = BacklogItemRepository::load(&repo, &ItemId::new("T", 99))
559            .await
560            .expect_err("should be missing");
561        assert_eq!(err, Error::NotFound(ItemId::new("T", 99)));
562    }
563
564    #[tokio::test]
565    async fn list_returns_all_items_sorted_by_rank() {
566        let (_dir, repo) = repo();
567        // Give items ranks that differ from their ID order and verify that rank determines the result.
568        let order = [3u32, 1, 10, 2];
569        let rs = ranks(order.len());
570        for (n, rank) in order.iter().zip(rs) {
571            BacklogItemRepository::save(&repo, &item(*n, rank))
572                .await
573                .expect("save succeeds");
574        }
575
576        let items = BacklogItemRepository::list(&repo)
577            .await
578            .expect("list succeeds");
579        let ids: Vec<u32> = items.iter().map(|i| i.id.number()).collect();
580        assert_eq!(ids, order.to_vec(), "rank 昇順(= 割当順)で返る");
581    }
582
583    #[tokio::test]
584    async fn list_rejects_filename_frontmatter_id_mismatch() {
585        let (_dir, repo) = repo();
586        BacklogItemRepository::save(&repo, &item(2, Rank::after(None)))
587            .await
588            .expect("save fixture");
589        fs::rename(
590            repo.tasks_dir().join("T-2.md"),
591            repo.tasks_dir().join("T-1.md"),
592        )
593        .await
594        .expect("rename corrupt fixture");
595
596        let err = BacklogItemRepository::list(&repo)
597            .await
598            .expect_err("filename/frontmatter mismatch must fail fast");
599        let message = err.to_string();
600        assert!(message.contains("filename"), "got {message}");
601        assert!(
602            message.contains("T-1") && message.contains("T-2"),
603            "got {message}"
604        );
605    }
606
607    #[tokio::test]
608    async fn list_rejects_duplicate_logical_ids() {
609        let (_dir, repo) = repo();
610        let ranks = ranks(2);
611        BacklogItemRepository::save(&repo, &item(1, ranks[0].clone()))
612            .await
613            .expect("save first fixture");
614        BacklogItemRepository::save(&repo, &item(2, ranks[1].clone()))
615            .await
616            .expect("save second fixture");
617        fs::copy(
618            repo.tasks_dir().join("T-1.md"),
619            repo.tasks_dir().join("T-2.md"),
620        )
621        .await
622        .expect("copy duplicate fixture");
623
624        let err = BacklogItemRepository::list(&repo)
625            .await
626            .expect_err("duplicate logical IDs must fail fast");
627        let message = err.to_string();
628        assert!(
629            message.contains("duplicate") && message.contains("T-1"),
630            "got {message}"
631        );
632    }
633
634    #[tokio::test]
635    async fn list_parallelizes_many_items_and_keeps_order() {
636        // Verify that concurrent reads and rayon parsing preserve the results for many files.
637        let (_dir, repo) = repo();
638        let n = 200usize;
639        let rs = ranks(n);
640        for (i, rank) in (1..=n).zip(rs) {
641            BacklogItemRepository::save(&repo, &item(i as u32, rank))
642                .await
643                .expect("save succeeds");
644        }
645
646        let items = BacklogItemRepository::list(&repo)
647            .await
648            .expect("list succeeds");
649        let ids: Vec<u32> = items.iter().map(|i| i.id.number()).collect();
650        let expected: Vec<u32> = (1..=n as u32).collect();
651        assert_eq!(ids, expected, "並列読込・パースでも rank 昇順を保つ");
652    }
653
654    #[tokio::test]
655    async fn list_on_uninitialized_dir_is_empty_not_error() {
656        let (_dir, repo) = repo();
657        let items = BacklogItemRepository::list(&repo)
658            .await
659            .expect("list must not error on missing dir");
660        assert!(items.is_empty());
661    }
662
663    #[tokio::test]
664    async fn delete_removes_file_and_missing_delete_errors() {
665        let (_dir, repo) = repo();
666        let item = sample_item();
667        BacklogItemRepository::save(&repo, &item)
668            .await
669            .expect("save succeeds");
670
671        BacklogItemRepository::delete(&repo, &item.id)
672            .await
673            .expect("delete succeeds");
674        assert_eq!(
675            BacklogItemRepository::load(&repo, &item.id)
676                .await
677                .expect_err("gone"),
678            Error::NotFound(item.id.clone())
679        );
680        assert_eq!(
681            BacklogItemRepository::delete(&repo, &item.id)
682                .await
683                .expect_err("already gone"),
684            Error::NotFound(item.id)
685        );
686    }
687
688    #[tokio::test]
689    async fn next_id_does_not_reuse_a_physically_deleted_id() {
690        let (_dir, repo) = repo();
691        let item = sample_item();
692        BacklogItemRepository::save(&repo, &item)
693            .await
694            .expect("save succeeds");
695        BacklogItemRepository::delete(&repo, &item.id)
696            .await
697            .expect("delete succeeds");
698
699        assert_eq!(
700            repo.next_id("T").await.expect("next id"),
701            ItemId::new("T", 2),
702            "a physically deleted ID must remain reserved"
703        );
704    }
705
706    #[tokio::test]
707    async fn archive_moves_file_out_of_tasks_and_missing_archive_errors() {
708        let (_dir, repo) = repo();
709        let item = sample_item();
710        BacklogItemRepository::save(&repo, &item)
711            .await
712            .expect("save succeeds");
713
714        let dest = repo.archive(&item.id).await.expect("archive succeeds");
715
716        // The item is no longer present in `tasks/`.
717        assert_eq!(
718            BacklogItemRepository::load(&repo, &item.id)
719                .await
720                .expect_err("gone from tasks"),
721            Error::NotFound(item.id.clone())
722        );
723        // The archived file exists at the destination.
724        assert!(dest.is_file(), "archived file exists at {dest:?}");
725        assert!(
726            dest.ends_with("archive/T-1.md"),
727            "archived under archive dir: {dest:?}"
728        );
729        // Archiving the same item again returns `NotFound`.
730        assert_eq!(
731            BacklogItemRepository::archive(&repo, &item.id)
732                .await
733                .expect_err("already archived"),
734            Error::NotFound(item.id)
735        );
736    }
737
738    #[tokio::test]
739    async fn next_id_increments_from_max_and_defaults_to_one() {
740        let (_dir, repo) = repo();
741
742        assert_eq!(repo.next_id("T").await.expect("empty"), ItemId::new("T", 1));
743
744        for (n, rank) in [1u32, 2, 7].into_iter().zip(ranks(3)) {
745            BacklogItemRepository::save(&repo, &item(n, rank))
746                .await
747                .expect("save succeeds");
748        }
749        assert_eq!(repo.next_id("T").await.expect("next"), ItemId::new("T", 8));
750        // Different prefixes are numbered independently.
751        assert_eq!(
752            repo.next_id("BUG").await.expect("next"),
753            ItemId::new("BUG", 1)
754        );
755    }
756
757    #[tokio::test]
758    async fn next_id_does_not_reuse_archived_ids() {
759        // Archived IDs must not be reused, so `next_id` scans both `tasks/` and `archive/`.
760        let (_dir, repo) = repo();
761
762        // Create T-1, then archive it; it leaves `tasks/`.
763        BacklogItemRepository::save(&repo, &item(1, ranks(1).remove(0)))
764            .await
765            .expect("save succeeds");
766        repo.archive(&ItemId::new("T", 1))
767            .await
768            .expect("archive succeeds");
769
770        // The next ID is T-2 because `archive/T-1.md` remains reserved.
771        assert_eq!(
772            repo.next_id("T").await.expect("next"),
773            ItemId::new("T", 2),
774            "archived id must not be reused"
775        );
776    }
777
778    #[tokio::test]
779    async fn archive_rejects_an_existing_destination_without_overwriting_it() {
780        let (_dir, repo) = repo();
781        let item = item(1, Rank::after(None));
782        BacklogItemRepository::save(&repo, &item)
783            .await
784            .expect("save fixture");
785        fs::create_dir_all(repo.archive_dir())
786            .await
787            .expect("create archive dir");
788        fs::copy(
789            repo.tasks_dir().join("T-1.md"),
790            repo.archive_dir().join("T-1.md"),
791        )
792        .await
793        .expect("create archive collision");
794
795        let err = BacklogItemRepository::archive(&repo, &item.id)
796            .await
797            .expect_err("archive collision must fail fast");
798        assert!(err.to_string().contains("already exists"), "got {err}");
799        assert!(repo.tasks_dir().join("T-1.md").is_file());
800        assert!(repo.archive_dir().join("T-1.md").is_file());
801    }
802
803    #[tokio::test]
804    async fn save_rejects_an_archived_duplicate_before_creating_an_active_file() {
805        let (_dir, repo) = repo();
806        let item = item(1, Rank::after(None));
807        BacklogItemRepository::save(&repo, &item)
808            .await
809            .expect("save fixture");
810        BacklogItemRepository::archive(&repo, &item.id)
811            .await
812            .expect("archive fixture");
813
814        let err = BacklogItemRepository::save(&repo, &item)
815            .await
816            .expect_err("saving an archived duplicate must fail fast");
817        assert!(err.to_string().contains("already exists"), "got {err}");
818        assert!(!repo.tasks_dir().join("T-1.md").exists());
819        assert!(repo.archive_dir().join("T-1.md").is_file());
820    }
821
822    #[tokio::test]
823    async fn next_id_rejects_filename_frontmatter_id_mismatch() {
824        let (_dir, repo) = repo();
825        BacklogItemRepository::save(&repo, &item(2, Rank::after(None)))
826            .await
827            .expect("save fixture");
828        fs::rename(
829            repo.tasks_dir().join("T-2.md"),
830            repo.tasks_dir().join("T-1.md"),
831        )
832        .await
833        .expect("rename corrupt fixture");
834
835        let err = repo
836            .next_id("T")
837            .await
838            .expect_err("next_id must validate existing records before allocating");
839        assert!(err.to_string().contains("filename"), "got {err}");
840    }
841
842    #[tokio::test]
843    async fn next_id_rejects_number_overflow_in_existing_files() {
844        let (_dir, repo) = repo();
845        let tasks = repo.tasks_dir();
846        fs::create_dir_all(&tasks).await.expect("create tasks dir");
847        let maximum = item(u32::MAX, Rank::after(None));
848        let text = crate::storage::markdown::to_markdown(&maximum).expect("serialize maximum ID");
849        fs::write(tasks.join("T-4294967295.md"), text)
850            .await
851            .expect("write maximum id fixture");
852
853        let err = repo
854            .next_id("T")
855            .await
856            .expect_err("the next id must not wrap around");
857        assert!(err.to_string().contains("T-4294967295"));
858    }
859
860    #[tokio::test]
861    async fn load_tolerates_crlf_delimiters() {
862        let (_dir, repo) = repo();
863        let dir = repo.tasks_dir();
864        fs::create_dir_all(&dir).await.expect("mkdir");
865        // Files edited on Windows with CRLF line endings remain readable.
866        fs::write(
867            dir.join("T-1.md"),
868            "+++\r\nid = \"T-1\"\r\ntitle = \"CRLF\"\r\nstatus = \"todo\"\r\nrank = \"i\"\r\ncreated = \"1970-01-01T00:00:00Z\"\r\nupdated = \"1970-01-01T00:00:00Z\"\r\n+++\r\n",
869        )
870        .await
871        .expect("write");
872
873        let item = BacklogItemRepository::load(&repo, &ItemId::new("T", 1))
874            .await
875            .expect("load succeeds");
876        assert_eq!(item.title, "CRLF");
877    }
878
879    #[tokio::test]
880    async fn corrupt_frontmatter_returns_parse_error_without_panic() {
881        let (_dir, repo) = repo();
882        let dir = repo.tasks_dir();
883        fs::create_dir_all(&dir).await.expect("mkdir");
884        // The closing delimiter is present, but the frontmatter is invalid TOML.
885        fs::write(dir.join("T-1.md"), "+++\nid = \nbroken\n+++\n\nbody")
886            .await
887            .expect("write");
888
889        let err = BacklogItemRepository::load(&repo, &ItemId::new("T", 1))
890            .await
891            .expect_err("should fail to parse");
892        assert!(matches!(err, Error::Parse { .. }), "got {err:?}");
893    }
894
895    #[tokio::test]
896    async fn unsafe_frontmatter_id_is_rejected_before_file_backend_uses_it() {
897        let (_dir, repo) = repo();
898        let dir = repo.tasks_dir();
899        fs::create_dir_all(&dir).await.expect("mkdir");
900        fs::write(
901            dir.join("T-1.md"),
902            r#"+++
903id = "../outside-1"
904title = "Unsafe"
905status = "todo"
906rank = "i"
907created = "1970-01-01T00:00:00Z"
908updated = "1970-01-01T00:00:00Z"
909+++
910"#,
911        )
912        .await
913        .expect("write unsafe frontmatter");
914
915        let err = BacklogItemRepository::list(&repo)
916            .await
917            .expect_err("unsafe frontmatter ID must be rejected");
918        assert!(err.to_string().contains("invalid item id"), "got {err:?}");
919    }
920
921    #[tokio::test]
922    async fn missing_frontmatter_delimiter_returns_error_without_panic() {
923        let (_dir, repo) = repo();
924        let dir = repo.tasks_dir();
925        fs::create_dir_all(&dir).await.expect("mkdir");
926        fs::write(dir.join("T-1.md"), "no frontmatter here\njust text")
927            .await
928            .expect("write");
929
930        let err = BacklogItemRepository::load(&repo, &ItemId::new("T", 1))
931            .await
932            .expect_err("should fail");
933        assert!(
934            matches!(err, Error::MissingFrontmatter { .. }),
935            "got {err:?}"
936        );
937    }
938
939    #[tokio::test]
940    async fn empty_title_on_load_returns_parse_error() {
941        let (_dir, repo) = repo();
942        let dir = repo.tasks_dir();
943        fs::create_dir_all(&dir).await.expect("mkdir");
944        // All required fields are present, but an empty title violates the model invariant.
945        fs::write(
946            dir.join("T-1.md"),
947            "+++\nid = \"T-1\"\ntitle = \"\"\nstatus = \"todo\"\nrank = \"i\"\ncreated = \"1970-01-01T00:00:00Z\"\nupdated = \"1970-01-01T00:00:00Z\"\n+++\n",
948        )
949        .await
950        .expect("write");
951
952        let err = BacklogItemRepository::load(&repo, &ItemId::new("T", 1))
953            .await
954            .expect_err("should fail");
955        assert!(matches!(err, Error::Parse { .. }), "got {err:?}");
956    }
957
958    #[tokio::test]
959    async fn missing_required_field_returns_parse_error() {
960        let (_dir, repo) = repo();
961        let dir = repo.tasks_dir();
962        fs::create_dir_all(&dir).await.expect("mkdir");
963        // Required fields such as `title` are missing.
964        fs::write(dir.join("T-1.md"), "+++\nid = \"T-1\"\n+++\n\nbody")
965            .await
966            .expect("write");
967
968        let err = BacklogItemRepository::load(&repo, &ItemId::new("T", 1))
969            .await
970            .expect_err("should fail");
971        assert!(matches!(err, Error::Parse { .. }), "got {err:?}");
972    }
973
974    // --- Sprint persistence ---
975
976    /// A sample sprint with goals, dates, and status.
977    fn sample_sprint() -> Sprint {
978        let mut s = Sprint::new(SprintId::new("S-1").unwrap(), "Sprint 1", ts(1_000)).unwrap();
979        s.goal = "## ゴール\n\nログイン機能を完成させる".to_string();
980        s.start = Some(ts(2_000));
981        s.end = Some(ts(9_000));
982        s.start(ts(2_000)).expect("planned -> active");
983        s
984    }
985
986    #[tokio::test]
987    async fn save_then_load_roundtrips_sprint() {
988        let (_dir, repo) = repo();
989        let sprint = sample_sprint();
990
991        SprintRepository::save(&repo, &sprint)
992            .await
993            .expect("save succeeds");
994        let loaded = SprintRepository::load(&repo, &sprint.id)
995            .await
996            .expect("load succeeds");
997
998        assert_eq!(loaded, sprint);
999    }
1000
1001    #[tokio::test]
1002    async fn save_then_load_roundtrips_default_sprint() {
1003        // The default sprint (no schedule or goal, still planned) also round-trips without data loss.
1004        let (_dir, repo) = repo();
1005        let sprint = Sprint::new(SprintId::new("S-1").unwrap(), "Sprint 1", ts(1_000)).unwrap();
1006
1007        SprintRepository::save(&repo, &sprint)
1008            .await
1009            .expect("save succeeds");
1010        let loaded = SprintRepository::load(&repo, &sprint.id)
1011            .await
1012            .expect("load succeeds");
1013
1014        assert_eq!(loaded, sprint);
1015        assert_eq!(loaded.start, None);
1016        assert_eq!(loaded.end, None);
1017        assert_eq!(loaded.goal, "");
1018    }
1019
1020    #[tokio::test]
1021    async fn sprint_frontmatter_carries_fields_and_goal_body() {
1022        let (_dir, repo) = repo();
1023        let sprint = sample_sprint();
1024        SprintRepository::save(&repo, &sprint)
1025            .await
1026            .expect("save succeeds");
1027
1028        let text = fs::read_to_string(repo.sprints_dir().join("S-1.md"))
1029            .await
1030            .expect("read file");
1031        assert!(text.contains("id = \"S-1\""), "frontmatter carries id");
1032        assert!(
1033            text.contains("title = \"Sprint 1\""),
1034            "frontmatter carries title"
1035        );
1036        assert!(
1037            text.contains("state = \"active\""),
1038            "frontmatter carries state"
1039        );
1040        let frontmatter = text.split("+++\n").nth(1).expect("frontmatter exists");
1041        assert!(
1042            !frontmatter.contains("sprint_goal"),
1043            "goal is not a frontmatter field"
1044        );
1045        assert!(text.contains("## ゴール"), "goal is stored as body");
1046    }
1047
1048    #[tokio::test]
1049    async fn load_missing_sprint_returns_not_found() {
1050        let (_dir, repo) = repo();
1051        let id = SprintId::new("S-99").unwrap();
1052
1053        let err = SprintRepository::load(&repo, &id)
1054            .await
1055            .expect_err("should be missing");
1056        assert_eq!(err, Error::SprintNotFound(id));
1057    }
1058
1059    #[tokio::test]
1060    async fn delete_sprint_removes_and_missing_is_not_found() {
1061        let (_dir, repo) = repo();
1062        let s = Sprint::new(SprintId::new("S-1").unwrap(), "S1", ts(1_000)).unwrap();
1063        SprintRepository::save(&repo, &s).await.expect("save");
1064        SprintRepository::delete(&repo, &s.id)
1065            .await
1066            .expect("delete");
1067        assert!(matches!(
1068            SprintRepository::load(&repo, &s.id).await,
1069            Err(Error::SprintNotFound(_))
1070        ));
1071        assert!(matches!(
1072            SprintRepository::delete(&repo, &s.id).await,
1073            Err(Error::SprintNotFound(_))
1074        ));
1075    }
1076
1077    #[tokio::test]
1078    async fn list_sprints_returns_all_in_creation_order() {
1079        let (_dir, repo) = repo();
1080        // The list is oldest-first even when files are saved in a different order.
1081        for (id, secs) in [("S-3", 3_000i64), ("S-1", 1_000), ("S-2", 2_000)] {
1082            let s = Sprint::new(SprintId::new(id).unwrap(), id, ts(secs)).unwrap();
1083            SprintRepository::save(&repo, &s)
1084                .await
1085                .expect("save succeeds");
1086        }
1087
1088        let ids: Vec<String> = SprintRepository::list(&repo)
1089            .await
1090            .expect("list succeeds")
1091            .into_iter()
1092            .map(|s| s.id.as_str().to_string())
1093            .collect();
1094        assert_eq!(ids, ["S-1", "S-2", "S-3"]);
1095    }
1096
1097    #[tokio::test]
1098    async fn list_sprints_rejects_filename_frontmatter_id_mismatch() {
1099        let (_dir, repo) = repo();
1100        let sprint =
1101            Sprint::new(SprintId::new("S-1").unwrap(), "Sprint 1", ts(1_000)).expect("sprint");
1102        SprintRepository::save(&repo, &sprint)
1103            .await
1104            .expect("save fixture");
1105        fs::rename(
1106            repo.sprints_dir().join("S-1.md"),
1107            repo.sprints_dir().join("S-2.md"),
1108        )
1109        .await
1110        .expect("rename corrupt fixture");
1111
1112        let err = SprintRepository::list(&repo)
1113            .await
1114            .expect_err("sprint filename/frontmatter mismatch must fail fast");
1115        let message = err.to_string();
1116        assert!(message.contains("filename"), "got {message}");
1117        assert!(
1118            message.contains("S-1") && message.contains("S-2"),
1119            "got {message}"
1120        );
1121    }
1122
1123    #[tokio::test]
1124    async fn list_sprints_rejects_duplicate_logical_ids() {
1125        let (_dir, repo) = repo();
1126        for (id, title) in [("S-1", "First"), ("S-2", "Second")] {
1127            let sprint = Sprint::new(SprintId::new(id).unwrap(), title, ts(1_000)).expect("sprint");
1128            SprintRepository::save(&repo, &sprint)
1129                .await
1130                .expect("save fixture");
1131        }
1132        fs::copy(
1133            repo.sprints_dir().join("S-1.md"),
1134            repo.sprints_dir().join("S-2.md"),
1135        )
1136        .await
1137        .expect("copy duplicate fixture");
1138
1139        let err = SprintRepository::list(&repo)
1140            .await
1141            .expect_err("duplicate sprint IDs must fail fast");
1142        let message = err.to_string();
1143        assert!(
1144            message.contains("duplicate") && message.contains("S-1"),
1145            "got {message}"
1146        );
1147    }
1148
1149    #[tokio::test]
1150    async fn list_sprints_on_empty_board_returns_empty() {
1151        let (_dir, repo) = repo();
1152        assert!(
1153            BacklogItemRepository::list(&repo)
1154                .await
1155                .expect("list succeeds")
1156                .is_empty()
1157        );
1158    }
1159
1160    #[tokio::test]
1161    async fn corrupt_sprint_state_returns_parse_error() {
1162        let (_dir, repo) = repo();
1163        let dir = repo.sprints_dir();
1164        fs::create_dir_all(&dir).await.expect("mkdir");
1165        // An unknown state is reported as a parse error without panicking.
1166        fs::write(
1167            dir.join("S-1.md"),
1168            "+++\nid = \"S-1\"\ntitle = \"Sprint 1\"\nstate = \"archived\"\ncreated = \"1970-01-01T00:00:00Z\"\nupdated = \"1970-01-01T00:00:00Z\"\n+++\n",
1169        )
1170        .await
1171        .expect("write");
1172
1173        let err = SprintRepository::load(&repo, &SprintId::new("S-1").unwrap())
1174            .await
1175            .expect_err("should fail");
1176        assert!(matches!(err, Error::Parse { .. }), "got {err:?}");
1177    }
1178}