#[derive(Debug, Clone, Default)]
pub struct SelectorContext {
pub instrument: Option<String>,
pub user: Option<String>,
}
impl SelectorContext {
#[must_use]
pub fn new(instrument: Option<&str>, user: Option<&str>) -> Self {
Self {
instrument: instrument.map(|s| s.to_ascii_lowercase()),
user: user.map(|s| s.to_ascii_lowercase()),
}
}
#[must_use]
pub fn from_config(config: &crate::config::Config) -> Self {
let instrument = config
.get_path("instrument.type")
.as_str()
.filter(|s| !s.trim().is_empty())
.map(|s| s.to_ascii_lowercase());
let user = config
.get_path("user.name")
.as_str()
.filter(|s| !s.trim().is_empty())
.map(|s| s.to_ascii_lowercase());
Self { instrument, user }
}
#[must_use]
pub fn matches(&self, selector: Option<&str>) -> bool {
let Some(sel) = selector else {
return true; };
if let Some(ref instrument) = self.instrument {
if instrument.eq_ignore_ascii_case(sel) {
return true;
}
}
if let Some(ref user) = self.user {
if user.eq_ignore_ascii_case(sel) {
return true;
}
}
false
}
#[must_use]
pub fn matches_directive(&self, directive: &crate::ast::Directive) -> bool {
self.matches(directive.selector.as_deref())
}
#[must_use]
pub fn filter_song(&self, song: &crate::ast::Song) -> crate::ast::Song {
let mut filtered_lines = Vec::new();
let mut suppress_depth: usize = 0;
for line in &song.lines {
match line {
crate::ast::Line::Directive(d) => {
if suppress_depth > 0 {
if d.kind.is_section_start() {
suppress_depth += 1;
} else if d.kind.is_section_end() {
suppress_depth -= 1;
}
continue;
}
if !self.matches_directive(d) {
if d.kind.is_section_start() {
suppress_depth = 1;
}
continue;
}
filtered_lines.push(line.clone());
}
_ => {
if suppress_depth == 0 {
filtered_lines.push(line.clone());
}
}
}
}
let mut metadata = song.metadata.clone();
for line in &filtered_lines {
if let crate::ast::Line::Directive(d) = line {
if d.selector.is_some() {
crate::parser::Parser::populate_metadata(&mut metadata, d);
}
}
}
crate::ast::Song {
metadata,
lines: filtered_lines,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_no_selector_always_matches() {
let ctx = SelectorContext::default();
assert!(ctx.matches(None));
}
#[test]
fn test_instrument_match() {
let ctx = SelectorContext::new(Some("guitar"), None);
assert!(ctx.matches(Some("guitar")));
}
#[test]
fn test_instrument_mismatch() {
let ctx = SelectorContext::new(Some("guitar"), None);
assert!(!ctx.matches(Some("piano")));
}
#[test]
fn test_instrument_case_insensitive() {
let ctx = SelectorContext::new(Some("Guitar"), None);
assert!(ctx.matches(Some("GUITAR")));
assert!(ctx.matches(Some("guitar")));
}
#[test]
fn test_user_match() {
let ctx = SelectorContext::new(None, Some("john"));
assert!(ctx.matches(Some("john")));
}
#[test]
fn test_user_mismatch() {
let ctx = SelectorContext::new(None, Some("john"));
assert!(!ctx.matches(Some("alice")));
}
#[test]
fn test_both_instrument_and_user() {
let ctx = SelectorContext::new(Some("guitar"), Some("john"));
assert!(ctx.matches(Some("guitar")));
assert!(ctx.matches(Some("john")));
assert!(!ctx.matches(Some("piano")));
}
#[test]
fn test_empty_context_rejects_selector() {
let ctx = SelectorContext::default();
assert!(!ctx.matches(Some("anything")));
}
#[test]
fn test_from_config() {
let config = crate::config::Config::parse(
r#"{"instrument": {"type": "ukulele"}, "user": {"name": "Alice"}}"#,
)
.unwrap();
let ctx = SelectorContext::from_config(&config);
assert!(ctx.matches(Some("ukulele")));
assert!(ctx.matches(Some("alice"))); assert!(!ctx.matches(Some("guitar")));
}
#[test]
fn test_from_config_missing_fields() {
let config = crate::config::Config::empty();
let ctx = SelectorContext::from_config(&config);
assert!(ctx.matches(None));
assert!(!ctx.matches(Some("guitar")));
}
#[test]
fn test_from_config_empty_instrument_treated_as_none() {
let config = crate::config::Config::parse(r#"{"instrument": {"type": ""}}"#).unwrap();
let ctx = SelectorContext::from_config(&config);
assert!(ctx.instrument.is_none(), "empty instrument should be None");
}
#[test]
fn test_from_config_whitespace_instrument_treated_as_none() {
let config = crate::config::Config::parse(r#"{"instrument": {"type": " "}}"#).unwrap();
let ctx = SelectorContext::from_config(&config);
assert!(
ctx.instrument.is_none(),
"whitespace-only instrument should be None"
);
}
#[test]
fn test_from_config_empty_user_treated_as_none() {
let config = crate::config::Config::parse(r#"{"user": {"name": ""}}"#).unwrap();
let ctx = SelectorContext::from_config(&config);
assert!(ctx.user.is_none(), "empty user.name should be None");
}
#[test]
fn test_from_config_whitespace_user_treated_as_none() {
let config = crate::config::Config::parse(r#"{"user": {"name": " "}}"#).unwrap();
let ctx = SelectorContext::from_config(&config);
assert!(
ctx.user.is_none(),
"whitespace-only user.name should be None"
);
}
#[test]
fn test_matches_directive() {
let ctx = SelectorContext::new(Some("guitar"), None);
let directive = crate::ast::Directive {
name: "textfont".to_string(),
value: Some("Courier".to_string()),
kind: crate::ast::DirectiveKind::TextFont,
selector: Some("guitar".to_string()),
};
assert!(ctx.matches_directive(&directive));
}
#[test]
fn test_matches_directive_no_selector() {
let ctx = SelectorContext::new(Some("guitar"), None);
let directive = crate::ast::Directive {
name: "textfont".to_string(),
value: Some("Courier".to_string()),
kind: crate::ast::DirectiveKind::TextFont,
selector: None,
};
assert!(ctx.matches_directive(&directive));
}
#[test]
fn test_matches_directive_mismatch() {
let ctx = SelectorContext::new(Some("guitar"), None);
let directive = crate::ast::Directive {
name: "textfont".to_string(),
value: Some("Courier".to_string()),
kind: crate::ast::DirectiveKind::TextFont,
selector: Some("piano".to_string()),
};
assert!(!ctx.matches_directive(&directive));
}
#[test]
fn test_empty_string_selector_does_not_match() {
let ctx = SelectorContext::new(Some("guitar"), Some("john"));
assert!(!ctx.matches(Some("")), "empty selector should not match");
}
#[test]
fn test_trailing_hyphen_directive_no_selector() {
let (kind, sel) = crate::ast::DirectiveKind::resolve_with_selector("title-");
assert_eq!(sel, None);
assert!(matches!(kind, crate::ast::DirectiveKind::Unknown(_)));
}
#[test]
fn test_with_selector_normalizes_to_lowercase() {
let d = crate::ast::Directive::with_selector("title", Some("Test".into()), "PIANO");
assert_eq!(d.selector.as_deref(), Some("piano"));
}
#[test]
fn test_filter_song_keeps_matching_directives() {
let song = crate::parse("{textfont-guitar: Courier}\nLyrics").unwrap();
let ctx = SelectorContext::new(Some("guitar"), None);
let filtered = ctx.filter_song(&song);
let has_directive = filtered
.lines
.iter()
.any(|l| matches!(l, crate::ast::Line::Directive(_)));
assert!(has_directive);
}
#[test]
fn test_filter_song_removes_non_matching_directives() {
let song = crate::parse("{textfont-piano: Courier}\nLyrics").unwrap();
let ctx = SelectorContext::new(Some("guitar"), None);
let filtered = ctx.filter_song(&song);
let has_directive = filtered
.lines
.iter()
.any(|l| matches!(l, crate::ast::Line::Directive(_)));
assert!(!has_directive);
}
#[test]
fn test_filter_song_keeps_unselectored_directives() {
let song = crate::parse("{textfont: Courier}\nLyrics").unwrap();
let ctx = SelectorContext::new(Some("guitar"), None);
let filtered = ctx.filter_song(&song);
let has_directive = filtered
.lines
.iter()
.any(|l| matches!(l, crate::ast::Line::Directive(_)));
assert!(has_directive);
}
#[test]
fn test_filter_song_keeps_lyrics_and_comments() {
let song = crate::parse("{textfont-piano: Courier}\nLyrics\n{comment: Note}").unwrap();
let ctx = SelectorContext::new(Some("guitar"), None);
let filtered = ctx.filter_song(&song);
let has_lyrics = filtered
.lines
.iter()
.any(|l| matches!(l, crate::ast::Line::Lyrics(_)));
assert!(has_lyrics);
}
#[test]
fn test_filter_song_mixed_selectors() {
let input = "{textfont-guitar: Courier}\n{textfont-piano: Times}\n[Am]Hello";
let song = crate::parse(input).unwrap();
let ctx = SelectorContext::new(Some("guitar"), None);
let filtered = ctx.filter_song(&song);
let directive_count = filtered
.lines
.iter()
.filter(|l| matches!(l, crate::ast::Line::Directive(_)))
.count();
assert_eq!(directive_count, 1);
}
#[test]
fn test_filter_song_removes_section_contents() {
let input =
"{start_of_chorus-piano}\n[C]La la la\n{end_of_chorus-piano}\n[Am]Regular lyrics";
let song = crate::parse(input).unwrap();
let ctx = SelectorContext::new(Some("guitar"), None);
let filtered = ctx.filter_song(&song);
let texts: Vec<_> = filtered
.lines
.iter()
.filter_map(|l| match l {
crate::ast::Line::Lyrics(ly) => Some(ly.text()),
_ => None,
})
.collect();
assert!(
!texts.iter().any(|t| t.contains("La la")),
"piano chorus lyrics should be removed"
);
assert!(
texts.iter().any(|t| t.contains("Regular")),
"unselectored lyrics should remain"
);
}
#[test]
fn test_filter_song_keeps_matching_section_contents() {
let input = "{start_of_chorus-guitar}\n[C]Guitar chorus\n{end_of_chorus-guitar}";
let song = crate::parse(input).unwrap();
let ctx = SelectorContext::new(Some("guitar"), None);
let filtered = ctx.filter_song(&song);
let has_chorus_lyrics = filtered.lines.iter().any(|l| match l {
crate::ast::Line::Lyrics(ly) => ly.text().contains("Guitar chorus"),
_ => false,
});
assert!(
has_chorus_lyrics,
"matching section contents should be kept"
);
}
#[test]
fn test_metadata_skips_selector_directives_during_parse() {
let input = "{title: Main Title}\n{title-band: Band Title}";
let song = crate::parse(input).unwrap();
assert_eq!(
song.metadata.title.as_deref(),
Some("Main Title"),
"selector-bearing title should not overwrite metadata during parsing"
);
}
#[test]
fn test_filter_song_rederives_metadata_from_matching_selector() {
let input = "{title: Main Title}\n{title-band: Band Title}";
let song = crate::parse(input).unwrap();
let ctx = SelectorContext::new(None, Some("band"));
let filtered = ctx.filter_song(&song);
assert_eq!(
filtered.metadata.title.as_deref(),
Some("Band Title"),
"matching selector title should override metadata after filtering"
);
}
#[test]
fn test_filter_song_keeps_base_metadata_when_no_selector_match() {
let input = "{title: Main Title}\n{title-band: Band Title}";
let song = crate::parse(input).unwrap();
let ctx = SelectorContext::new(Some("guitar"), None);
let filtered = ctx.filter_song(&song);
assert_eq!(
filtered.metadata.title.as_deref(),
Some("Main Title"),
"base metadata should remain when selector doesn't match"
);
}
}