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