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
325#[derive(Debug, Default, PartialEq)]
336struct TagPlan {
337 created_via_group: Vec<String>,
340 set_description: Vec<(String, String)>,
343 groupless_missing: Vec<String>,
346 to_delete: Vec<String>,
348}
349
350impl TagPlan {
351 fn is_empty(&self) -> bool {
352 self.created_via_group.is_empty()
353 && self.set_description.is_empty()
354 && self.groupless_missing.is_empty()
355 && self.to_delete.is_empty()
356 }
357}
358
359fn plan_tags(
363 explicit: &BTreeMap<String, Option<String>>,
364 group_tags: &BTreeSet<String>,
365 server_tags: &BTreeSet<String>,
366 prune: bool,
367) -> TagPlan {
368 let will_exist: BTreeSet<String> = server_tags.iter().chain(group_tags).cloned().collect();
370
371 let created_via_group: Vec<String> = group_tags.difference(server_tags).cloned().collect();
372
373 let mut set_description: Vec<(String, String)> = explicit
374 .iter()
375 .filter_map(|(name, desc)| match desc {
376 Some(d) if will_exist.contains(name) => Some((name.clone(), d.clone())),
377 _ => None,
378 })
379 .collect();
380 set_description.sort();
381
382 let groupless_missing: Vec<String> = explicit
383 .keys()
384 .filter(|name| !group_tags.contains(*name) && !server_tags.contains(*name))
385 .cloned()
386 .collect();
387
388 let to_delete: Vec<String> = if prune {
389 let desired: BTreeSet<&String> = explicit.keys().chain(group_tags).collect();
390 server_tags
391 .iter()
392 .filter(|t| !desired.contains(t))
393 .cloned()
394 .collect()
395 } else {
396 Vec::new()
397 };
398
399 TagPlan {
400 created_via_group,
401 set_description,
402 groupless_missing,
403 to_delete,
404 }
405}
406
407pub fn tag_push(
408 config: &Config,
409 discourse_name: &str,
410 local_path: &Path,
411 prune: bool,
412 dry_run: bool,
413) -> Result<()> {
414 let discourse = select_discourse(config, Some(discourse_name))?;
415 ensure_api_credentials(discourse)?;
416 let client = DiscourseClient::new(discourse)?;
417
418 let content = fs::read_to_string(local_path)
419 .with_context(|| format!("reading {}", local_path.display()))?;
420 let taxonomy: TaxonomyFile = if is_json_path(local_path) {
421 serde_json::from_str(&content).context("parsing taxonomy JSON")?
422 } else {
423 serde_yaml::from_str(&content).context("parsing taxonomy YAML")?
424 };
425
426 if taxonomy.version != 1 {
427 anyhow::bail!("unsupported taxonomy file version: {}", taxonomy.version);
428 }
429
430 let explicit: BTreeMap<String, Option<String>> = taxonomy
436 .tags
437 .iter()
438 .map(|t| (t.name.clone(), t.description.clone()))
439 .collect();
440 let group_tags: BTreeSet<String> = taxonomy
441 .tag_groups
442 .iter()
443 .flat_map(|g| g.tags.clone())
444 .collect();
445
446 let server_tag_names: BTreeSet<String> =
447 client.list_tags()?.into_iter().map(|t| t.text).collect();
448
449 let server_groups = client.list_tag_groups()?;
453 let groups_available = server_groups.is_some();
454 if !groups_available && !taxonomy.tag_groups.is_empty() {
455 eprintln!(
456 "Warning: tag groups not accessible (requires admin API key); groups in the file cannot be reconciled and their tags cannot be created."
457 );
458 }
459 let effective_group_tags: BTreeSet<String> = if groups_available {
460 group_tags.clone()
461 } else {
462 BTreeSet::new()
463 };
464 let server_groups = server_groups.unwrap_or_default();
465
466 let tag_plan = plan_tags(&explicit, &effective_group_tags, &server_tag_names, prune);
467
468 let server_groups_by_name: BTreeMap<String, &TagGroupInfo> =
470 server_groups.iter().map(|g| (g.name.clone(), g)).collect();
471 let desired_group_names: BTreeSet<String> =
472 taxonomy.tag_groups.iter().map(|g| g.name.clone()).collect();
473 let server_group_names: BTreeSet<String> =
474 server_groups.iter().map(|g| g.name.clone()).collect();
475
476 let groups_to_create: Vec<&TagGroupEntry> = if groups_available {
477 taxonomy
478 .tag_groups
479 .iter()
480 .filter(|g| !server_group_names.contains(&g.name))
481 .collect()
482 } else {
483 Vec::new()
484 };
485 let groups_to_update: Vec<(&TagGroupEntry, u64)> = if groups_available {
486 taxonomy
487 .tag_groups
488 .iter()
489 .filter_map(|g| server_groups_by_name.get(&g.name).map(|sg| (g, sg.id)))
490 .filter(|(desired, _id)| {
491 let server = server_groups_by_name.get(&desired.name).unwrap();
493 let mut server_tags = server.tag_names.clone();
494 server_tags.sort();
495 let mut desired_tags = desired.tags.clone();
496 desired_tags.sort();
497 server_tags != desired_tags
498 || server.one_per_topic != desired.one_per_topic
499 || server.parent_tag_name != desired.parent_tag
500 })
501 .collect()
502 } else {
503 Vec::new()
504 };
505 let groups_to_delete: Vec<(&str, u64)> = if prune && groups_available {
506 server_groups
507 .iter()
508 .filter(|g| !desired_group_names.contains(&g.name))
509 .map(|g| (g.name.as_str(), g.id))
510 .collect()
511 } else {
512 Vec::new()
513 };
514
515 if dry_run {
517 println!("[dry-run] Tag group plan:");
518 if groups_to_create.is_empty() && groups_to_update.is_empty() && groups_to_delete.is_empty()
519 {
520 println!(" (no group changes)");
521 }
522 for g in &groups_to_create {
523 println!(
524 " + create group: {} (tags: [{}])",
525 g.name,
526 g.tags.join(", ")
527 );
528 }
529 for (g, _id) in &groups_to_update {
530 println!(
531 " ~ update group: {} (tags: [{}])",
532 g.name,
533 g.tags.join(", ")
534 );
535 }
536 for (name, _id) in &groups_to_delete {
537 println!(" - delete group: {}", name);
538 }
539
540 println!("[dry-run] Tag plan:");
541 if tag_plan.is_empty() {
542 println!(" (no tag changes)");
543 }
544 for name in &tag_plan.created_via_group {
545 println!(" + create tag: {} (via its tag group)", name);
546 }
547 for (name, desc) in &tag_plan.set_description {
548 println!(" ~ set description: {} ({:?})", name, desc);
549 }
550 for name in &tag_plan.groupless_missing {
551 println!(
552 " ! cannot create tag: {} (Discourse has no create-tag API; add it to a tag group or create it by tagging a topic)",
553 name
554 );
555 }
556 for name in &tag_plan.to_delete {
557 println!(" - delete tag: {}", name);
558 }
559
560 println!("[dry-run] No changes applied.");
561 return Ok(());
562 }
563
564 for g in &groups_to_create {
566 let payload = build_tag_group_payload(g);
567 client
568 .create_tag_group(&payload)
569 .with_context(|| format!("creating tag group '{}'", g.name))?;
570 println!(" + created group: {}", g.name);
571 }
572 for (g, id) in &groups_to_update {
573 let payload = build_tag_group_payload(g);
574 client
575 .update_tag_group(*id, &payload)
576 .with_context(|| format!("updating tag group '{}'", g.name))?;
577 println!(" ~ updated group: {}", g.name);
578 }
579
580 let now_existing: BTreeSet<String> = client.list_tags()?.into_iter().map(|t| t.text).collect();
582 for name in &tag_plan.created_via_group {
583 if now_existing.contains(name) {
584 println!(" + created tag: {} (via its tag group)", name);
585 }
586 }
587
588 for (name, desc) in &tag_plan.set_description {
590 if now_existing.contains(name) {
591 client
592 .update_tag(name, Some(desc))
593 .with_context(|| format!("setting description on tag '{}'", name))?;
594 println!(" ~ set description: {}", name);
595 }
596 }
597
598 for name in &tag_plan.to_delete {
600 client
601 .delete_tag(name)
602 .with_context(|| format!("deleting tag '{}'", name))?;
603 println!(" - deleted tag: {}", name);
604 }
605 for (name, id) in &groups_to_delete {
606 client
607 .delete_tag_group(*id)
608 .with_context(|| format!("deleting tag group '{}'", name))?;
609 println!(" - deleted group: {}", name);
610 }
611
612 if !tag_plan.groupless_missing.is_empty() {
617 anyhow::bail!(
618 "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.",
619 discourse_name,
620 tag_plan.groupless_missing.join(", ")
621 );
622 }
623
624 println!("Push complete.");
625 Ok(())
626}
627
628fn build_tag_group_payload(entry: &TagGroupEntry) -> serde_json::Value {
629 let mut group = serde_json::Map::new();
630 group.insert("name".to_string(), serde_json::json!(entry.name));
631 group.insert("tag_names".to_string(), serde_json::json!(entry.tags));
632 group.insert(
633 "one_per_topic".to_string(),
634 serde_json::json!(entry.one_per_topic),
635 );
636 if let Some(parent) = &entry.parent_tag {
637 group.insert("parent_tag_name".to_string(), serde_json::json!([parent]));
638 }
639 if let Some(perms) = &entry.permissions {
640 let perm_map: BTreeMap<&String, u64> = perms
641 .iter()
642 .map(|(k, v)| {
643 let level = match v.as_str() {
644 "full" => 1,
645 "readonly" => 3,
646 _ => v.parse().unwrap_or(1),
647 };
648 (k, level)
649 })
650 .collect();
651 group.insert("permissions".to_string(), serde_json::json!(perm_map));
652 }
653 serde_json::json!({ "tag_group": group })
654}
655
656#[cfg(test)]
657mod tests {
658 use super::{next_tags_after_apply, next_tags_after_remove, plan_tags, validate_rename_names};
659 use std::collections::{BTreeMap, BTreeSet};
660
661 fn s(items: &[&str]) -> Vec<String> {
662 items.iter().map(|x| x.to_string()).collect()
663 }
664
665 fn set(items: &[&str]) -> BTreeSet<String> {
666 items.iter().map(|x| x.to_string()).collect()
667 }
668
669 fn explicit(pairs: &[(&str, Option<&str>)]) -> BTreeMap<String, Option<String>> {
670 pairs
671 .iter()
672 .map(|(n, d)| (n.to_string(), d.map(|s| s.to_string())))
673 .collect()
674 }
675
676 #[test]
677 fn apply_adds_when_absent() {
678 let got = next_tags_after_apply(&s(&["foo", "bar"]), "baz").unwrap();
679 assert_eq!(got, s(&["foo", "bar", "baz"]));
680 }
681
682 #[test]
683 fn apply_returns_none_when_already_present() {
684 assert!(next_tags_after_apply(&s(&["foo", "bar"]), "foo").is_none());
685 }
686
687 #[test]
688 fn apply_to_empty_list_works() {
689 let got = next_tags_after_apply(&s(&[]), "first").unwrap();
690 assert_eq!(got, s(&["first"]));
691 }
692
693 #[test]
694 fn remove_drops_present_tag() {
695 let got = next_tags_after_remove(&s(&["foo", "bar", "baz"]), "bar").unwrap();
696 assert_eq!(got, s(&["foo", "baz"]));
697 }
698
699 #[test]
700 fn remove_returns_none_when_absent() {
701 assert!(next_tags_after_remove(&s(&["foo", "bar"]), "baz").is_none());
702 }
703
704 #[test]
705 fn remove_last_tag_leaves_empty_list() {
706 let got = next_tags_after_remove(&s(&["only"]), "only").unwrap();
707 assert!(got.is_empty());
708 }
709
710 #[test]
711 fn apply_is_case_sensitive() {
712 let got = next_tags_after_apply(&s(&["Foo"]), "foo").unwrap();
715 assert_eq!(got, s(&["Foo", "foo"]));
716 }
717
718 #[test]
719 fn rename_trims_inputs() {
720 let (old, new) = validate_rename_names(" foo ", " bar ").unwrap();
721 assert_eq!(old, "foo");
722 assert_eq!(new, "bar");
723 }
724
725 #[test]
726 fn rename_rejects_empty_old() {
727 assert!(validate_rename_names("", "bar").is_err());
728 assert!(validate_rename_names(" ", "bar").is_err());
729 }
730
731 #[test]
732 fn rename_rejects_empty_new() {
733 assert!(validate_rename_names("foo", "").is_err());
734 assert!(validate_rename_names("foo", " ").is_err());
735 }
736
737 #[test]
738 fn rename_rejects_identical_names() {
739 let err = validate_rename_names("foo", "foo").unwrap_err();
740 assert!(err.to_string().contains("identical"));
741 }
742
743 #[test]
744 fn rename_rejects_whitespace_in_new_name() {
745 let err = validate_rename_names("foo", "bar baz").unwrap_err();
746 assert!(err.to_string().contains("whitespace"));
747 }
748
749 #[test]
750 fn rename_treats_trim_only_difference_as_identical() {
751 let err = validate_rename_names("foo ", "foo").unwrap_err();
753 assert!(err.to_string().contains("identical"));
754 }
755
756 #[test]
759 fn plan_group_tag_absent_is_created_via_group() {
760 let p = plan_tags(
763 &explicit(&[]),
764 &set(&["acoustic", "jazz"]),
765 &set(&["jazz"]),
766 false,
767 );
768 assert_eq!(p.created_via_group, s(&["acoustic"]));
769 assert!(p.groupless_missing.is_empty());
770 }
771
772 #[test]
773 fn plan_groupless_missing_is_reported_not_created() {
774 let p = plan_tags(&explicit(&[("orphan", None)]), &set(&[]), &set(&[]), false);
777 assert_eq!(p.groupless_missing, s(&["orphan"]));
778 assert!(p.created_via_group.is_empty());
779 assert!(p.set_description.is_empty());
780 }
781
782 #[test]
783 fn plan_sets_description_for_group_created_tag() {
784 let p = plan_tags(
787 &explicit(&[("jazz", Some("Jazz music"))]),
788 &set(&["jazz"]),
789 &set(&[]),
790 false,
791 );
792 assert_eq!(
793 p.set_description,
794 vec![("jazz".to_string(), "Jazz music".to_string())]
795 );
796 assert!(p.groupless_missing.is_empty());
797 }
798
799 #[test]
800 fn plan_no_description_set_for_uncreatable_orphan() {
801 let p = plan_tags(
803 &explicit(&[("orphan", Some("x"))]),
804 &set(&[]),
805 &set(&[]),
806 false,
807 );
808 assert!(p.set_description.is_empty());
809 assert_eq!(p.groupless_missing, s(&["orphan"]));
810 }
811
812 #[test]
813 fn plan_sets_description_for_existing_server_tag() {
814 let p = plan_tags(
815 &explicit(&[("rock", Some("Rock"))]),
816 &set(&[]),
817 &set(&["rock"]),
818 false,
819 );
820 assert_eq!(
821 p.set_description,
822 vec![("rock".to_string(), "Rock".to_string())]
823 );
824 assert!(p.groupless_missing.is_empty());
825 }
826
827 #[test]
828 fn plan_prune_deletes_undesired_server_tags() {
829 let p = plan_tags(
830 &explicit(&[("keep", None)]),
831 &set(&[]),
832 &set(&["keep", "old"]),
833 true,
834 );
835 assert_eq!(p.to_delete, s(&["old"]));
836 }
837
838 #[test]
839 fn plan_without_prune_deletes_nothing() {
840 let p = plan_tags(&explicit(&[]), &set(&[]), &set(&["old"]), false);
841 assert!(p.to_delete.is_empty());
842 }
843
844 #[test]
845 fn plan_group_tag_still_desired_is_not_pruned() {
846 let p = plan_tags(&explicit(&[]), &set(&["jazz"]), &set(&["jazz"]), true);
849 assert!(p.to_delete.is_empty());
850 }
851
852 #[test]
853 fn plan_no_group_access_makes_group_only_explicit_tags_orphans() {
854 let p = plan_tags(&explicit(&[("jazz", None)]), &set(&[]), &set(&[]), false);
858 assert_eq!(p.groupless_missing, s(&["jazz"]));
859 }
860}