macos_tags/
lib.rs

1#![warn(missing_docs)]
2#![warn(unused_extern_crates)]
3#![warn(unused_qualifications)]
4
5//! Rust library for modifying macOS tags
6
7use serde::{Deserialize, Serialize};
8use std::{collections::HashSet, fmt, io, path::Path};
9use thiserror::Error;
10
11#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
12/// Represents a macOS tag
13pub enum Tag {
14    /// Gray tag color
15    Gray,
16    /// Green tag color
17    Green,
18    /// Purple tag color
19    Purple,
20    /// Blue tag color
21    Blue,
22    /// Yellow tag color
23    Yellow,
24    /// Red tag color
25    Red,
26    /// Orange tag color
27    Orange,
28    /// Custom tag name (uncolored)
29    Custom(String),
30}
31
32#[derive(Debug, Error)]
33/// Represents an error that can occur when working with tags
34pub enum TagError {
35    /// Error when working with extended attributes
36    #[error("xattr operation failed")]
37    XAttr(#[from] io::Error),
38    /// Error when working with plist data
39    #[error("plist operation failed")]
40    Plist(#[from] plist::Error),
41    /// Error when user-provided data is invalid
42    #[error("tag metadata for `{0}` is invalid")]
43    Invalid(String),
44    /// Unknown error
45    #[error("unknown error")]
46    Unknown,
47}
48
49impl fmt::Display for Tag {
50    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
51        match self {
52            Self::Gray => write!(f, "Gray\n1"),
53            Self::Green => write!(f, "Green\n2"),
54            Self::Purple => write!(f, "Purple\n3"),
55            Self::Blue => write!(f, "Blue\n4"),
56            Self::Yellow => write!(f, "Yellow\n5"),
57            Self::Red => write!(f, "Red\n6"),
58            Self::Orange => write!(f, "Orange\n7"),
59            Self::Custom(val) => write!(f, "{}", val),
60        }
61    }
62}
63
64impl Tag {
65    /// Converts a `String` into a `Tag`
66    pub fn from_string(s: &str) -> Result<Self, TagError> {
67        if s.contains('\n') {
68            let tag = s
69                .split_once('\n')
70                .ok_or_else(|| TagError::Invalid(s.to_owned()))?;
71
72            match tag {
73                ("Gray", "1") => return Ok(Tag::Gray),
74                ("Green", "2") => return Ok(Tag::Green),
75                ("Purple", "3") => return Ok(Tag::Purple),
76                ("Blue", "4") => return Ok(Tag::Blue),
77                ("Yellow", "5") => return Ok(Tag::Yellow),
78                ("Red", "6") => return Ok(Tag::Red),
79                ("Orange", "7") => return Ok(Tag::Orange),
80                _ => return Err(TagError::Invalid(s.to_owned())),
81            }
82        }
83
84        Ok(Tag::Custom(s.to_string()))
85    }
86}
87
88/// Adds a tag for provided file
89///
90/// # Examples
91///
92/// ```rust
93/// use std::path::Path;
94/// use macos_tags::{add_tag, Tag};
95/// fn main() -> Result<(), Box<dyn std::error::Error>> {
96///     let p = Path::new("./readme.md");
97///     add_tag(p, &Tag::Green)?;
98///     Ok(())
99/// }
100/// ```
101pub fn add_tag(path: &Path, tag: &Tag) -> Result<HashSet<Tag>, TagError> {
102    let tag_metadata =
103        xattr::get(path, "com.apple.metadata:_kMDItemUserTags").map_err(TagError::XAttr)?;
104
105    let parsed_tags = match tag_metadata {
106        Some(t) => plist::from_bytes::<plist::Value>(&t).map_err(TagError::Plist)?,
107        None => plist::Value::Array(vec![]),
108    };
109
110    match parsed_tags {
111        plist::Value::Array(t) => {
112            // Converting into HashSet because `dedup` doesn't seem to work
113            let mut existing_tag_set = t.iter().fold(HashSet::new(), |mut acc, x| {
114                if let plist::Value::String(s) = x {
115                    acc.insert(s.to_owned());
116                }
117                acc
118            });
119
120            existing_tag_set.insert(tag.to_string());
121
122            let tags_to_set = &existing_tag_set
123                .iter()
124                .map(|t| plist::Value::String(t.to_string()))
125                .collect::<Vec<_>>();
126
127            let final_tag_set = existing_tag_set
128                .iter()
129                .map(|t| Tag::from_string(t))
130                .collect::<Result<HashSet<_>, TagError>>()?;
131
132            let mut binary_buffer: Vec<u8> = vec![];
133            plist::to_writer_binary(&mut binary_buffer, &tags_to_set).map_err(TagError::Plist)?;
134            xattr::set(path, "com.apple.metadata:_kMDItemUserTags", &binary_buffer)
135                .map_err(TagError::XAttr)?;
136            Ok(final_tag_set)
137        }
138        _ => Err(TagError::Unknown),
139    }
140}
141
142/// Sets all tags for provided file
143///
144/// # Examples
145///
146/// ```rust
147/// use std::path::Path;
148/// use macos_tags::{set_tags, Tag};
149/// fn main() -> Result<(), Box<dyn std::error::Error>> {
150///     let p = Path::new("./readme.md");
151///     set_tags(p, &[Tag::Green, Tag::Red].into())?;
152///     Ok(())
153/// }
154/// ```
155pub fn set_tags(path: &Path, tags: &HashSet<Tag>) -> Result<HashSet<Tag>, TagError> {
156    let tags_to_set = &tags
157        .iter()
158        .map(|t| plist::Value::String(t.to_string()))
159        .collect::<Vec<_>>();
160
161    let mut binary_buffer: Vec<u8> = vec![];
162    plist::to_writer_binary(&mut binary_buffer, &tags_to_set).map_err(TagError::Plist)?;
163    xattr::set(path, "com.apple.metadata:_kMDItemUserTags", &binary_buffer)
164        .map_err(TagError::XAttr)?;
165
166    Ok(tags.clone())
167}
168
169/// Removes a tag for provided file
170///
171/// # Examples
172///
173/// ```rust
174/// use std::path::Path;
175/// use macos_tags::{remove_tag, Tag};
176/// fn main() -> Result<(), Box<dyn std::error::Error>> {
177///     let p = Path::new("./readme.md");
178///     remove_tag(p, &Tag::Green)?;
179///     Ok(())
180/// }
181/// ```
182pub fn remove_tag(path: &Path, tag: &Tag) -> Result<HashSet<Tag>, TagError> {
183    let tag_metadata =
184        xattr::get(path, "com.apple.metadata:_kMDItemUserTags").map_err(TagError::XAttr)?;
185
186    let parsed_tags = match tag_metadata {
187        Some(t) => plist::from_bytes::<plist::Value>(&t).map_err(TagError::Plist)?,
188        None => plist::Value::Array(vec![]),
189    };
190
191    match parsed_tags {
192        plist::Value::Array(t) => {
193            // Converting into HashSet because `dedup` doesn't seem to work
194            let mut existing_tag_set = t.iter().fold(HashSet::new(), |mut acc, x| {
195                if let plist::Value::String(s) = x {
196                    acc.insert(s.to_owned());
197                }
198                acc
199            });
200
201            existing_tag_set.remove(&tag.to_string());
202
203            let tags_to_set = &existing_tag_set
204                .iter()
205                .map(|t| plist::Value::String(t.to_string()))
206                .collect::<Vec<_>>();
207
208            let final_tag_set = existing_tag_set
209                .iter()
210                .map(|t| Tag::from_string(t))
211                .collect::<Result<HashSet<_>, TagError>>()?;
212
213            let mut binary_buffer: Vec<u8> = vec![];
214            plist::to_writer_binary(&mut binary_buffer, &tags_to_set).map_err(TagError::Plist)?;
215            xattr::set(path, "com.apple.metadata:_kMDItemUserTags", &binary_buffer)
216                .map_err(TagError::XAttr)?;
217            Ok(final_tag_set)
218        }
219        _ => Err(TagError::Unknown),
220    }
221}
222
223/// Prunes all tags for provided file
224///
225/// # Examples
226///
227/// ```rust
228/// use std::path::Path;
229/// use macos_tags::{prune_tags, Tag};
230/// fn main() -> Result<(), Box<dyn std::error::Error>> {
231///     let p = Path::new("./readme.md");
232///     prune_tags(p)?;
233///     Ok(())
234/// }
235/// ```
236pub fn prune_tags(path: &Path) -> Result<HashSet<Tag>, TagError> {
237    xattr::remove(path, "com.apple.metadata:_kMDItemUserTags").map_err(TagError::XAttr)?;
238    Ok(HashSet::<Tag>::with_capacity(0))
239}
240
241/// Reads tags for provided file
242///
243/// # Examples
244///
245/// ```rust
246/// use std::path::Path;
247/// use macos_tags::{read_tags, Tag};
248/// fn main() -> Result<(), Box<dyn std::error::Error>> {
249///     let p = Path::new("./readme.md");
250///     read_tags(p)?;
251///     Ok(())
252/// }
253/// ```
254pub fn read_tags(path: &Path) -> Result<HashSet<Tag>, TagError> {
255    let tag_metadata =
256        xattr::get(path, "com.apple.metadata:_kMDItemUserTags").map_err(TagError::XAttr)?;
257
258    let existing_tags = match tag_metadata {
259        Some(t) => plist::from_bytes::<plist::Value>(&t).map_err(TagError::Plist)?,
260        None => plist::Value::Array(vec![]),
261    };
262
263    match existing_tags {
264        plist::Value::Array(t) => {
265            let parsed_tags: HashSet<Tag> = t
266                .iter()
267                .filter_map(|t| {
268                    if let plist::Value::String(s) = t {
269                        let tag = Tag::from_string(s)
270                            .map_err(|_| TagError::Invalid(s.to_owned()))
271                            .ok()?;
272                        Some(tag)
273                    } else {
274                        None
275                    }
276                })
277                .collect::<HashSet<Tag>>();
278
279            Ok(parsed_tags)
280        }
281        _ => Err(TagError::Unknown),
282    }
283}