Skip to main content

dsc/commands/
tag.rs

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(&current, 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
70/// Compute the resulting tag list when adding `tag` to `current`. Returns
71/// None when the tag is already present.
72fn 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
81/// Compute the resulting tag list when removing `tag` from `current`. Returns
82/// None when the tag is not present.
83fn 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(&current, 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
119/// Rename a tag on the server, preserving every topic association.
120///
121/// Discourse's tag-update endpoint accepts a new `id` (slug) which it then
122/// applies in-place to every topic carrying the old tag. This is the safe
123/// alternative to delete+create, which would unlink every topic.
124pub 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    // Look up the old tag and ensure the new name is not already taken.
138    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
163/// Validate and normalise the rename inputs. Returns `(old, new)` after
164/// trimming. Rejects empty names, identical names, and obvious-typo whitespace.
165fn 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// ─── Taxonomy file schema ─────────────────────────────────────────────────────
190
191/// The on-disk taxonomy file (version 1).
192#[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
227// ─── Pull ─────────────────────────────────────────────────────────────────────
228
229pub 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    // Collect tag entries with descriptions
237    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    // Attempt tag groups (admin-only)
248    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                        // Discourse returns permissions as {"group_name": "level_int"}
255                        // or a more complex structure; normalize to group→level string
256                        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, // not returned by list endpoint
263                        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    // Discourse API returns permissions as: {"everyone": 1} where 1=full, 3=readonly
300    // or as an object. Normalize to string labels.
301    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// ─── Push ─────────────────────────────────────────────────────────────────────
326
327/// The reconciliation plan for tags (not groups), computed before any writes so
328/// the dry-run and the apply path share one source of truth.
329///
330/// Discourse has no admin create-tag endpoint (`PUT /tag/{name}.json` 404s for a
331/// non-existent tag); a tag is materialised only by being placed in a tag group
332/// or assigned to a topic. So creation is a side effect of group reconciliation,
333/// and any desired tag that belongs to no group and does not already exist
334/// simply cannot be created - that case is surfaced, not silently dropped.
335#[derive(Debug, Default, PartialEq)]
336struct TagPlan {
337    /// Desired tags absent from the server that a group will materialise
338    /// (named in a group). Created as a side effect of group create/update.
339    created_via_group: Vec<String>,
340    /// `(name, description)` for tags that will exist after group reconciliation
341    /// and carry a description to set.
342    set_description: Vec<(String, String)>,
343    /// Explicit `tags:` entries in no group and absent from the server: no API
344    /// can create them. Reported so the run does not pretend to have applied them.
345    groupless_missing: Vec<String>,
346    /// Tags on the server but not desired (prune only).
347    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
359/// Partition the desired tags against the server. `group_tags` is the set of
360/// tags that group reconciliation will materialise (empty when the admin group
361/// endpoint is unreachable).
362fn plan_tags(
363    explicit: &BTreeMap<String, Option<String>>,
364    group_tags: &BTreeSet<String>,
365    server_tags: &BTreeSet<String>,
366    prune: bool,
367) -> TagPlan {
368    // Tags that will exist once groups are reconciled.
369    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    // The file's tags arrive in two forms: explicit `tags:` entries (each may
431    // carry a description) and tags named inside a group. A tag group's
432    // `POST /tag_groups.json` with `tag_names` is the ONLY admin-API way to
433    // create a tag, so groups are reconciled FIRST; their tags then exist and
434    // descriptions can be set. See `update_tag` for why.
435    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    // Group reconciliation needs the admin endpoint; fetch it up front so the
450    // whole plan reflects what will actually happen. Without it, no group tags
451    // can be materialised, so those tags fall through to `groupless_missing`.
452    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    // ── Compute the tag-group plan (only when the admin endpoint is reachable) ─
469    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                // Only update if something differs
492                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    // ── Dry-run: print the plan (groups first, then tags); apply nothing ──────
516    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    // ── Apply, groups first so their tags are materialised ────────────────────
565    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    // Re-read tags: group reconciliation just materialised the group tags.
581    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    // Set descriptions on tags that now exist.
589    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    // Prune: delete undesired tags (singular endpoint), then undesired groups.
599    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    // Any desired tag that belongs to no group and did not already exist could
613    // not be created (no admin create-tag endpoint). Report after doing all the
614    // achievable work, so the exit code reflects the incomplete apply rather
615    // than aborting on the first one.
616    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        // Discourse tags are lowercase canonically, but we don't normalize —
713        // the API returns and accepts whatever is sent. Document the behaviour.
714        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        // After trimming, "foo " and "foo" are the same.
752        let err = validate_rename_names("foo ", "foo").unwrap_err();
753        assert!(err.to_string().contains("identical"));
754    }
755
756    // ── plan_tags (bug #2: create-tag ordering) ───────────────────────────────
757
758    #[test]
759    fn plan_group_tag_absent_is_created_via_group() {
760        // A tag named in a group but missing from the server is materialised by
761        // group reconciliation, not a standalone (impossible) create.
762        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        // An explicit tag in no group and absent from the server cannot be
775        // created by any API - it must be surfaced, not silently attempted.
776        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        // In a group (so it will exist) + has a description → set it after group
785        // reconciliation.
786        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        // An orphan can't be created, so its description can't be set either.
802        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        // A tag desired only via a group must not be pruned just because it's
847        // absent from the explicit list.
848        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        // When the admin group endpoint is unreachable the caller passes empty
855        // group_tags; an explicit tag that only lived in a group can no longer
856        // be created and is reported.
857        let p = plan_tags(&explicit(&[("jazz", None)]), &set(&[]), &set(&[]), false);
858        assert_eq!(p.groupless_missing, s(&["jazz"]));
859    }
860}