use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Default)]
pub struct Context {
pub include: Vec<String>,
pub exclude: Vec<String>,
}
impl Context {
pub fn is_empty(&self) -> bool {
self.include.is_empty() && self.exclude.is_empty()
}
pub fn matches(&self, ticket_tags: &[String]) -> bool {
if self.is_empty() {
return true;
}
for excl in &self.exclude {
if ticket_tags.iter().any(|t| t == excl) {
return false;
}
}
if ticket_tags.is_empty() {
return true;
}
self.include
.iter()
.all(|inc| ticket_tags.iter().any(|t| t == inc))
}
pub fn serialize(&self) -> String {
let mut parts: Vec<String> = Vec::new();
for t in &self.include {
parts.push(format!("+{}", t));
}
for t in &self.exclude {
parts.push(format!("-{}", t));
}
parts.join(" ")
}
}
pub fn parse_context(raw: &str) -> Context {
let mut include = Vec::new();
let mut exclude = Vec::new();
for token in raw.split_whitespace() {
if let Some(tag) = token.strip_prefix('+') {
if !tag.is_empty() {
include.push(tag.to_string());
}
} else if let Some(tag) = token.strip_prefix('-') {
if !tag.is_empty() {
exclude.push(tag.to_string());
}
} else if !token.is_empty() {
include.push(token.to_string());
}
}
Context { include, exclude }
}
pub fn load(tickets_dir: &Path) -> Context {
if let Ok(val) = std::env::var("TKT_CONTEXT") {
if !val.is_empty() {
return parse_context(&val);
}
}
let path = context_file_path(tickets_dir);
if let Ok(content) = std::fs::read_to_string(&path) {
let trimmed = content.trim();
if !trimmed.is_empty() {
return parse_context(trimmed);
}
}
Context::default()
}
pub fn save(tickets_dir: &Path, ctx: &Context) -> std::io::Result<()> {
let path = context_file_path(tickets_dir);
if ctx.is_empty() {
if path.exists() {
std::fs::remove_file(&path)?;
}
} else {
std::fs::write(&path, format!("{}\n", ctx.serialize()))?;
}
Ok(())
}
fn context_file_path(tickets_dir: &Path) -> PathBuf {
tickets_dir.join(".context")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_context_basic() {
let ctx = parse_context("+backend +api -frontend");
assert_eq!(ctx.include, vec!["backend", "api"]);
assert_eq!(ctx.exclude, vec!["frontend"]);
}
#[test]
fn parse_context_bare_words() {
let ctx = parse_context("backend api");
assert_eq!(ctx.include, vec!["backend", "api"]);
assert!(ctx.exclude.is_empty());
}
#[test]
fn parse_context_empty() {
let ctx = parse_context("");
assert!(ctx.is_empty());
}
#[test]
fn matches_empty_context() {
let ctx = Context::default();
assert!(ctx.matches(&[]));
assert!(ctx.matches(&["anything".into()]));
}
#[test]
fn matches_include() {
let ctx = parse_context("+backend");
assert!(ctx.matches(&["backend".into(), "api".into()]));
assert!(!ctx.matches(&["frontend".into()]));
assert!(ctx.matches(&[]));
}
#[test]
fn matches_exclude() {
let ctx = parse_context("-frontend");
assert!(ctx.matches(&["backend".into()]));
assert!(!ctx.matches(&["frontend".into()]));
assert!(ctx.matches(&[]));
}
#[test]
fn matches_combined() {
let ctx = parse_context("+backend -experimental");
assert!(ctx.matches(&["backend".into()]));
assert!(!ctx.matches(&["backend".into(), "experimental".into()]));
assert!(!ctx.matches(&["frontend".into()]));
assert!(ctx.matches(&[]));
}
#[test]
fn save_and_load() {
let dir = tempfile::tempdir().unwrap();
let ctx = parse_context("+backend +api -legacy");
save(dir.path(), &ctx).unwrap();
let loaded = load(dir.path());
assert_eq!(loaded.include, vec!["backend", "api"]);
assert_eq!(loaded.exclude, vec!["legacy"]);
}
#[test]
fn clear_removes_file() {
let dir = tempfile::tempdir().unwrap();
let ctx = parse_context("+backend");
save(dir.path(), &ctx).unwrap();
assert!(dir.path().join(".context").exists());
save(dir.path(), &Context::default()).unwrap();
assert!(!dir.path().join(".context").exists());
}
}