use anyhow::Result;
use std::collections::HashSet;
use std::fmt;
use crate::jobstore::{JobDir, resolve_root};
use crate::schema::{Response, TagSetData};
#[derive(Debug)]
pub struct InvalidTag {
pub value: String,
pub reason: &'static str,
}
impl fmt::Display for InvalidTag {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "invalid tag {:?}: {}", self.value, self.reason)
}
}
impl std::error::Error for InvalidTag {}
pub fn validate_stored_tag(tag: &str) -> Result<(), InvalidTag> {
if tag.is_empty() {
return Err(InvalidTag {
value: tag.to_string(),
reason: "tag must not be empty",
});
}
if tag.ends_with(".*") {
return Err(InvalidTag {
value: tag.to_string(),
reason: "stored tag may not end with '.*' (use exact tag names for run/tag set)",
});
}
for segment in tag.split('.') {
if segment.is_empty() {
return Err(InvalidTag {
value: tag.to_string(),
reason: "tag segments must not be empty (no leading, trailing, or consecutive dots)",
});
}
if !segment
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-')
{
return Err(InvalidTag {
value: tag.to_string(),
reason: "tag segments may only contain alphanumeric characters and hyphens",
});
}
}
Ok(())
}
pub fn validate_filter_pattern(pattern: &str) -> Result<(), InvalidTag> {
if let Some(prefix) = pattern.strip_suffix(".*") {
validate_stored_tag(prefix).map_err(|e| InvalidTag {
value: pattern.to_string(),
reason: e.reason,
})
} else {
validate_stored_tag(pattern)
}
}
pub fn dedup_tags(tags: Vec<String>) -> Result<Vec<String>> {
let mut seen = HashSet::new();
let mut result = Vec::new();
for tag in tags {
validate_stored_tag(&tag).map_err(anyhow::Error::from)?;
if seen.insert(tag.clone()) {
result.push(tag);
}
}
Ok(result)
}
pub fn matches_all_patterns(job_tags: &[String], patterns: &[String]) -> bool {
patterns.iter().all(|pattern| {
if let Some(prefix) = pattern.strip_suffix(".*") {
job_tags
.iter()
.any(|t| t == prefix || t.starts_with(&format!("{prefix}.")))
} else {
job_tags.iter().any(|t| t == pattern)
}
})
}
pub struct TagOpts<'a> {
pub root: Option<&'a str>,
pub job_id: &'a str,
pub tags: Vec<String>,
}
pub fn execute(opts: TagOpts) -> Result<()> {
let root = resolve_root(opts.root);
let job_dir = JobDir::open(&root, opts.job_id)?;
let new_tags = dedup_tags(opts.tags)?;
let mut meta = job_dir.read_meta()?;
meta.tags = new_tags.clone();
job_dir.write_meta_atomic(&meta)?;
let response = Response::new(
"tag_set",
TagSetData {
job_id: job_dir.job_id.clone(),
tags: new_tags,
},
);
response.print();
Ok(())
}