use std::path::{Path, PathBuf};
use serde::Deserialize;
use crate::collect::ai_attribution::AgenticMode;
use crate::core::config::expand_path;
pub const ENV_AI_MARKERS: &str = "TGA_AI_MARKERS";
pub const DEFAULT_MARKER_FILE: &str = "~/.config/tga/ai-markers.yaml";
pub const MAX_MARKERS: usize = 256;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum MarkerScope {
Trailer,
Message,
Email,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum MarkerMode {
FullAgentic,
IdeAssisted,
}
impl MarkerMode {
pub fn as_agentic_mode(self) -> AgenticMode {
match self {
MarkerMode::FullAgentic => AgenticMode::FullAgentic,
MarkerMode::IdeAssisted => AgenticMode::IdeAssisted,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[serde(deny_unknown_fields)]
#[non_exhaustive]
pub struct MarkerSpec {
pub tool: String,
pub mode: MarkerMode,
pub scope: MarkerScope,
pub pattern: String,
}
impl MarkerSpec {
pub fn new(
tool: impl Into<String>,
mode: MarkerMode,
scope: MarkerScope,
pattern: impl Into<String>,
) -> Self {
Self {
tool: tool.into(),
mode,
scope,
pattern: pattern.into(),
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
#[serde(deny_unknown_fields)]
#[non_exhaustive]
pub struct MarkerConfig {
#[serde(default)]
pub markers: Vec<MarkerSpec>,
}
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum MarkerConfigError {
#[error("cannot read the marker file: {0}")]
Read(#[from] std::io::Error),
#[error("not a valid marker document: {0}")]
Parse(#[from] serde_yaml::Error),
#[error("marker {index} (tool `{tool}`) has an invalid pattern `{pattern}`: {source}")]
Pattern {
index: usize,
tool: String,
pattern: String,
#[source]
source: regex::Error,
},
#[error("marker {index} has an empty `tool` label")]
EmptyTool {
index: usize,
},
#[error("{count} markers exceeds the {max}-marker cap")]
TooMany {
count: usize,
max: usize,
},
}
impl MarkerConfig {
pub fn from_yaml_str(yaml: &str) -> Result<Self, MarkerConfigError> {
let cfg: Self = serde_yaml::from_str(yaml)?;
if cfg.markers.len() > MAX_MARKERS {
return Err(MarkerConfigError::TooMany {
count: cfg.markers.len(),
max: MAX_MARKERS,
});
}
Ok(cfg)
}
pub fn load_from(path: &Path) -> Result<Self, MarkerConfigError> {
let text = std::fs::read_to_string(path)?;
Self::from_yaml_str(&text)
}
pub fn len(&self) -> usize {
self.markers.len()
}
pub fn is_empty(&self) -> bool {
self.markers.is_empty()
}
}
pub fn marker_file_path() -> PathBuf {
marker_file_path_from(std::env::var(ENV_AI_MARKERS).ok().as_deref())
}
pub(crate) fn marker_file_path_from(raw: Option<&str>) -> PathBuf {
let raw = match raw {
Some(v) if !v.trim().is_empty() => v,
_ => DEFAULT_MARKER_FILE,
};
expand_path(Path::new(raw))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn spec_round_trips_from_yaml() {
let cfg = MarkerConfig::from_yaml_str(
"markers:\n - tool: acme-bot\n mode: full_agentic\n scope: message\n pattern: 'Acme Bot'\n",
)
.expect("parses");
assert_eq!(cfg.len(), 1);
assert_eq!(
cfg.markers[0],
MarkerSpec::new(
"acme-bot",
MarkerMode::FullAgentic,
MarkerScope::Message,
"Acme Bot"
)
);
}
#[test]
fn yaml_scopes_deserialize() {
let cfg = MarkerConfig::from_yaml_str(
"markers:\n\
\x20 - { tool: a, mode: full_agentic, scope: trailer, pattern: x }\n\
\x20 - { tool: b, mode: ide_assisted, scope: message, pattern: y }\n\
\x20 - { tool: c, mode: full_agentic, scope: email, pattern: z }\n",
)
.expect("parses");
let scopes: Vec<MarkerScope> = cfg.markers.iter().map(|m| m.scope).collect();
assert_eq!(
scopes,
vec![
MarkerScope::Trailer,
MarkerScope::Message,
MarkerScope::Email
]
);
assert_eq!(cfg.markers[1].mode, MarkerMode::IdeAssisted);
}
#[test]
fn marker_mode_maps_to_agentic_mode() {
assert_eq!(
MarkerMode::FullAgentic.as_agentic_mode(),
AgenticMode::FullAgentic
);
assert_eq!(
MarkerMode::IdeAssisted.as_agentic_mode(),
AgenticMode::IdeAssisted
);
}
#[test]
fn unknown_key_is_rejected() {
let err = MarkerConfig::from_yaml_str(
"markers:\n - tool: a\n mode: full_agentic\n scope: message\n patern: oops\n",
)
.expect_err("unknown key rejected");
assert!(matches!(err, MarkerConfigError::Parse(_)), "{err}");
let err = MarkerConfig::from_yaml_str("marker:\n - {}\n").expect_err("unknown top key");
assert!(matches!(err, MarkerConfigError::Parse(_)), "{err}");
}
#[test]
fn unknown_scope_is_rejected() {
let err = MarkerConfig::from_yaml_str(
"markers:\n - { tool: a, mode: full_agentic, scope: subject, pattern: x }\n",
)
.expect_err("unknown scope rejected");
assert!(matches!(err, MarkerConfigError::Parse(_)), "{err}");
}
#[test]
fn a_file_over_the_cap_is_rejected() {
let mut yaml = String::from("markers:\n");
for i in 0..=MAX_MARKERS {
yaml.push_str(&format!(
" - {{ tool: t{i}, mode: full_agentic, scope: message, pattern: x }}\n"
));
}
let err = MarkerConfig::from_yaml_str(&yaml).expect_err("over cap");
assert!(
matches!(err, MarkerConfigError::TooMany { count, max }
if count == MAX_MARKERS + 1 && max == MAX_MARKERS),
"{err}"
);
let at_cap: String = yaml
.lines()
.take(MAX_MARKERS + 1)
.collect::<Vec<_>>()
.join("\n");
assert_eq!(
MarkerConfig::from_yaml_str(&at_cap)
.expect("at cap parses")
.len(),
MAX_MARKERS
);
}
#[test]
fn empty_document_is_empty() {
let cfg = MarkerConfig::from_yaml_str("markers: []\n").expect("parses");
assert!(cfg.is_empty());
assert_eq!(cfg.len(), 0);
}
#[test]
fn load_from_reports_a_missing_file() {
let err = MarkerConfig::load_from(Path::new("/definitely/not/here/ai-markers.yaml"))
.expect_err("missing file is an error at this level");
assert!(matches!(err, MarkerConfigError::Read(_)), "{err}");
}
#[test]
fn env_var_overrides_default_path() {
assert_eq!(
marker_file_path_from(Some("/tmp/tga-markers-5414.yaml")),
PathBuf::from("/tmp/tga-markers-5414.yaml")
);
assert!(marker_file_path_from(Some(" ")).ends_with(".config/tga/ai-markers.yaml"));
assert!(marker_file_path_from(None).ends_with(".config/tga/ai-markers.yaml"));
}
}