cartulary 0.3.0-alpha.1

The knowledge layer of your project — decisions, issues, docs, all in one place.
Documentation
//! Registry of user-facing concept docs.
//!
//! Each [`Concept`] pairs a public name (the argument to `cartu man
//! <name>`) with its Markdown source, embedded at compile time via
//! `include_str!`. The Markdown source carries a YAML frontmatter
//! block whose `summary:` field powers the `cartu man` listing.
//!
//! Adding a concept: write a `<thing>.user.md` next to the `<thing>.rs`
//! it documents (or under `src/domain/doc/` for cross-cutting concepts
//! that no single type owns), then add an entry to [`REGISTRY`].

#[derive(Debug)]
pub struct Concept {
    pub name: &'static str,
    pub source: &'static str,
}

pub const REGISTRY: &[Concept] = &[
    Concept {
        name: "companion",
        source: include_str!("../model/companion.user.md"),
    },
    Concept {
        name: "config",
        source: include_str!("config.user.md"),
    },
    Concept {
        name: "decision-record",
        source: include_str!("../model/decision_record.user.md"),
    },
    Concept {
        name: "decisions",
        source: include_str!("../model/decision_record/dr_status.user.md"),
    },
    Concept {
        name: "entry-identity",
        source: include_str!("../model/entry_identity.user.md"),
    },
    Concept {
        name: "event-log",
        source: include_str!("../model/event/event_log.user.md"),
    },
    Concept {
        name: "frontmatter",
        source: include_str!("frontmatter.user.md"),
    },
    Concept {
        name: "issue",
        source: include_str!("../model/issue.user.md"),
    },
    Concept {
        name: "issue-link",
        source: include_str!("../model/issue/issue_link.user.md"),
    },
    Concept {
        name: "record-link",
        source: include_str!("../model/decision_record/record_link.user.md"),
    },
    Concept {
        name: "status",
        source: include_str!("../model/status.user.md"),
    },
    Concept {
        name: "tag",
        source: include_str!("../model/tag.user.md"),
    },
    Concept {
        name: "tag-descriptor",
        source: include_str!("../model/tag_descriptor.user.md"),
    },
    Concept {
        name: "ulid",
        source: include_str!("../model/ulid.user.md"),
    },
    Concept {
        name: "workspace",
        source: include_str!("workspace.user.md"),
    },
];

/// Split a `.user.md` source into `(summary, body)`.
///
/// Recognises a `---\n…\n---\n` YAML block at the very start of the
/// source; reads the `summary:` field if present. When no
/// frontmatter is present, returns `(None, source)` — the renderer
/// then sees the whole file.
pub(crate) fn split(source: &str) -> (Option<&str>, &str) {
    let Some(rest) = source.strip_prefix("---\n") else {
        return (None, source);
    };
    let Some(end) = rest.find("\n---") else {
        return (None, source);
    };
    let frontmatter = &rest[..end];
    let body = rest[end + 4..].trim_start_matches('\n');
    let summary = frontmatter
        .lines()
        .find_map(|l| l.strip_prefix("summary:").map(str::trim));
    (summary, body)
}

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

    const SUMMARY_MAX_CHARS: usize = 100;

    #[test]
    fn every_concept_has_a_unique_name() {
        let mut names: Vec<&str> = REGISTRY.iter().map(|c| c.name).collect();
        names.sort();
        let original_len = names.len();
        names.dedup();
        assert_eq!(
            names.len(),
            original_len,
            "duplicate concept name in REGISTRY"
        );
    }

    /// Anti-stale check on the listing: a `.user.md` without a
    /// `summary:` field would render as a blank entry in `cartu man`.
    /// Failing the build forces every concept author to write one.
    #[test]
    fn every_concept_carries_a_summary_under_the_max_length() {
        for c in REGISTRY {
            let summary = split(c.source).0.unwrap_or_else(|| {
                panic!("concept '{}' is missing a `summary:` frontmatter", c.name)
            });
            assert!(
                !summary.is_empty(),
                "concept '{}' has an empty summary",
                c.name
            );
            assert!(
                summary.chars().count() <= SUMMARY_MAX_CHARS,
                "concept '{}' summary is {} chars (max {SUMMARY_MAX_CHARS})",
                c.name,
                summary.chars().count(),
            );
        }
    }

    #[test]
    fn split_handles_source_without_frontmatter() {
        let (summary, body) = split("# Heading\n\nbody");
        assert!(summary.is_none());
        assert_eq!(body, "# Heading\n\nbody");
    }

    #[test]
    fn split_extracts_summary_and_strips_frontmatter() {
        let src = "---\nsummary: A short summary.\n---\n# Heading\n";
        let (summary, body) = split(src);
        assert_eq!(summary, Some("A short summary."));
        assert_eq!(body, "# Heading\n");
    }
}