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
327pub 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    // Desired tag set = explicit tags + all tags mentioned in groups
351    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    // Build description map from explicit entries
359    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    // ── Reconcile tags ────────────────────────────────────────────────────────
366    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    // Tags that exist and have a description to set
377    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    // ── Reconcile tag groups ──────────────────────────────────────────────────
423    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            // Only update if something differs
455            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        // Discourse tags are lowercase canonically, but we don't normalize —
603        // the API returns and accepts whatever is sent. Document the behaviour.
604        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        // After trimming, "foo " and "foo" are the same.
642        let err = validate_rename_names("foo ", "foo").unwrap_err();
643        assert!(err.to_string().contains("identical"));
644    }
645}