Skip to main content

ralph_api/
preset_domain.rs

1use std::path::{Path, PathBuf};
2
3use serde::Deserialize;
4use serde::Serialize;
5use serde_yaml::Value;
6use tracing::warn;
7
8use crate::collection_domain::CollectionSummary;
9
10#[derive(Debug, Clone, Serialize)]
11#[serde(rename_all = "camelCase")]
12pub struct PresetRecord {
13    pub id: String,
14    pub name: String,
15    pub source: String,
16    #[serde(skip_serializing_if = "Option::is_none")]
17    pub description: Option<String>,
18    #[serde(skip_serializing_if = "Option::is_none")]
19    pub path: Option<String>,
20}
21
22#[derive(Debug, Clone)]
23pub struct PresetDomain {
24    workspace_root: PathBuf,
25}
26
27impl PresetDomain {
28    pub fn new(workspace_root: impl AsRef<Path>) -> Self {
29        Self {
30            workspace_root: workspace_root.as_ref().to_path_buf(),
31        }
32    }
33
34    pub fn list(&self, collections: &[CollectionSummary]) -> Vec<PresetRecord> {
35        let hats_dir = self.workspace_root.join(".ralph/hats");
36
37        let mut builtin = read_builtin_presets(&self.workspace_root);
38        let mut directory = read_presets_from_dir(&hats_dir, "directory", true);
39        let mut collection_presets: Vec<_> = collections
40            .iter()
41            .map(|collection| PresetRecord {
42                id: collection.id.clone(),
43                name: collection.name.clone(),
44                source: "collection".to_string(),
45                description: collection.description.clone(),
46                path: None,
47            })
48            .collect();
49
50        builtin.sort_by(|a, b| a.name.cmp(&b.name).then(a.id.cmp(&b.id)));
51        directory.sort_by(|a, b| a.name.cmp(&b.name).then(a.id.cmp(&b.id)));
52        collection_presets.sort_by(|a, b| a.name.cmp(&b.name).then(a.id.cmp(&b.id)));
53
54        let mut presets =
55            Vec::with_capacity(builtin.len() + directory.len() + collection_presets.len());
56        presets.extend(builtin);
57        presets.extend(directory);
58        presets.extend(collection_presets);
59        presets
60    }
61}
62
63#[derive(Debug, Deserialize)]
64struct BuiltinPresetIndexEntry {
65    name: String,
66    description: String,
67}
68
69fn read_builtin_presets(workspace_root: &Path) -> Vec<PresetRecord> {
70    let index_path = workspace_root.join("presets").join("index.json");
71    let content = match std::fs::read_to_string(&index_path) {
72        Ok(content) => content,
73        Err(error) => {
74            warn!(path = %index_path.display(), %error, "failed reading builtin preset index");
75            return read_presets_from_dir(&workspace_root.join("presets"), "builtin", false);
76        }
77    };
78
79    let mut entries: Vec<BuiltinPresetIndexEntry> = match serde_json::from_str(&content) {
80        Ok(entries) => entries,
81        Err(error) => {
82            warn!(path = %index_path.display(), %error, "failed parsing builtin preset index");
83            return read_presets_from_dir(&workspace_root.join("presets"), "builtin", false);
84        }
85    };
86
87    entries.sort_by(|a, b| a.name.cmp(&b.name));
88
89    entries
90        .into_iter()
91        .map(|entry| PresetRecord {
92            id: format!("builtin:{}", entry.name),
93            name: entry.name,
94            source: "builtin".to_string(),
95            description: Some(entry.description),
96            path: None,
97        })
98        .collect()
99}
100
101fn read_presets_from_dir(dir: &Path, source: &str, include_path: bool) -> Vec<PresetRecord> {
102    if !dir.exists() {
103        return Vec::new();
104    }
105
106    let Ok(entries) = std::fs::read_dir(dir) else {
107        return Vec::new();
108    };
109
110    let mut files: Vec<PathBuf> = entries
111        .filter_map(Result::ok)
112        .map(|entry| entry.path())
113        .filter(|path| path.is_file())
114        .filter(|path| path.extension().is_some_and(|extension| extension == "yml"))
115        .collect();
116
117    files.sort();
118
119    files
120        .into_iter()
121        .filter_map(|path| {
122            let file_stem = path.file_stem()?.to_str()?.to_string();
123            let description = read_preset_description(&path);
124
125            Some(PresetRecord {
126                id: format!("{source}:{file_stem}"),
127                name: file_stem,
128                source: source.to_string(),
129                description,
130                path: include_path.then(|| path.display().to_string()),
131            })
132        })
133        .collect()
134}
135
136fn read_preset_description(path: &Path) -> Option<String> {
137    let content = std::fs::read_to_string(path).ok()?;
138    let parsed: Value = match serde_yaml::from_str(&content) {
139        Ok(parsed) => parsed,
140        Err(error) => {
141            warn!(path = %path.display(), %error, "failed parsing preset yaml");
142            return None;
143        }
144    };
145
146    parsed
147        .as_mapping()
148        .and_then(|mapping| mapping.get(Value::String("description".to_string())))
149        .and_then(Value::as_str)
150        .map(std::string::ToString::to_string)
151}