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 effects: Default::default(),
195 }
196}
197
198pub fn add_to(catalog: &mut Catalog, directory: &Path) -> Vec<String> {
201 let (profiles, problems) = load_all(directory, catalog);
202 let Some(tab) = catalog.tabs.iter_mut().find(|tab| tab.id == "profiles") else {
203 return problems;
204 };
205 tab.items
206 .retain(|item| !matches!(item, Item::Group(group) if group.id == GROUP));
207 if !profiles.is_empty() {
208 tab.items.insert(
209 0,
210 Item::Group(Group {
211 id: GROUP.to_string(),
212 name: "Your profiles".to_string(),
213 description: format!("Saved in {}", directory.display()),
214 multi_select: true,
215 items: profiles
216 .iter()
217 .map(|(slug, profile)| Item::Entry(entry(slug, profile)))
218 .collect(),
219 }),
220 );
221 }
222 problems
223}
224
225pub fn from_queue(name: &str, entries: &[Entry]) -> Profile {
228 let mut ids: Vec<String> = Vec::new();
229 for entry in entries {
230 let members = match &entry.action {
231 Action::Profile { entries } => entries.clone(),
232 _ => vec![entry.id.clone()],
233 };
234 for id in members {
235 if !ids.contains(&id) {
236 ids.push(id);
237 }
238 }
239 }
240 Profile {
241 name: name.trim().to_string(),
242 description: String::new(),
243 entries: ids,
244 }
245}
246
247#[cfg(test)]
248mod tests {
249 use super::*;
250
251 fn profile(name: &str, entries: &[&str]) -> Profile {
252 Profile {
253 name: name.into(),
254 description: String::new(),
255 entries: entries.iter().map(|id| id.to_string()).collect(),
256 }
257 }
258
259 #[test]
260 fn names_become_file_names() {
261 assert_eq!(slug("My Workstation!").as_deref(), Some("my-workstation"));
262 assert_eq!(
263 slug(" Gaming / RuneScape ").as_deref(),
264 Some("gaming-runescape")
265 );
266 assert_eq!(slug("???"), None);
267 assert_eq!(slug(""), None);
268 }
269
270 #[test]
271 fn profiles_are_saved_loaded_checked_and_listed() {
272 let catalog = Catalog::builtin(false).unwrap();
273 let directory = tempfile::tempdir().unwrap();
274 let saved = save(
275 directory.path(),
276 &profile("Dev Box", &["git", "ripgrep", "dotfiles-nvim"]),
277 &catalog,
278 )
279 .unwrap();
280 assert_eq!(saved, directory.path().join("dev-box.toml"));
281 assert!(
282 std::fs::read_to_string(&saved)
283 .unwrap()
284 .starts_with("# A HelixForge workstation profile")
285 );
286 assert_eq!(
287 read(&saved, &catalog).unwrap().entries,
288 ["git", "ripgrep", "dotfiles-nvim"]
289 );
290 save(directory.path(), &profile("dev box", &["git"]), &catalog).unwrap();
292 assert_eq!(read(&saved, &catalog).unwrap().entries, ["git"]);
293 assert!(matches!(
294 save(directory.path(), &profile("Bad", &["git", "no-such-entry"]), &catalog),
295 Err(ProfileError::Unknown(unknown)) if unknown == ["no-such-entry"]
296 ));
297 assert!(matches!(
298 save(directory.path(), &profile("Empty", &[]), &catalog),
299 Err(ProfileError::Empty)
300 ));
301 assert!(matches!(
302 save(directory.path(), &profile("!!!", &["git"]), &catalog),
303 Err(ProfileError::Name(_))
304 ));
305 assert!(matches!(
307 save(
308 directory.path(),
309 &profile("Evil\u{1b}[2J", &["git"]),
310 &catalog
311 ),
312 Err(ProfileError::Control)
313 ));
314 let unknown = check(&profile("x", &["git\u{1b}]52;c;x\u{7}"]), &catalog)
315 .unwrap_err()
316 .to_string();
317 assert!(!unknown.chars().any(char::is_control), "{unknown:?}");
318 let written = directory.path().join("evil.toml");
319 std::fs::write(&written, "name = \"Evil\\u001b[2J\"\nentries = [\"git\"]\n").unwrap();
320 assert!(matches!(
321 read(&written, &catalog),
322 Err(ProfileError::Control)
323 ));
324 std::fs::remove_file(&written).unwrap();
325 std::fs::write(directory.path().join("broken.toml"), "name = 3\n").unwrap();
326 let (found, problems) = load_all(directory.path(), &catalog);
327 assert_eq!(found.len(), 1);
328 assert_eq!(problems.len(), 1, "{problems:?}");
329 assert!(problems[0].contains("broken.toml"));
330 }
331
332 #[test]
333 fn they_appear_in_the_profiles_area_and_plan_like_any_profile() {
334 let mut catalog = Catalog::builtin(false).unwrap();
335 let directory = tempfile::tempdir().unwrap();
336 save(
337 directory.path(),
338 &profile("Dev Box", &["git", "ripgrep"]),
339 &catalog,
340 )
341 .unwrap();
342 assert!(add_to(&mut catalog, directory.path()).is_empty());
343 add_to(&mut catalog, directory.path());
345 let tab = catalog
346 .tabs
347 .iter()
348 .find(|tab| tab.id == "profiles")
349 .unwrap();
350 let groups: Vec<&str> = tab
351 .items
352 .iter()
353 .filter_map(|item| match item {
354 Item::Group(group) => Some(group.id.as_str()),
355 Item::Entry(_) => None,
356 })
357 .collect();
358 assert_eq!(groups, [GROUP]);
359 let entry = catalog.find("profile-dev-box").unwrap();
360 assert_eq!(entry.name, "Dev Box");
361 assert!(
362 matches!(&entry.action, Action::Profile { entries } if entries == &["git", "ripgrep"])
363 );
364 let platform = helixforge_platform::LinuxInfo {
365 id: "arch".into(),
366 pretty_name: "Arch Linux".into(),
367 id_like: vec![],
368 manager: Some(helixforge_core::PackageManager::Arch),
369 };
370 let nothing = crate::Machine {
371 holds: &|_| false,
372 on_path: &|_| false,
373 };
374 let planned =
375 crate::plan_with([entry], &platform, &|id| catalog.find(id), nothing).unwrap();
376 let ids: Vec<&str> = planned.steps.iter().map(crate::Step::id).collect();
377 assert_eq!(ids, ["git", "ripgrep"]);
378 }
379
380 #[test]
381 fn a_queue_becomes_a_flat_profile() {
382 let catalog = Catalog::builtin(false).unwrap();
383 let queue: Vec<Entry> = ["developer-basics", "git", "meld"]
384 .iter()
385 .map(|id| catalog.find(id).unwrap().clone())
386 .collect();
387 let made = from_queue(" Mine ", &queue);
388 assert_eq!(made.name, "Mine");
389 assert_eq!(made.entries, ["git", "ripgrep", "fzf", "neovim", "meld"]);
390 }
391}