use crate::OutputFormat;
use crate::config::Config;
use crate::diagnostic::{Diagnostic, DiagnosticCode};
use crate::load::load_rfcs;
use crate::parse::{load_adrs, load_guards_with_warnings, load_work_items};
use anyhow::{Context, Result};
use comfy_table::{Attribute, Cell, ContentArrangement, Table, presets::UTF8_FULL};
use regex::Regex;
use serde::Serialize;
use std::sync::LazyLock;
static TAG_RE_RESULT: LazyLock<Result<Regex, regex::Error>> =
LazyLock::new(|| Regex::new(r"^[a-z][a-z0-9-]*$"));
pub fn tag_re() -> Result<&'static Regex, regex::Error> {
TAG_RE_RESULT.as_ref().map_err(|e| e.clone())
}
fn validate_tag_format(tag: &str) -> Result<()> {
let re = tag_re().map_err(|e| {
Diagnostic::new(
DiagnosticCode::E0806InvalidPattern,
format!("Failed to compile tag regex: {e}"),
"",
)
})?;
if !re.is_match(tag) {
return Err(Diagnostic::new(
DiagnosticCode::E1101TagInvalidFormat,
format!(
"Invalid tag format '{tag}': tags must match ^[a-z][a-z0-9-]*$ (lowercase letters, digits, hyphens; start with a letter)"
),
tag,
)
.into());
}
Ok(())
}
fn read_config_table(config: &Config) -> Result<toml::Table> {
let config_path = config.gov_root.join("config.toml");
let content = std::fs::read_to_string(&config_path)
.with_context(|| format!("Failed to read config: {}", config_path.display()))?;
toml::from_str::<toml::Table>(&content)
.with_context(|| format!("Failed to parse config: {}", config_path.display()))
}
fn write_config_table(config: &Config, table: &toml::Table) -> Result<()> {
let config_path = config.gov_root.join("config.toml");
let content = toml::to_string_pretty(table).with_context(|| "Failed to serialize config")?;
std::fs::write(&config_path, content)
.with_context(|| format!("Failed to write config: {}", config_path.display()))?;
Ok(())
}
fn get_allowed_tags(table: &toml::Table) -> Result<Vec<String>> {
let Some(tags_val) = table.get("tags") else {
return Ok(vec![]);
};
let tags_table = tags_val.as_table().ok_or_else(|| {
Diagnostic::new(
DiagnosticCode::E0501ConfigInvalid,
"'tags' in config.toml must be a table",
"gov/config.toml",
)
})?;
let Some(allowed_val) = tags_table.get("allowed") else {
return Ok(vec![]);
};
let arr = allowed_val.as_array().ok_or_else(|| {
Diagnostic::new(
DiagnosticCode::E0501ConfigInvalid,
"'tags.allowed' in config.toml must be an array",
"gov/config.toml",
)
})?;
let mut tags = Vec::new();
for item in arr {
let s = item.as_str().ok_or_else(|| {
Diagnostic::new(
DiagnosticCode::E0501ConfigInvalid,
"'tags.allowed' items must be strings",
"gov/config.toml",
)
})?;
tags.push(s.to_string());
}
Ok(tags)
}
fn set_allowed_tags(table: &mut toml::Table, tags: Vec<String>) -> Result<()> {
let arr: toml::value::Array = tags.into_iter().map(toml::Value::String).collect();
let tags_table = table
.entry("tags")
.or_insert_with(|| toml::Value::Table(toml::Table::new()))
.as_table_mut()
.ok_or_else(|| {
Diagnostic::new(
DiagnosticCode::E0501ConfigInvalid,
"'tags' in config.toml must be a table",
"gov/config.toml",
)
})?;
tags_table.insert("allowed".to_string(), toml::Value::Array(arr));
Ok(())
}
fn count_tag_usage(config: &Config, tag: &str) -> Result<usize> {
let mut count = 0;
let rfcs = load_rfcs(config).map_err(Diagnostic::from)?;
for rfc_index in &rfcs {
if rfc_index.rfc.tags.iter().any(|t| t == tag) {
count += 1;
}
for clause in &rfc_index.clauses {
if clause.spec.tags.iter().any(|t| t == tag) {
count += 1;
}
}
}
let adrs = load_adrs(config)?;
for adr in &adrs {
if adr.spec.govctl.tags.iter().any(|t| t == tag) {
count += 1;
}
}
let items = load_work_items(config)?;
for item in &items {
if item.spec.govctl.tags.iter().any(|t| t == tag) {
count += 1;
}
}
let guard_result = load_guards_with_warnings(config)?;
for guard in &guard_result.items {
if guard.spec.govctl.tags.iter().any(|t| t == tag) {
count += 1;
}
}
Ok(count)
}
pub fn tag_new(config: &Config, tag: &str, op: crate::write::WriteOp) -> Result<Vec<Diagnostic>> {
validate_tag_format(tag)?;
let mut table = read_config_table(config)?;
let mut allowed = get_allowed_tags(&table)?;
if allowed.contains(&tag.to_string()) {
return Err(Diagnostic::new(
DiagnosticCode::E1102TagAlreadyExists,
format!("Tag '{tag}' already exists in [tags] allowed"),
tag,
)
.into());
}
allowed.push(tag.to_string());
set_allowed_tags(&mut table, allowed)?;
if !op.is_preview() {
write_config_table(config, &table)?;
println!("Added tag: {tag}");
} else {
println!("Would add tag: {tag}");
}
Ok(vec![])
}
pub fn tag_delete(
config: &Config,
tag: &str,
op: crate::write::WriteOp,
) -> Result<Vec<Diagnostic>> {
let mut table = read_config_table(config)?;
let allowed = get_allowed_tags(&table)?;
if !allowed.contains(&tag.to_string()) {
return Err(Diagnostic::new(
DiagnosticCode::E1103TagNotFound,
format!("Tag '{tag}' not found in [tags] allowed"),
tag,
)
.into());
}
let usage = count_tag_usage(config, tag)?;
if usage > 0 {
return Err(Diagnostic::new(
DiagnosticCode::E1104TagStillReferenced,
format!(
"Cannot delete tag '{tag}': still used by {usage} artifact(s). Remove the tag from all artifacts first."
),
tag,
)
.into());
}
let new_allowed: Vec<String> = allowed.into_iter().filter(|t| t != tag).collect();
set_allowed_tags(&mut table, new_allowed)?;
if !op.is_preview() {
write_config_table(config, &table)?;
println!("Deleted tag: {tag}");
} else {
println!("Would delete tag: {tag}");
}
Ok(vec![])
}
pub fn tag_list(config: &Config, output: OutputFormat) -> Result<Vec<Diagnostic>> {
let table = read_config_table(config)?;
let allowed = get_allowed_tags(&table)?;
#[derive(Serialize)]
struct TagEntry {
tag: String,
usage: usize,
}
let entries: Vec<TagEntry> = allowed
.iter()
.map(|tag| {
let usage = count_tag_usage(config, tag).unwrap_or(0);
TagEntry {
tag: tag.clone(),
usage,
}
})
.collect();
match output {
OutputFormat::Json => {
println!(
"{}",
serde_json::to_string_pretty(&entries).unwrap_or_else(|_| "[]".to_string())
);
}
OutputFormat::Plain => {
for e in &entries {
println!("{}\t{}", e.tag, e.usage);
}
}
OutputFormat::Table => {
let mut table_out = Table::new();
table_out
.load_preset(UTF8_FULL)
.set_content_arrangement(ContentArrangement::Dynamic)
.set_header(vec![
Cell::new("Tag").add_attribute(Attribute::Bold),
Cell::new("Usage").add_attribute(Attribute::Bold),
]);
for e in &entries {
table_out.add_row(vec![Cell::new(&e.tag), Cell::new(e.usage.to_string())]);
}
println!("{table_out}");
}
}
Ok(vec![])
}