use serde::{Deserialize, Serialize};
use uuid::Uuid;
use super::types::{kinds, Message, Severity};
pub const SOURCES: &[&str] = &[
"companions",
"fact-check",
"socrates",
"inner-editor",
"timeline-critique",
"world",
"translation",
"lexicon",
"variety",
"ai",
"bund",
"other",
];
pub const COMPANION_SOURCES: &[&str] = &["fact-check", "socrates", "inner-editor", "timeline-critique"];
pub fn is_companion_source(source: &str) -> bool {
COMPANION_SOURCES.contains(&source)
}
pub fn message_source(msg: &Message) -> &'static str {
match msg.kind.as_str() {
kinds::FACT_CHECK_WARNING => "fact-check",
kinds::SOCRATIC_INQUIRY => "socrates",
kinds::INNER_EDITOR_OBSERVATION => "inner-editor",
kinds::TIMELINE_ORPHAN_WARNING | kinds::TIMELINE_FUZZY_OVERLAP_WARNING => {
"timeline-critique"
}
kinds::WORLD_COMPILER_PROPOSAL => "world",
kinds::TRANSLATION_RESULT
| kinds::TRANSLATION_MEMORY_LISTING
| kinds::TRANSLATION_CORPUS_PROGRESS
| kinds::TRANSLATION_EVAL_RESULT
| kinds::TRANSLATION_EXPORT_RESULT
| kinds::TRANSLATION_UNCOVERED_WORD_REPORT => "translation",
kinds::LEXICON_PROPOSAL => "lexicon",
kinds::VARIETY_RENDERING => "variety",
kinds::AI_TASK_COMPLETE => "ai",
kinds::BUND_PRINT | kinds::BUND_LOG => "bund",
_ => "other",
}
}
fn severity_rank(s: Severity) -> u8 {
match s {
Severity::Progress => 0,
Severity::Info => 1,
Severity::Warning => 2,
Severity::Contradiction => 3,
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct OutputFilter {
pub source: Option<String>,
pub min_severity: Option<Severity>,
pub only_open_paragraph: bool,
}
impl OutputFilter {
pub fn is_active(&self) -> bool {
self.source.is_some() || self.min_severity.is_some() || self.only_open_paragraph
}
pub fn clear(&mut self) {
*self = OutputFilter::default();
}
pub fn matches(&self, msg: &Message, open_paragraph: Option<Uuid>) -> bool {
if let Some(src) = self.source.as_deref() {
if src == "companions" {
if !is_companion_source(message_source(msg)) {
return false;
}
} else if message_source(msg) != src {
return false;
}
}
if let Some(min) = self.min_severity {
if severity_rank(msg.severity) < severity_rank(min) {
return false;
}
}
if self.only_open_paragraph
&& (open_paragraph.is_none() || msg.source_paragraph_id != open_paragraph)
{
return false;
}
true
}
pub fn summary(&self) -> String {
if !self.is_active() {
return String::new();
}
let mut parts = Vec::new();
if let Some(src) = &self.source {
parts.push(format!("src:{src}"));
}
if let Some(min) = self.min_severity {
parts.push(format!("≥{}", min.as_str()));
}
if self.only_open_paragraph {
parts.push("¶ this paragraph".to_string());
}
parts.join(" · ")
}
pub fn cycle_source(&mut self) {
self.source = match &self.source {
None => Some(SOURCES[0].to_string()),
Some(cur) => {
let idx = SOURCES.iter().position(|s| s == cur);
match idx {
Some(i) if i + 1 < SOURCES.len() => Some(SOURCES[i + 1].to_string()),
_ => None,
}
}
};
}
pub fn cycle_min_severity(&mut self) {
self.min_severity = match self.min_severity {
None => Some(Severity::Info),
Some(Severity::Info) => Some(Severity::Warning),
Some(Severity::Warning) => Some(Severity::Contradiction),
Some(Severity::Contradiction) => None,
Some(Severity::Progress) => None,
};
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::pane::output::{Lifetime, Message};
fn msg(kind: &str, severity: Severity, para: Option<Uuid>) -> Message {
let mut m = Message::new(kind, severity, Lifetime::UntilActedOn, serde_json::json!({}));
if let Some(p) = para {
m = m.with_source_paragraph(p);
}
m
}
#[test]
fn source_classification_covers_known_kinds() {
assert_eq!(message_source(&msg(kinds::FACT_CHECK_WARNING, Severity::Warning, None)), "fact-check");
assert_eq!(message_source(&msg(kinds::SOCRATIC_INQUIRY, Severity::Info, None)), "socrates");
assert_eq!(message_source(&msg(kinds::TIMELINE_ORPHAN_WARNING, Severity::Warning, None)), "timeline-critique");
assert_eq!(message_source(&msg(kinds::TRANSLATION_RESULT, Severity::Info, None)), "translation");
assert_eq!(message_source(&msg("something_unknown", Severity::Info, None)), "other");
}
#[test]
fn empty_filter_matches_everything() {
let f = OutputFilter::default();
assert!(!f.is_active());
assert!(f.matches(&msg(kinds::FACT_CHECK_WARNING, Severity::Info, None), None));
assert!(f.summary().is_empty());
}
#[test]
fn source_filter_narrows() {
let mut f = OutputFilter::default();
f.source = Some("socrates".into());
assert!(f.is_active());
assert!(f.matches(&msg(kinds::SOCRATIC_INQUIRY, Severity::Info, None), None));
assert!(!f.matches(&msg(kinds::FACT_CHECK_WARNING, Severity::Info, None), None));
}
#[test]
fn companions_meta_source_matches_all_companions() {
let mut f = OutputFilter::default();
f.source = Some("companions".into());
for k in [
kinds::FACT_CHECK_WARNING,
kinds::SOCRATIC_INQUIRY,
kinds::INNER_EDITOR_OBSERVATION,
kinds::TIMELINE_ORPHAN_WARNING,
] {
assert!(f.matches(&msg(k, Severity::Warning, None), None), "{k} should match companions");
}
assert!(!f.matches(&msg(kinds::TRANSLATION_RESULT, Severity::Info, None), None));
assert!(!f.matches(&msg(kinds::BUND_PRINT, Severity::Info, None), None));
assert!(SOURCES.contains(&"companions"));
}
#[test]
fn min_severity_hides_lower_and_progress() {
let mut f = OutputFilter::default();
f.min_severity = Some(Severity::Warning);
assert!(f.matches(&msg(kinds::FACT_CHECK_WARNING, Severity::Warning, None), None));
assert!(f.matches(&msg(kinds::FACT_CHECK_WARNING, Severity::Contradiction, None), None));
assert!(!f.matches(&msg(kinds::FACT_CHECK_WARNING, Severity::Info, None), None));
assert!(!f.matches(&msg(kinds::AI_TASK_COMPLETE, Severity::Progress, None), None));
}
#[test]
fn only_open_paragraph_requires_a_match() {
let para = Uuid::new_v4();
let other = Uuid::new_v4();
let mut f = OutputFilter::default();
f.only_open_paragraph = true;
assert!(f.matches(&msg(kinds::FACT_CHECK_WARNING, Severity::Info, Some(para)), Some(para)));
assert!(!f.matches(&msg(kinds::FACT_CHECK_WARNING, Severity::Info, Some(other)), Some(para)));
assert!(!f.matches(&msg(kinds::FACT_CHECK_WARNING, Severity::Info, Some(para)), None));
assert!(!f.matches(&msg(kinds::BUND_PRINT, Severity::Info, None), Some(para)));
}
#[test]
fn cycle_source_wraps_through_all_then_off() {
let mut f = OutputFilter::default();
for expected in SOURCES {
f.cycle_source();
assert_eq!(f.source.as_deref(), Some(*expected));
}
f.cycle_source();
assert_eq!(f.source, None, "cycles back to off after the last source");
}
#[test]
fn cycle_min_severity_loops() {
let mut f = OutputFilter::default();
f.cycle_min_severity();
assert_eq!(f.min_severity, Some(Severity::Info));
f.cycle_min_severity();
assert_eq!(f.min_severity, Some(Severity::Warning));
f.cycle_min_severity();
assert_eq!(f.min_severity, Some(Severity::Contradiction));
f.cycle_min_severity();
assert_eq!(f.min_severity, None);
}
#[test]
fn summary_is_compact_and_nonempty_when_active() {
let mut f = OutputFilter { source: Some("fact-check".into()), ..Default::default() };
f.min_severity = Some(Severity::Warning);
f.only_open_paragraph = true;
let s = f.summary();
assert!(s.contains("fact-check"));
assert!(s.contains("warning"));
assert!(s.contains('¶'));
}
mod prop {
use super::super::{message_source, OutputFilter, SOURCES};
use crate::pane::output::{Lifetime, Message, Severity};
use proptest::prelude::*;
fn arb_severity() -> impl Strategy<Value = Severity> {
prop_oneof![
Just(Severity::Info),
Just(Severity::Warning),
Just(Severity::Contradiction),
Just(Severity::Progress),
]
}
fn message(kind: &str, sev: Severity) -> Message {
Message::new(kind, sev, Lifetime::UntilActedOn, serde_json::json!({}))
}
proptest! {
#[test]
fn source_is_always_in_the_known_set(kind in ".{0,24}", sev in arb_severity()) {
prop_assert!(SOURCES.contains(&message_source(&message(&kind, sev))));
}
#[test]
fn matches_never_panics(
kind in ".{0,24}",
sev in arb_severity(),
src in proptest::option::of("[a-z-]{0,16}"),
only in any::<bool>(),
) {
let m = message(&kind, sev);
let f = OutputFilter { source: src, min_severity: Some(sev), only_open_paragraph: only };
let _ = f.matches(&m, None);
let _ = f.matches(&m, Some(uuid::Uuid::new_v4()));
}
#[test]
fn source_filter_is_exact(kind in ".{0,24}", sev in arb_severity()) {
let m = message(&kind, sev);
let own = message_source(&m).to_string();
let pass = OutputFilter { source: Some(own.clone()), ..Default::default() };
prop_assert!(pass.matches(&m, None));
let other = if own == "other" { "fact-check" } else { "other" };
let reject = OutputFilter { source: Some(other.to_string()), ..Default::default() };
prop_assert!(!reject.matches(&m, None));
}
}
}
}