1use crate::api::{DiscourseClient, TagGroupInfo};
2use crate::cli::ListFormat;
3use crate::commands::common::{ensure_api_credentials, not_found, select_discourse};
4use crate::config::Config;
5use anyhow::{Context, Result};
6use serde::{Deserialize, Serialize};
7use std::collections::{BTreeMap, BTreeSet};
8use std::fs;
9use std::path::Path;
10
11pub fn tag_list(config: &Config, discourse_name: &str, format: ListFormat) -> Result<()> {
12 let discourse = select_discourse(config, Some(discourse_name))?;
13 ensure_api_credentials(discourse)?;
14 let client = DiscourseClient::new(discourse)?;
15
16 let mut tags = client.list_tags()?;
17 tags.sort_by(|a, b| a.text.cmp(&b.text));
18
19 match format {
20 ListFormat::Text => {
21 if tags.is_empty() {
22 println!("No tags found.");
23 return Ok(());
24 }
25 let name_width = tags.iter().map(|t| t.text.len()).max().unwrap_or(0).max(4);
26 for tag in &tags {
27 println!("{:<width$} {}", tag.text, tag.count, width = name_width);
28 }
29 }
30 ListFormat::Json => {
31 println!("{}", serde_json::to_string_pretty(&tags)?);
32 }
33 ListFormat::Yaml => {
34 println!("{}", serde_yaml::to_string(&tags)?);
35 }
36 }
37
38 Ok(())
39}
40
41pub fn tag_apply(
42 config: &Config,
43 discourse_name: &str,
44 topic_id: u64,
45 tag: &str,
46 dry_run: bool,
47) -> Result<()> {
48 let discourse = select_discourse(config, Some(discourse_name))?;
49 ensure_api_credentials(discourse)?;
50 let client = DiscourseClient::new(discourse)?;
51
52 let current = client.fetch_topic_tags(topic_id)?;
53 let Some(next) = next_tags_after_apply(¤t, tag) else {
54 println!("Topic {} already tagged '{}'", topic_id, tag);
55 return Ok(());
56 };
57 if dry_run {
58 println!(
59 "[dry-run] would set tags on topic {} to: [{}]",
60 topic_id,
61 next.join(", ")
62 );
63 return Ok(());
64 }
65 let after = client.set_topic_tags(topic_id, &next)?;
66 println!("Topic {} tags: [{}]", topic_id, after.join(", "));
67 Ok(())
68}
69
70fn next_tags_after_apply(current: &[String], tag: &str) -> Option<Vec<String>> {
73 if current.iter().any(|t| t == tag) {
74 return None;
75 }
76 let mut next = current.to_vec();
77 next.push(tag.to_string());
78 Some(next)
79}
80
81fn next_tags_after_remove(current: &[String], tag: &str) -> Option<Vec<String>> {
84 if !current.iter().any(|t| t == tag) {
85 return None;
86 }
87 Some(current.iter().filter(|t| *t != tag).cloned().collect())
88}
89
90pub fn tag_remove(
91 config: &Config,
92 discourse_name: &str,
93 topic_id: u64,
94 tag: &str,
95 dry_run: bool,
96) -> Result<()> {
97 let discourse = select_discourse(config, Some(discourse_name))?;
98 ensure_api_credentials(discourse)?;
99 let client = DiscourseClient::new(discourse)?;
100
101 let current = client.fetch_topic_tags(topic_id)?;
102 let Some(next) = next_tags_after_remove(¤t, tag) else {
103 println!("Topic {} does not have tag '{}'", topic_id, tag);
104 return Ok(());
105 };
106 if dry_run {
107 println!(
108 "[dry-run] would set tags on topic {} to: [{}]",
109 topic_id,
110 next.join(", ")
111 );
112 return Ok(());
113 }
114 let after = client.set_topic_tags(topic_id, &next)?;
115 println!("Topic {} tags: [{}]", topic_id, after.join(", "));
116 Ok(())
117}
118
119pub fn tag_rename(
125 config: &Config,
126 discourse_name: &str,
127 old_name: &str,
128 new_name: &str,
129 dry_run: bool,
130) -> Result<()> {
131 let (old_norm, new_norm) = validate_rename_names(old_name, new_name)?;
132
133 let discourse = select_discourse(config, Some(discourse_name))?;
134 ensure_api_credentials(discourse)?;
135 let client = DiscourseClient::new(discourse)?;
136
137 let tags = client.list_tags()?;
139 if !tags.iter().any(|t| t.text == old_norm) {
140 return Err(not_found("tag", &old_norm));
141 }
142 if tags.iter().any(|t| t.text == new_norm) {
143 return Err(anyhow::anyhow!(
144 "cannot rename to '{}': a tag with that name already exists on '{}' (would merge; not supported)",
145 new_norm,
146 discourse_name
147 ));
148 }
149
150 if dry_run {
151 println!(
152 "[dry-run] would rename tag '{}' -> '{}' on '{}'",
153 old_norm, new_norm, discourse_name
154 );
155 return Ok(());
156 }
157
158 client.rename_tag(&old_norm, &new_norm)?;
159 println!("Renamed tag '{}' -> '{}'", old_norm, new_norm);
160 Ok(())
161}
162
163fn validate_rename_names(old: &str, new: &str) -> Result<(String, String)> {
166 let old_t = old.trim();
167 let new_t = new.trim();
168 if old_t.is_empty() {
169 return Err(anyhow::anyhow!("old tag name is empty"));
170 }
171 if new_t.is_empty() {
172 return Err(anyhow::anyhow!("new tag name is empty"));
173 }
174 if old_t == new_t {
175 return Err(anyhow::anyhow!(
176 "old and new tag names are identical: '{}'",
177 old_t
178 ));
179 }
180 if new_t.chars().any(|c| c.is_whitespace()) {
181 return Err(anyhow::anyhow!(
182 "new tag name '{}' contains whitespace; Discourse tags must be slug-style",
183 new_t
184 ));
185 }
186 Ok((old_t.to_string(), new_t.to_string()))
187}
188
189#[derive(Debug, Serialize, Deserialize, Clone)]
193pub struct TaxonomyFile {
194 pub version: u32,
195 #[serde(default, skip_serializing_if = "Vec::is_empty")]
196 pub tags: Vec<TagEntry>,
197 #[serde(default, skip_serializing_if = "Vec::is_empty")]
198 pub tag_groups: Vec<TagGroupEntry>,
199}
200
201#[derive(Debug, Serialize, Deserialize, Clone)]
202pub struct TagEntry {
203 pub name: String,
204 #[serde(default, skip_serializing_if = "Option::is_none")]
205 pub description: Option<String>,
206}
207
208#[derive(Debug, Serialize, Deserialize, Clone)]
209pub struct TagGroupEntry {
210 pub name: String,
211 #[serde(default, skip_serializing_if = "Option::is_none")]
212 pub description: Option<String>,
213 #[serde(default, skip_serializing_if = "is_false")]
214 pub one_per_topic: bool,
215 #[serde(default, skip_serializing_if = "Option::is_none")]
216 pub parent_tag: Option<String>,
217 #[serde(default, skip_serializing_if = "Option::is_none")]
218 pub permissions: Option<BTreeMap<String, String>>,
219 #[serde(default)]
220 pub tags: Vec<String>,
221}
222
223fn is_false(v: &bool) -> bool {
224 !v
225}
226
227pub fn tag_pull(config: &Config, discourse_name: &str, local_path: &Path) -> Result<()> {
230 let discourse = select_discourse(config, Some(discourse_name))?;
231 ensure_api_credentials(discourse)?;
232 let client = DiscourseClient::new(discourse)?;
233
234 let server_tags = client.list_tags()?;
235
236 let mut tag_entries: Vec<TagEntry> = Vec::new();
238 for t in &server_tags {
239 let description = client.get_tag_description(&t.text).unwrap_or(None);
240 tag_entries.push(TagEntry {
241 name: t.text.clone(),
242 description,
243 });
244 }
245 tag_entries.sort_by(|a, b| a.name.cmp(&b.name));
246
247 let tag_groups = match client.list_tag_groups()? {
249 Some(groups) => {
250 let mut entries: Vec<TagGroupEntry> = groups
251 .into_iter()
252 .map(|g| {
253 let permissions = g.permissions.and_then(|p| {
254 parse_tag_group_permissions(&p)
257 });
258 let mut tags = g.tag_names;
259 tags.sort();
260 TagGroupEntry {
261 name: g.name,
262 description: None, one_per_topic: g.one_per_topic,
264 parent_tag: g.parent_tag_name,
265 permissions,
266 tags,
267 }
268 })
269 .collect();
270 entries.sort_by(|a, b| a.name.cmp(&b.name));
271 entries
272 }
273 None => {
274 eprintln!(
275 "Warning: tag groups not accessible (requires admin API key); omitting from output."
276 );
277 Vec::new()
278 }
279 };
280
281 let taxonomy = TaxonomyFile {
282 version: 1,
283 tags: tag_entries,
284 tag_groups,
285 };
286
287 let content = if is_json_path(local_path) {
288 serde_json::to_string_pretty(&taxonomy).context("serializing taxonomy as JSON")?
289 } else {
290 serde_yaml::to_string(&taxonomy).context("serializing taxonomy as YAML")?
291 };
292
293 fs::write(local_path, &content).with_context(|| format!("writing {}", local_path.display()))?;
294 println!("Wrote taxonomy to {}", local_path.display());
295 Ok(())
296}
297
298fn parse_tag_group_permissions(value: &serde_json::Value) -> Option<BTreeMap<String, String>> {
299 let obj = value.as_object()?;
302 if obj.is_empty() {
303 return None;
304 }
305 let mut map = BTreeMap::new();
306 for (group, level) in obj {
307 let level_str = match level.as_u64() {
308 Some(1) => "full".to_string(),
309 Some(3) => "readonly".to_string(),
310 Some(n) => n.to_string(),
311 None => level.as_str().unwrap_or("full").to_string(),
312 };
313 map.insert(group.clone(), level_str);
314 }
315 Some(map)
316}
317
318fn is_json_path(p: &Path) -> bool {
319 p.extension()
320 .and_then(|e| e.to_str())
321 .map(|e| e.eq_ignore_ascii_case("json"))
322 .unwrap_or(false)
323}
324
325pub fn tag_push(
328 config: &Config,
329 discourse_name: &str,
330 local_path: &Path,
331 prune: bool,
332 dry_run: bool,
333) -> Result<()> {
334 let discourse = select_discourse(config, Some(discourse_name))?;
335 ensure_api_credentials(discourse)?;
336 let client = DiscourseClient::new(discourse)?;
337
338 let content = fs::read_to_string(local_path)
339 .with_context(|| format!("reading {}", local_path.display()))?;
340 let taxonomy: TaxonomyFile = if is_json_path(local_path) {
341 serde_json::from_str(&content).context("parsing taxonomy JSON")?
342 } else {
343 serde_yaml::from_str(&content).context("parsing taxonomy YAML")?
344 };
345
346 if taxonomy.version != 1 {
347 anyhow::bail!("unsupported taxonomy file version: {}", taxonomy.version);
348 }
349
350 let desired_tags: BTreeSet<String> = taxonomy
352 .tags
353 .iter()
354 .map(|t| t.name.clone())
355 .chain(taxonomy.tag_groups.iter().flat_map(|g| g.tags.clone()))
356 .collect();
357
358 let desired_descriptions: BTreeMap<String, Option<String>> = taxonomy
360 .tags
361 .iter()
362 .map(|t| (t.name.clone(), t.description.clone()))
363 .collect();
364
365 let server_tags = client.list_tags()?;
367 let server_tag_names: BTreeSet<String> = server_tags.iter().map(|t| t.text.clone()).collect();
368
369 let tags_to_create: Vec<&String> = desired_tags.difference(&server_tag_names).collect();
370 let tags_to_delete: Vec<&String> = if prune {
371 server_tag_names.difference(&desired_tags).collect()
372 } else {
373 Vec::new()
374 };
375
376 let tags_to_update: Vec<(&String, &Option<String>)> = desired_descriptions
378 .iter()
379 .filter(|(name, desc)| server_tag_names.contains(*name) && desc.is_some())
380 .collect();
381
382 if dry_run {
383 println!("[dry-run] Tag plan:");
384 if tags_to_create.is_empty() && tags_to_delete.is_empty() && tags_to_update.is_empty() {
385 println!(" (no tag changes)");
386 }
387 for name in &tags_to_create {
388 println!(" + create tag: {}", name);
389 }
390 for (name, desc) in &tags_to_update {
391 println!(
392 " ~ update tag: {} (set description: {:?})",
393 name,
394 desc.as_deref().unwrap_or("")
395 );
396 }
397 for name in &tags_to_delete {
398 println!(" - delete tag: {}", name);
399 }
400 } else {
401 for name in &tags_to_create {
402 let desc = desired_descriptions.get(*name).and_then(|d| d.as_deref());
403 client
404 .update_tag(name, desc)
405 .with_context(|| format!("creating/updating tag '{}'", name))?;
406 println!(" + created tag: {}", name);
407 }
408 for (name, desc) in &tags_to_update {
409 client
410 .update_tag(name, desc.as_deref())
411 .with_context(|| format!("updating tag '{}'", name))?;
412 println!(" ~ updated tag: {}", name);
413 }
414 for name in &tags_to_delete {
415 client
416 .delete_tag(name)
417 .with_context(|| format!("deleting tag '{}'", name))?;
418 println!(" - deleted tag: {}", name);
419 }
420 }
421
422 let server_groups = match client.list_tag_groups()? {
424 Some(groups) => groups,
425 None => {
426 if !taxonomy.tag_groups.is_empty() {
427 eprintln!(
428 "Warning: tag groups not accessible (requires admin API key); skipping group reconciliation."
429 );
430 }
431 return Ok(());
432 }
433 };
434
435 let server_groups_by_name: BTreeMap<String, &TagGroupInfo> =
436 server_groups.iter().map(|g| (g.name.clone(), g)).collect();
437
438 let desired_group_names: BTreeSet<String> =
439 taxonomy.tag_groups.iter().map(|g| g.name.clone()).collect();
440 let server_group_names: BTreeSet<String> =
441 server_groups.iter().map(|g| g.name.clone()).collect();
442
443 let groups_to_create: Vec<&TagGroupEntry> = taxonomy
444 .tag_groups
445 .iter()
446 .filter(|g| !server_group_names.contains(&g.name))
447 .collect();
448
449 let groups_to_update: Vec<(&TagGroupEntry, u64)> = taxonomy
450 .tag_groups
451 .iter()
452 .filter_map(|g| server_groups_by_name.get(&g.name).map(|sg| (g, sg.id)))
453 .filter(|(desired, _id)| {
454 let server = server_groups_by_name.get(&desired.name).unwrap();
456 let mut server_tags = server.tag_names.clone();
457 server_tags.sort();
458 let mut desired_tags = desired.tags.clone();
459 desired_tags.sort();
460 server_tags != desired_tags
461 || server.one_per_topic != desired.one_per_topic
462 || server.parent_tag_name != desired.parent_tag
463 })
464 .collect();
465
466 let groups_to_delete: Vec<(&str, u64)> = if prune {
467 server_groups
468 .iter()
469 .filter(|g| !desired_group_names.contains(&g.name))
470 .map(|g| (g.name.as_str(), g.id))
471 .collect()
472 } else {
473 Vec::new()
474 };
475
476 if dry_run {
477 println!("[dry-run] Tag group plan:");
478 if groups_to_create.is_empty() && groups_to_update.is_empty() && groups_to_delete.is_empty()
479 {
480 println!(" (no group changes)");
481 }
482 for g in &groups_to_create {
483 println!(
484 " + create group: {} (tags: [{}])",
485 g.name,
486 g.tags.join(", ")
487 );
488 }
489 for (g, _id) in &groups_to_update {
490 println!(
491 " ~ update group: {} (tags: [{}])",
492 g.name,
493 g.tags.join(", ")
494 );
495 }
496 for (name, _id) in &groups_to_delete {
497 println!(" - delete group: {}", name);
498 }
499 } else {
500 for g in &groups_to_create {
501 let payload = build_tag_group_payload(g);
502 client
503 .create_tag_group(&payload)
504 .with_context(|| format!("creating tag group '{}'", g.name))?;
505 println!(" + created group: {}", g.name);
506 }
507 for (g, id) in &groups_to_update {
508 let payload = build_tag_group_payload(g);
509 client
510 .update_tag_group(*id, &payload)
511 .with_context(|| format!("updating tag group '{}'", g.name))?;
512 println!(" ~ updated group: {}", g.name);
513 }
514 for (name, id) in &groups_to_delete {
515 client
516 .delete_tag_group(*id)
517 .with_context(|| format!("deleting tag group '{}'", name))?;
518 println!(" - deleted group: {}", name);
519 }
520 }
521
522 if dry_run {
523 println!("[dry-run] No changes applied.");
524 } else {
525 println!("Push complete.");
526 }
527 Ok(())
528}
529
530fn build_tag_group_payload(entry: &TagGroupEntry) -> serde_json::Value {
531 let mut group = serde_json::Map::new();
532 group.insert("name".to_string(), serde_json::json!(entry.name));
533 group.insert("tag_names".to_string(), serde_json::json!(entry.tags));
534 group.insert(
535 "one_per_topic".to_string(),
536 serde_json::json!(entry.one_per_topic),
537 );
538 if let Some(parent) = &entry.parent_tag {
539 group.insert("parent_tag_name".to_string(), serde_json::json!([parent]));
540 }
541 if let Some(perms) = &entry.permissions {
542 let perm_map: BTreeMap<&String, u64> = perms
543 .iter()
544 .map(|(k, v)| {
545 let level = match v.as_str() {
546 "full" => 1,
547 "readonly" => 3,
548 _ => v.parse().unwrap_or(1),
549 };
550 (k, level)
551 })
552 .collect();
553 group.insert("permissions".to_string(), serde_json::json!(perm_map));
554 }
555 serde_json::json!({ "tag_group": group })
556}
557
558#[cfg(test)]
559mod tests {
560 use super::{next_tags_after_apply, next_tags_after_remove, validate_rename_names};
561
562 fn s(items: &[&str]) -> Vec<String> {
563 items.iter().map(|x| x.to_string()).collect()
564 }
565
566 #[test]
567 fn apply_adds_when_absent() {
568 let got = next_tags_after_apply(&s(&["foo", "bar"]), "baz").unwrap();
569 assert_eq!(got, s(&["foo", "bar", "baz"]));
570 }
571
572 #[test]
573 fn apply_returns_none_when_already_present() {
574 assert!(next_tags_after_apply(&s(&["foo", "bar"]), "foo").is_none());
575 }
576
577 #[test]
578 fn apply_to_empty_list_works() {
579 let got = next_tags_after_apply(&s(&[]), "first").unwrap();
580 assert_eq!(got, s(&["first"]));
581 }
582
583 #[test]
584 fn remove_drops_present_tag() {
585 let got = next_tags_after_remove(&s(&["foo", "bar", "baz"]), "bar").unwrap();
586 assert_eq!(got, s(&["foo", "baz"]));
587 }
588
589 #[test]
590 fn remove_returns_none_when_absent() {
591 assert!(next_tags_after_remove(&s(&["foo", "bar"]), "baz").is_none());
592 }
593
594 #[test]
595 fn remove_last_tag_leaves_empty_list() {
596 let got = next_tags_after_remove(&s(&["only"]), "only").unwrap();
597 assert!(got.is_empty());
598 }
599
600 #[test]
601 fn apply_is_case_sensitive() {
602 let got = next_tags_after_apply(&s(&["Foo"]), "foo").unwrap();
605 assert_eq!(got, s(&["Foo", "foo"]));
606 }
607
608 #[test]
609 fn rename_trims_inputs() {
610 let (old, new) = validate_rename_names(" foo ", " bar ").unwrap();
611 assert_eq!(old, "foo");
612 assert_eq!(new, "bar");
613 }
614
615 #[test]
616 fn rename_rejects_empty_old() {
617 assert!(validate_rename_names("", "bar").is_err());
618 assert!(validate_rename_names(" ", "bar").is_err());
619 }
620
621 #[test]
622 fn rename_rejects_empty_new() {
623 assert!(validate_rename_names("foo", "").is_err());
624 assert!(validate_rename_names("foo", " ").is_err());
625 }
626
627 #[test]
628 fn rename_rejects_identical_names() {
629 let err = validate_rename_names("foo", "foo").unwrap_err();
630 assert!(err.to_string().contains("identical"));
631 }
632
633 #[test]
634 fn rename_rejects_whitespace_in_new_name() {
635 let err = validate_rename_names("foo", "bar baz").unwrap_err();
636 assert!(err.to_string().contains("whitespace"));
637 }
638
639 #[test]
640 fn rename_treats_trim_only_difference_as_identical() {
641 let err = validate_rename_names("foo ", "foo").unwrap_err();
643 assert!(err.to_string().contains("identical"));
644 }
645}