tix-tracker 0.12.0

A terminal issue tracker: markdown + YAML frontmatter issues with a kanban TUI and CLI
Documentation
#[allow(clippy::wildcard_imports)]
use super::*;

/// Whether a frontmatter document still carries the retired `links:` key.
///
/// The key is not part of the model at all, so it cannot be detected after
/// deserialization, and it must never be *read* as typed data (C-001). The
/// frontmatter is therefore also parsed as a plain YAML mapping and checked for
/// a **top-level** `links` key.
///
/// A raw line scan was tried first and was wrong in both directions: it matched
/// an indented `links:` under some other key, and it missed a quoted `"links":`
/// key. Matching the parsed top-level key gets both right, and a line inside a
/// `|`/`>` block scalar is unambiguously not a key.
///
/// A frontmatter that will not parse as a mapping falls back to a line scan.
/// That fallback is **indentation-aware**, matching only column-0 keys.
///
/// It has to be. `serde_yaml` rejects duplicate keys when deserializing into a
/// `Mapping` but *tolerates* a duplicate unknown key when deserializing into
/// `Issue`, so a file can reach the fallback and still parse. An earlier
/// indentation-blind fallback then matched the *nested* `links:` in
/// `meta:\n  links: []` and reported a retired key on a file that has none —
/// the two code paths disagreeing about what "top-level" means, which is
/// precisely what the primary path was introduced to get right.
///
/// **An empty `links` is not a finding.** The key is reported because its *value*
/// is no longer readable, and an empty value has nothing to lose. Treating mere
/// presence as the defect made `tix lint` reject every issue file this repository
/// has ever written — they all carry a `links: []` from the old model — with no
/// way to make them valid short of hand-editing every file, because the mission
/// provides no migration for the key. A tool that declares its entire existing
/// corpus invalid on the strength of a field that was empty is not reporting a
/// problem, it is refusing to read its own data.
/// Every top-level key this model knows, in `Issue`'s declaration order, plus the
/// retired `links:` at the end.
///
/// The trailing `links` is not a field — see below — so "declaration order" holds
/// for the modelled keys and the retired key is simply appended.
///
/// Used by [`capture_extra_frontmatter`] to decide what is "unknown". Kept as an
/// explicit list rather than derived from the struct because a macro cannot read a
/// struct's own field names, and a mistake here is silent data loss: a key wrongly
/// listed as known is dropped on write, which is the bug #1361 exists to remove.
///
/// A mistake in the other direction is equally silent and was equally real: when
/// `parent` was added to [`Issue`] and not here, [`capture_extra_frontmatter`] took it
/// for an unknown key and the writer emitted `parent: 7` **twice** in one file — once
/// from the field, once spliced back from the capture. The file still parsed, so
/// nothing failed until a test compared bytes.
/// `known_frontmatter_keys_cover_every_written_key` derives the key set from a
/// fully-populated [`Issue`] and is the guard for it: adding a frontmatter field
/// without registering its key here is now a compile-clean, test-red change.
///
/// The retired `links:` is here on purpose. It is **recognised but never captured**,
/// so it is reported by its own `retired_links_key` finding and then dropped (C-001).
/// Capturing it would round-trip it and quietly undo the typed-relationship
/// migration — the data would come back, but the model would still not understand it.
pub const KNOWN_FRONTMATTER_KEYS: &[&str] = &[
    "number",
    "id",
    "title",
    "status",
    "labels",
    "type",
    "priority",
    "assignee",
    "estimate",
    "commit",
    "gitlab_id",
    "linear_id",
    "mission",
    "task",
    "relationships",
    "related_issues",
    "parent",
    // Mission `recurring-issues-01M3C7G7` (WP02): the series/occurrence table.
    "series",
    // Mission `project-issue-health-status-01M3C8HX` (WP02): the current health
    // projection. Registered here the moment the field was added — leaving it out is
    // the `parent` defect above, which wrote the key twice in one file.
    "health",
    "files",
    "cycles",
    "milestones",
    // Mission `first-class-projects-01M3BYBW` (WP03): the project association
    // list. Registered the moment the field was added — the `parent` defect above
    // is exactly what an unregistered new field does.
    "projects",
    // Mission `epics-work-items-01M3BYW4` (WP03): the epic reference list.
    // Registered the moment the field was added — an unregistered key is captured
    // as unknown and spliced back, so every file gains a duplicate block. The
    // `known_frontmatter_keys_cover_every_written_key` fixture populates it so this
    // guard can go red.
    "epics",
    "reviewer",
    "start_date",
    "due_date",
    "entered_at",
    "source",
    // Mission `per-issue-time-tracking-01M3C9A4` (WP01 T004): the issue's current
    // time entries. Registered the moment the field was added — an unregistered
    // key is captured as unknown and spliced back, so every file that carries one
    // gains a duplicate block (the `parent` defect above). The
    // `known_frontmatter_keys_cover_every_written_key` fixture populates it, so
    // this guard goes red if the registration ever drifts.
    "time_entries",
    "created",
    "updated",
    "links",
];

/// Capture every top-level frontmatter key this model does not know, as
/// `key -> raw source block` (#1361).
///
/// **Raw block, not a parsed value.** Re-serialising a parsed value would re-quote
/// and re-indent it, so a key written one way would come back another way and every
/// write would produce a diff. Splicing the original bytes keeps the round trip
/// byte-stable for content this model has no opinion about — which is the whole
/// point of preserving it.
///
/// A block is the key's own line plus every following line that continues it: an
/// indented line, or a block-sequence item. The scan is textual on purpose. A YAML
/// parse would be tidier, but the raw text is what has to be reproduced, and
/// re-deriving block boundaries from a parse is how a nested list ends up half
/// captured.
///
/// `links` is skipped. See [`KNOWN_FRONTMATTER_KEYS`].
pub fn capture_extra_frontmatter(frontmatter: &str) -> BTreeMap<String, String> {
    let mut out = BTreeMap::new();
    let mut current: Option<(String, String)> = None;
    for line in frontmatter.lines() {
        match top_level_key(line) {
            Some(key) => {
                if let Some((name, block)) = current.take() {
                    out.insert(name, block);
                }
                if KNOWN_FRONTMATTER_KEYS.contains(&key.as_str()) {
                    continue;
                }
                current = Some((key, line.to_string()));
            }
            None => {
                // A continuation line: indented, or a block-sequence item. A blank
                // line ends the block, because YAML does too.
                if let Some((_, block)) = current.as_mut() {
                    if line.trim().is_empty() {
                        current = None;
                    } else {
                        block.push('\n');
                        block.push_str(line);
                    }
                }
            }
        }
    }
    if let Some((name, block)) = current.take() {
        out.insert(name, block);
    }
    out
}