csift 0.12.1

ripgrep for Claude Code session transcripts: fast regex list/search over ~/.claude/projects/**/*.jsonl
//! role.class.sub selector machinery: label_selectors / label_selected / LabelFilter.

use super::*;

/// True iff `selector` (a dotted `role.class.sub` path) is a dot-SEGMENT prefix of `path` -
/// the `-t` match rule (GOLD §6). `agent` matches `agent.tool.use`; `agent.tool` matches
/// `agent.tool.use`/`agent.tool.result` but NOT a hypothetical `agent.toolbar` (segment-wise,
/// so a partial trailing segment never leaks). Shared by the `-t` gate and the `--siblings` caps.
#[must_use]
pub fn selector_is_segment_prefix(selector: &str, path: &str) -> bool {
    match path.strip_prefix(selector) {
        Some(rest) => rest.is_empty() || rest.starts_with('.'),
        None => false,
    }
}

/// Every VALID `-t` selector: each dot-segment prefix of every [`Class::path`] in [`Class::ALL`]
/// (role / role.class / role.class.sub), in taxonomy order, de-duplicated. The single source of
/// truth for the `-t` value space - the clap value_parser validates against it and the `--help`
/// / error text lists it (so a new [`Class`] leaf automatically widens the selector space).
#[must_use]
pub fn label_selectors() -> Vec<String> {
    let mut out: Vec<String> = Vec::new();
    for class in Class::ALL {
        let segs: Vec<&str> = class.path().split('.').collect();
        for i in 1..=segs.len() {
            let prefix = segs[..i].join(".");
            if !out.contains(&prefix) {
                out.push(prefix);
            }
        }
    }
    out
}

/// True iff `selector` is a valid `-t` value: some [`Class`] path has it as a
/// segment-prefix, or it is the `.*` glob form of such a prefix.
#[must_use]
pub fn selector_is_valid(selector: &str) -> bool {
    let bare = selector.strip_suffix(".*").unwrap_or(selector);
    !bare.is_empty()
        && Class::ALL
            .iter()
            .any(|c| selector_is_segment_prefix(bare, c.path()))
}

/// The three selector forms (v0.9.4 - the C-25 visibility law):
/// - a BARE ROLE (`user` / `agent` / `harness`, i.e. any single-segment selector)
///   matches only the leaves the model RECEIVED - the coarse "show me the
///   conversation" ask (a superseded draft under `-t user` poisoned a real
///   downstream consumer, which is how this law was born);
/// - a GLOB (`user.*`, or any valid prefix + `.*`) matches EVERY leaf under the
///   prefix, visibility ignored - the explicit "truly everything" form;
/// - an intermediate prefix or full leaf path (`harness.compaction`,
///   `user.unsent`) matches by segment-prefix regardless of visibility - a
///   deliberate drill-down names what it wants.
///
/// `delivered` is the RECORD-level override ([`crate::model::Record::delivery_override`]):
/// `Some(_)` when Claude Code's own request-assembler drop predicate disagrees with the
/// leaf default for the record this label came from, `None` otherwise. Only the
/// bare-role form consults it, because only the bare-role form claims to be the
/// conversation. `None` = no record in hand (or none that disagrees), so the leaf
/// default stands.
#[must_use]
pub fn selector_matches(selector: &str, path: &str, delivered: Option<bool>) -> bool {
    if let Some(prefix) = selector.strip_suffix(".*") {
        return selector_is_segment_prefix(prefix, path);
    }
    if !selector_is_segment_prefix(selector, path) {
        return false;
    }
    if selector.contains('.') {
        return true; // intermediate prefix or exact leaf: any visibility.
    }
    // A bare role: what the model received - the leaf default unless this record's
    // own delivery verdict overrides it.
    if let Some(d) = delivered {
        return d;
    }
    Class::ALL
        .iter()
        .find(|c| c.path() == path)
        .is_none_or(|c| c.llm_visible())
}

/// Does a record-label `path` satisfy the active `-t` selectors? Empty selectors ⇒ every label is
/// eligible (no `-t` filter). Otherwise the label matches iff ANY selector matches it under the
/// three-form rule ([`selector_matches`]) - `-t agent` surfaces the agent role's delivered leaves,
/// `-t agent.tool` use+result, `-t 'user.*'` every user leaf incl. `user.unsent`. `delivered`
/// is the record-level override the bare-role form consults (see [`selector_matches`]).
#[must_use]
pub fn label_selected(selectors: &[String], path: &str, delivered: Option<bool>) -> bool {
    selectors.is_empty()
        || selectors
            .iter()
            .any(|s| selector_matches(s, path, delivered))
}

/// The active `-t`/`-T` label filter - the include selectors (empty ⇒ every label) MINUS the
/// exclude selectors, both matched by the same segment-prefix rule. The ONE membership
/// predicate every hit-selection surface keys on (the rg `-t`/`-T` duality). Exclusion only
/// ever SHRINKS the include set, so the §7 stage-1 role prefilter (a conservative superset)
/// stays valid unchanged.
#[derive(Debug, Clone, Copy)]
pub struct LabelFilter<'a> {
    include: &'a [String],
    exclude: &'a [String],
    /// The RECORD-level delivery verdict this filter is being applied WITH (see
    /// [`LabelFilter::with_delivery`]). `None` on the base filter, which is what every
    /// per-ARGS use wants (a candidate gate, `reaches_gated`, the statically-empty
    /// check): those decide before any record is in hand.
    delivered: Option<bool>,
    /// The RECORD-level SURVIVAL verdict: is this record still in the conversation Claude
    /// Code's own chain rule reconstructs ([`LabelFilter::with_survival`])? `true` on the
    /// base filter, so a decision taken before any record is in hand is unchanged.
    live: bool,
}

