use anyhow::{bail, Context, Result};
use glob::Pattern;
use serde::{Deserialize, Serialize};
use std::collections::BTreeSet;
use std::path::Path;
use crate::types::{classify_file, FileCategory};
const TAG_MAX_LEN: usize = 32;
const FRONTMATTER_FALLBACK_FIELDS: &[&str] = &["bobbin-tags", "labels"];
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(default)]
pub struct TagsConfig {
pub rules: Vec<TagRule>,
pub effects: std::collections::HashMap<String, TagEffect>,
pub effects_scoped: Vec<ScopedEffect>,
pub frontmatter: FrontmatterConfig,
pub comments: CommentsConfig,
pub bundles: Vec<BundleConfig>,
pub ontology: OntologyConfig,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(default)]
pub struct OntologyConfig {
pub tags: std::collections::HashMap<String, TagDefinition>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(default)]
pub struct TagDefinition {
pub parent: Option<String>,
pub relates_to: Vec<String>,
pub description: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BundleConfig {
pub name: String,
pub description: String,
#[serde(default)]
pub keywords: Vec<String>,
#[serde(default)]
pub tags: Vec<String>,
#[serde(default)]
pub files: Vec<String>,
#[serde(default)]
pub refs: Vec<String>,
#[serde(default)]
pub docs: Vec<String>,
#[serde(default)]
pub beads: Vec<String>,
#[serde(default)]
pub includes: Vec<String>,
#[serde(default)]
pub implements: Vec<String>,
#[serde(default)]
pub depends_on: Vec<String>,
#[serde(default)]
pub tests: Vec<String>,
#[serde(default)]
pub repos: Vec<String>,
#[serde(default)]
pub slug: Option<String>,
}
impl BundleConfig {
pub fn slug(&self) -> String {
self.slug
.clone()
.unwrap_or_else(|| self.name.replace('/', "-"))
}
pub fn parent_name(&self) -> Option<&str> {
self.name.rsplit_once('/').map(|(parent, _)| parent)
}
pub fn member_files(&self) -> Vec<String> {
let mut files: Vec<String> = self.files.clone();
files.extend(self.docs.iter().cloned());
for r in &self.refs {
if let Some(parsed) = BundleRef::parse(r) {
files.push(parsed.file_path().to_string());
}
}
files.sort();
files.dedup();
files
}
}
#[derive(Debug, Clone)]
pub struct BundleRef {
pub repo: Option<String>,
pub file: String,
pub target: RefTarget,
pub modifier: Option<String>,
}
#[derive(Debug, Clone)]
pub enum RefTarget {
WholeFile,
Symbol(String),
Heading(String),
}
impl BundleRef {
pub fn parse(input: &str) -> Option<Self> {
if input.is_empty() {
return None;
}
let (main, modifier) = if let Some(idx) = input.find(" +") {
(&input[..idx], Some(input[idx + 2..].to_string()))
} else {
(input, None)
};
let (main, modifier) = if let Some(idx) = main.find('[') {
let tag_filter = main[idx..].trim_end_matches(']').to_string();
(
&main[..idx],
Some(modifier.map_or(tag_filter.clone(), |m| format!("{} {}", m, tag_filter))),
)
} else {
(main, modifier)
};
let (repo, path_and_target) = if let Some((r, rest)) = main.split_once(':') {
if !r.contains('/') && !r.contains('.') && !rest.starts_with(':') {
(Some(r.to_string()), rest)
} else {
(None, main)
}
} else {
(None, main)
};
if let Some((file, symbol)) = path_and_target.split_once("::") {
return Some(Self {
repo,
file: file.to_string(),
target: RefTarget::Symbol(symbol.to_string()),
modifier,
});
}
if let Some((file, heading)) = path_and_target.split_once('#') {
return Some(Self {
repo,
file: file.to_string(),
target: RefTarget::Heading(heading.to_string()),
modifier,
});
}
Some(Self {
repo,
file: path_and_target.to_string(),
target: RefTarget::WholeFile,
modifier,
})
}
pub fn file_path(&self) -> &str {
&self.file
}
pub fn display_l0(&self) -> String {
let prefix = self
.repo
.as_ref()
.map_or(String::new(), |r| format!("{}:", r));
match &self.target {
RefTarget::WholeFile => format!("{}{}", prefix, self.file),
RefTarget::Symbol(s) => format!("{}{}::{}", prefix, self.file, s),
RefTarget::Heading(h) => format!("{}{}#{}", prefix, self.file, h),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct FrontmatterConfig {
pub enabled: bool,
pub field: String,
}
impl Default for FrontmatterConfig {
fn default() -> Self {
Self {
enabled: true,
field: "tags".to_string(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct CommentsConfig {
pub enabled: bool,
pub prefix: String,
}
impl Default for CommentsConfig {
fn default() -> Self {
Self {
enabled: true,
prefix: "bobbin:tag".to_string(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TagRule {
pub pattern: String,
pub tags: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub repo: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct TagEffect {
#[serde(default)]
pub boost: f32,
#[serde(default)]
pub exclude: bool,
#[serde(default)]
pub pin: bool,
#[serde(default)]
pub budget_reserve: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ScopedEffect {
pub tag: String,
pub role: String,
#[serde(default)]
pub boost: f32,
#[serde(default)]
pub exclude: bool,
#[serde(default)]
pub pin: bool,
#[serde(default)]
pub budget_reserve: usize,
}
#[derive(Debug, Clone, PartialEq)]
pub struct ResolvedEffect {
pub boost: f32,
pub exclude: bool,
pub pin: bool,
pub budget_reserve: usize,
}
impl TagsConfig {
pub fn load(path: &Path) -> Result<Self> {
let content =
std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
toml::from_str(&content).with_context(|| format!("parsing {}", path.display()))
}
pub fn load_or_default(path: &Path) -> Self {
if path.exists() {
match Self::load(path) {
Ok(config) => config,
Err(e) => {
eprintln!(
"warning: failed to parse {}: {e:#}; using defaults",
path.display()
);
Self::default()
}
}
} else {
Self::default()
}
}
pub fn save(&self, path: &Path) -> Result<()> {
let content = toml::to_string_pretty(self).context("serializing tags config")?;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("creating {}", parent.display()))?;
}
std::fs::write(path, content).with_context(|| format!("writing {}", path.display()))
}
pub fn tags_path(repo_root: &Path) -> std::path::PathBuf {
repo_root.join(".bobbin").join("tags.toml")
}
pub fn resolve_effect(&self, tag: &str, role: Option<&str>) -> Option<ResolvedEffect> {
if let Some(role) = role {
let mut best: Option<(&ScopedEffect, usize)> = None;
for scoped in &self.effects_scoped {
if scoped.tag != tag {
continue;
}
if let Ok(pat) = Pattern::new(&scoped.role) {
if pat.matches(role) {
let specificity = role_specificity(&scoped.role);
if best.map_or(true, |(_, s)| specificity > s) {
best = Some((scoped, specificity));
}
}
}
}
if let Some((scoped, _)) = best {
return Some(ResolvedEffect {
boost: scoped.boost,
exclude: scoped.exclude,
pin: scoped.pin,
budget_reserve: scoped.budget_reserve,
});
}
}
self.effects.get(tag).map(|e| ResolvedEffect {
boost: e.boost,
exclude: e.exclude,
pin: e.pin,
budget_reserve: e.budget_reserve,
})
}
pub fn resolve_pin(&self, tags_str: &str, role: Option<&str>) -> Option<usize> {
if tags_str.is_empty() {
return None;
}
let mut max_reserve: usize = 0;
let mut is_pinned = false;
for tag in tags_str.split(',') {
if let Some(effect) = self.resolve_effect(tag, role) {
if effect.pin {
is_pinned = true;
max_reserve = max_reserve.max(effect.budget_reserve);
}
}
}
if is_pinned {
Some(max_reserve)
} else {
None
}
}
pub fn find_bundle(&self, name_or_slug: &str) -> Option<&BundleConfig> {
let name = name_or_slug.strip_prefix("b:").unwrap_or(name_or_slug);
self.bundles
.iter()
.find(|b| b.name == name || b.slug() == name)
.or_else(|| {
let as_path = name.replace('-', "/");
self.bundles.iter().find(|b| b.name == as_path)
})
}
pub fn build_bundle_file_filter(&self, bundle_name: &str) -> Option<String> {
let bundle = self.find_bundle(bundle_name)?;
let files = bundle.member_files();
let mut clauses: Vec<String> = Vec::new();
if !files.is_empty() {
let file_list: Vec<String> = files
.iter()
.map(|f| format!("'{}'", f.replace('\'', "''")))
.collect();
clauses.push(format!("file_path IN ({})", file_list.join(", ")));
}
if !bundle.tags.is_empty() {
clauses.push(build_tag_include_filter(&bundle.tags));
}
if clauses.is_empty() {
None
} else {
Some(format!("({})", clauses.join(" OR ")))
}
}
pub fn match_bundle_keywords(&self, query: &str) -> Vec<(&BundleConfig, String)> {
let query_lower = query.to_lowercase();
let mut matches: Vec<(&BundleConfig, String)> = Vec::new();
for bundle in &self.bundles {
for keyword in &bundle.keywords {
let keyword_lower = keyword.to_lowercase();
if query_lower.contains(&keyword_lower) {
matches.push((bundle, format!("keyword:{}", keyword)));
break; }
}
}
matches
}
pub fn expand_tags_via_ontology(&self, tags: &[String]) -> Vec<String> {
if self.ontology.tags.is_empty() {
return tags.to_vec();
}
let mut expanded: BTreeSet<String> = tags.iter().cloned().collect();
let mut queue: Vec<String> = tags.to_vec();
while let Some(tag) = queue.pop() {
for (child_name, child_def) in &self.ontology.tags {
if child_def.parent.as_deref() == Some(tag.as_str())
&& !expanded.contains(child_name)
{
expanded.insert(child_name.clone());
queue.push(child_name.clone());
}
}
}
expanded.into_iter().collect()
}
pub fn tag_ancestors(&self, tag: &str) -> Vec<String> {
let mut ancestors = Vec::new();
let mut current = tag.to_string();
let mut seen: BTreeSet<String> = BTreeSet::new();
while let Some(def) = self.ontology.tags.get(¤t) {
if let Some(ref parent) = def.parent {
if seen.contains(parent) {
break; }
ancestors.push(parent.clone());
seen.insert(parent.clone());
current = parent.clone();
} else {
break;
}
}
ancestors
}
pub fn related_tags(&self, tag: &str) -> Vec<String> {
self.ontology
.tags
.get(tag)
.map(|def| def.relates_to.clone())
.unwrap_or_default()
}
pub fn build_bundle_file_filter_with_ontology(&self, bundle_name: &str) -> Option<String> {
let bundle = self.find_bundle(bundle_name)?;
let files = bundle.member_files();
let mut clauses: Vec<String> = Vec::new();
if !files.is_empty() {
let file_list: Vec<String> = files
.iter()
.map(|f| format!("'{}'", f.replace('\'', "''")))
.collect();
clauses.push(format!("file_path IN ({})", file_list.join(", ")));
}
if !bundle.tags.is_empty() {
let expanded_tags = self.expand_tags_via_ontology(&bundle.tags);
clauses.push(build_tag_include_filter(&expanded_tags));
}
if clauses.is_empty() {
None
} else {
Some(format!("({})", clauses.join(" OR ")))
}
}
}
impl OntologyConfig {
pub fn children(&self, tag: &str) -> Vec<String> {
self.tags
.iter()
.filter(|(_, def)| def.parent.as_deref() == Some(tag))
.map(|(name, _)| name.clone())
.collect()
}
pub fn descendants(&self, tag: &str) -> Vec<String> {
let mut result = Vec::new();
let mut queue = vec![tag.to_string()];
let mut seen: BTreeSet<String> = BTreeSet::new();
seen.insert(tag.to_string());
while let Some(current) = queue.pop() {
for (name, def) in &self.tags {
if def.parent.as_deref() == Some(current.as_str()) && !seen.contains(name) {
result.push(name.clone());
seen.insert(name.clone());
queue.push(name.clone());
}
}
}
result
}
pub fn roots(&self) -> Vec<String> {
self.tags
.iter()
.filter(|(_, def)| def.parent.is_none())
.map(|(name, _)| name.clone())
.collect()
}
pub fn is_empty(&self) -> bool {
self.tags.is_empty()
}
}
fn role_specificity(pattern: &str) -> usize {
pattern
.split('/')
.filter(|seg| !seg.contains('*') && !seg.contains('?'))
.count()
}
pub fn build_tag_include_filter(tags: &[String]) -> String {
let clauses: Vec<String> = tags.iter().map(|t| tag_match_clause(t)).collect();
if clauses.len() == 1 {
clauses.into_iter().next().unwrap()
} else {
format!("({})", clauses.join(" OR "))
}
}
pub fn build_tag_exclude_filter(tags: &[String]) -> String {
let clauses: Vec<String> = tags
.iter()
.map(|t| format!("NOT ({})", tag_match_clause(t)))
.collect();
if clauses.len() == 1 {
clauses.into_iter().next().unwrap()
} else {
format!("({})", clauses.join(" AND "))
}
}
pub fn build_effect_exclude_filter(config: &TagsConfig, role: Option<&str>) -> Option<String> {
let mut excluded_tags: Vec<String> = Vec::new();
for (tag, effect) in &config.effects {
if effect.exclude {
if let Some(r) = role {
let scoped_override = config
.effects_scoped
.iter()
.filter(|s| s.tag == *tag)
.filter(|s| Pattern::new(&s.role).map(|p| p.matches(r)).unwrap_or(false))
.max_by_key(|s| role_specificity(&s.role));
if let Some(s) = scoped_override {
if !s.exclude {
continue; }
}
}
excluded_tags.push(tag.clone());
}
}
if let Some(role) = role {
for scoped in &config.effects_scoped {
if !scoped.exclude {
continue;
}
if excluded_tags.contains(&scoped.tag) {
continue; }
if let Ok(pat) = Pattern::new(&scoped.role) {
if pat.matches(role) {
excluded_tags.push(scoped.tag.clone());
}
}
}
}
if excluded_tags.is_empty() {
None
} else {
Some(build_tag_exclude_filter(&excluded_tags))
}
}
fn tag_match_clause(tag: &str) -> String {
let escaped = tag.replace('\'', "''");
format!(
"(tags = '{e}' OR tags LIKE '{e},%' OR tags LIKE '%,{e}' OR tags LIKE '%,{e},%')",
e = escaped
)
}
pub fn validate_tag(tag: &str) -> Result<()> {
if tag.is_empty() {
bail!("tag name cannot be empty");
}
if tag.len() > TAG_MAX_LEN {
bail!("tag '{}' exceeds {} character limit", tag, TAG_MAX_LEN);
}
if !tag
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == ':')
{
bail!(
"tag '{}' contains invalid characters (allowed: a-z, 0-9, -, :)",
tag
);
}
Ok(())
}
pub fn convention_tags(file_path: &str) -> Vec<String> {
let mut tags = Vec::new();
match classify_file(file_path) {
FileCategory::Test => tags.push("auto:test".to_string()),
FileCategory::Documentation => tags.push("auto:docs".to_string()),
FileCategory::Config => tags.push("auto:config".to_string()),
FileCategory::Custom(ref name) => tags.push(format!("auto:{}", name)),
FileCategory::Source => {}
}
let lower = file_path.to_lowercase();
if lower.ends_with(".min.js")
|| lower.ends_with(".min.css")
|| lower.ends_with(".gen.go")
|| lower.ends_with(".generated.ts")
|| lower.contains("/generated/")
|| lower.ends_with("_generated.rs")
{
tags.push("auto:generated".to_string());
}
tags
}
pub fn resolve_tags(config: &TagsConfig, file_path: &str, repo: Option<&str>) -> String {
let mut tags: BTreeSet<String> = BTreeSet::new();
tags.extend(convention_tags(file_path));
for rule in &config.rules {
if let Some(ref rule_repo) = rule.repo {
if repo.map(|r| r != rule_repo).unwrap_or(true) {
continue;
}
}
match Pattern::new(&rule.pattern) {
Ok(pat) => {
let prefixed = format!("_/{}", file_path);
if pat.matches(file_path) || pat.matches(&prefixed) {
tags.extend(rule.tags.iter().cloned());
}
}
Err(_) => {
}
}
}
let tag_vec: Vec<String> = tags.into_iter().collect();
tag_vec.join(",")
}
pub fn resolve_tags_for_chunks(
config: &TagsConfig,
file_path: &str,
repo: Option<&str>,
content: &str,
chunks: &mut [crate::types::Chunk],
) {
let file_tags_str = resolve_tags(config, file_path, repo);
let mut file_tags: BTreeSet<String> = if file_tags_str.is_empty() {
BTreeSet::new()
} else {
file_tags_str.split(',').map(|s| s.to_string()).collect()
};
if config.frontmatter.enabled {
let fm_tags = extract_frontmatter_tags(content, &config.frontmatter);
file_tags.extend(fm_tags);
}
let comment_tags = if config.comments.enabled {
extract_comment_tags(content, &config.comments)
} else {
std::collections::HashMap::new()
};
for chunk in chunks.iter_mut() {
let mut tags = file_tags.clone();
if chunk.name.as_deref() == Some("init")
&& matches!(chunk.chunk_type, crate::types::ChunkType::Function)
&& file_path.ends_with(".go")
{
tags.insert("auto:init".to_string());
}
if chunk.start_line > 1 {
if let Some(ct) = comment_tags.get(&(chunk.start_line - 1)) {
tags.extend(ct.iter().cloned());
}
}
if let Some(ct) = comment_tags.get(&chunk.start_line) {
tags.extend(ct.iter().cloned());
}
if !tags.is_empty() {
chunk.tags = tags.into_iter().collect::<Vec<_>>().join(",");
}
}
}
pub fn extract_frontmatter_tags(content: &str, config: &FrontmatterConfig) -> Vec<String> {
let trimmed = content.trim_start();
if !trimmed.starts_with("---") {
return vec![];
}
let after_fence = &trimmed[3..];
let Some(close_pos) = after_fence.find("\n---") else {
return vec![];
};
let fm_text = &after_fence[..close_pos];
let fields_to_check: Vec<&str> = std::iter::once(config.field.as_str())
.chain(FRONTMATTER_FALLBACK_FIELDS.iter().copied())
.collect();
for field in fields_to_check {
let tags = parse_yaml_field_tags(fm_text, field);
if !tags.is_empty() {
return tags;
}
}
vec![]
}
fn parse_yaml_field_tags(fm_text: &str, field: &str) -> Vec<String> {
let lines: Vec<&str> = fm_text.lines().collect();
let field_prefix = format!("{}:", field);
for (i, line) in lines.iter().enumerate() {
let trimmed = line.trim();
if !trimmed.starts_with(&field_prefix) {
continue;
}
let after_key = trimmed[field_prefix.len()..].trim();
if after_key.is_empty() {
return parse_yaml_block_list(&lines[i + 1..]);
} else if after_key.starts_with('[') && after_key.ends_with(']') {
let inner = &after_key[1..after_key.len() - 1];
return parse_tag_values(inner.split(','));
} else {
return parse_tag_values(std::iter::once(after_key));
}
}
vec![]
}
fn parse_yaml_block_list(lines: &[&str]) -> Vec<String> {
let mut tags = Vec::new();
for line in lines {
let trimmed = line.trim();
if trimmed.starts_with("- ") {
let val = trimmed[2..].trim().trim_matches('"').trim_matches('\'');
if let Some(tag) = normalize_content_tag(val) {
tags.push(tag);
}
} else if !trimmed.is_empty() {
break;
}
}
tags
}
fn parse_tag_values<'a>(values: impl Iterator<Item = &'a str>) -> Vec<String> {
values
.map(|v| v.trim().trim_matches('"').trim_matches('\''))
.filter(|v| !v.is_empty())
.filter_map(normalize_content_tag)
.collect()
}
fn normalize_content_tag(raw: &str) -> Option<String> {
let tag = raw.trim().to_lowercase();
if tag.is_empty() {
return None;
}
let namespaced = if tag.contains(':') {
tag
} else {
format!("user:{}", tag)
};
if validate_tag(&namespaced).is_ok() {
Some(namespaced)
} else {
None
}
}
pub fn extract_comment_tags(
content: &str,
config: &CommentsConfig,
) -> std::collections::HashMap<u32, Vec<String>> {
let mut result = std::collections::HashMap::new();
let prefix = &config.prefix;
for (idx, line) in content.lines().enumerate() {
let line_num = idx as u32 + 1;
let trimmed = line.trim();
let directive = None
.or_else(|| extract_after_comment_prefix(trimmed, "//", prefix))
.or_else(|| extract_after_comment_prefix(trimmed, "#", prefix))
.or_else(|| extract_block_comment_directive(trimmed, prefix));
if let Some(tag_str) = directive {
let tags = parse_tag_values(tag_str.split_whitespace());
if !tags.is_empty() {
result.insert(line_num, tags);
}
}
}
result
}
fn extract_after_comment_prefix<'a>(
line: &'a str,
comment_prefix: &str,
directive_prefix: &str,
) -> Option<&'a str> {
let stripped = line.strip_prefix(comment_prefix)?.trim_start();
let after = stripped.strip_prefix(directive_prefix)?;
if after.is_empty() {
return None; }
if !after.starts_with(char::is_whitespace) {
return None; }
Some(after.trim())
}
fn extract_block_comment_directive<'a>(line: &'a str, directive_prefix: &str) -> Option<&'a str> {
let inner = line.strip_prefix("/*")?.strip_suffix("*/")?.trim();
let after = inner.strip_prefix(directive_prefix)?;
if after.is_empty() {
return None;
}
if !after.starts_with(char::is_whitespace) {
return None;
}
Some(after.trim())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_validate_tag_valid() {
assert!(validate_tag("deprecated").is_ok());
assert!(validate_tag("auto:test").is_ok());
assert!(validate_tag("my-tag-123").is_ok());
assert!(validate_tag("a").is_ok());
}
#[test]
fn test_validate_tag_invalid() {
assert!(validate_tag("").is_err());
assert!(validate_tag("UPPERCASE").is_err());
assert!(validate_tag("has space").is_err());
assert!(validate_tag("has.dot").is_err());
assert!(validate_tag(&"a".repeat(33)).is_err());
}
#[test]
fn test_convention_tags() {
assert_eq!(convention_tags("tests/test_foo.py"), vec!["auto:test"]);
assert_eq!(convention_tags("docs/guide.md"), vec!["auto:docs"]);
assert_eq!(convention_tags("Cargo.toml"), vec!["auto:config"]);
assert!(convention_tags("src/main.rs").is_empty());
}
#[test]
fn test_convention_tags_generated() {
assert!(convention_tags("dist/bundle.min.js").contains(&"auto:generated".to_string()));
}
#[test]
fn test_resolve_tags_empty_config() {
let config = TagsConfig::default();
assert_eq!(resolve_tags(&config, "src/main.rs", None), "");
assert_eq!(
resolve_tags(&config, "tests/test_foo.py", None),
"auto:test"
);
}
#[test]
fn test_resolve_tags_with_rules() {
let config = TagsConfig {
rules: vec![
TagRule {
pattern: "docs/deprecated/**".to_string(),
tags: vec!["deprecated".to_string()],
repo: None,
},
TagRule {
pattern: "src/auth/**".to_string(),
tags: vec!["security".to_string(), "critical".to_string()],
repo: None,
},
],
..Default::default()
};
assert_eq!(
resolve_tags(&config, "docs/deprecated/old.md", None),
"auto:docs,deprecated"
);
assert_eq!(
resolve_tags(&config, "src/auth/login.rs", None),
"critical,security"
);
assert_eq!(resolve_tags(&config, "src/utils.rs", None), "");
}
#[test]
fn test_resolve_tags_repo_scoped() {
let config = TagsConfig {
rules: vec![TagRule {
pattern: "*.md".to_string(),
tags: vec!["ops-docs".to_string()],
repo: Some("aegis".to_string()),
}],
..Default::default()
};
assert_eq!(
resolve_tags(&config, "guide.md", Some("aegis")),
"auto:docs,ops-docs"
);
assert_eq!(
resolve_tags(&config, "guide.md", Some("bobbin")),
"auto:docs"
);
assert_eq!(resolve_tags(&config, "guide.md", None), "auto:docs");
}
#[test]
fn test_resolve_tags_globstar_root_relative() {
let config = TagsConfig {
rules: vec![
TagRule {
pattern: "**/snapshots/**/*.md".to_string(),
tags: vec!["type:memory".to_string()],
repo: None,
},
TagRule {
pattern: "**/CHANGELOG.md".to_string(),
tags: vec!["type:changelog".to_string()],
repo: None,
},
],
..Default::default()
};
assert_eq!(
resolve_tags(&config, "snapshots/ian/2026-03-12.md", None),
"auto:docs,type:memory"
);
assert_eq!(
resolve_tags(&config, "CHANGELOG.md", None),
"auto:docs,type:changelog"
);
assert_eq!(
resolve_tags(&config, "foo/snapshots/ian/2026-03-12.md", None),
"auto:docs,type:memory"
);
assert_eq!(resolve_tags(&config, "src/main.rs", None), "");
}
#[test]
fn test_tags_config_roundtrip() {
let config = TagsConfig {
rules: vec![TagRule {
pattern: "**/*_test.go".to_string(),
tags: vec!["test".to_string()],
repo: None,
}],
effects: {
let mut m = std::collections::HashMap::new();
m.insert(
"deprecated".to_string(),
TagEffect {
boost: -0.8,
exclude: false,
..Default::default()
},
);
m.insert(
"noise".to_string(),
TagEffect {
boost: 0.0,
exclude: true,
..Default::default()
},
);
m
},
effects_scoped: vec![ScopedEffect {
tag: "internal".to_string(),
role: "external/*".to_string(),
boost: 0.0,
exclude: true,
..Default::default()
}],
..Default::default()
};
let toml_str = toml::to_string_pretty(&config).unwrap();
let parsed: TagsConfig = toml::from_str(&toml_str).unwrap();
assert_eq!(parsed.rules.len(), 1);
assert_eq!(parsed.rules[0].pattern, "**/*_test.go");
assert!((parsed.effects["deprecated"].boost - (-0.8)).abs() < f32::EPSILON);
assert!(!parsed.effects["deprecated"].exclude);
assert!(parsed.effects["noise"].exclude);
assert_eq!(parsed.effects_scoped.len(), 1);
assert_eq!(parsed.effects_scoped[0].tag, "internal");
assert_eq!(parsed.effects_scoped[0].role, "external/*");
assert!(parsed.effects_scoped[0].exclude);
assert!(parsed.frontmatter.enabled);
assert_eq!(parsed.frontmatter.field, "tags");
assert!(parsed.comments.enabled);
assert_eq!(parsed.comments.prefix, "bobbin:tag");
}
#[test]
fn test_tags_config_backward_compat() {
let toml_str = r#"
[[rules]]
pattern = "*.md"
tags = ["docs"]
[effects.deprecated]
boost = -0.8
"#;
let config: TagsConfig = toml::from_str(toml_str).unwrap();
assert_eq!(config.rules.len(), 1);
assert!(!config.effects["deprecated"].exclude); assert!(config.effects_scoped.is_empty()); }
#[test]
fn test_resolve_effect_global_only() {
let config = TagsConfig {
effects: {
let mut m = std::collections::HashMap::new();
m.insert(
"deprecated".to_string(),
TagEffect {
boost: -0.8,
exclude: false,
..Default::default()
},
);
m
},
..Default::default()
};
let resolved = config.resolve_effect("deprecated", None);
assert_eq!(
resolved,
Some(ResolvedEffect {
boost: -0.8,
exclude: false,
pin: false,
budget_reserve: 0,
})
);
assert_eq!(config.resolve_effect("unknown", None), None);
}
#[test]
fn test_resolve_effect_scoped_overrides_global() {
let config = TagsConfig {
effects: {
let mut m = std::collections::HashMap::new();
m.insert(
"internal".to_string(),
TagEffect {
boost: 0.0,
exclude: false,
..Default::default()
},
);
m
},
effects_scoped: vec![ScopedEffect {
tag: "internal".to_string(),
role: "external/*".to_string(),
boost: 0.0,
exclude: true,
..Default::default()
}],
..Default::default()
};
let resolved = config.resolve_effect("internal", Some("external/user1"));
assert_eq!(
resolved,
Some(ResolvedEffect {
boost: 0.0,
exclude: true,
pin: false,
budget_reserve: 0,
})
);
let resolved = config.resolve_effect("internal", Some("aegis/crew/ellie"));
assert_eq!(
resolved,
Some(ResolvedEffect {
boost: 0.0,
exclude: false,
pin: false,
budget_reserve: 0,
})
);
}
#[test]
fn test_resolve_effect_most_specific_role_wins() {
let config = TagsConfig {
effects_scoped: vec![
ScopedEffect {
tag: "test".to_string(),
role: "aegis/*".to_string(),
boost: -0.3,
exclude: false,
..Default::default()
},
ScopedEffect {
tag: "test".to_string(),
role: "aegis/crew/sentinel".to_string(),
boost: 0.3,
exclude: false,
..Default::default()
},
],
..Default::default()
};
let resolved = config.resolve_effect("test", Some("aegis/crew/sentinel"));
assert_eq!(
resolved,
Some(ResolvedEffect {
boost: 0.3,
exclude: false,
pin: false,
budget_reserve: 0,
})
);
let resolved = config.resolve_effect("test", Some("aegis/crew/ellie"));
assert_eq!(
resolved,
Some(ResolvedEffect {
boost: -0.3,
exclude: false,
pin: false,
budget_reserve: 0,
})
);
}
#[test]
fn test_build_tag_include_filter_single() {
let filter = build_tag_include_filter(&["user:canonical".to_string()]);
assert!(filter.contains("tags = 'user:canonical'"));
assert!(filter.contains("tags LIKE 'user:canonical,%'"));
assert!(filter.contains("tags LIKE '%,user:canonical'"));
assert!(filter.contains("tags LIKE '%,user:canonical,%'"));
}
#[test]
fn test_build_tag_include_filter_multiple() {
let filter = build_tag_include_filter(&[
"user:canonical".to_string(),
"user:architecture".to_string(),
]);
assert!(filter.starts_with('('));
assert!(filter.contains(" OR "));
}
#[test]
fn test_build_tag_exclude_filter_single() {
let filter = build_tag_exclude_filter(&["auto:test".to_string()]);
assert!(filter.starts_with("NOT ("));
}
#[test]
fn test_build_tag_exclude_filter_multiple() {
let filter =
build_tag_exclude_filter(&["auto:test".to_string(), "user:deprecated".to_string()]);
assert!(filter.starts_with('('));
assert!(filter.contains(" AND "));
}
#[test]
fn test_build_effect_exclude_filter_global_only() {
let config = TagsConfig {
effects: {
let mut m = std::collections::HashMap::new();
m.insert(
"noise".to_string(),
TagEffect {
boost: 0.0,
exclude: true,
..Default::default()
},
);
m.insert(
"deprecated".to_string(),
TagEffect {
boost: -0.8,
exclude: false,
..Default::default()
},
);
m
},
..Default::default()
};
let filter = build_effect_exclude_filter(&config, None);
assert!(filter.is_some());
let f = filter.unwrap();
assert!(f.contains("noise"));
assert!(!f.contains("deprecated")); }
#[test]
fn test_build_effect_exclude_filter_scoped_adds_exclude() {
let config = TagsConfig {
effects_scoped: vec![ScopedEffect {
tag: "internal".to_string(),
role: "external/*".to_string(),
boost: 0.0,
exclude: true,
..Default::default()
}],
..Default::default()
};
let filter = build_effect_exclude_filter(&config, Some("external/user1"));
assert!(filter.is_some());
assert!(filter.unwrap().contains("internal"));
let filter = build_effect_exclude_filter(&config, Some("aegis/crew/ellie"));
assert!(filter.is_none());
}
#[test]
fn test_build_effect_exclude_filter_scoped_overrides_global() {
let config = TagsConfig {
effects: {
let mut m = std::collections::HashMap::new();
m.insert(
"internal".to_string(),
TagEffect {
boost: 0.0,
exclude: true, ..Default::default()
},
);
m
},
effects_scoped: vec![ScopedEffect {
tag: "internal".to_string(),
role: "aegis/*".to_string(),
boost: 0.0,
exclude: false, ..Default::default()
}],
..Default::default()
};
let filter = build_effect_exclude_filter(&config, Some("aegis/crew/ellie"));
assert!(filter.is_none());
let filter = build_effect_exclude_filter(&config, Some("external/user1"));
assert!(filter.is_some());
assert!(filter.unwrap().contains("internal"));
}
#[test]
fn test_build_effect_exclude_filter_none_when_empty() {
let config = TagsConfig::default();
assert!(build_effect_exclude_filter(&config, None).is_none());
}
#[test]
fn test_role_specificity() {
assert_eq!(role_specificity("*"), 0);
assert_eq!(role_specificity("aegis/*"), 1);
assert_eq!(role_specificity("aegis/crew/*"), 2);
assert_eq!(role_specificity("aegis/crew/sentinel"), 3);
}
#[test]
fn test_toml_with_effects_scoped() {
let toml_str = r#"
[effects.noise]
exclude = true
[[effects_scoped]]
tag = "canonical"
role = "aegis/*"
boost = 0.5
[[effects_scoped]]
tag = "internal"
role = "external/*"
exclude = true
"#;
let config: TagsConfig = toml::from_str(toml_str).unwrap();
assert!(config.effects["noise"].exclude);
assert_eq!(config.effects_scoped.len(), 2);
assert_eq!(config.effects_scoped[0].tag, "canonical");
assert!((config.effects_scoped[0].boost - 0.5).abs() < f32::EPSILON);
assert!(config.effects_scoped[1].exclude);
}
#[test]
fn test_frontmatter_tags_inline_list() {
let content = "---\ntitle: Auth Architecture\ntags: [canonical, architecture, security]\n---\n\n# Auth\n";
let config = FrontmatterConfig::default();
let tags = extract_frontmatter_tags(content, &config);
assert_eq!(
tags,
vec!["user:canonical", "user:architecture", "user:security"]
);
}
#[test]
fn test_frontmatter_tags_single_value() {
let content = "---\ntags: canonical\n---\n\n# Doc\n";
let config = FrontmatterConfig::default();
let tags = extract_frontmatter_tags(content, &config);
assert_eq!(tags, vec!["user:canonical"]);
}
#[test]
fn test_frontmatter_tags_block_list() {
let content = "---\ntags:\n - canonical\n - architecture\n---\n\n# Doc\n";
let config = FrontmatterConfig::default();
let tags = extract_frontmatter_tags(content, &config);
assert_eq!(tags, vec!["user:canonical", "user:architecture"]);
}
#[test]
fn test_frontmatter_tags_with_namespace() {
let content = "---\ntags: [auto:test, security]\n---\n\n# Doc\n";
let config = FrontmatterConfig::default();
let tags = extract_frontmatter_tags(content, &config);
assert_eq!(tags, vec!["auto:test", "user:security"]);
}
#[test]
fn test_frontmatter_tags_quoted() {
let content = "---\ntags: [\"canonical\", 'architecture']\n---\n\n# Doc\n";
let config = FrontmatterConfig::default();
let tags = extract_frontmatter_tags(content, &config);
assert_eq!(tags, vec!["user:canonical", "user:architecture"]);
}
#[test]
fn test_frontmatter_fallback_field() {
let content = "---\nbobbin-tags: [security]\n---\n\n# Doc\n";
let config = FrontmatterConfig::default();
let tags = extract_frontmatter_tags(content, &config);
assert_eq!(tags, vec!["user:security"]);
}
#[test]
fn test_frontmatter_labels_fallback() {
let content = "---\nlabels: [internal, deprecated]\n---\n\n# Doc\n";
let config = FrontmatterConfig::default();
let tags = extract_frontmatter_tags(content, &config);
assert_eq!(tags, vec!["user:internal", "user:deprecated"]);
}
#[test]
fn test_frontmatter_no_frontmatter() {
let content = "# Just a heading\n\nSome content\n";
let config = FrontmatterConfig::default();
let tags = extract_frontmatter_tags(content, &config);
assert!(tags.is_empty());
}
#[test]
fn test_frontmatter_no_tags_field() {
let content = "---\ntitle: Something\nauthor: Someone\n---\n\n# Doc\n";
let config = FrontmatterConfig::default();
let tags = extract_frontmatter_tags(content, &config);
assert!(tags.is_empty());
}
#[test]
fn test_frontmatter_custom_field() {
let content = "---\ncategories: [security, ops]\n---\n\n# Doc\n";
let config = FrontmatterConfig {
enabled: true,
field: "categories".to_string(),
};
let tags = extract_frontmatter_tags(content, &config);
assert_eq!(tags, vec!["user:security", "user:ops"]);
}
#[test]
fn test_frontmatter_invalid_tags_skipped() {
let content = "---\ntags: [valid, INVALID, also-valid]\n---\n\n# Doc\n";
let config = FrontmatterConfig::default();
let tags = extract_frontmatter_tags(content, &config);
assert_eq!(tags, vec!["user:valid", "user:invalid", "user:also-valid"]);
}
#[test]
fn test_comment_tags_rust() {
let content = "use std::io;\n\n// bobbin:tag security critical\nfn authenticate() {\n}\n";
let config = CommentsConfig::default();
let tags = extract_comment_tags(content, &config);
assert_eq!(
tags.get(&3),
Some(&vec![
"user:security".to_string(),
"user:critical".to_string()
])
);
assert_eq!(tags.len(), 1);
}
#[test]
fn test_comment_tags_python() {
let content = "import os\n\n# bobbin:tag deprecated\ndef old_handler():\n pass\n";
let config = CommentsConfig::default();
let tags = extract_comment_tags(content, &config);
assert_eq!(tags.get(&3), Some(&vec!["user:deprecated".to_string()]));
}
#[test]
fn test_comment_tags_block_comment() {
let content = "/* bobbin:tag internal */\npub(crate) fn helper() {}\n";
let config = CommentsConfig::default();
let tags = extract_comment_tags(content, &config);
assert_eq!(tags.get(&1), Some(&vec!["user:internal".to_string()]));
}
#[test]
fn test_comment_tags_no_false_positive() {
let content = "// bobbin:tagging system\n// just a comment\nfn foo() {}\n";
let config = CommentsConfig::default();
let tags = extract_comment_tags(content, &config);
assert!(tags.is_empty());
}
#[test]
fn test_comment_tags_empty_directive() {
let content = "// bobbin:tag\nfn foo() {}\n";
let config = CommentsConfig::default();
let tags = extract_comment_tags(content, &config);
assert!(tags.is_empty());
}
#[test]
fn test_comment_tags_with_namespace() {
let content = "// bobbin:tag auto:test user:critical\nfn test_auth() {}\n";
let config = CommentsConfig::default();
let tags = extract_comment_tags(content, &config);
assert_eq!(
tags.get(&1),
Some(&vec!["auto:test".to_string(), "user:critical".to_string()])
);
}
#[test]
fn test_comment_tags_disabled() {
let content = "// bobbin:tag security\nfn auth() {}\n";
let config = CommentsConfig {
enabled: false,
prefix: "bobbin:tag".to_string(),
};
let tags = extract_comment_tags(content, &config);
assert!(!tags.is_empty()); }
#[test]
fn test_normalize_content_tag() {
assert_eq!(
normalize_content_tag("canonical"),
Some("user:canonical".to_string())
);
assert_eq!(
normalize_content_tag("auto:test"),
Some("auto:test".to_string())
);
assert_eq!(
normalize_content_tag("SECURITY"),
Some("user:security".to_string())
);
assert_eq!(normalize_content_tag(""), None);
assert_eq!(normalize_content_tag(" "), None);
}
#[test]
fn test_toml_with_frontmatter_and_comments() {
let toml_str = r#"
[frontmatter]
enabled = true
field = "categories"
[comments]
enabled = true
prefix = "tag:"
"#;
let config: TagsConfig = toml::from_str(toml_str).unwrap();
assert!(config.frontmatter.enabled);
assert_eq!(config.frontmatter.field, "categories");
assert!(config.comments.enabled);
assert_eq!(config.comments.prefix, "tag:");
}
#[test]
fn test_toml_backward_compat_no_frontmatter_comments() {
let toml_str = r#"
[[rules]]
pattern = "*.md"
tags = ["docs"]
"#;
let config: TagsConfig = toml::from_str(toml_str).unwrap();
assert!(config.frontmatter.enabled);
assert_eq!(config.frontmatter.field, "tags");
assert!(config.comments.enabled);
assert_eq!(config.comments.prefix, "bobbin:tag");
}
#[test]
fn test_auto_init_tag_go_init_function() {
use crate::types::{Chunk, ChunkType};
let config = TagsConfig::default();
let mut chunks = vec![
Chunk {
id: "1".to_string(),
file_path: "cmd/main.go".to_string(),
chunk_type: ChunkType::Function,
name: Some("init".to_string()),
start_line: 10,
end_line: 20,
content: "func init() { cobra.AddCommand(fooCmd) }".to_string(),
language: "go".to_string(),
tags: String::new(),
},
Chunk {
id: "2".to_string(),
file_path: "cmd/main.go".to_string(),
chunk_type: ChunkType::Function,
name: Some("main".to_string()),
start_line: 25,
end_line: 35,
content: "func main() { Execute() }".to_string(),
language: "go".to_string(),
tags: String::new(),
},
];
resolve_tags_for_chunks(&config, "cmd/main.go", None, "", &mut chunks);
assert!(
chunks[0].tags.contains("auto:init"),
"Go init() should get auto:init tag"
);
assert!(
!chunks[1].tags.contains("auto:init"),
"Go main() should NOT get auto:init tag"
);
}
#[test]
fn test_auto_init_tag_not_applied_to_rust() {
use crate::types::{Chunk, ChunkType};
let config = TagsConfig::default();
let mut chunks = vec![Chunk {
id: "1".to_string(),
file_path: "src/lib.rs".to_string(),
chunk_type: ChunkType::Function,
name: Some("init".to_string()),
start_line: 1,
end_line: 5,
content: "fn init() {}".to_string(),
language: "rust".to_string(),
tags: String::new(),
}];
resolve_tags_for_chunks(&config, "src/lib.rs", None, "", &mut chunks);
assert!(
!chunks[0].tags.contains("auto:init"),
"Rust init() should NOT get auto:init tag"
);
}
#[test]
fn test_resolve_pin_basic() {
let config = TagsConfig {
effects: {
let mut m = std::collections::HashMap::new();
m.insert(
"user:critical".to_string(),
TagEffect {
pin: true,
budget_reserve: 50,
..Default::default()
},
);
m.insert(
"user:boost-only".to_string(),
TagEffect {
boost: 0.3,
..Default::default()
},
);
m
},
..Default::default()
};
assert_eq!(config.resolve_pin("user:critical", None), Some(50));
assert_eq!(config.resolve_pin("user:boost-only", None), None);
assert_eq!(config.resolve_pin("", None), None);
assert_eq!(config.resolve_pin("user:unknown", None), None);
assert_eq!(
config.resolve_pin("user:boost-only,user:critical", None),
Some(50)
);
}
#[test]
fn test_resolve_pin_scoped() {
let config = TagsConfig {
effects: {
let mut m = std::collections::HashMap::new();
m.insert(
"user:guardrails".to_string(),
TagEffect {
pin: true,
budget_reserve: 30,
..Default::default()
},
);
m
},
effects_scoped: vec![ScopedEffect {
tag: "user:guardrails".to_string(),
role: "external/*".to_string(),
pin: false, ..Default::default()
}],
..Default::default()
};
assert_eq!(
config.resolve_pin("user:guardrails", Some("aegis/crew/ellie")),
Some(30)
);
assert_eq!(
config.resolve_pin("user:guardrails", Some("external/user1")),
None
);
}
fn ontology_config() -> TagsConfig {
let toml_str = r#"
[ontology.tags.security]
description = "Security-related code"
[ontology.tags.auth]
parent = "security"
relates_to = ["session", "token"]
[ontology.tags.session]
parent = "auth"
[[bundles]]
name = "domain/security"
description = "Security subsystem"
tags = ["security"]
"#;
toml::from_str(toml_str).unwrap()
}
#[test]
fn test_expand_tags_via_ontology_includes_descendants() {
let config = ontology_config();
let expanded = config.expand_tags_via_ontology(&["security".to_string()]);
assert!(expanded.contains(&"security".to_string()));
assert!(expanded.contains(&"auth".to_string()));
assert!(expanded.contains(&"session".to_string()));
}
#[test]
fn test_expand_tags_via_ontology_leaf_unchanged() {
let config = ontology_config();
let expanded = config.expand_tags_via_ontology(&["session".to_string()]);
assert_eq!(expanded, vec!["session".to_string()]);
}
#[test]
fn test_expand_tags_via_ontology_noop_without_ontology() {
let config = TagsConfig::default();
let input = vec!["security".to_string(), "auth".to_string()];
assert_eq!(config.expand_tags_via_ontology(&input), input);
}
#[test]
fn test_build_bundle_file_filter_with_ontology_expands_tags() {
let config = ontology_config();
let filter = config
.build_bundle_file_filter_with_ontology("domain/security")
.expect("bundle filter");
assert!(filter.contains("auth"), "filter was: {filter}");
assert!(filter.contains("session"), "filter was: {filter}");
}
}