1use std::collections::{BTreeMap, BTreeSet};
43use std::path::{Path, PathBuf};
44
45use serde::{Deserialize, Serialize};
46
47use crate::config::Layout;
48use crate::error::{Error, Result};
49use crate::views::IssueRec;
50
51pub const VERSION: &str = "vissue-satchel/1";
54
55#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
57#[serde(default)]
58pub struct Slice {
59 pub projects: Vec<String>,
61 pub issues: Vec<String>,
63}
64
65impl Slice {
66 #[must_use]
68 pub fn is_empty(&self) -> bool {
69 self.projects.is_empty() && self.issues.is_empty()
70 }
71}
72
73#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
75pub struct Satchel {
76 pub version: String,
78 pub asked: Slice,
80 pub issues: Vec<String>,
82 pub carried: Vec<String>,
84 pub needs: Vec<String>,
86 pub packed_by: String,
88 pub packed_at: String,
90}
91
92#[derive(Debug, Clone, PartialEq, Eq)]
94pub struct Report {
95 pub issues: usize,
97 pub needs: usize,
99 pub files: usize,
101 pub notes: Vec<String>,
103}
104
105impl Report {
106 #[must_use]
108 pub fn render(&self) -> String {
109 let mut out = format!(
110 "issues={} deeds={} files={}\n",
111 self.issues, self.needs, self.files
112 );
113 for note in &self.notes {
114 out.push_str(note);
115 out.push('\n');
116 }
117 out
118 }
119}
120
121pub fn pack(layout: &Layout, asked: &Slice, dest: &Path) -> Result<Report> {
133 if asked.is_empty() {
134 return Err(Error::Other(anyhow::anyhow!(
135 "a satchel needs a project or an issue to pack"
136 )));
137 }
138 let recs = crate::catalog::load_recs(layout)?;
139 let by_id: BTreeMap<&str, &IssueRec> =
140 recs.iter().map(|r| (r.heading.id.as_str(), r)).collect();
141
142 let mut named: BTreeSet<String> = BTreeSet::new();
144 for project in &asked.projects {
145 for rec in &recs {
146 if rec.project == *project {
147 named.insert(rec.heading.id.clone());
148 }
149 }
150 }
151 for id in &asked.issues {
152 if !by_id.contains_key(id.as_str()) {
153 return Err(Error::IssueNotFound { id: id.clone() });
154 }
155 named.insert(id.clone());
156 }
157 if named.is_empty() {
158 return Err(Error::Other(anyhow::anyhow!(
159 "nothing to pack: {:?} matched no issue",
160 asked.projects
161 )));
162 }
163
164 let mut chosen = named.clone();
166 let mut frontier: Vec<String> = named.iter().cloned().collect();
167 while let Some(id) = frontier.pop() {
168 let Some(rec) = by_id.get(id.as_str()) else {
169 continue;
170 };
171 let mut reach: Vec<String> = rec.heading.blocked_by();
172 if let Some(parent) = rec.heading.parent().map(str::to_string) {
173 reach.push(parent);
174 }
175 for next in reach {
176 if by_id.contains_key(next.as_str()) && chosen.insert(next.clone()) {
177 frontier.push(next);
178 }
179 }
180 }
181
182 let carried: Vec<String> = chosen.difference(&named).cloned().collect();
183 let mut needs: BTreeSet<String> = BTreeSet::new();
184 for id in &chosen {
185 if let Some(rec) = by_id.get(id.as_str()) {
186 needs.extend(rec.heading.deeds());
187 }
188 }
189
190 let data = dest.join("data");
192 let issues_dir = data.join("issues");
193 std::fs::create_dir_all(&issues_dir).map_err(Error::from)?;
194 let mut payload: Vec<(PathBuf, String)> = Vec::new();
195 for id in &chosen {
196 let Some(rec) = by_id.get(id.as_str()) else {
197 continue;
198 };
199 let text = crate::catalog::org_text_from(rec)?;
200 let rel = PathBuf::from("data/issues").join(format!("{id}.org"));
201 write_payload(dest, &rel, text.as_bytes())?;
202 payload.push((rel, digest(text.as_bytes())));
203 }
204
205 let satchel = Satchel {
206 version: VERSION.to_string(),
207 asked: asked.clone(),
208 issues: chosen.iter().cloned().collect(),
209 carried,
210 needs: needs.iter().cloned().collect(),
211 packed_by: crate::config::identity(layout),
212 packed_at: crate::model::today_inactive_bracket(),
213 };
214 let described = serde_json::to_string_pretty(&satchel)
215 .map(|json| json + "\n")
216 .map_err(|e| Error::Other(anyhow::anyhow!("{e}")))?;
217 let rel = PathBuf::from("data/satchel.json");
218 write_payload(dest, &rel, described.as_bytes())?;
219 payload.push((rel, digest(described.as_bytes())));
220
221 payload.sort();
222 write_manifest(dest, &payload)?;
223 write_declaration(dest, &satchel)?;
224
225 let mut notes = Vec::new();
226 if !satchel.needs.is_empty() {
227 notes.push(format!(
228 "{} deed accessions are named and not enclosed; the deed store exports them",
229 satchel.needs.len()
230 ));
231 }
232 if !satchel.carried.is_empty() {
233 notes.push(format!(
234 "{} issues came along as blockers or parents of what was asked for",
235 satchel.carried.len()
236 ));
237 }
238 Ok(Report {
239 issues: satchel.issues.len(),
240 needs: satchel.needs.len(),
241 files: payload.len(),
242 notes,
243 })
244}
245
246pub fn seal(dir: &Path) -> Result<Report> {
258 let satchel = describe(dir)?;
259 let mut payload: Vec<(PathBuf, String)> = Vec::new();
260 for found in walk(&dir.join("data"))? {
261 let rel = found
262 .strip_prefix(dir)
263 .map_err(|e| Error::Other(anyhow::anyhow!("{e}")))?
264 .to_path_buf();
265 let bytes = std::fs::read(&found).map_err(Error::from)?;
266 payload.push((rel, digest(&bytes)));
267 }
268 payload.sort();
269 write_manifest(dir, &payload)?;
270
271 let mut notes = shortfall(dir, &satchel);
272 let atoms = atom_lines(dir);
273 if atoms > 0 {
274 notes.push(format!("{atoms} atoms arrived from a pack"));
275 }
276 Ok(Report {
277 issues: satchel.issues.len(),
278 needs: satchel.needs.len(),
279 files: payload.len(),
280 notes,
281 })
282}
283
284fn shortfall(dir: &Path, satchel: &Satchel) -> Vec<String> {
290 let enclosed = enclosed_deeds(dir);
291 let short = satchel
292 .needs
293 .iter()
294 .filter(|acc| !enclosed.contains(*acc))
295 .count();
296 if short == 0 {
297 return Vec::new();
298 }
299 vec![format!(
300 "{short} of {} deed accessions are named and not enclosed",
301 satchel.needs.len()
302 )]
303}
304
305fn signature_note(dir: &Path) -> String {
317 let manifest = dir.join("manifest-sha256.txt");
318 let signature = manifest.with_extension("txt.sig");
319 if signature.is_file() {
320 format!(
321 "the payload matches the manifest, and the manifest carries a signature this check \
322 did not verify: `deedar vouch check {}` says whether a key you accept made it",
323 manifest.display()
324 )
325 } else {
326 "the payload matches the manifest, and the manifest is unsigned, so this establishes \
327 that the bag arrived as written and nothing about who wrote it"
328 .to_string()
329 }
330}
331
332fn atom_lines(dir: &Path) -> usize {
335 let Ok(entries) = std::fs::read_dir(dir.join("data").join("atoms")) else {
336 return 0;
337 };
338 entries
339 .flatten()
340 .filter_map(|entry| std::fs::read_to_string(entry.path()).ok())
341 .map(|text| text.lines().filter(|l| !l.trim().is_empty()).count())
342 .sum()
343}
344
345fn enclosed_deeds(dir: &Path) -> BTreeSet<String> {
347 let mut out = BTreeSet::new();
348 let Ok(entries) = std::fs::read_dir(dir.join("data").join("deeds")) else {
349 return out;
350 };
351 for entry in entries.flatten() {
352 if entry.path().is_dir()
353 && let Some(name) = entry.file_name().to_str()
354 {
355 out.insert(name.to_string());
356 }
357 }
358 out
359}
360
361pub fn verify(dir: &Path) -> Result<Report> {
372 let manifest = dir.join("manifest-sha256.txt");
373 let text = std::fs::read_to_string(&manifest)
374 .map_err(|_| Error::Other(anyhow::anyhow!("no manifest at {}", manifest.display())))?;
375 let mut listed: BTreeMap<PathBuf, String> = BTreeMap::new();
376 for line in text.lines().filter(|l| !l.trim().is_empty()) {
377 let Some((hash, rel)) = line.split_once(" ") else {
378 return Err(Error::Other(anyhow::anyhow!(
379 "manifest: not an entry: {line:?}"
380 )));
381 };
382 listed.insert(PathBuf::from(rel), hash.to_string());
383 }
384
385 let mut notes = Vec::new();
386 for (rel, want) in &listed {
387 let path = dir.join(rel);
388 let Ok(bytes) = std::fs::read(&path) else {
389 notes.push(format!("missing {}", rel.display()));
390 continue;
391 };
392 let got = digest(&bytes);
393 if got != *want {
394 notes.push(format!("changed {}", rel.display()));
395 }
396 }
397 for found in walk(&dir.join("data"))? {
398 let rel = found
399 .strip_prefix(dir)
400 .map_err(|e| Error::Other(anyhow::anyhow!("{e}")))?
401 .to_path_buf();
402 if !listed.contains_key(&rel) {
403 notes.push(format!("unlisted {}", rel.display()));
404 }
405 }
406
407 let described = dir.join("data").join("satchel.json");
408 let satchel: Satchel = std::fs::read_to_string(&described)
409 .map_err(|_| Error::Other(anyhow::anyhow!("no data/satchel.json")))
410 .and_then(|raw| serde_json::from_str(&raw).map_err(Error::from))?;
411 if satchel.version != VERSION {
412 notes.push(format!(
413 "packed as {} and read as {VERSION}",
414 satchel.version
415 ));
416 }
417
418 if notes.is_empty() {
419 let mut notes = shortfall(dir, &satchel);
423 let atoms = atom_lines(dir);
424 if atoms > 0 {
425 notes.push(format!("{atoms} atoms arrived from a pack"));
426 }
427 notes.push(signature_note(dir));
428 Ok(Report {
429 issues: satchel.issues.len(),
430 needs: satchel.needs.len(),
431 files: listed.len(),
432 notes,
433 })
434 } else {
435 Err(Error::Other(anyhow::anyhow!(
436 "the satchel does not check out:\n{}",
437 notes.join("\n")
438 )))
439 }
440}
441
442pub fn describe(dir: &Path) -> Result<Satchel> {
448 let path = dir.join("data").join("satchel.json");
449 let raw = std::fs::read_to_string(&path)
450 .map_err(|_| Error::Other(anyhow::anyhow!("no satchel at {}", path.display())))?;
451 serde_json::from_str(&raw).map_err(Error::from)
452}
453
454fn write_payload(dest: &Path, rel: &Path, bytes: &[u8]) -> Result<()> {
455 let path = dest.join(rel);
456 if let Some(parent) = path.parent() {
457 std::fs::create_dir_all(parent).map_err(Error::from)?;
458 }
459 std::fs::write(&path, bytes).map_err(Error::from)
460}
461
462fn write_manifest(dest: &Path, payload: &[(PathBuf, String)]) -> Result<()> {
463 let mut out = String::new();
464 for (rel, hash) in payload {
465 out.push_str(&format!("{hash} {}\n", rel.display()));
468 }
469 std::fs::write(dest.join("manifest-sha256.txt"), out).map_err(Error::from)
470}
471
472fn write_declaration(dest: &Path, satchel: &Satchel) -> Result<()> {
473 std::fs::write(
474 dest.join("bagit.txt"),
475 "BagIt-Version: 1.0\nTag-File-Character-Encoding: UTF-8\n",
476 )
477 .map_err(Error::from)?;
478 let info = format!(
479 "Bag-Software-Agent: vissue {}\nBagging-Date: {}\nSource-Organization: {}\nExternal-Description: {} issues and {} deed accessions from a vissue tracker\nInternal-Sender-Identifier: {}\n",
480 env!("CARGO_PKG_VERSION"),
481 satchel.packed_at,
482 satchel.packed_by,
483 satchel.issues.len(),
484 satchel.needs.len(),
485 VERSION,
486 );
487 std::fs::write(dest.join("bag-info.txt"), info).map_err(Error::from)
488}
489
490fn walk(dir: &Path) -> Result<Vec<PathBuf>> {
491 let mut out = Vec::new();
492 if !dir.is_dir() {
493 return Ok(out);
494 }
495 let mut stack = vec![dir.to_path_buf()];
496 while let Some(at) = stack.pop() {
497 for entry in std::fs::read_dir(&at).map_err(Error::from)? {
498 let entry = entry.map_err(Error::from)?;
499 let path = entry.path();
500 if path.is_dir() {
501 stack.push(path);
502 } else {
503 out.push(path);
504 }
505 }
506 }
507 out.sort();
508 Ok(out)
509}
510
511fn digest(bytes: &[u8]) -> String {
512 use sha2::{Digest, Sha256};
513 let hash = Sha256::digest(bytes);
514 hash.iter().map(|b| format!("{b:02x}")).collect()
515}
516
517#[cfg(test)]
518mod tests {
519 use super::*;
520 use crate::config::DEFAULT_PREFIX;
521 use crate::ops::{self, CreateOpts};
522
523 fn made(layout: &Layout, title: &str, opts: CreateOpts<'_>) -> String {
525 let report = ops::create(layout, "sample", title, opts).expect("create");
526 report
527 .split_whitespace()
528 .next()
529 .expect("an id in the report")
530 .to_string()
531 }
532
533 fn tracker() -> (tempfile::TempDir, Layout) {
534 let dir = tempfile::tempdir().expect("tempdir");
535 let layout = Layout::new(dir.path(), DEFAULT_PREFIX);
536 std::fs::create_dir_all(layout.projects_dir()).expect("projects");
537 (dir, layout)
538 }
539
540 #[test]
543 fn a_named_issue_brings_its_blockers_and_its_plan() {
544 let (_dir, layout) = tracker();
545 let plan = made(&layout, "the plan", CreateOpts::default());
546 let blocker = made(&layout, "the blocker", CreateOpts::default());
547 let work = made(
548 &layout,
549 "the work",
550 CreateOpts {
551 parent: Some(plan.as_str()),
552 ..CreateOpts::default()
553 },
554 );
555 ops::update(&layout, &work, None, None, Some(&blocker), None).expect("blocked by");
556 let bystander = made(&layout, "not asked for", CreateOpts::default());
557
558 let out = tempfile::tempdir().expect("out");
559 let report = pack(
560 &layout,
561 &Slice {
562 projects: Vec::new(),
563 issues: vec![work.clone()],
564 },
565 out.path(),
566 )
567 .expect("packs");
568
569 let described = describe(out.path()).expect("describes");
570 assert!(described.issues.contains(&work), "{described:?}");
571 assert!(described.issues.contains(&plan), "the plan is missing");
572 assert!(
573 described.issues.contains(&blocker),
574 "the blocker is missing"
575 );
576 assert!(
577 !described.issues.contains(&bystander),
578 "a sibling came along uninvited"
579 );
580 assert_eq!(described.carried.len(), 2, "{:?}", described.carried);
582 assert_eq!(report.issues, 3);
583 assert!(
584 out.path()
585 .join("data/issues")
586 .join(format!("{work}.org"))
587 .is_file()
588 );
589 }
590
591 #[test]
594 fn a_satchel_checks_out_until_something_moves() {
595 let (_dir, layout) = tracker();
596 let one = made(&layout, "first", CreateOpts::default());
597 let out = tempfile::tempdir().expect("out");
598 pack(
599 &layout,
600 &Slice {
601 projects: vec!["sample".into()],
602 issues: Vec::new(),
603 },
604 out.path(),
605 )
606 .expect("packs");
607
608 verify(out.path()).expect("a fresh satchel checks out");
609
610 let issue = out.path().join("data/issues").join(format!("{one}.org"));
612 std::fs::write(&issue, "* TODO something else\n").expect("write");
613 let err = verify(out.path()).expect_err("an edited payload passed");
614 assert!(format!("{err}").contains("changed"), "{err}");
615 }
616
617 #[test]
621 fn a_file_nobody_listed_is_a_finding() {
622 let (_dir, layout) = tracker();
623 made(&layout, "first", CreateOpts::default());
624 let out = tempfile::tempdir().expect("out");
625 pack(
626 &layout,
627 &Slice {
628 projects: vec!["sample".into()],
629 issues: Vec::new(),
630 },
631 out.path(),
632 )
633 .expect("packs");
634 verify(out.path()).expect("checks out");
635
636 std::fs::write(out.path().join("data/extra.sh"), "rm -rf /\n").expect("write");
637 let err = verify(out.path()).expect_err("an unlisted file passed");
638 assert!(format!("{err}").contains("unlisted"), "{err}");
639 }
640
641 #[test]
644 fn sealing_accounts_for_what_arrived_after_packing() {
645 let (_dir, layout) = tracker();
646 made(&layout, "first", CreateOpts::default());
647 let out = tempfile::tempdir().expect("out");
648 pack(
649 &layout,
650 &Slice {
651 projects: vec!["sample".into()],
652 issues: Vec::new(),
653 },
654 out.path(),
655 )
656 .expect("packs");
657
658 let deeds = out.path().join("data/deeds/deed-file-note");
660 std::fs::create_dir_all(&deeds).expect("mkdir");
661 std::fs::write(deeds.join("deed.bin"), b"deed bytes").expect("write");
662
663 let err = verify(out.path()).expect_err("an unsealed addition passed");
666 assert!(format!("{err}").contains("unlisted"), "{err}");
667
668 let report = seal(out.path()).expect("seals");
669 assert!(report.files >= 3, "{report:?}");
670 verify(out.path()).expect("a sealed satchel checks out");
671 }
672
673 #[test]
676 fn a_pack_can_put_what_the_seat_learned_in_too() {
677 let (_dir, layout) = tracker();
678 made(&layout, "first", CreateOpts::default());
679 let out = tempfile::tempdir().expect("out");
680 pack(
681 &layout,
682 &Slice {
683 projects: vec!["sample".into()],
684 issues: Vec::new(),
685 },
686 out.path(),
687 )
688 .expect("packs");
689
690 let atoms = out.path().join("data/atoms");
692 std::fs::create_dir_all(&atoms).expect("mkdir");
693 std::fs::write(
694 atoms.join("seat.jsonl"),
695 "{\"id\":\"a1\",\"text\":\"what was learned\"}\n {\"id\":\"a2\",\"text\":\"and this\"}\n",
696 )
697 .expect("write");
698
699 let sealed = seal(out.path()).expect("seals");
700 assert!(
701 sealed.notes.iter().any(|n| n.contains("2 atoms")),
702 "{:?}",
703 sealed.notes
704 );
705 let checked = verify(out.path()).expect("checks out");
706 assert!(
707 checked.notes.iter().any(|n| n.contains("2 atoms")),
708 "{:?}",
709 checked.notes
710 );
711
712 std::fs::write(atoms.join("late.jsonl"), "{\"id\":\"a3\"}\n").expect("write");
715 let err = verify(out.path()).expect_err("a late atom file passed");
716 assert!(format!("{err}").contains("unlisted"), "{err}");
717 }
718
719 #[test]
726 fn checking_a_satchel_says_what_it_did_not_check() {
727 let (_dir, layout) = tracker();
728 made(&layout, "first", CreateOpts::default());
729 let out = tempfile::tempdir().expect("out");
730 pack(
731 &layout,
732 &Slice {
733 projects: vec!["sample".into()],
734 issues: Vec::new(),
735 },
736 out.path(),
737 )
738 .expect("packs");
739
740 let unsigned = verify(out.path()).expect("checks out");
741 let said = unsigned.notes.join(" ");
742 assert!(
743 said.contains("nothing about who wrote it"),
744 "an unsigned satchel did not say so: {said}"
745 );
746
747 std::fs::write(
750 out.path().join("manifest-sha256.txt.sig"),
751 "ed25519 aa bb\n",
752 )
753 .expect("write");
754 let signed = verify(out.path()).expect("still checks out");
755 let said = signed.notes.join(" ");
756 assert!(said.contains("did not verify"), "{said}");
757 assert!(said.contains("vouch check"), "{said}");
758 }
759
760 #[test]
763 fn an_empty_or_unknown_slice_is_refused() {
764 let (_dir, layout) = tracker();
765 let out = tempfile::tempdir().expect("out");
766 assert!(pack(&layout, &Slice::default(), out.path()).is_err());
767 assert!(
768 pack(
769 &layout,
770 &Slice {
771 projects: Vec::new(),
772 issues: vec!["sample-nope".into()],
773 },
774 out.path(),
775 )
776 .is_err()
777 );
778 }
779}