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(
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(&current, 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
84/// Compute the resulting tag list when adding `tag` to `current`. Returns
85/// None when the tag is already present.
86fn 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
95/// Compute the resulting tag list when removing `tag` from `current`. Returns
96/// None when the tag is not present.
97fn 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(&current, 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
133/// Rename a tag on the server, preserving every topic association.
134///
135/// Discourse's tag-update endpoint accepts a new `id` (slug) which it then
136/// applies in-place to every topic carrying the old tag. This is the safe
137/// alternative to delete+create, which would unlink every topic.
138pub 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    // Look up the old tag and ensure the new name is not already taken.
152    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
177/// Validate and normalise the rename inputs. Returns `(old, new)` after
178/// trimming. Rejects empty names, identical names, and obvious-typo whitespace.
179fn 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// ─── Taxonomy file schema ─────────────────────────────────────────────────────
204
205/// The on-disk taxonomy file (version 1).
206#[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
241// ─── Pull ─────────────────────────────────────────────────────────────────────
242
243pub 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    // Collect tag entries with descriptions
255    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    // Attempt tag groups (admin-only)
266    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                        // Discourse returns permissions as {"group_name": "level_int"}
273                        // or a more complex structure; normalize to group→level string
274                        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, // not returned by list endpoint
281                        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    // Discourse API returns permissions as: {"everyone": 1} where 1=full, 3=readonly
317    // or as an object. Normalize to string labels.
318    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
342// ─── Push ─────────────────────────────────────────────────────────────────────
343
344pub 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    // Desired tag set = explicit tags + all tags mentioned in groups
368    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    // Build description map from explicit entries
376    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    // ── Reconcile tags ────────────────────────────────────────────────────────
383    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    // Tags that exist and have a description to set
394    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    // ── Reconcile tag groups ──────────────────────────────────────────────────
433    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            // Only update if something differs
469            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        // 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}