/// A BARE ROLE selector (`user` / `agent` / `harness`) - one segment, no glob. The form
/// that claims to be "the conversation", and so the only one the delivery and survival
/// verdicts narrow.
fn is_bare_role(selector: &str) -> bool {
    !selector.contains('.')
}

impl<'a> LabelFilter<'a> {
    #[must_use]
    pub fn new(include: &'a [String], exclude: &'a [String]) -> Self {
        Self {
            include,
            exclude,
            delivered: None,
            live: true,
        }
    }

    /// The same filter, applied to ONE record whose delivery verdict is `delivered`
    /// ([`crate::model::Record::delivery_override`]). Bare-role selection then follows
    /// what the model received rather than the leaf default; every other selector form
    /// is unchanged. Applied ONCE per record, at the hit-emission and census seams.
    #[must_use]
    pub fn with_delivery(self, delivered: Option<bool>) -> Self {
        Self { delivered, ..self }
    }

    /// The same filter, applied to ONE record whose SURVIVAL verdict is `live` (false only
    /// for a record Claude Code's conversation chain no longer reaches). A bare ROLE
    /// selector then skips it: `-t user` means "what the human said in this conversation",
    /// and a prompt that was recalled or rewound past is not in it. Every other selector
    /// form - an intermediate prefix, a full leaf, a glob - reaches it exactly as before,
    /// which is what makes `-t user.rewound` and `-t 'user.*'` the deliberate drill-downs.
    /// Applied ONCE per record, at the hit-emission and census seams.
    #[must_use]
    pub fn with_survival(self, live: bool) -> Self {
        Self { live, ..self }
    }

    /// Every label is eligible - the no-filter view (`--siblings` rendering ignores `-t`/`-T`;
    /// selectors filter HITS, never a turn's other records).
    #[must_use]
    pub fn all() -> LabelFilter<'static> {
        LabelFilter {
            include: &[],
            exclude: &[],
            delivered: None,
            live: true,
        }
    }

    /// Does a record-label `path` survive include-minus-exclude? Both sides speak
    /// the same three-form rule ([`selector_matches`]) and both see this filter's
    /// record-level delivery verdict, so `-T user` excludes the delivered user leaves
    /// while `-T 'user.*'` excludes them all.
    #[must_use]
    pub fn selected(&self, path: &str) -> bool {
        let reaches = |s: &String| {
            (self.live || !is_bare_role(s)) && selector_matches(s, path, self.delivered)
        };
        let included = if self.live {
            label_selected(self.include, path, self.delivered)
        } else {
            self.include.is_empty() || self.include.iter().any(&reaches)
        };
        included && !self.exclude.iter().any(&reaches)
    }

    /// True when NO leaf of [`Class::ALL`] survives - a statically-contradictory `-t`/`-T`
    /// combination that could never match anything (hard error at the caller, fail-loud).
    #[must_use]
    pub fn is_statically_empty(&self) -> bool {
        Class::ALL.iter().all(|c| !self.selected(c.path()))
    }
}

/// clap value_parser for one `-t`/`--label` selector: accept a dotted `role.class.sub` path
/// that is a segment-prefix of some [`Class`] path; reject anything else with a HARD error that
/// LISTS the valid selectors (0 back-compat - the old flat `thinking`/`tool`/`tool-response`
/// therefore error; bare `user`/`agent`/`harness` are now valid ROLE selectors - GOLD §6).
pub(crate) fn parse_label_selector(s: &str) -> Result<String, String> {
    let s = s.trim();
    if selector_is_valid(s) {
        return Ok(s.to_string());
    }
    // A RETIRED spelling gets a direct successor pointer (faster convergence than scanning
    // the full selector list) - a guidance hint, not a compat shim: still a hard error.
    let legacy = match s {
        "thinking" => Some(("pre-v0.5 flat spelling", "agent.thinking")),
        "tool" => Some(("pre-v0.5 flat spelling", "agent.tool")),
        "tool-response" => Some(("pre-v0.5 flat spelling", "agent.tool.result")),
        // v0.12.0 renamed the resume repair prompt out of the schedule family: the record
        // comes from the resume loader, never from the scheduler. Same predicate, same
        // records, new path.
        "harness.schedule.continuation" => Some(("pre-v0.12.0 name", "harness.resume.prompt")),
        _ => None,
    };
    let hint = legacy.map_or(String::new(), |(era, t)| {
        format!(" ('{s}' is the {era} — today that is `{t}`.)")
    });
    Err(format!(
        "unknown label selector '{s}'.{hint} A selector is a dotted role.class.sub path, any \
         prefix of one (a bare role selects its LLM-visible leaves only), or a prefix + `.*` \
         (every leaf under it, visibility ignored). Valid: {}",
        label_selectors().join(", ")
    ))
}