1use std::collections::BTreeMap;
27use std::path::{Path, PathBuf};
28
29use serde::Serialize;
30use serde_yaml_ng::Value;
31
32use crate::diag::Diagnostics;
33use crate::yaml::YamlFile;
34
35pub const PROFILES_FILE: &str = "profiles.yml";
36
37pub const LOCAL_TYPE: &str = "local";
40
41pub static BUILTIN_LOCAL: std::sync::LazyLock<ProfileTarget> = std::sync::LazyLock::new(|| ProfileTarget {
43 kind: LOCAL_TYPE.into(),
44 fields: serde_json::Map::new(),
45});
46
47#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
49pub enum Role {
50 Source,
51 Destination,
52}
53
54impl Role {
55 pub fn as_str(self) -> &'static str {
56 match self {
57 Role::Source => "source",
58 Role::Destination => "destination",
59 }
60 }
61 pub fn section(self) -> &'static str {
63 match self {
64 Role::Source => "sources",
65 Role::Destination => "destinations",
66 }
67 }
68}
69
70#[derive(Debug, Clone, Serialize)]
72pub struct ProfileTarget {
73 #[serde(rename = "type")]
74 pub kind: String,
75 #[serde(skip)]
77 pub fields: serde_json::Map<String, serde_json::Value>,
78}
79
80#[derive(Debug, Clone, Serialize)]
81pub struct Profile {
82 pub target: String,
84 pub targets: BTreeMap<String, ProfileTarget>,
85}
86
87#[derive(Debug, Clone, Default)]
88pub struct Profiles {
89 pub path: PathBuf,
91 pub found_by: &'static str,
93 pub file: Option<YamlFile>,
95 pub sources: BTreeMap<String, Profile>,
96 pub destinations: BTreeMap<String, Profile>,
97}
98
99pub fn profiles_dir(cli: Option<&Path>) -> PathBuf {
102 locate(cli, None).0
103}
104
105pub fn locate(cli: Option<&Path>, project: Option<&Path>) -> (PathBuf, &'static str) {
109 if let Some(p) = cli {
110 return (p.to_path_buf(), "--profiles-dir");
111 }
112 if let Some(p) = std::env::var_os("DRE_PROFILES_DIR").filter(|p| !p.is_empty()) {
113 return (PathBuf::from(p), "DRE_PROFILES_DIR");
114 }
115 if let Some(p) = project.filter(|p| p.join(PROFILES_FILE).is_file()) {
116 return (p.to_path_buf(), "the project directory");
117 }
118 (crate::dre_home(), "~/.dre")
119}
120
121impl Profiles {
122 pub fn load(dir: &Path, found_by: &'static str, diags: &mut Diagnostics) -> Profiles {
123 let path = crate::slash(&dir.join(PROFILES_FILE));
124 let mut out = Profiles {
125 path: path.clone(),
126 found_by,
127 ..Default::default()
128 };
129 if !path.is_file() {
130 return out;
131 }
132 let Some(yf) = YamlFile::load(&path, path.clone(), diags) else {
133 out.file = Some(YamlFile {
135 display: path,
136 text: String::new(),
137 value: Value::Null,
138 });
139 return out;
140 };
141 let file = Some(path.clone());
142 match &yf.value {
143 Value::Mapping(m) => {
144 for (k, v) in m {
145 let key = k.as_str().unwrap_or_default();
146 let role = match key {
147 "sources" => Role::Source,
148 "destinations" => Role::Destination,
149 _ => {
150 diags.error(
151 "invalid-profiles",
152 file.clone(),
153 yf.line_of(key, None),
154 format!(
155 "unknown profiles.yml section `{key}`; profiles go under `sources:` or `destinations:`"
156 ),
157 );
158 continue;
159 }
160 };
161 let section_line = yf.line_of(key, None);
162 let parsed = match v {
163 Value::Mapping(ps) => ps
164 .iter()
165 .filter_map(|(k, v)| {
166 let name = k.as_str()?;
167 parse_profile(role, name, v, &yf, section_line, diags)
168 .map(|p| (name.to_string(), p))
169 })
170 .collect(),
171 Value::Null => BTreeMap::new(),
172 _ => {
173 diags.error(
174 "invalid-profiles",
175 file.clone(),
176 section_line,
177 format!("`{key}` must be a map of profile names"),
178 );
179 BTreeMap::new()
180 }
181 };
182 match role {
183 Role::Source => out.sources = parsed,
184 Role::Destination => out.destinations = parsed,
185 }
186 }
187 }
188 Value::Null => {}
189 _ => diags.error(
190 "invalid-profiles",
191 file,
192 None,
193 "profiles.yml must be a map with `sources:` and/or `destinations:`",
194 ),
195 }
196 out.file = Some(yf);
197 out
198 }
199
200 pub fn exists(&self) -> bool {
201 self.file.is_some()
202 }
203
204 fn section(&self, role: Role) -> &BTreeMap<String, Profile> {
205 match role {
206 Role::Source => &self.sources,
207 Role::Destination => &self.destinations,
208 }
209 }
210
211 pub fn get(&self, role: Role, name: &str) -> Option<&Profile> {
212 self.section(role).get(name)
213 }
214
215 pub fn declares(&self, role: Role, name: &str) -> bool {
217 self.file
218 .as_ref()
219 .and_then(|f| f.value.get(role.section()))
220 .is_some_and(|s| s.get(name).is_some())
221 }
222
223 pub fn is_builtin_local(&self, name: &str) -> bool {
225 name == LOCAL_TYPE && !self.destinations.contains_key(name)
226 }
227
228 pub fn line_of(&self, role: Role, name: &str) -> Option<usize> {
230 let f = self.file.as_ref()?;
231 f.line_of(name, f.line_of(role.section(), None))
232 }
233
234 pub fn target(&self, role: Role, profile: &str, target: Option<&str>) -> Option<(&str, &ProfileTarget)> {
236 let p = self.get(role, profile)?;
237 let t = target.unwrap_or(&p.target);
238 p.targets.get_key_value(t).map(|(k, o)| (k.as_str(), o))
239 }
240}
241
242fn parse_profile(
243 role: Role,
244 name: &str,
245 v: &Value,
246 yf: &YamlFile,
247 section_line: Option<usize>,
248 diags: &mut Diagnostics,
249) -> Option<Profile> {
250 let file = Some(yf.display.clone());
251 let line = yf.line_of(name, section_line);
252 let what = format!("{} profile `{name}`", role.as_str());
253 let Some(m) = v.as_mapping() else {
254 diags.error(
255 "invalid-profile",
256 file,
257 line,
258 format!("{what} must be a map with `target` and `targets`"),
259 );
260 return None;
261 };
262 let target = m.get("target").and_then(Value::as_str);
263 let targets = m.get("targets").and_then(Value::as_mapping);
264 let (Some(target), Some(targets)) = (target, targets) else {
265 let hint = if m.contains_key("outputs") {
266 " (`outputs:` is now `targets:`)"
267 } else {
268 ""
269 };
270 diags.error(
271 "invalid-profile",
272 file,
273 line,
274 format!("{what} needs a `target` and a map of named `targets`{hint}"),
275 );
276 return None;
277 };
278 let mut parsed = BTreeMap::new();
279 let mut ok = true;
280 for (k, o) in targets {
281 let Some(tname) = k.as_str() else { continue };
282 let kind = o.get("type").and_then(Value::as_str);
283 let Some(kind) = kind else {
284 diags.error(
285 "invalid-profile",
286 file.clone(),
287 yf.line_of(tname, line),
288 format!("target `{tname}` of {what} has no `type`"),
289 );
290 ok = false;
291 continue;
292 };
293 let fields = match serde_json::to_value(o) {
294 Ok(serde_json::Value::Object(mut f)) => {
295 f.remove("type");
296 f
297 }
298 _ => serde_json::Map::new(),
299 };
300 parsed.insert(
301 tname.to_string(),
302 ProfileTarget {
303 kind: kind.to_string(),
304 fields,
305 },
306 );
307 }
308 if !parsed.contains_key(target) && ok {
309 diags.error(
310 "invalid-profile",
311 file,
312 yf.line_of("target", line),
313 format!(
314 "{what} has target `{target}`, which isn't one of its targets ({})",
315 parsed
316 .keys()
317 .map(|k| format!("`{k}`"))
318 .collect::<Vec<_>>()
319 .join(", ")
320 ),
321 );
322 return None;
323 }
324 ok.then_some(Profile {
325 target: target.to_string(),
326 targets: parsed,
327 })
328}