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(
12 config: &Config,
13 discourse_name: &str,
14 format: ListFormat,
15) -> Result<()> {
16 let discourse = select_discourse(config, Some(discourse_name))?;
17 ensure_api_credentials(discourse)?;
18 let client = DiscourseClient::new(discourse)?;
19
20 let mut tags = client.list_tags()?;
21 tags.sort_by(|a, b| a.text.cmp(&b.text));
22
23 match format {
24 ListFormat::Text => {
25 if tags.is_empty() {
26 println!("No tags found.");
27 return Ok(());
28 }
29 let name_width = tags
30 .iter()
31 .map(|t| t.text.len())
32 .max()
33 .unwrap_or(0)
34 .max(4);
35 for tag in &tags {
36 println!(
37 "{:<width$} {}",
38 tag.text,
39 tag.count,
40 width = name_width
41 );
42 }
43 }
44 ListFormat::Json => {
45 println!("{}", serde_json::to_string_pretty(&tags)?);
46 }
47 ListFormat::Yaml => {
48 println!("{}", serde_yaml::to_string(&tags)?);
49 }
50 }
51
52 Ok(())
53}
54
55pub fn tag_apply(
56 config: &Config,
57 discourse_name: &str,
58 topic_id: u64,
59 tag: &str,
60 dry_run: bool,
61) -> Result<()> {
62 let discourse = select_discourse(config, Some(discourse_name))?;
63 ensure_api_credentials(discourse)?;
64 let client = DiscourseClient::new(discourse)?;
65
66 let current = client.fetch_topic_tags(topic_id)?;
67 let Some(next) = next_tags_after_apply(¤t, tag) else {
68 println!("Topic {} already tagged '{}'", topic_id, tag);
69 return Ok(());
70 };
71 if dry_run {
72 println!(
73 "[dry-run] would set tags on topic {} to: [{}]",
74 topic_id,
75 next.join(", ")
76 );
77 return Ok(());
78 }
79 let after = client.set_topic_tags(topic_id, &next)?;
80 println!("Topic {} tags: [{}]", topic_id, after.join(", "));
81 Ok(())
82}
83
84fn next_tags_after_apply(current: &[String], tag: &str) -> Option<Vec<String>> {
87 if current.iter().any(|t| t == tag) {
88 return None;
89 }
90 let mut next = current.to_vec();
91 next.push(tag.to_string());
92 Some(next)
93}
94
95fn next_tags_after_remove(current: &[String], tag: &str) -> Option<Vec<String>> {
98 if !current.iter().any(|t| t == tag) {
99 return None;
100 }
101 Some(current.iter().filter(|t| *t != tag).cloned().collect())
102}
103
104pub fn tag_remove(
105 config: &Config,
106 discourse_name: &str,
107 topic_id: u64,
108 tag: &str,
109 dry_run: bool,
110) -> Result<()> {
111 let discourse = select_discourse(config, Some(discourse_name))?;
112 ensure_api_credentials(discourse)?;
113 let client = DiscourseClient::new(discourse)?;
114
115 let current = client.fetch_topic_tags(topic_id)?;
116 let Some(next) = next_tags_after_remove(¤t, tag) else {
117 println!("Topic {} does not have tag '{}'", topic_id, tag);
118 return Ok(());
119 };
120 if dry_run {
121 println!(
122 "[dry-run] would set tags on topic {} to: [{}]",
123 topic_id,
124 next.join(", ")
125 );
126 return Ok(());
127 }
128 let after = client.set_topic_tags(topic_id, &next)?;
129 println!("Topic {} tags: [{}]", topic_id, after.join(", "));
130 Ok(())
131}
132
133pub fn tag_rename(
139 config: &Config,
140 discourse_name: &str,
141 old_name: &str,
142 new_name: &str,
143 dry_run: bool,
144) -> Result<()> {
145 let (old_norm, new_norm) = validate_rename_names(old_name, new_name)?;
146
147 let discourse = select_discourse(config, Some(discourse_name))?;
148 ensure_api_credentials(discourse)?;
149 let client = DiscourseClient::new(discourse)?;
150
151 let tags = client.list_tags()?;
153 if !tags.iter().any(|t| t.text == old_norm) {
154 return Err(not_found("tag", &old_norm));
155 }
156 if tags.iter().any(|t| t.text == new_norm) {
157 return Err(anyhow::anyhow!(
158 "cannot rename to '{}': a tag with that name already exists on '{}' (would merge; not supported)",
159 new_norm,
160 discourse_name
161 ));
162 }
163
164 if dry_run {
165 println!(
166 "[dry-run] would rename tag '{}' -> '{}' on '{}'",
167 old_norm, new_norm, discourse_name
168 );
169 return Ok(());
170 }
171
172 client.rename_tag(&old_norm, &new_norm)?;
173 println!("Renamed tag '{}' -> '{}'", old_norm, new_norm);
174 Ok(())
175}
176
177fn validate_rename_names(old: &str, new: &str) -> Result<(String, String)> {
180 let old_t = old.trim();
181 let new_t = new.trim();
182 if old_t.is_empty() {
183 return Err(anyhow::anyhow!("old tag name is empty"));
184 }
185 if new_t.is_empty() {
186 return Err(anyhow::anyhow!("new tag name is empty"));
187 }
188 if old_t == new_t {
189 return Err(anyhow::anyhow!(
190 "old and new tag names are identical: '{}'",
191 old_t
192 ));
193 }
194 if new_t.chars().any(|c| c.is_whitespace()) {
195 return Err(anyhow::anyhow!(
196 "new tag name '{}' contains whitespace; Discourse tags must be slug-style",
197 new_t
198 ));
199 }
200 Ok((old_t.to_string(), new_t.to_string()))
201}
202
203#[derive(Debug, Serialize, Deserialize, Clone)]
207pub struct TaxonomyFile {
208 pub version: u32,
209 #[serde(default, skip_serializing_if = "Vec::is_empty")]
210 pub tags: Vec<TagEntry>,
211 #[serde(default, skip_serializing_if = "Vec::is_empty")]
212 pub tag_groups: Vec<TagGroupEntry>,
213}
214
215#[derive(Debug, Serialize, Deserialize, Clone)]
216pub struct TagEntry {
217 pub name: String,
218 #[serde(default, skip_serializing_if = "Option::is_none")]
219 pub description: Option<String>,
220}
221
222#[derive(Debug, Serialize, Deserialize, Clone)]
223pub struct TagGroupEntry {
224 pub name: String,
225 #[serde(default, skip_serializing_if = "Option::is_none")]
226 pub description: Option<String>,
227 #[serde(default, skip_serializing_if = "is_false")]
228 pub one_per_topic: bool,
229 #[serde(default, skip_serializing_if = "Option::is_none")]
230 pub parent_tag: Option<String>,
231 #[serde(default, skip_serializing_if = "Option::is_none")]
232 pub permissions: Option<BTreeMap<String, String>>,
233 #[serde(default)]
234 pub tags: Vec<String>,
235}
236
237fn is_false(v: &bool) -> bool {
238 !v
239}
240
241pub fn tag_pull(
244 config: &Config,
245 discourse_name: &str,
246 local_path: &Path,
247) -> Result<()> {
248 let discourse = select_discourse(config, Some(discourse_name))?;
249 ensure_api_credentials(discourse)?;
250 let client = DiscourseClient::new(discourse)?;
251
252 let server_tags = client.list_tags()?;
253
254 let mut tag_entries: Vec<TagEntry> = Vec::new();
256 for t in &server_tags {
257 let description = client.get_tag_description(&t.text).unwrap_or(None);
258 tag_entries.push(TagEntry {
259 name: t.text.clone(),
260 description,
261 });
262 }
263 tag_entries.sort_by(|a, b| a.name.cmp(&b.name));
264
265 let tag_groups = match client.list_tag_groups()? {
267 Some(groups) => {
268 let mut entries: Vec<TagGroupEntry> = groups
269 .into_iter()
270 .map(|g| {
271 let permissions = g.permissions.and_then(|p| {
272 parse_tag_group_permissions(&p)
275 });
276 let mut tags = g.tag_names;
277 tags.sort();
278 TagGroupEntry {
279 name: g.name,
280 description: None, one_per_topic: g.one_per_topic,
282 parent_tag: g.parent_tag_name,
283 permissions,
284 tags,
285 }
286 })
287 .collect();
288 entries.sort_by(|a, b| a.name.cmp(&b.name));
289 entries
290 }
291 None => {
292 eprintln!("Warning: tag groups not accessible (requires admin API key); omitting from output.");
293 Vec::new()
294 }
295 };
296
297 let taxonomy = TaxonomyFile {
298 version: 1,
299 tags: tag_entries,
300 tag_groups,
301 };
302
303 let content = if is_json_path(local_path) {
304 serde_json::to_string_pretty(&taxonomy).context("serializing taxonomy as JSON")?
305 } else {
306 serde_yaml::to_string(&taxonomy).context("serializing taxonomy as YAML")?
307 };
308
309 fs::write(local_path, &content)
310 .with_context(|| format!("writing {}", local_path.display()))?;
311 println!("Wrote taxonomy to {}", local_path.display());
312 Ok(())
313}
314
315fn parse_tag_group_permissions(value: &serde_json::Value) -> Option<BTreeMap<String, String>> {
316 let obj = value.as_object()?;
319 if obj.is_empty() {
320 return None;
321 }
322 let mut map = BTreeMap::new();
323 for (group, level) in obj {
324 let level_str = match level.as_u64() {
325 Some(1) => "full".to_string(),
326 Some(3) => "readonly".to_string(),
327 Some(n) => n.to_string(),
328 None => level.as_str().unwrap_or("full").to_string(),
329 };
330 map.insert(group.clone(), level_str);
331 }
332 Some(map)
333}
334
335fn is_json_path(p: &Path) -> bool {
336 p.extension()
337 .and_then(|e| e.to_str())
338 .map(|e| e.eq_ignore_ascii_case("json"))
339 .unwrap_or(false)
340}
341
342pub fn tag_push(
345 config: &Config,
346 discourse_name: &str,
347 local_path: &Path,
348 prune: bool,
349 dry_run: bool,
350) -> Result<()> {
351 let discourse = select_discourse(config, Some(discourse_name))?;
352 ensure_api_credentials(discourse)?;
353 let client = DiscourseClient::new(discourse)?;
354
355 let content = fs::read_to_string(local_path)
356 .with_context(|| format!("reading {}", local_path.display()))?;
357 let taxonomy: TaxonomyFile = if is_json_path(local_path) {
358 serde_json::from_str(&content).context("parsing taxonomy JSON")?
359 } else {
360 serde_yaml::from_str(&content).context("parsing taxonomy YAML")?
361 };
362
363 if taxonomy.version != 1 {
364 anyhow::bail!("unsupported taxonomy file version: {}", taxonomy.version);
365 }
366
367 let desired_tags: BTreeSet<String> = taxonomy
369 .tags
370 .iter()
371 .map(|t| t.name.clone())
372 .chain(taxonomy.tag_groups.iter().flat_map(|g| g.tags.clone()))
373 .collect();
374
375 let desired_descriptions: BTreeMap<String, Option<String>> = taxonomy
377 .tags
378 .iter()
379 .map(|t| (t.name.clone(), t.description.clone()))
380 .collect();
381
382 let server_tags = client.list_tags()?;
384 let server_tag_names: BTreeSet<String> = server_tags.iter().map(|t| t.text.clone()).collect();
385
386 let tags_to_create: Vec<&String> = desired_tags.difference(&server_tag_names).collect();
387 let tags_to_delete: Vec<&String> = if prune {
388 server_tag_names.difference(&desired_tags).collect()
389 } else {
390 Vec::new()
391 };
392
393 let tags_to_update: Vec<(&String, &Option<String>)> = desired_descriptions
395 .iter()
396 .filter(|(name, desc)| server_tag_names.contains(*name) && desc.is_some())
397 .collect();
398
399 if dry_run {
400 println!("[dry-run] Tag plan:");
401 if tags_to_create.is_empty() && tags_to_delete.is_empty() && tags_to_update.is_empty() {
402 println!(" (no tag changes)");
403 }
404 for name in &tags_to_create {
405 println!(" + create tag: {}", name);
406 }
407 for (name, desc) in &tags_to_update {
408 println!(" ~ update tag: {} (set description: {:?})", name, desc.as_deref().unwrap_or(""));
409 }
410 for name in &tags_to_delete {
411 println!(" - delete tag: {}", name);
412 }
413 } else {
414 for name in &tags_to_create {
415 let desc = desired_descriptions.get(*name).and_then(|d| d.as_deref());
416 client.update_tag(name, desc)
417 .with_context(|| format!("creating/updating tag '{}'", name))?;
418 println!(" + created tag: {}", name);
419 }
420 for (name, desc) in &tags_to_update {
421 client.update_tag(name, desc.as_deref())
422 .with_context(|| format!("updating tag '{}'", name))?;
423 println!(" ~ updated tag: {}", name);
424 }
425 for name in &tags_to_delete {
426 client.delete_tag(name)
427 .with_context(|| format!("deleting tag '{}'", name))?;
428 println!(" - deleted tag: {}", name);
429 }
430 }
431
432 let server_groups = match client.list_tag_groups()? {
434 Some(groups) => groups,
435 None => {
436 if !taxonomy.tag_groups.is_empty() {
437 eprintln!("Warning: tag groups not accessible (requires admin API key); skipping group reconciliation.");
438 }
439 return Ok(());
440 }
441 };
442
443 let server_groups_by_name: BTreeMap<String, &TagGroupInfo> = server_groups
444 .iter()
445 .map(|g| (g.name.clone(), g))
446 .collect();
447
448 let desired_group_names: BTreeSet<String> =
449 taxonomy.tag_groups.iter().map(|g| g.name.clone()).collect();
450 let server_group_names: BTreeSet<String> =
451 server_groups.iter().map(|g| g.name.clone()).collect();
452
453 let groups_to_create: Vec<&TagGroupEntry> = taxonomy
454 .tag_groups
455 .iter()
456 .filter(|g| !server_group_names.contains(&g.name))
457 .collect();
458
459 let groups_to_update: Vec<(&TagGroupEntry, u64)> = taxonomy
460 .tag_groups
461 .iter()
462 .filter_map(|g| {
463 server_groups_by_name
464 .get(&g.name)
465 .map(|sg| (g, sg.id))
466 })
467 .filter(|(desired, _id)| {
468 let server = server_groups_by_name.get(&desired.name).unwrap();
470 let mut server_tags = server.tag_names.clone();
471 server_tags.sort();
472 let mut desired_tags = desired.tags.clone();
473 desired_tags.sort();
474 server_tags != desired_tags
475 || server.one_per_topic != desired.one_per_topic
476 || server.parent_tag_name != desired.parent_tag
477 })
478 .collect();
479
480 let groups_to_delete: Vec<(&str, u64)> = if prune {
481 server_groups
482 .iter()
483 .filter(|g| !desired_group_names.contains(&g.name))
484 .map(|g| (g.name.as_str(), g.id))
485 .collect()
486 } else {
487 Vec::new()
488 };
489
490 if dry_run {
491 println!("[dry-run] Tag group plan:");
492 if groups_to_create.is_empty() && groups_to_update.is_empty() && groups_to_delete.is_empty()
493 {
494 println!(" (no group changes)");
495 }
496 for g in &groups_to_create {
497 println!(" + create group: {} (tags: [{}])", g.name, g.tags.join(", "));
498 }
499 for (g, _id) in &groups_to_update {
500 println!(" ~ update group: {} (tags: [{}])", g.name, g.tags.join(", "));
501 }
502 for (name, _id) in &groups_to_delete {
503 println!(" - delete group: {}", name);
504 }
505 } else {
506 for g in &groups_to_create {
507 let payload = build_tag_group_payload(g);
508 client.create_tag_group(&payload)
509 .with_context(|| format!("creating tag group '{}'", g.name))?;
510 println!(" + created group: {}", g.name);
511 }
512 for (g, id) in &groups_to_update {
513 let payload = build_tag_group_payload(g);
514 client.update_tag_group(*id, &payload)
515 .with_context(|| format!("updating tag group '{}'", g.name))?;
516 println!(" ~ updated group: {}", g.name);
517 }
518 for (name, id) in &groups_to_delete {
519 client.delete_tag_group(*id)
520 .with_context(|| format!("deleting tag group '{}'", name))?;
521 println!(" - deleted group: {}", name);
522 }
523 }
524
525 if dry_run {
526 println!("[dry-run] No changes applied.");
527 } else {
528 println!("Push complete.");
529 }
530 Ok(())
531}
532
533fn build_tag_group_payload(entry: &TagGroupEntry) -> serde_json::Value {
534 let mut group = serde_json::Map::new();
535 group.insert("name".to_string(), serde_json::json!(entry.name));
536 group.insert("tag_names".to_string(), serde_json::json!(entry.tags));
537 group.insert("one_per_topic".to_string(), serde_json::json!(entry.one_per_topic));
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}