Skip to main content

guicons_cli/
add.rs

1use guicons_core::IconManifest;
2use std::fs;
3use std::path::Path;
4use toml_edit::{DocumentMut, InlineTable, Item, Table, Value};
5
6#[derive(Debug)]
7pub enum AddError {
8    Manifest(Vec<String>),
9    AlreadyExists(Vec<String>),
10    Plan(String),
11    Io(String),
12    InvalidResult(Vec<String>),
13}
14
15struct AddPlan {
16    family: String,
17    size: Option<u16>,
18    field_name: &'static str,
19    /// (variant, field value) - `variant: None` means the field goes
20    /// straight on the family/size table, not under `variants.<name>`.
21    items: Vec<(Option<String>, String)>,
22}
23
24/// Writes a new entry into `icons.gui.toml`, preserving everything else in
25/// the file (`toml_edit`, not `guicons-core`'s read-only `toml_span`
26/// parser). Returns the manifest keys that were added.
27pub fn add(
28    manifest_path: &Path,
29    source: &str,
30    name: Option<&str>,
31    variants: &[String],
32    size: Option<u16>,
33    force: bool,
34) -> Result<Vec<String>, AddError> {
35    let manifest_path = &guicons_core::resolve_manifest_redirect(manifest_path);
36    let existing_manifest = if manifest_path.exists() {
37        let (manifest, errors) = guicons_core::load_icon_manifest(manifest_path);
38        if !errors.is_empty() {
39            return Err(AddError::Manifest(errors.iter().map(|e| e.to_string()).collect()));
40        }
41        Some(manifest)
42    } else {
43        None
44    };
45
46    let plan = plan_add(manifest_path, source, name, variants, size, existing_manifest.as_ref()).map_err(AddError::Plan)?;
47
48    if !force {
49        if let Some(manifest) = &existing_manifest {
50            let collisions: Vec<String> = plan
51                .items
52                .iter()
53                .map(|(variant, _)| compute_key(&plan.family, plan.size, variant.as_deref()))
54                .filter(|key| manifest.entry_for_key(key).is_some())
55                .collect();
56            if !collisions.is_empty() {
57                return Err(AddError::AlreadyExists(collisions));
58            }
59        }
60    }
61
62    let existing_content = if manifest_path.exists() {
63        fs::read_to_string(manifest_path).map_err(|e| AddError::Io(e.to_string()))?
64    } else {
65        String::new()
66    };
67    let mut doc = existing_content
68        .parse::<DocumentMut>()
69        .map_err(|e| AddError::Io(e.to_string()))?;
70
71    let path = table_path(&plan.family, plan.size);
72    for (variant, value) in &plan.items {
73        set_entry(&mut doc, &path, variant.as_deref(), plan.field_name, value);
74    }
75    let new_content = doc.to_string();
76
77    validate(manifest_path, &new_content)?;
78
79    guicons_net::write_atomic(manifest_path, new_content.as_bytes()).map_err(|e| AddError::Io(e.to_string()))?;
80
81    Ok(plan
82        .items
83        .iter()
84        .map(|(variant, _)| compute_key(&plan.family, plan.size, variant.as_deref()))
85        .collect())
86}
87
88/// Writes `content` to a scratch file next to the real manifest and
89/// re-parses it with `guicons-core` before the real file is touched -
90/// `toml_edit` only guarantees the result is syntactically valid TOML, not
91/// that it's still a valid guicons manifest.
92fn validate(manifest_path: &Path, content: &str) -> Result<(), AddError> {
93    let dir = manifest_path.parent().unwrap_or_else(|| Path::new("."));
94    let scratch = tempfile::Builder::new()
95        .prefix(".icons-add-")
96        .suffix(".gui.toml")
97        .tempfile_in(dir)
98        .map_err(|e| AddError::Io(e.to_string()))?;
99    fs::write(scratch.path(), content).map_err(|e| AddError::Io(e.to_string()))?;
100    let (_, errors) = guicons_core::load_icon_manifest(scratch.path());
101    if errors.is_empty() {
102        Ok(())
103    } else {
104        Err(AddError::InvalidResult(errors.iter().map(|e| e.to_string()).collect()))
105    }
106}
107
108fn plan_add(
109    manifest_path: &Path,
110    source: &str,
111    name: Option<&str>,
112    variants: &[String],
113    size: Option<u16>,
114    manifest: Option<&IconManifest>,
115) -> Result<AddPlan, String> {
116    let iconify = source
117        .split_once(':')
118        .filter(|(provider, _)| is_iconify_prefix(provider) && !Path::new(source).exists());
119    if let Some((provider, base)) = iconify {
120        if base.is_empty() {
121            return Err(format!("`{source}` has no icon name after the `:`"));
122        }
123
124        if !variants.is_empty() {
125            let family = name
126                .ok_or("`--name` is required when using `--variants`")?
127                .to_string();
128            let items = variants
129                .iter()
130                .map(|variant| {
131                    let mut id = format!("{provider}:{base}");
132                    if let Some(size) = size {
133                        id.push('-');
134                        id.push_str(&size.to_string());
135                    }
136                    id.push('-');
137                    id.push_str(variant);
138                    (Some(variant.clone()), id)
139                })
140                .collect();
141            return Ok(AddPlan { family, size, field_name: "iconify", items });
142        }
143
144        if let Some(manifest) = manifest {
145            if let Some((family, decomposed_size, variant)) = guicons_core::decompose_iconify_id(source, manifest) {
146                return Ok(AddPlan {
147                    family: name.map(str::to_string).unwrap_or(family),
148                    size: decomposed_size,
149                    field_name: "iconify",
150                    items: vec![(variant, source.to_string())],
151                });
152            }
153        }
154
155        let family = name.map(str::to_string).unwrap_or_else(|| base.to_string());
156        return Ok(AddPlan {
157            family,
158            size: None,
159            field_name: "iconify",
160            items: vec![(None, source.to_string())],
161        });
162    }
163
164    if !variants.is_empty() {
165        return Err("`--variants` only makes sense with an iconify source (`set:name`)".to_string());
166    }
167    let family = match name {
168        Some(name) => name.to_string(),
169        None => Path::new(source)
170            .file_stem()
171            .and_then(|stem| stem.to_str())
172            .map(str::to_string)
173            .ok_or_else(|| format!("could not derive a name from `{source}`; pass `--name`"))?,
174    };
175    Ok(AddPlan {
176        family,
177        size: None,
178        field_name: "file",
179        items: vec![(None, file_relative_to_roots(manifest_path, source, manifest)?)],
180    })
181}
182
183fn is_iconify_prefix(provider: &str) -> bool {
184    !provider.is_empty() && provider.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
185}
186
187/// `source` (relative to the current directory) as the manifest spells it:
188/// relative to the first of its roots that contains it.
189fn file_relative_to_roots(manifest_path: &Path, source: &str, manifest: Option<&IconManifest>) -> Result<String, String> {
190    let file = guicons_core::canonicalize_or_self(Path::new(source));
191    if !file.is_file() {
192        return Err(format!("`{source}` doesn't exist"));
193    }
194    let manifest_dir = manifest_path.parent().filter(|dir| !dir.as_os_str().is_empty()).unwrap_or_else(|| Path::new("."));
195    let roots = match manifest {
196        Some(manifest) if !manifest.roots().is_empty() => manifest.roots().to_vec(),
197        _ => vec![manifest_dir.to_path_buf()],
198    };
199    roots
200        .iter()
201        .find_map(|root| file.strip_prefix(guicons_core::canonicalize_or_self(root)).ok())
202        .map(|relative| relative.to_string_lossy().replace('\\', "/"))
203        .ok_or_else(|| {
204            let roots: Vec<String> = roots.iter().map(|root| root.display().to_string()).collect();
205            format!("`{source}` isn't under the manifest's roots ({})", roots.join(", "))
206        })
207}
208
209fn compute_key(family: &str, size: Option<u16>, variant: Option<&str>) -> String {
210    let mut key = family.to_string();
211    if let Some(size) = size {
212        key.push('-');
213        key.push_str(&size.to_string());
214    }
215    if let Some(variant) = variant {
216        key.push('-');
217        key.push_str(variant);
218    }
219    key
220}
221
222fn table_path(family: &str, size: Option<u16>) -> Vec<String> {
223    let mut path = vec![family.to_string()];
224    if let Some(size) = size {
225        path.push(size.to_string());
226    }
227    path
228}
229
230/// The table at `parent[key]`, converting an inline table in place (keeping
231/// its entries) and creating it if missing.
232fn child_table<'a>(parent: &'a mut Table, key: &str) -> &'a mut Table {
233    let item = parent.entry(key).or_insert_with(|| Item::Table(Table::new()));
234    if let Some(inline) = item.as_inline_table().cloned() {
235        *item = Item::Table(inline.into_table());
236    } else if !item.is_table() {
237        *item = Item::Table(Table::new());
238    }
239    item.as_table_mut().unwrap()
240}
241
242fn navigate_or_create<'a>(table: &'a mut Table, path: &[String]) -> &'a mut Table {
243    let mut current = table;
244    for segment in path {
245        current = child_table(current, segment);
246    }
247    current
248}
249
250fn set_entry(doc: &mut DocumentMut, path: &[String], variant: Option<&str>, field_name: &str, field_value: &str) {
251    let table = navigate_or_create(doc.as_table_mut(), path);
252    match variant {
253        Some(variant) => {
254            let variants_table = child_table(table, "variants");
255            let mut inline = InlineTable::new();
256            inline.insert(field_name, Value::from(field_value));
257            variants_table.insert(variant, Item::Value(Value::InlineTable(inline)));
258        }
259        None => {
260            table.insert(field_name, toml_edit::value(field_value));
261        }
262    }
263}