Skip to main content

agent_exec/
tag.rs

1//! Implementation of the `tag` sub-command and shared tag utilities.
2//!
3//! Tag format rules:
4//! - A stored tag is a non-empty dot-separated sequence of segments.
5//! - Each segment consists of alphanumeric characters and hyphens only.
6//! - Tags may NOT end with `.*` (that syntax is reserved for list filter patterns).
7//! - A list filter pattern is either an exact stored tag, or a stored-tag prefix
8//!   terminated with `.*` (e.g. `hoge.*`, `hoge.fuga.*`).
9
10use anyhow::Result;
11use std::collections::HashSet;
12use std::fmt;
13
14use crate::jobstore::{JobDir, resolve_root};
15use crate::schema::{Response, TagSetData};
16
17/// Error type for invalid tag values or filter patterns.
18#[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
32/// Validate a stored tag value.
33///
34/// A valid stored tag is a non-empty dot-separated sequence of segments where
35/// each segment contains only alphanumeric characters and hyphens.
36/// The `.*` suffix is not allowed in stored tags.
37pub 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
70/// Validate a list filter pattern.
71///
72/// Valid patterns are:
73/// - An exact stored tag (validated by `validate_stored_tag`).
74/// - A namespace prefix pattern ending in `.*` where the prefix before `.*`
75///   is itself a valid stored tag.
76pub fn validate_filter_pattern(pattern: &str) -> Result<(), InvalidTag> {
77    if let Some(prefix) = pattern.strip_suffix(".*") {
78        // Validate the prefix part as a stored tag.
79        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
88/// Deduplicate tags, preserving first-seen order, and validate each one.
89///
90/// Returns an error if any tag is invalid.
91pub 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
103/// Check whether a job's tags satisfy all filter patterns (logical AND).
104///
105/// Returns true when every pattern matches at least one tag in `job_tags`.
106pub fn matches_all_patterns(job_tags: &[String], patterns: &[String]) -> bool {
107    patterns.iter().all(|pattern| {
108        if let Some(prefix) = pattern.strip_suffix(".*") {
109            // Namespace prefix match: at least one tag starts with "prefix."
110            job_tags
111                .iter()
112                .any(|t| t == prefix || t.starts_with(&format!("{prefix}.")))
113        } else {
114            // Exact match.
115            job_tags.iter().any(|t| t == pattern)
116        }
117    })
118}
119
120/// Options for the `tag set` sub-command.
121pub struct TagOpts<'a> {
122    pub root: Option<&'a str>,
123    pub job_id: &'a str,
124    pub tags: Vec<String>,
125}
126
127/// Execute `tag set`: replace tags on an existing job's meta.json atomically.
128pub fn execute(opts: TagOpts) -> Result<()> {
129    let root = resolve_root(opts.root);
130    let job_dir = JobDir::open(&root, opts.job_id)?;
131
132    // Validate and deduplicate the requested tags.
133    let new_tags = dedup_tags(opts.tags)?;
134
135    // Load the existing meta.json, update tags, and write back atomically.
136    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: job_dir.job_id.clone(),
144            tags: new_tags,
145        },
146    );
147    response.print();
148    Ok(())
149}