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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 assert_eq!(
718 BacklogItemRepository::load(&repo, &item.id)
719 .await
720 .expect_err("gone from tasks"),
721 Error::NotFound(item.id.clone())
722 );
723 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 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 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 let (_dir, repo) = repo();
761
762 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 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 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 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 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 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 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 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 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 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}