Skip to main content

helixforge_engine/
profiles.rs

1//! Workstation profiles of the user's own: a name, a description and the
2//! entries to run, kept as TOML in `$XDG_CONFIG_HOME/helixforge/profiles/`,
3//! exported and imported as the same files. Each becomes a profile entry in
4//! the catalog's Profiles area, under "Your profiles", so it is planned,
5//! queued and run like the built-in ones.
6
7use helixforge_catalog::Catalog;
8use helixforge_core::{Action, Entry, Group, Item, printable, valid_id};
9use serde::{Deserialize, Serialize};
10use std::path::{Path, PathBuf};
11
12/// Prefix of the ids user profiles have in the catalog.
13pub const PREFIX: &str = "profile-";
14/// The id of the group they appear in.
15pub const GROUP: &str = "your-profiles";
16
17/// A profile as stored and exported.
18#[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    /// Catalog entry ids, run in this order (dependencies still go first).
25    pub entries: Vec<String>,
26}
27
28/// Why a profile cannot be used.
29#[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
47/// `$XDG_CONFIG_HOME/helixforge/profiles`, or under `~/.config`.
48pub 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
56/// A file name from a profile name: lower case letters, digits and dashes.
57pub 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
70/// Read and check one profile file against the catalog.
71pub 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
80/// Every entry must exist in the catalog, there must be one, and the name
81/// and description must be plain text: a shared file could otherwise carry
82/// terminal escape sequences into the menu.
83pub 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
106/// Write a profile into `directory` under its name's slug; an existing one
107/// of that name is replaced. Returns the file.
108pub 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
120/// Write a profile to any file, as `export` does.
121pub 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    // Beside it first, then renamed, so a crash never leaves half a file.
131    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
140/// The profiles in `directory`, each with its slug; unreadable or invalid
141/// ones are returned as problems instead.
142pub 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
176/// A profile as a catalog entry.
177pub 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
197/// Add the user's profiles to the catalog's Profiles area as "Your profiles",
198/// replacing an earlier copy of that group. Returns the problems found.
199pub 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
224/// The entries of a planned queue as a profile: profile entries contribute
225/// their members, so a profile is never nested in another.
226pub 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        // Saving under the same name replaces it.
290        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        // Escape sequences in a shared file never reach the terminal.
305        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        // Adding again replaces the group rather than repeating it.
343        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}