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 items: Vec<(Option<String>, String)>,
22}
23
24pub 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
88fn 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
187fn 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
230fn 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}