cartulary 0.3.0-alpha.1

The knowledge layer of your project — decisions, issues, docs, all in one place.
Documentation
//! Resolve a free-form companion reference (canonical stem,
//! filename, or a unique prefix of either) to one of five typed
//! outcomes. The CLI maps each outcome to a distinct message and
//! exit code; this module owns the resolution.

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 {
    /// A canonical companion is on disk and renderable as text.
    Found {
        identifier: CompanionIdentifier,
        body: Body,
    },
    /// A canonical companion is the unique target but no file exists yet.
    Absent { filename: &'static str },
    /// The unique target is not addressable for rendering (non-canonical
    /// kind, or binary content under a canonical name).
    Unmanaged { identifier: CompanionIdentifier },
    /// The input prefix matches more than one candidate.
    AmbiguousId {
        candidates: Vec<CompanionIdentifier>,
    },
    /// No candidate matches.
    Inexistant,
}

#[derive(Debug, Clone)]
struct Candidate {
    identifier: CompanionIdentifier,
    /// `Some(name)` when the candidate is one of the three canonical
    /// filenames; `None` for non-canonical (Other) companions.
    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) => {
            // Non-canonical on-disk companions are not addressable via
            // this command — they may be binary or arbitrary user files.
            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"),
    }
}

/// Sole purpose: keep [`CompanionKind`] mentioned so the file can
/// be read without unused-import noise from documentation cross-refs.
#[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);
        // Empty repo → all three canonical filenames are in the
        // candidate set as Absent. Input "" is rejected, but a single
        // letter that matches several is ambiguous.
        let stub = Stub::default();
        // No two canonical filenames share a non-trivial prefix; use a
        // disk-side collision instead.
        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:?}"),
        }
    }
}