1use helixforge_catalog::Catalog;
8use helixforge_core::{Action, Entry, Group, Item, printable, valid_id};
9use serde::{Deserialize, Serialize};
10use std::path::{Path, PathBuf};
11
12pub const PREFIX: &str = "profile-";
14pub const GROUP: &str = "your-profiles";
16
17#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
19#[serde(deny_unknown_fields)]
20pub struct Profile {
21 pub name: String,
22 #[serde(default)]
23 pub description: String,
24 pub entries: Vec<String>,
26}
27
28#[derive(Debug, thiserror::Error)]
30pub enum ProfileError {
31 #[error("cannot read {0}: {1}")]
32 Read(PathBuf, String),
33 #[error("{0} is not a HelixForge profile: {1}")]
34 Format(PathBuf, String),
35 #[error("the profile has no entries")]
36 Empty,
37 #[error("the profile's name or description contains control characters")]
38 Control,
39 #[error("the profile names entries this catalog does not have: {}", .0.join(", "))]
40 Unknown(Vec<String>),
41 #[error("the name {0:?} gives no usable file name; use letters or digits")]
42 Name(String),
43 #[error("cannot write {0}: {1}")]
44 Write(PathBuf, String),
45}
46
47pub fn dir() -> Option<PathBuf> {
49 let base = std::env::var_os("XDG_CONFIG_HOME")
50 .map(PathBuf::from)
51 .filter(|path| path.is_absolute())
52 .or_else(|| std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".config")))?;
53 Some(base.join("helixforge/profiles"))
54}
55
56pub fn slug(name: &str) -> Option<String> {
58 let mut slug = String::new();
59 for character in name.to_lowercase().chars() {
60 if character.is_ascii_alphanumeric() {
61 slug.push(character);
62 } else if !slug.ends_with('-') && !slug.is_empty() {
63 slug.push('-');
64 }
65 }
66 let slug = slug.trim_end_matches('-').to_string();
67 (!slug.is_empty() && valid_id(&format!("{PREFIX}{slug}"))).then_some(slug)
68}
69
70pub fn read(path: &Path, catalog: &Catalog) -> Result<Profile, ProfileError> {
72 let text = std::fs::read_to_string(path)
73 .map_err(|error| ProfileError::Read(path.to_path_buf(), error.to_string()))?;
74 let profile: Profile = toml::from_str(&text)
75 .map_err(|error| ProfileError::Format(path.to_path_buf(), printable(error.message())))?;
76 check(&profile, catalog)?;
77 Ok(profile)
78}
79
80pub fn check(profile: &Profile, catalog: &Catalog) -> Result<(), ProfileError> {
84 if profile.entries.is_empty() {
85 return Err(ProfileError::Empty);
86 }
87 if [&profile.name, &profile.description]
88 .iter()
89 .any(|text| text.chars().any(char::is_control))
90 {
91 return Err(ProfileError::Control);
92 }
93 let unknown: Vec<String> = profile
94 .entries
95 .iter()
96 .filter(|id| catalog.find(id).is_none())
97 .map(|id| printable(id))
98 .collect();
99 if unknown.is_empty() {
100 Ok(())
101 } else {
102 Err(ProfileError::Unknown(unknown))
103 }
104}
105
106pub fn save(
109 directory: &Path,
110 profile: &Profile,
111 catalog: &Catalog,
112) -> Result<PathBuf, ProfileError> {
113 check(profile, catalog)?;
114 let slug = slug(&profile.name).ok_or_else(|| ProfileError::Name(profile.name.clone()))?;
115 let path = directory.join(format!("{slug}.toml"));
116 write(&path, profile)?;
117 Ok(path)
118}
119
120pub fn write(path: &Path, profile: &Profile) -> Result<(), ProfileError> {
122 let failed = |error: String| ProfileError::Write(path.to_path_buf(), error);
123 if let Some(parent) = path
124 .parent()
125 .filter(|parent| !parent.as_os_str().is_empty())
126 {
127 std::fs::create_dir_all(parent).map_err(|error| failed(error.to_string()))?;
128 }
129 let text = toml::to_string(profile).map_err(|error| failed(error.to_string()))?;
130 let staging = path.with_extension("toml.new");
132 std::fs::write(
133 &staging,
134 format!("# A HelixForge workstation profile: helixforge profile import <file>\n{text}"),
135 )
136 .map_err(|error| failed(error.to_string()))?;
137 std::fs::rename(&staging, path).map_err(|error| failed(error.to_string()))
138}
139
140pub fn load_all(directory: &Path, catalog: &Catalog) -> (Vec<(String, Profile)>, Vec<String>) {
143 let mut found = Vec::new();
144 let mut problems = Vec::new();
145 let Ok(listing) = std::fs::read_dir(directory) else {
146 return (found, problems);
147 };
148 let mut paths: Vec<PathBuf> = listing
149 .flatten()
150 .map(|item| item.path())
151 .filter(|path| {
152 path.extension()
153 .is_some_and(|extension| extension == "toml")
154 })
155 .collect();
156 paths.sort();
157 for path in paths {
158 let Some(slug) = path
159 .file_stem()
160 .and_then(|stem| slug(&stem.to_string_lossy()))
161 else {
162 problems.push(format!(
163 "{}: not a usable profile file name",
164 path.display()
165 ));
166 continue;
167 };
168 match read(&path, catalog) {
169 Ok(profile) => found.push((slug, profile)),
170 Err(error) => problems.push(error.to_string()),
171 }
172 }
173 (found, problems)
174}
175
176pub fn entry(slug: &str, profile: &Profile) -> Entry {
178 Entry {
179 id: format!("{PREFIX}{slug}"),
180 name: printable(&profile.name),
181 description: if profile.description.is_empty() {
182 format!("Your profile: {}.", printable(&profile.entries.join(", ")))
183 } else {
184 printable(&profile.description)
185 },
186 flags: String::new(),
187 multi_select: true,
188 writes: Vec::new(),
189 action: Action::Profile {
190 entries: profile.entries.clone(),
191 },
192 detect: None,
193 needs: Default::default(),
194 }
195}
196
197pub fn add_to(catalog: &mut Catalog, directory: &Path) -> Vec<String> {
200 let (profiles, problems) = load_all(directory, catalog);
201 let Some(tab) = catalog.tabs.iter_mut().find(|tab| tab.id == "profiles") else {
202 return problems;
203 };
204 tab.items
205 .retain(|item| !matches!(item, Item::Group(group) if group.id == GROUP));
206 if !profiles.is_empty() {
207 tab.items.insert(
208 0,
209 Item::Group(Group {
210 id: GROUP.to_string(),
211 name: "Your profiles".to_string(),
212 description: format!("Saved in {}", directory.display()),
213 multi_select: true,
214 items: profiles
215 .iter()
216 .map(|(slug, profile)| Item::Entry(entry(slug, profile)))
217 .collect(),
218 }),
219 );
220 }
221 problems
222}
223
224pub fn from_queue(name: &str, entries: &[Entry]) -> Profile {
227 let mut ids: Vec<String> = Vec::new();
228 for entry in entries {
229 let members = match &entry.action {
230 Action::Profile { entries } => entries.clone(),
231 _ => vec![entry.id.clone()],
232 };
233 for id in members {
234 if !ids.contains(&id) {
235 ids.push(id);
236 }
237 }
238 }
239 Profile {
240 name: name.trim().to_string(),
241 description: String::new(),
242 entries: ids,
243 }
244}
245
246#[cfg(test)]
247mod tests {
248 use super::*;
249
250 fn profile(name: &str, entries: &[&str]) -> Profile {
251 Profile {
252 name: name.into(),
253 description: String::new(),
254 entries: entries.iter().map(|id| id.to_string()).collect(),
255 }
256 }
257
258 #[test]
259 fn names_become_file_names() {
260 assert_eq!(slug("My Workstation!").as_deref(), Some("my-workstation"));
261 assert_eq!(
262 slug(" Gaming / RuneScape ").as_deref(),
263 Some("gaming-runescape")
264 );
265 assert_eq!(slug("???"), None);
266 assert_eq!(slug(""), None);
267 }
268
269 #[test]
270 fn profiles_are_saved_loaded_checked_and_listed() {
271 let catalog = Catalog::builtin(false).unwrap();
272 let directory = tempfile::tempdir().unwrap();
273 let saved = save(
274 directory.path(),
275 &profile("Dev Box", &["git", "ripgrep", "dotfiles-nvim"]),
276 &catalog,
277 )
278 .unwrap();
279 assert_eq!(saved, directory.path().join("dev-box.toml"));
280 assert!(
281 std::fs::read_to_string(&saved)
282 .unwrap()
283 .starts_with("# A HelixForge workstation profile")
284 );
285 assert_eq!(
286 read(&saved, &catalog).unwrap().entries,
287 ["git", "ripgrep", "dotfiles-nvim"]
288 );
289 save(directory.path(), &profile("dev box", &["git"]), &catalog).unwrap();
291 assert_eq!(read(&saved, &catalog).unwrap().entries, ["git"]);
292 assert!(matches!(
293 save(directory.path(), &profile("Bad", &["git", "no-such-entry"]), &catalog),
294 Err(ProfileError::Unknown(unknown)) if unknown == ["no-such-entry"]
295 ));
296 assert!(matches!(
297 save(directory.path(), &profile("Empty", &[]), &catalog),
298 Err(ProfileError::Empty)
299 ));
300 assert!(matches!(
301 save(directory.path(), &profile("!!!", &["git"]), &catalog),
302 Err(ProfileError::Name(_))
303 ));
304 assert!(matches!(
306 save(
307 directory.path(),
308 &profile("Evil\u{1b}[2J", &["git"]),
309 &catalog
310 ),
311 Err(ProfileError::Control)
312 ));
313 let unknown = check(&profile("x", &["git\u{1b}]52;c;x\u{7}"]), &catalog)
314 .unwrap_err()
315 .to_string();
316 assert!(!unknown.chars().any(char::is_control), "{unknown:?}");
317 let written = directory.path().join("evil.toml");
318 std::fs::write(&written, "name = \"Evil\\u001b[2J\"\nentries = [\"git\"]\n").unwrap();
319 assert!(matches!(
320 read(&written, &catalog),
321 Err(ProfileError::Control)
322 ));
323 std::fs::remove_file(&written).unwrap();
324 std::fs::write(directory.path().join("broken.toml"), "name = 3\n").unwrap();
325 let (found, problems) = load_all(directory.path(), &catalog);
326 assert_eq!(found.len(), 1);
327 assert_eq!(problems.len(), 1, "{problems:?}");
328 assert!(problems[0].contains("broken.toml"));
329 }
330
331 #[test]
332 fn they_appear_in_the_profiles_area_and_plan_like_any_profile() {
333 let mut catalog = Catalog::builtin(false).unwrap();
334 let directory = tempfile::tempdir().unwrap();
335 save(
336 directory.path(),
337 &profile("Dev Box", &["git", "ripgrep"]),
338 &catalog,
339 )
340 .unwrap();
341 assert!(add_to(&mut catalog, directory.path()).is_empty());
342 add_to(&mut catalog, directory.path());
344 let tab = catalog
345 .tabs
346 .iter()
347 .find(|tab| tab.id == "profiles")
348 .unwrap();
349 let groups: Vec<&str> = tab
350 .items
351 .iter()
352 .filter_map(|item| match item {
353 Item::Group(group) => Some(group.id.as_str()),
354 Item::Entry(_) => None,
355 })
356 .collect();
357 assert_eq!(groups, [GROUP]);
358 let entry = catalog.find("profile-dev-box").unwrap();
359 assert_eq!(entry.name, "Dev Box");
360 assert!(
361 matches!(&entry.action, Action::Profile { entries } if entries == &["git", "ripgrep"])
362 );
363 let platform = helixforge_platform::LinuxInfo {
364 id: "arch".into(),
365 pretty_name: "Arch Linux".into(),
366 id_like: vec![],
367 manager: Some(helixforge_core::PackageManager::Arch),
368 };
369 let nothing = crate::Machine {
370 holds: &|_| false,
371 on_path: &|_| false,
372 };
373 let planned =
374 crate::plan_with([entry], &platform, &|id| catalog.find(id), nothing).unwrap();
375 let ids: Vec<&str> = planned.steps.iter().map(crate::Step::id).collect();
376 assert_eq!(ids, ["git", "ripgrep"]);
377 }
378
379 #[test]
380 fn a_queue_becomes_a_flat_profile() {
381 let catalog = Catalog::builtin(false).unwrap();
382 let queue: Vec<Entry> = ["developer-basics", "git", "meld"]
383 .iter()
384 .map(|id| catalog.find(id).unwrap().clone())
385 .collect();
386 let made = from_queue(" Mine ", &queue);
387 assert_eq!(made.name, "Mine");
388 assert_eq!(made.entries, ["git", "ripgrep", "fzf", "neovim", "meld"]);
389 }
390}