Skip to main content

doiget_cli/commands/
tag.rs

1//! `doiget tag <ref> [tags...]` and `doiget annotate <ref> <text>` subcommands.
2//!
3//! `doiget tag` adds / removes tags and collection membership on a stored
4//! entry by mutating the `[doiget].tags` and `[doiget].collections` arrays in
5//! the metadata TOML (issue #294). All mutations are idempotent.
6//!
7//! `doiget annotate` sets or clears the `[doiget].annotation` freeform string.
8//!
9//! Both commands require the entry to be in the store first (they only mutate
10//! the `[doiget]` table; they never fetch or download anything).
11
12use std::io::Write;
13
14use anyhow::{bail, Context, Result};
15
16use doiget_core::store::{FsStore, Store};
17
18use super::output::OutputMode;
19use super::resolve_store_root;
20
21/// Run the `tag` subcommand: add/remove tags or collections on a stored entry.
22///
23/// `add` — tags to add (idempotent).
24/// `remove` — tags to remove.
25/// `collection_add` — collections to join (idempotent).
26/// `collection_remove` — collections to leave.
27/// `list` — print current tags / collections / annotation then exit.
28#[allow(clippy::too_many_arguments)]
29pub fn run(
30    ref_str: String,
31    add: Vec<String>,
32    remove: Vec<String>,
33    collection_add: Vec<String>,
34    collection_remove: Vec<String>,
35    list: bool,
36    mode: OutputMode,
37    quiet_was_explicit: bool,
38) -> Result<()> {
39    let ref_ = super::parse_ref_or_exit(&ref_str)?;
40    let safekey = ref_.safekey();
41
42    let store_root = resolve_store_root()?;
43    let store = FsStore::new(store_root)?;
44
45    let mut metadata = store
46        .read(&safekey)
47        .with_context(|| format!("failed to read store entry for {ref_str}"))?
48        .with_context(|| {
49            format!("no store entry for {ref_str}; run `doiget fetch {ref_str}` first")
50        })?;
51
52    {
53        let ext = metadata.doiget.as_mut().with_context(|| {
54            format!(
55                "entry {ref_str} has no [doiget] table; \
56                 run `doiget fetch {ref_str}` first"
57            )
58        })?;
59
60        if list {
61            if mode == OutputMode::Quiet && quiet_was_explicit {
62                return Ok(());
63            }
64            let stdout = std::io::stdout();
65            let mut out = stdout.lock();
66            if mode == OutputMode::Json {
67                let v = serde_json::json!({
68                    "ref": ref_str,
69                    "tags": &ext.tags,
70                    "collections": &ext.collections,
71                    "annotation": &ext.annotation,
72                });
73                let s = serde_json::to_string_pretty(&v)
74                    .context("failed to serialize tag info to JSON")?;
75                writeln!(out, "{s}").context("failed to write tag info JSON to stdout")?;
76            } else {
77                let tags_str = if ext.tags.is_empty() {
78                    "-".to_string()
79                } else {
80                    ext.tags.join(", ")
81                };
82                let cols_str = if ext.collections.is_empty() {
83                    "-".to_string()
84                } else {
85                    ext.collections.join(", ")
86                };
87                let ann_str = ext.annotation.as_deref().unwrap_or("-");
88                writeln!(out, "tags:        {tags_str}").context("stdout write")?;
89                writeln!(out, "collections: {cols_str}").context("stdout write")?;
90                writeln!(out, "annotation:  {ann_str}").context("stdout write")?;
91            }
92            return Ok(());
93        }
94
95        if add.is_empty()
96            && remove.is_empty()
97            && collection_add.is_empty()
98            && collection_remove.is_empty()
99        {
100            bail!(
101                "no action specified; provide <tag>... to add, \
102                 or use --remove / --collection / --list"
103            );
104        }
105
106        for t in &add {
107            if !ext.tags.contains(t) {
108                ext.tags.push(t.clone());
109            }
110        }
111        for t in &remove {
112            ext.tags.retain(|x| x != t);
113        }
114        for c in &collection_add {
115            if !ext.collections.contains(c) {
116                ext.collections.push(c.clone());
117            }
118        }
119        for c in &collection_remove {
120            ext.collections.retain(|x| x != c);
121        }
122    }
123
124    store
125        .write_user_authored(&safekey, &metadata, None)
126        .with_context(|| format!("failed to write updated metadata for {ref_str}"))?;
127
128    Ok(())
129}
130
131/// Run the `annotate` subcommand: set or clear the freeform annotation on a
132/// stored entry. Exactly one of `text` (Some) or `clear = true` must be given.
133pub fn run_annotate(ref_str: String, text: Option<String>, clear: bool) -> Result<()> {
134    if !clear && text.is_none() {
135        // `docs/ERRORS.md` §4: a missing required argument is misuse, which
136        // is exit 2. A bare `bail!` gave the generic 1 — the same gap #492
137        // closed for an unparsable ref, one argument over.
138        super::output::print_err(format_args!("error: provide annotation <text> or --clear"));
139        return Err(anyhow::Error::new(super::fetch::CliExit(2)));
140    }
141
142    let ref_ = super::parse_ref_or_exit(&ref_str)?;
143    let safekey = ref_.safekey();
144
145    let store_root = resolve_store_root()?;
146    let store = FsStore::new(store_root)?;
147
148    let mut metadata = store
149        .read(&safekey)
150        .with_context(|| format!("failed to read store entry for {ref_str}"))?
151        .with_context(|| {
152            format!("no store entry for {ref_str}; run `doiget fetch {ref_str}` first")
153        })?;
154
155    {
156        let ext = metadata.doiget.as_mut().with_context(|| {
157            format!(
158                "entry {ref_str} has no [doiget] table; \
159                 run `doiget fetch {ref_str}` first"
160            )
161        })?;
162
163        if clear {
164            ext.annotation = None;
165        } else if let Some(t) = text {
166            if t.is_empty() {
167                bail!(
168                    "annotation text must not be empty; \
169                     use --clear to remove the annotation"
170                );
171            }
172            ext.annotation = Some(t);
173        }
174    }
175
176    store
177        .write_user_authored(&safekey, &metadata, None)
178        .with_context(|| format!("failed to write updated metadata for {ref_str}"))?;
179
180    Ok(())
181}