ralph_api/
preset_domain.rs1use 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}