1use anyhow::Result;
11use std::collections::HashSet;
12use std::fmt;
13
14use crate::jobstore::{JobDir, resolve_root};
15use crate::schema::{Response, TagSetData};
16
17#[derive(Debug)]
19pub struct InvalidTag {
20 pub value: String,
21 pub reason: &'static str,
22}
23
24impl fmt::Display for InvalidTag {
25 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
26 write!(f, "invalid tag {:?}: {}", self.value, self.reason)
27 }
28}
29
30impl std::error::Error for InvalidTag {}
31
32pub fn validate_stored_tag(tag: &str) -> Result<(), InvalidTag> {
38 if tag.is_empty() {
39 return Err(InvalidTag {
40 value: tag.to_string(),
41 reason: "tag must not be empty",
42 });
43 }
44 if tag.ends_with(".*") {
45 return Err(InvalidTag {
46 value: tag.to_string(),
47 reason: "stored tag may not end with '.*' (use exact tag names for run/tag set)",
48 });
49 }
50 for segment in tag.split('.') {
51 if segment.is_empty() {
52 return Err(InvalidTag {
53 value: tag.to_string(),
54 reason: "tag segments must not be empty (no leading, trailing, or consecutive dots)",
55 });
56 }
57 if !segment
58 .chars()
59 .all(|c| c.is_ascii_alphanumeric() || c == '-')
60 {
61 return Err(InvalidTag {
62 value: tag.to_string(),
63 reason: "tag segments may only contain alphanumeric characters and hyphens",
64 });
65 }
66 }
67 Ok(())
68}
69
70pub fn validate_filter_pattern(pattern: &str) -> Result<(), InvalidTag> {
77 if let Some(prefix) = pattern.strip_suffix(".*") {
78 validate_stored_tag(prefix).map_err(|e| InvalidTag {
80 value: pattern.to_string(),
81 reason: e.reason,
82 })
83 } else {
84 validate_stored_tag(pattern)
85 }
86}
87
88pub fn dedup_tags(tags: Vec<String>) -> Result<Vec<String>> {
92 let mut seen = HashSet::new();
93 let mut result = Vec::new();
94 for tag in tags {
95 validate_stored_tag(&tag).map_err(anyhow::Error::from)?;
96 if seen.insert(tag.clone()) {
97 result.push(tag);
98 }
99 }
100 Ok(result)
101}
102
103pub fn matches_all_patterns(job_tags: &[String], patterns: &[String]) -> bool {
107 patterns.iter().all(|pattern| {
108 if let Some(prefix) = pattern.strip_suffix(".*") {
109 job_tags
111 .iter()
112 .any(|t| t == prefix || t.starts_with(&format!("{prefix}.")))
113 } else {
114 job_tags.iter().any(|t| t == pattern)
116 }
117 })
118}
119
120pub struct TagOpts<'a> {
122 pub root: Option<&'a str>,
123 pub job_id: &'a str,
124 pub tags: Vec<String>,
125}
126
127pub fn execute(opts: TagOpts) -> Result<()> {
129 let root = resolve_root(opts.root);
130 let job_dir = JobDir::open(&root, opts.job_id)?;
131
132 let new_tags = dedup_tags(opts.tags)?;
134
135 let mut meta = job_dir.read_meta()?;
137 meta.tags = new_tags.clone();
138 job_dir.write_meta_atomic(&meta)?;
139
140 let response = Response::new(
141 "tag_set",
142 TagSetData {
143 job_id: opts.job_id.to_string(),
144 tags: new_tags,
145 },
146 );
147 response.print();
148 Ok(())
149}