use crate::domain::model::body::Body;
use crate::domain::model::issue::companion::canonical::CANONICAL_FILENAMES;
use crate::domain::model::issue::companion::{
CompanionContent, CompanionIdentifier, CompanionKind,
};
use crate::domain::model::record_ref::IssueRef;
use crate::domain::usecases::issue::IssueRepository;
#[derive(Debug)]
pub enum ReadCompanionOutcome {
Found {
identifier: CompanionIdentifier,
body: Body,
},
Absent { filename: &'static str },
Unmanaged { identifier: CompanionIdentifier },
AmbiguousId {
candidates: Vec<CompanionIdentifier>,
},
Inexistant,
}
#[derive(Debug, Clone)]
struct Candidate {
identifier: CompanionIdentifier,
canonical: Option<&'static str>,
on_disk: bool,
}
pub fn read_companion(
repo: &dyn IssueRepository,
id: &IssueRef,
input: &str,
) -> anyhow::Result<ReadCompanionOutcome> {
if input.is_empty() {
return Ok(ReadCompanionOutcome::Inexistant);
}
let candidates = build_candidates(repo, id)?;
let matches: Vec<Candidate> = candidates
.into_iter()
.filter(|c| c.identifier.as_str().starts_with(input))
.collect();
match matches.as_slice() {
[] => Ok(ReadCompanionOutcome::Inexistant),
[single] => resolve_single(repo, id, single.clone()),
_ => Ok(ReadCompanionOutcome::AmbiguousId {
candidates: matches.into_iter().map(|c| c.identifier).collect(),
}),
}
}
fn build_candidates(repo: &dyn IssueRepository, id: &IssueRef) -> anyhow::Result<Vec<Candidate>> {
let mut out: Vec<Candidate> = Vec::new();
let on_disk = repo.issue_companions(id)?;
for c in &on_disk {
out.push(Candidate {
identifier: c.identifier.clone(),
canonical: canonical_name_of(c.identifier.as_str()),
on_disk: true,
});
}
for name in CANONICAL_FILENAMES {
let already = out.iter().any(|c| c.identifier.as_str() == *name);
if !already {
out.push(Candidate {
identifier: CompanionIdentifier::new(name).expect("canonical filename is valid"),
canonical: Some(name),
on_disk: false,
});
}
}
Ok(out)
}
fn canonical_name_of(s: &str) -> Option<&'static str> {
CANONICAL_FILENAMES.iter().copied().find(|n| *n == s)
}
fn resolve_single(
repo: &dyn IssueRepository,
id: &IssueRef,
cand: Candidate,
) -> anyhow::Result<ReadCompanionOutcome> {
match (cand.canonical, cand.on_disk) {
(Some(filename), false) => Ok(ReadCompanionOutcome::Absent { filename }),
(Some(_), true) | (None, true) => {
if cand.canonical.is_none() {
return Ok(ReadCompanionOutcome::Unmanaged {
identifier: cand.identifier,
});
}
match repo.read_companion(id, &cand.identifier)? {
Some(CompanionContent::Text(body)) => Ok(ReadCompanionOutcome::Found {
identifier: cand.identifier,
body,
}),
Some(CompanionContent::Binary(_)) => Ok(ReadCompanionOutcome::Unmanaged {
identifier: cand.identifier,
}),
None => Ok(ReadCompanionOutcome::Inexistant),
}
}
(None, false) => unreachable!("non-canonical candidates only come from the on-disk scan"),
}
}
#[allow(dead_code)]
const _KIND_REF: Option<CompanionKind> = None;
#[cfg(test)]
mod tests {
use super::*;
use crate::domain::model::issue::companion::{
CompanionContent, CompanionIdentifier, CompanionKind, IssueCompanion, IssueCompanions,
};
use crate::domain::model::issue::test_fixtures::ir;
use crate::domain::model::issue::Issue;
use std::cell::RefCell;
use std::collections::HashMap;
#[derive(Default)]
struct Stub {
companions: RefCell<HashMap<IssueRef, IssueCompanions>>,
bodies: RefCell<HashMap<(IssueRef, String), CompanionContent>>,
}
impl Stub {
fn with_companion(self, id: &IssueRef, name: &str, kind: CompanionKind) -> Self {
let identifier = CompanionIdentifier::new(name).unwrap();
let mut map = self.companions.borrow_mut();
let entry = map.entry(id.clone()).or_default();
let mut list: Vec<IssueCompanion> = entry.iter().cloned().collect();
list.push(IssueCompanion::new(identifier, kind));
*entry = list.into_iter().collect();
drop(map);
self
}
fn with_text(self, id: &IssueRef, name: &str, body: &str) -> Self {
self.bodies.borrow_mut().insert(
(id.clone(), name.to_string()),
CompanionContent::Text(Body::new(body)),
);
self
}
fn with_binary(self, id: &IssueRef, name: &str, bytes: Vec<u8>) -> Self {
self.bodies.borrow_mut().insert(
(id.clone(), name.to_string()),
CompanionContent::Binary(bytes),
);
self
}
}
impl IssueRepository for Stub {
fn save(&self, _i: &Issue) -> anyhow::Result<()> {
Ok(())
}
fn list(&self) -> anyhow::Result<crate::domain::model::issue::IssueCollection> {
Ok(crate::domain::model::issue::IssueCollection::default())
}
fn find_by_id(&self, _id: &IssueRef) -> anyhow::Result<Option<Issue>> {
Ok(None)
}
fn issue_companions(&self, id: &IssueRef) -> anyhow::Result<IssueCompanions> {
Ok(self
.companions
.borrow()
.get(id)
.cloned()
.unwrap_or_default())
}
fn read_companion(
&self,
id: &IssueRef,
identifier: &CompanionIdentifier,
) -> anyhow::Result<Option<CompanionContent>> {
Ok(self
.bodies
.borrow()
.get(&(id.clone(), identifier.as_str().to_string()))
.cloned())
}
}
#[test]
fn empty_input_yields_inexistant() {
let id = ir(1);
let stub = Stub::default();
let out = read_companion(&stub, &id, "").unwrap();
assert!(matches!(out, ReadCompanionOutcome::Inexistant));
}
#[test]
fn canonical_stem_resolves_to_found_when_on_disk() {
let id = ir(1);
let stub = Stub::default()
.with_companion(&id, "plan.md", CompanionKind::Plan)
.with_text(&id, "plan.md", "# Plan body\n");
let out = read_companion(&stub, &id, "plan").unwrap();
match out {
ReadCompanionOutcome::Found { identifier, body } => {
assert_eq!(identifier.as_str(), "plan.md");
assert!(body.as_str().contains("Plan body"));
}
other => panic!("unexpected outcome: {other:?}"),
}
}
#[test]
fn single_letter_prefix_resolves_when_unique() {
let id = ir(1);
let stub = Stub::default()
.with_companion(&id, "plan.md", CompanionKind::Plan)
.with_text(&id, "plan.md", "body");
let out = read_companion(&stub, &id, "p").unwrap();
assert!(matches!(out, ReadCompanionOutcome::Found { .. }));
}
#[test]
fn canonical_filename_not_on_disk_yields_absent() {
let id = ir(1);
let stub = Stub::default();
let out = read_companion(&stub, &id, "plan").unwrap();
match out {
ReadCompanionOutcome::Absent { filename } => assert_eq!(filename, "plan.md"),
other => panic!("unexpected outcome: {other:?}"),
}
}
#[test]
fn non_canonical_prefix_match_yields_unmanaged() {
let id = ir(1);
let stub = Stub::default().with_companion(&id, "mockup UX.xml", CompanionKind::Other);
let out = read_companion(&stub, &id, "mockup").unwrap();
match out {
ReadCompanionOutcome::Unmanaged { identifier } => {
assert_eq!(identifier.as_str(), "mockup UX.xml")
}
other => panic!("unexpected outcome: {other:?}"),
}
}
#[test]
fn canonical_on_disk_with_binary_content_is_unmanaged() {
let id = ir(1);
let stub = Stub::default()
.with_companion(&id, "plan.md", CompanionKind::Plan)
.with_binary(&id, "plan.md", vec![0xFF, 0xD8]);
let out = read_companion(&stub, &id, "plan").unwrap();
assert!(matches!(out, ReadCompanionOutcome::Unmanaged { .. }));
}
#[test]
fn prefix_matching_multiple_canonical_entries_is_ambiguous() {
let id = ir(1);
let stub = Stub::default();
let stub = stub.with_companion(&id, "plan-archive.md", CompanionKind::Other);
let out = read_companion(&stub, &id, "plan").unwrap();
match out {
ReadCompanionOutcome::AmbiguousId { candidates } => {
let names: Vec<&str> = candidates.iter().map(|c| c.as_str()).collect();
assert!(names.contains(&"plan.md"));
assert!(names.contains(&"plan-archive.md"));
}
other => panic!("unexpected outcome: {other:?}"),
}
}
#[test]
fn no_prefix_match_yields_inexistant() {
let id = ir(1);
let stub = Stub::default().with_companion(&id, "plan.md", CompanionKind::Plan);
let out = read_companion(&stub, &id, "zzz").unwrap();
assert!(matches!(out, ReadCompanionOutcome::Inexistant));
}
#[test]
fn implementation_notes_stem_resolves_to_full_filename() {
let id = ir(1);
let stub = Stub::default()
.with_companion(
&id,
"implementation-notes.md",
CompanionKind::ImplementationNotes,
)
.with_text(&id, "implementation-notes.md", "notes");
let out = read_companion(&stub, &id, "im").unwrap();
match out {
ReadCompanionOutcome::Found { identifier, .. } => {
assert_eq!(identifier.as_str(), "implementation-notes.md")
}
other => panic!("unexpected outcome: {other:?}"),
}
}
}