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 existing_manifest = if manifest_path.exists() {
36        let (manifest, errors) = guicons_core::load_icon_manifest(manifest_path);
37        if !errors.is_empty() {
38            return Err(AddError::Manifest(errors.iter().map(|e| e.to_string()).collect()));
39        }
40        Some(manifest)
41    } else {
42        None
43    };
44
45    let plan = plan_add(source, name, variants, size, existing_manifest.as_ref()).map_err(AddError::Plan)?;
46
47    if !force {
48        if let Some(manifest) = &existing_manifest {
49            let collisions: Vec<String> = plan
50                .items
51                .iter()
52                .map(|(variant, _)| compute_key(&plan.family, plan.size, variant.as_deref()))
53                .filter(|key| manifest.entry_for_key(key).is_some())
54                .collect();
55            if !collisions.is_empty() {
56                return Err(AddError::AlreadyExists(collisions));
57            }
58        }
59    }
60
61    let existing_content = if manifest_path.exists() {
62        fs::read_to_string(manifest_path).map_err(|e| AddError::Io(e.to_string()))?
63    } else {
64        String::new()
65    };
66    let mut doc = existing_content
67        .parse::<DocumentMut>()
68        .map_err(|e| AddError::Io(e.to_string()))?;
69
70    let path = table_path(&plan.family, plan.size);
71    for (variant, value) in &plan.items {
72        set_entry(&mut doc, &path, variant.as_deref(), plan.field_name, value);
73    }
74    let new_content = doc.to_string();
75
76    validate(manifest_path, &new_content)?;
77
78    fs::write(manifest_path, &new_content).map_err(|e| AddError::Io(e.to_string()))?;
79
80    Ok(plan
81        .items
82        .iter()
83        .map(|(variant, _)| compute_key(&plan.family, plan.size, variant.as_deref()))
84        .collect())
85}
86
87/// Writes `content` to a scratch file next to the real manifest and
88/// re-parses it with `guicons-core` before the real file is touched -
89/// `toml_edit` only guarantees the result is syntactically valid TOML, not
90/// that it's still a valid guicons manifest.
91fn validate(manifest_path: &Path, content: &str) -> Result<(), AddError> {
92    let dir = manifest_path.parent().unwrap_or_else(|| Path::new("."));
93    let scratch = tempfile::Builder::new()
94        .prefix(".icons-add-")
95        .suffix(".gui.toml")
96        .tempfile_in(dir)
97        .map_err(|e| AddError::Io(e.to_string()))?;
98    fs::write(scratch.path(), content).map_err(|e| AddError::Io(e.to_string()))?;
99    let (_, errors) = guicons_core::load_icon_manifest(scratch.path());
100    if errors.is_empty() {
101        Ok(())
102    } else {
103        Err(AddError::InvalidResult(errors.iter().map(|e| e.to_string()).collect()))
104    }
105}
106
107fn plan_add(
108    source: &str,
109    name: Option<&str>,
110    variants: &[String],
111    size: Option<u16>,
112    manifest: Option<&IconManifest>,
113) -> Result<AddPlan, String> {
114    if let Some((provider, base)) = source.split_once(':') {
115        if base.is_empty() {
116            return Err(format!("`{source}` has no icon name after the `:`"));
117        }
118
119        if !variants.is_empty() {
120            let family = name
121                .ok_or("`--name` is required when using `--variants`")?
122                .to_string();
123            let items = variants
124                .iter()
125                .map(|variant| {
126                    let mut id = format!("{provider}:{base}");
127                    if let Some(size) = size {
128                        id.push('-');
129                        id.push_str(&size.to_string());
130                    }
131                    id.push('-');
132                    id.push_str(variant);
133                    (Some(variant.clone()), id)
134                })
135                .collect();
136            return Ok(AddPlan { family, size, field_name: "iconify", items });
137        }
138
139        if let Some(manifest) = manifest {
140            if let Some((family, decomposed_size, variant)) = guicons_core::decompose_iconify_id(source, manifest) {
141                return Ok(AddPlan {
142                    family: name.map(str::to_string).unwrap_or(family),
143                    size: decomposed_size,
144                    field_name: "iconify",
145                    items: vec![(variant, source.to_string())],
146                });
147            }
148        }
149
150        let family = name.map(str::to_string).unwrap_or_else(|| base.to_string());
151        return Ok(AddPlan {
152            family,
153            size: None,
154            field_name: "iconify",
155            items: vec![(None, source.to_string())],
156        });
157    }
158
159    if !variants.is_empty() {
160        return Err("`--variants` only makes sense with an iconify source (`set:name`)".to_string());
161    }
162    let family = match name {
163        Some(name) => name.to_string(),
164        None => Path::new(source)
165            .file_stem()
166            .and_then(|stem| stem.to_str())
167            .map(str::to_string)
168            .ok_or_else(|| format!("could not derive a name from `{source}`; pass `--name`"))?,
169    };
170    Ok(AddPlan {
171        family,
172        size: None,
173        field_name: "file",
174        items: vec![(None, source.to_string())],
175    })
176}
177
178fn compute_key(family: &str, size: Option<u16>, variant: Option<&str>) -> String {
179    let mut key = family.to_string();
180    if let Some(size) = size {
181        key.push('-');
182        key.push_str(&size.to_string());
183    }
184    if let Some(variant) = variant {
185        key.push('-');
186        key.push_str(variant);
187    }
188    key
189}
190
191fn table_path(family: &str, size: Option<u16>) -> Vec<String> {
192    let mut path = vec![family.to_string()];
193    if let Some(size) = size {
194        path.push(size.to_string());
195    }
196    path
197}
198
199fn navigate_or_create<'a>(table: &'a mut Table, path: &[String]) -> &'a mut Table {
200    let mut current = table;
201    for segment in path {
202        if !matches!(current.get(segment), Some(item) if item.is_table()) {
203            current.insert(segment, Item::Table(Table::new()));
204        }
205        current = current.get_mut(segment).unwrap().as_table_mut().unwrap();
206    }
207    current
208}
209
210fn set_entry(doc: &mut DocumentMut, path: &[String], variant: Option<&str>, field_name: &str, field_value: &str) {
211    let table = navigate_or_create(doc.as_table_mut(), path);
212    match variant {
213        Some(variant) => {
214            if !matches!(table.get("variants"), Some(item) if item.is_table()) {
215                table.insert("variants", Item::Table(Table::new()));
216            }
217            let variants_table = table.get_mut("variants").unwrap().as_table_mut().unwrap();
218            let mut inline = InlineTable::new();
219            inline.insert(field_name, Value::from(field_value));
220            variants_table.insert(variant, Item::Value(Value::InlineTable(inline)));
221        }
222        None => {
223            table.insert(field_name, toml_edit::value(field_value));
224        }
225    }
226}