cartulary 0.3.0-alpha.1

The knowledge layer of your project — decisions, issues, docs, all in one place.
Documentation
//! Use case: render a concept doc by name.

use super::render::Renderer;

/// Port: lookup the Markdown source for a concept by name.
///
/// Implemented by the registry under `domain/doc/` (the production
/// adapter walks an `include_str!`-backed table; tests use a stub).
pub trait DocSource {
    /// Return the Markdown source for `name`, or `None` if the topic
    /// is unknown.
    fn lookup(&self, name: &str) -> Option<&str>;

    /// Return every known topic name, sorted, for the listing variant
    /// (`cartu man` with no argument).
    fn topics(&self) -> Vec<String>;
}

/// Render the concept doc named `name` through `renderer`.
///
/// Returns the rendered output. Returns an [`UnknownTopic`] error if
/// the source has no entry for `name`; callers (the CLI, the site
/// build use case) translate that into a user-facing message.
pub fn show(
    name: &str,
    source: &impl DocSource,
    renderer: &impl Renderer,
) -> Result<String, UnknownTopic> {
    match source.lookup(name) {
        Some(md) => Ok(renderer.render(md)),
        None => Err(UnknownTopic {
            name: name.to_string(),
            available: source.topics(),
        }),
    }
}

/// Returned by [`show`] when the requested topic is not in the source.
/// Carries the available topics so the CLI can suggest alternatives.
#[derive(Debug, PartialEq, Eq)]
pub struct UnknownTopic {
    pub name: String,
    pub available: Vec<String>,
}

impl std::fmt::Display for UnknownTopic {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "unknown topic '{}'", self.name)?;
        if !self.available.is_empty() {
            write!(f, "; available: {}", self.available.join(", "))?;
        }
        Ok(())
    }
}

impl std::error::Error for UnknownTopic {}

#[cfg(test)]
mod tests {
    use super::*;

    struct StubSource {
        entries: Vec<(&'static str, &'static str)>,
    }

    impl DocSource for StubSource {
        fn lookup(&self, name: &str) -> Option<&str> {
            self.entries
                .iter()
                .find(|(n, _)| *n == name)
                .map(|(_, src)| *src)
        }

        fn topics(&self) -> Vec<String> {
            let mut t: Vec<String> = self.entries.iter().map(|(n, _)| n.to_string()).collect();
            t.sort();
            t
        }
    }

    /// Identity renderer: returns the source unchanged. Lets the use
    /// case test assert *that* the renderer was called without
    /// pinning down the rendering format.
    struct IdentityRenderer;

    impl Renderer for IdentityRenderer {
        fn render(&self, source: &str) -> String {
            source.to_string()
        }
    }

    #[test]
    fn show_returns_rendered_source_for_a_known_topic() {
        let source = StubSource {
            entries: vec![("issue", "# Issue\n\nA piece of work.")],
        };
        let out = show("issue", &source, &IdentityRenderer).unwrap();
        assert_eq!(out, "# Issue\n\nA piece of work.");
    }

    #[test]
    fn show_returns_unknown_topic_with_available_list() {
        let source = StubSource {
            entries: vec![("issue", "x"), ("status", "y")],
        };
        let err = show("decision-record", &source, &IdentityRenderer).unwrap_err();
        assert_eq!(err.name, "decision-record");
        assert_eq!(
            err.available,
            vec!["issue".to_string(), "status".to_string()]
        );
    }

    #[test]
    fn unknown_topic_display_lists_alternatives_when_available() {
        let err = UnknownTopic {
            name: "x".to_string(),
            available: vec!["a".to_string(), "b".to_string()],
        };
        let s = err.to_string();
        assert!(s.contains("unknown topic 'x'"));
        assert!(s.contains("available: a, b"));
    }

    #[test]
    fn unknown_topic_display_omits_available_section_when_empty() {
        let err = UnknownTopic {
            name: "x".to_string(),
            available: vec![],
        };
        assert_eq!(err.to_string(), "unknown topic 'x'");
    }
}