use crate::api::{DiscourseClient, TagGroupInfo};
use crate::cli::ListFormat;
use crate::commands::common::{ensure_api_credentials, not_found, select_discourse};
use crate::config::Config;
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, BTreeSet};
use std::fs;
use std::path::Path;
pub fn tag_list(config: &Config, discourse_name: &str, format: ListFormat) -> Result<()> {
let discourse = select_discourse(config, Some(discourse_name))?;
ensure_api_credentials(discourse)?;
let client = DiscourseClient::new(discourse)?;
let mut tags = client.list_tags()?;
tags.sort_by(|a, b| a.text.cmp(&b.text));
match format {
ListFormat::Text => {
if tags.is_empty() {
println!("No tags found.");
return Ok(());
}
let name_width = tags.iter().map(|t| t.text.len()).max().unwrap_or(0).max(4);
for tag in &tags {
println!("{:<width$} {}", tag.text, tag.count, width = name_width);
}
}
ListFormat::Json => {
println!("{}", serde_json::to_string_pretty(&tags)?);
}
ListFormat::Yaml => {
println!("{}", serde_yaml::to_string(&tags)?);
}
}
Ok(())
}
pub fn tag_apply(
config: &Config,
discourse_name: &str,
topic_id: u64,
tag: &str,
dry_run: bool,
) -> Result<()> {
let discourse = select_discourse(config, Some(discourse_name))?;
ensure_api_credentials(discourse)?;
let client = DiscourseClient::new(discourse)?;
let current = client.fetch_topic_tags(topic_id)?;
let Some(next) = next_tags_after_apply(¤t, tag) else {
println!("Topic {} already tagged '{}'", topic_id, tag);
return Ok(());
};
if dry_run {
println!(
"[dry-run] would set tags on topic {} to: [{}]",
topic_id,
next.join(", ")
);
return Ok(());
}
let after = client.set_topic_tags(topic_id, &next)?;
println!("Topic {} tags: [{}]", topic_id, after.join(", "));
Ok(())
}
fn next_tags_after_apply(current: &[String], tag: &str) -> Option<Vec<String>> {
if current.iter().any(|t| t == tag) {
return None;
}
let mut next = current.to_vec();
next.push(tag.to_string());
Some(next)
}
fn next_tags_after_remove(current: &[String], tag: &str) -> Option<Vec<String>> {
if !current.iter().any(|t| t == tag) {
return None;
}
Some(current.iter().filter(|t| *t != tag).cloned().collect())
}
pub fn tag_remove(
config: &Config,
discourse_name: &str,
topic_id: u64,
tag: &str,
dry_run: bool,
) -> Result<()> {
let discourse = select_discourse(config, Some(discourse_name))?;
ensure_api_credentials(discourse)?;
let client = DiscourseClient::new(discourse)?;
let current = client.fetch_topic_tags(topic_id)?;
let Some(next) = next_tags_after_remove(¤t, tag) else {
println!("Topic {} does not have tag '{}'", topic_id, tag);
return Ok(());
};
if dry_run {
println!(
"[dry-run] would set tags on topic {} to: [{}]",
topic_id,
next.join(", ")
);
return Ok(());
}
let after = client.set_topic_tags(topic_id, &next)?;
println!("Topic {} tags: [{}]", topic_id, after.join(", "));
Ok(())
}
pub fn tag_rename(
config: &Config,
discourse_name: &str,
old_name: &str,
new_name: &str,
dry_run: bool,
) -> Result<()> {
let (old_norm, new_norm) = validate_rename_names(old_name, new_name)?;
let discourse = select_discourse(config, Some(discourse_name))?;
ensure_api_credentials(discourse)?;
let client = DiscourseClient::new(discourse)?;
let tags = client.list_tags()?;
if !tags.iter().any(|t| t.text == old_norm) {
return Err(not_found("tag", &old_norm));
}
if tags.iter().any(|t| t.text == new_norm) {
return Err(anyhow::anyhow!(
"cannot rename to '{}': a tag with that name already exists on '{}' (would merge; not supported)",
new_norm,
discourse_name
));
}
if dry_run {
println!(
"[dry-run] would rename tag '{}' -> '{}' on '{}'",
old_norm, new_norm, discourse_name
);
return Ok(());
}
client.rename_tag(&old_norm, &new_norm)?;
println!("Renamed tag '{}' -> '{}'", old_norm, new_norm);
Ok(())
}
fn validate_rename_names(old: &str, new: &str) -> Result<(String, String)> {
let old_t = old.trim();
let new_t = new.trim();
if old_t.is_empty() {
return Err(anyhow::anyhow!("old tag name is empty"));
}
if new_t.is_empty() {
return Err(anyhow::anyhow!("new tag name is empty"));
}
if old_t == new_t {
return Err(anyhow::anyhow!(
"old and new tag names are identical: '{}'",
old_t
));
}
if new_t.chars().any(|c| c.is_whitespace()) {
return Err(anyhow::anyhow!(
"new tag name '{}' contains whitespace; Discourse tags must be slug-style",
new_t
));
}
Ok((old_t.to_string(), new_t.to_string()))
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct TaxonomyFile {
pub version: u32,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub tags: Vec<TagEntry>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub tag_groups: Vec<TagGroupEntry>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct TagEntry {
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct TagGroupEntry {
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(default, skip_serializing_if = "is_false")]
pub one_per_topic: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub parent_tag: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub permissions: Option<BTreeMap<String, String>>,
#[serde(default)]
pub tags: Vec<String>,
}
fn is_false(v: &bool) -> bool {
!v
}
pub fn tag_pull(config: &Config, discourse_name: &str, local_path: &Path) -> Result<()> {
let discourse = select_discourse(config, Some(discourse_name))?;
ensure_api_credentials(discourse)?;
let client = DiscourseClient::new(discourse)?;
let server_tags = client.list_tags()?;
let mut tag_entries: Vec<TagEntry> = Vec::new();
for t in &server_tags {
let description = client.get_tag_description(&t.text).unwrap_or(None);
tag_entries.push(TagEntry {
name: t.text.clone(),
description,
});
}
tag_entries.sort_by(|a, b| a.name.cmp(&b.name));
let tag_groups = match client.list_tag_groups()? {
Some(groups) => {
let mut entries: Vec<TagGroupEntry> = groups
.into_iter()
.map(|g| {
let permissions = g.permissions.and_then(|p| {
parse_tag_group_permissions(&p)
});
let mut tags = g.tag_names;
tags.sort();
TagGroupEntry {
name: g.name,
description: None, one_per_topic: g.one_per_topic,
parent_tag: g.parent_tag_name,
permissions,
tags,
}
})
.collect();
entries.sort_by(|a, b| a.name.cmp(&b.name));
entries
}
None => {
eprintln!(
"Warning: tag groups not accessible (requires admin API key); omitting from output."
);
Vec::new()
}
};
let taxonomy = TaxonomyFile {
version: 1,
tags: tag_entries,
tag_groups,
};
let content = if is_json_path(local_path) {
serde_json::to_string_pretty(&taxonomy).context("serializing taxonomy as JSON")?
} else {
serde_yaml::to_string(&taxonomy).context("serializing taxonomy as YAML")?
};
fs::write(local_path, &content).with_context(|| format!("writing {}", local_path.display()))?;
println!("Wrote taxonomy to {}", local_path.display());
Ok(())
}
fn parse_tag_group_permissions(value: &serde_json::Value) -> Option<BTreeMap<String, String>> {
let obj = value.as_object()?;
if obj.is_empty() {
return None;
}
let mut map = BTreeMap::new();
for (group, level) in obj {
let level_str = match level.as_u64() {
Some(1) => "full".to_string(),
Some(3) => "readonly".to_string(),
Some(n) => n.to_string(),
None => level.as_str().unwrap_or("full").to_string(),
};
map.insert(group.clone(), level_str);
}
Some(map)
}
fn is_json_path(p: &Path) -> bool {
p.extension()
.and_then(|e| e.to_str())
.map(|e| e.eq_ignore_ascii_case("json"))
.unwrap_or(false)
}
#[derive(Debug, Default, PartialEq)]
struct TagPlan {
created_via_group: Vec<String>,
set_description: Vec<(String, String)>,
groupless_missing: Vec<String>,
to_delete: Vec<String>,
}
impl TagPlan {
fn is_empty(&self) -> bool {
self.created_via_group.is_empty()
&& self.set_description.is_empty()
&& self.groupless_missing.is_empty()
&& self.to_delete.is_empty()
}
}
fn plan_tags(
explicit: &BTreeMap<String, Option<String>>,
group_tags: &BTreeSet<String>,
server_tags: &BTreeSet<String>,
prune: bool,
) -> TagPlan {
let will_exist: BTreeSet<String> = server_tags.iter().chain(group_tags).cloned().collect();
let created_via_group: Vec<String> = group_tags.difference(server_tags).cloned().collect();
let mut set_description: Vec<(String, String)> = explicit
.iter()
.filter_map(|(name, desc)| match desc {
Some(d) if will_exist.contains(name) => Some((name.clone(), d.clone())),
_ => None,
})
.collect();
set_description.sort();
let groupless_missing: Vec<String> = explicit
.keys()
.filter(|name| !group_tags.contains(*name) && !server_tags.contains(*name))
.cloned()
.collect();
let to_delete: Vec<String> = if prune {
let desired: BTreeSet<&String> = explicit.keys().chain(group_tags).collect();
server_tags
.iter()
.filter(|t| !desired.contains(t))
.cloned()
.collect()
} else {
Vec::new()
};
TagPlan {
created_via_group,
set_description,
groupless_missing,
to_delete,
}
}
pub fn tag_push(
config: &Config,
discourse_name: &str,
local_path: &Path,
prune: bool,
dry_run: bool,
) -> Result<()> {
let discourse = select_discourse(config, Some(discourse_name))?;
ensure_api_credentials(discourse)?;
let client = DiscourseClient::new(discourse)?;
let content = fs::read_to_string(local_path)
.with_context(|| format!("reading {}", local_path.display()))?;
let taxonomy: TaxonomyFile = if is_json_path(local_path) {
serde_json::from_str(&content).context("parsing taxonomy JSON")?
} else {
serde_yaml::from_str(&content).context("parsing taxonomy YAML")?
};
if taxonomy.version != 1 {
anyhow::bail!("unsupported taxonomy file version: {}", taxonomy.version);
}
let explicit: BTreeMap<String, Option<String>> = taxonomy
.tags
.iter()
.map(|t| (t.name.clone(), t.description.clone()))
.collect();
let group_tags: BTreeSet<String> = taxonomy
.tag_groups
.iter()
.flat_map(|g| g.tags.clone())
.collect();
let server_tag_names: BTreeSet<String> =
client.list_tags()?.into_iter().map(|t| t.text).collect();
let server_groups = client.list_tag_groups()?;
let groups_available = server_groups.is_some();
if !groups_available && !taxonomy.tag_groups.is_empty() {
eprintln!(
"Warning: tag groups not accessible (requires admin API key); groups in the file cannot be reconciled and their tags cannot be created."
);
}
let effective_group_tags: BTreeSet<String> = if groups_available {
group_tags.clone()
} else {
BTreeSet::new()
};
let server_groups = server_groups.unwrap_or_default();
let tag_plan = plan_tags(&explicit, &effective_group_tags, &server_tag_names, prune);
let server_groups_by_name: BTreeMap<String, &TagGroupInfo> =
server_groups.iter().map(|g| (g.name.clone(), g)).collect();
let desired_group_names: BTreeSet<String> =
taxonomy.tag_groups.iter().map(|g| g.name.clone()).collect();
let server_group_names: BTreeSet<String> =
server_groups.iter().map(|g| g.name.clone()).collect();
let groups_to_create: Vec<&TagGroupEntry> = if groups_available {
taxonomy
.tag_groups
.iter()
.filter(|g| !server_group_names.contains(&g.name))
.collect()
} else {
Vec::new()
};
let groups_to_update: Vec<(&TagGroupEntry, u64)> = if groups_available {
taxonomy
.tag_groups
.iter()
.filter_map(|g| server_groups_by_name.get(&g.name).map(|sg| (g, sg.id)))
.filter(|(desired, _id)| {
let server = server_groups_by_name.get(&desired.name).unwrap();
let mut server_tags = server.tag_names.clone();
server_tags.sort();
let mut desired_tags = desired.tags.clone();
desired_tags.sort();
server_tags != desired_tags
|| server.one_per_topic != desired.one_per_topic
|| server.parent_tag_name != desired.parent_tag
})
.collect()
} else {
Vec::new()
};
let groups_to_delete: Vec<(&str, u64)> = if prune && groups_available {
server_groups
.iter()
.filter(|g| !desired_group_names.contains(&g.name))
.map(|g| (g.name.as_str(), g.id))
.collect()
} else {
Vec::new()
};
if dry_run {
println!("[dry-run] Tag group plan:");
if groups_to_create.is_empty() && groups_to_update.is_empty() && groups_to_delete.is_empty()
{
println!(" (no group changes)");
}
for g in &groups_to_create {
println!(
" + create group: {} (tags: [{}])",
g.name,
g.tags.join(", ")
);
}
for (g, _id) in &groups_to_update {
println!(
" ~ update group: {} (tags: [{}])",
g.name,
g.tags.join(", ")
);
}
for (name, _id) in &groups_to_delete {
println!(" - delete group: {}", name);
}
println!("[dry-run] Tag plan:");
if tag_plan.is_empty() {
println!(" (no tag changes)");
}
for name in &tag_plan.created_via_group {
println!(" + create tag: {} (via its tag group)", name);
}
for (name, desc) in &tag_plan.set_description {
println!(" ~ set description: {} ({:?})", name, desc);
}
for name in &tag_plan.groupless_missing {
println!(
" ! cannot create tag: {} (Discourse has no create-tag API; add it to a tag group or create it by tagging a topic)",
name
);
}
for name in &tag_plan.to_delete {
println!(" - delete tag: {}", name);
}
println!("[dry-run] No changes applied.");
return Ok(());
}
for g in &groups_to_create {
let payload = build_tag_group_payload(g);
client
.create_tag_group(&payload)
.with_context(|| format!("creating tag group '{}'", g.name))?;
println!(" + created group: {}", g.name);
}
for (g, id) in &groups_to_update {
let payload = build_tag_group_payload(g);
client
.update_tag_group(*id, &payload)
.with_context(|| format!("updating tag group '{}'", g.name))?;
println!(" ~ updated group: {}", g.name);
}
let now_existing: BTreeSet<String> = client.list_tags()?.into_iter().map(|t| t.text).collect();
for name in &tag_plan.created_via_group {
if now_existing.contains(name) {
println!(" + created tag: {} (via its tag group)", name);
}
}
for (name, desc) in &tag_plan.set_description {
if now_existing.contains(name) {
client
.update_tag(name, Some(desc))
.with_context(|| format!("setting description on tag '{}'", name))?;
println!(" ~ set description: {}", name);
}
}
for name in &tag_plan.to_delete {
client
.delete_tag(name)
.with_context(|| format!("deleting tag '{}'", name))?;
println!(" - deleted tag: {}", name);
}
for (name, id) in &groups_to_delete {
client
.delete_tag_group(*id)
.with_context(|| format!("deleting tag group '{}'", name))?;
println!(" - deleted group: {}", name);
}
if !tag_plan.groupless_missing.is_empty() {
anyhow::bail!(
"these tags are in no tag group and do not exist on '{}', and Discourse has no admin create-tag endpoint, so they were not created: {}. Add them to a tag group in the file, or create them by tagging a topic.",
discourse_name,
tag_plan.groupless_missing.join(", ")
);
}
println!("Push complete.");
Ok(())
}
fn build_tag_group_payload(entry: &TagGroupEntry) -> serde_json::Value {
let mut group = serde_json::Map::new();
group.insert("name".to_string(), serde_json::json!(entry.name));
group.insert("tag_names".to_string(), serde_json::json!(entry.tags));
group.insert(
"one_per_topic".to_string(),
serde_json::json!(entry.one_per_topic),
);
if let Some(parent) = &entry.parent_tag {
group.insert("parent_tag_name".to_string(), serde_json::json!([parent]));
}
if let Some(perms) = &entry.permissions {
let perm_map: BTreeMap<&String, u64> = perms
.iter()
.map(|(k, v)| {
let level = match v.as_str() {
"full" => 1,
"readonly" => 3,
_ => v.parse().unwrap_or(1),
};
(k, level)
})
.collect();
group.insert("permissions".to_string(), serde_json::json!(perm_map));
}
serde_json::json!({ "tag_group": group })
}
#[cfg(test)]
mod tests {
use super::{next_tags_after_apply, next_tags_after_remove, plan_tags, validate_rename_names};
use std::collections::{BTreeMap, BTreeSet};
fn s(items: &[&str]) -> Vec<String> {
items.iter().map(|x| x.to_string()).collect()
}
fn set(items: &[&str]) -> BTreeSet<String> {
items.iter().map(|x| x.to_string()).collect()
}
fn explicit(pairs: &[(&str, Option<&str>)]) -> BTreeMap<String, Option<String>> {
pairs
.iter()
.map(|(n, d)| (n.to_string(), d.map(|s| s.to_string())))
.collect()
}
#[test]
fn apply_adds_when_absent() {
let got = next_tags_after_apply(&s(&["foo", "bar"]), "baz").unwrap();
assert_eq!(got, s(&["foo", "bar", "baz"]));
}
#[test]
fn apply_returns_none_when_already_present() {
assert!(next_tags_after_apply(&s(&["foo", "bar"]), "foo").is_none());
}
#[test]
fn apply_to_empty_list_works() {
let got = next_tags_after_apply(&s(&[]), "first").unwrap();
assert_eq!(got, s(&["first"]));
}
#[test]
fn remove_drops_present_tag() {
let got = next_tags_after_remove(&s(&["foo", "bar", "baz"]), "bar").unwrap();
assert_eq!(got, s(&["foo", "baz"]));
}
#[test]
fn remove_returns_none_when_absent() {
assert!(next_tags_after_remove(&s(&["foo", "bar"]), "baz").is_none());
}
#[test]
fn remove_last_tag_leaves_empty_list() {
let got = next_tags_after_remove(&s(&["only"]), "only").unwrap();
assert!(got.is_empty());
}
#[test]
fn apply_is_case_sensitive() {
let got = next_tags_after_apply(&s(&["Foo"]), "foo").unwrap();
assert_eq!(got, s(&["Foo", "foo"]));
}
#[test]
fn rename_trims_inputs() {
let (old, new) = validate_rename_names(" foo ", " bar ").unwrap();
assert_eq!(old, "foo");
assert_eq!(new, "bar");
}
#[test]
fn rename_rejects_empty_old() {
assert!(validate_rename_names("", "bar").is_err());
assert!(validate_rename_names(" ", "bar").is_err());
}
#[test]
fn rename_rejects_empty_new() {
assert!(validate_rename_names("foo", "").is_err());
assert!(validate_rename_names("foo", " ").is_err());
}
#[test]
fn rename_rejects_identical_names() {
let err = validate_rename_names("foo", "foo").unwrap_err();
assert!(err.to_string().contains("identical"));
}
#[test]
fn rename_rejects_whitespace_in_new_name() {
let err = validate_rename_names("foo", "bar baz").unwrap_err();
assert!(err.to_string().contains("whitespace"));
}
#[test]
fn rename_treats_trim_only_difference_as_identical() {
let err = validate_rename_names("foo ", "foo").unwrap_err();
assert!(err.to_string().contains("identical"));
}
#[test]
fn plan_group_tag_absent_is_created_via_group() {
let p = plan_tags(
&explicit(&[]),
&set(&["acoustic", "jazz"]),
&set(&["jazz"]),
false,
);
assert_eq!(p.created_via_group, s(&["acoustic"]));
assert!(p.groupless_missing.is_empty());
}
#[test]
fn plan_groupless_missing_is_reported_not_created() {
let p = plan_tags(&explicit(&[("orphan", None)]), &set(&[]), &set(&[]), false);
assert_eq!(p.groupless_missing, s(&["orphan"]));
assert!(p.created_via_group.is_empty());
assert!(p.set_description.is_empty());
}
#[test]
fn plan_sets_description_for_group_created_tag() {
let p = plan_tags(
&explicit(&[("jazz", Some("Jazz music"))]),
&set(&["jazz"]),
&set(&[]),
false,
);
assert_eq!(
p.set_description,
vec![("jazz".to_string(), "Jazz music".to_string())]
);
assert!(p.groupless_missing.is_empty());
}
#[test]
fn plan_no_description_set_for_uncreatable_orphan() {
let p = plan_tags(
&explicit(&[("orphan", Some("x"))]),
&set(&[]),
&set(&[]),
false,
);
assert!(p.set_description.is_empty());
assert_eq!(p.groupless_missing, s(&["orphan"]));
}
#[test]
fn plan_sets_description_for_existing_server_tag() {
let p = plan_tags(
&explicit(&[("rock", Some("Rock"))]),
&set(&[]),
&set(&["rock"]),
false,
);
assert_eq!(
p.set_description,
vec![("rock".to_string(), "Rock".to_string())]
);
assert!(p.groupless_missing.is_empty());
}
#[test]
fn plan_prune_deletes_undesired_server_tags() {
let p = plan_tags(
&explicit(&[("keep", None)]),
&set(&[]),
&set(&["keep", "old"]),
true,
);
assert_eq!(p.to_delete, s(&["old"]));
}
#[test]
fn plan_without_prune_deletes_nothing() {
let p = plan_tags(&explicit(&[]), &set(&[]), &set(&["old"]), false);
assert!(p.to_delete.is_empty());
}
#[test]
fn plan_group_tag_still_desired_is_not_pruned() {
let p = plan_tags(&explicit(&[]), &set(&["jazz"]), &set(&["jazz"]), true);
assert!(p.to_delete.is_empty());
}
#[test]
fn plan_no_group_access_makes_group_only_explicit_tags_orphans() {
let p = plan_tags(&explicit(&[("jazz", None)]), &set(&[]), &set(&[]), false);
assert_eq!(p.groupless_missing, s(&["jazz"]));
}
}