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#[derive(Debug, Clone)]
18pub struct FileRepository {
19 root: PathBuf,
20}
21
22type ItemRecord = (PathBuf, BacklogItem);
23type SprintRecord = (PathBuf, Sprint);
24
25impl FileRepository {
26 pub fn new(root: impl Into<PathBuf>) -> Self {
28 Self { root: root.into() }
29 }
30
31 #[must_use]
33 pub fn tasks_dir(&self) -> PathBuf {
34 self.root.join("tasks")
35 }
36
37 #[must_use]
39 pub fn sprints_dir(&self) -> PathBuf {
40 self.root.join("sprints")
41 }
42
43 pub(crate) fn sprint_path_for(&self, id: &SprintId) -> PathBuf {
45 self.sprints_dir().join(format!("{id}.md"))
46 }
47
48 pub(crate) fn path_for(&self, id: &ItemId) -> Result<PathBuf> {
50 self.safe_item_path(&self.tasks_dir(), id)
51 }
52
53 fn archive_dir(&self) -> PathBuf {
55 self.root.join("archive")
56 }
57
58 fn archive_path_for(&self, id: &ItemId) -> Result<PathBuf> {
60 self.safe_item_path(&self.archive_dir(), id)
61 }
62
63 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 assert_eq!(
773 BacklogItemRepository::load(&repo, &item.id)
774 .await
775 .expect_err("gone from tasks"),
776 Error::NotFound(item.id.clone())
777 );
778 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 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 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 let (_dir, repo) = repo();
903
904 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 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 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 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 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 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 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 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 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 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}