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 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
87fn 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}