1use std::collections::HashMap;
2use std::path::Path;
3
4use yaml_rust2::yaml::Hash;
5use yaml_rust2::Yaml;
6
7use crate::config::yaml_decode::{
8 hash_get, load_yaml, validate_known_keys, yaml_array, yaml_hash, yaml_string,
9};
10use crate::profile::types::{
11 ProfileConfig, ProfileExecution, ProfileMetadata, ProfileRunner, ProfileRunnerAuth,
12 ProfileValidation, PROFILE_API_VERSION, PROFILE_KIND,
13};
14use crate::{ConfigError, FloeResult};
15
16pub fn parse_profile(path: &Path) -> FloeResult<ProfileConfig> {
18 let docs = load_yaml(path)?;
19 if docs.is_empty() {
20 return Err(Box::new(ConfigError("profile YAML is empty".to_string())));
21 }
22 if docs.len() > 1 {
23 return Err(Box::new(ConfigError(
24 "profile YAML contains multiple documents; expected one".to_string(),
25 )));
26 }
27 parse_profile_doc(&docs[0])
28}
29
30pub fn parse_profile_from_str(contents: &str) -> FloeResult<ProfileConfig> {
32 use yaml_rust2::YamlLoader;
33 let docs = YamlLoader::load_from_str(contents)
34 .map_err(|e| Box::new(ConfigError(format!("YAML parse error: {e}"))))?;
35 if docs.is_empty() {
36 return Err(Box::new(ConfigError("profile YAML is empty".to_string())));
37 }
38 if docs.len() > 1 {
39 return Err(Box::new(ConfigError(
40 "profile YAML contains multiple documents; expected one".to_string(),
41 )));
42 }
43 parse_profile_doc(&docs[0])
44}
45
46fn parse_profile_doc(doc: &Yaml) -> FloeResult<ProfileConfig> {
47 let root = yaml_hash(doc, "profile")?;
48 validate_known_keys(
49 root,
50 "profile",
51 &[
52 "apiVersion",
53 "kind",
54 "metadata",
55 "execution",
56 "variables",
57 "validation",
58 ],
59 )?;
60
61 let api_version = get_required_string(root, "apiVersion", "profile")?;
62 if api_version != PROFILE_API_VERSION {
63 return Err(Box::new(ConfigError(format!(
64 "profile.apiVersion: expected \"{PROFILE_API_VERSION}\", got \"{api_version}\""
65 ))));
66 }
67
68 let kind = get_required_string(root, "kind", "profile")?;
69 if kind != PROFILE_KIND {
70 return Err(Box::new(ConfigError(format!(
71 "profile.kind: expected \"{PROFILE_KIND}\", got \"{kind}\""
72 ))));
73 }
74
75 let metadata_yaml = hash_get(root, "metadata").ok_or_else(|| {
76 Box::new(ConfigError("profile.metadata is required".to_string()))
77 as Box<dyn std::error::Error + Send + Sync>
78 })?;
79 let metadata = parse_metadata(metadata_yaml)?;
80
81 let execution = match hash_get(root, "execution") {
82 Some(value) => Some(parse_execution(value)?),
83 None => None,
84 };
85
86 let variables = match hash_get(root, "variables") {
87 Some(value) => parse_variables(value)?,
88 None => HashMap::new(),
89 };
90
91 let validation = match hash_get(root, "validation") {
92 Some(value) => Some(parse_validation(value)?),
93 None => None,
94 };
95
96 Ok(ProfileConfig {
97 api_version,
98 kind,
99 metadata,
100 execution,
101 variables,
102 validation,
103 })
104}
105
106fn parse_metadata(value: &Yaml) -> FloeResult<ProfileMetadata> {
107 let hash = yaml_hash(value, "profile.metadata")?;
108 validate_known_keys(
109 hash,
110 "profile.metadata",
111 &["name", "description", "env", "tags"],
112 )?;
113
114 let name = get_required_string(hash, "name", "profile.metadata")?;
115 let description = get_optional_string(hash, "description", "profile.metadata")?;
116 let env = get_optional_string(hash, "env", "profile.metadata")?;
117
118 let tags = match hash_get(hash, "tags") {
119 Some(value) => {
120 let arr = yaml_array(value, "profile.metadata.tags")?;
121 let mut tags = Vec::with_capacity(arr.len());
122 for item in arr {
123 tags.push(yaml_string(item, "profile.metadata.tags[]")?);
124 }
125 Some(tags)
126 }
127 None => None,
128 };
129
130 Ok(ProfileMetadata {
131 name,
132 description,
133 env,
134 tags,
135 })
136}
137
138fn parse_execution(value: &Yaml) -> FloeResult<ProfileExecution> {
139 let hash = yaml_hash(value, "profile.execution")?;
140 validate_known_keys(hash, "profile.execution", &["runner"])?;
141
142 let runner_yaml = hash_get(hash, "runner").ok_or_else(|| {
143 Box::new(ConfigError(
144 "profile.execution.runner is required".to_string(),
145 )) as Box<dyn std::error::Error + Send + Sync>
146 })?;
147 let runner = parse_runner(runner_yaml)?;
148
149 Ok(ProfileExecution { runner })
150}
151
152fn parse_runner(value: &Yaml) -> FloeResult<ProfileRunner> {
153 let hash = yaml_hash(value, "profile.execution.runner")?;
154 validate_known_keys(
155 hash,
156 "profile.execution.runner",
157 &[
158 "type",
159 "command",
160 "args",
161 "timeout_seconds",
162 "ttl_seconds_after_finished",
163 "poll_interval_seconds",
164 "secrets",
165 "workspace_url",
166 "existing_cluster_id",
167 "config_uri",
168 "python_file_uri",
169 "job_name",
170 "auth",
171 "env_parameters",
172 ],
173 )?;
174
175 let runner_type = get_required_string(hash, "type", "profile.execution.runner")?;
176
177 let command = get_optional_string(hash, "command", "profile.execution.runner")?;
178 let args = get_optional_string_list(hash, "args", "profile.execution.runner")?;
179 let timeout_seconds = get_optional_u64(hash, "timeout_seconds", "profile.execution.runner")?;
180 let ttl_seconds_after_finished = get_optional_u64(
181 hash,
182 "ttl_seconds_after_finished",
183 "profile.execution.runner",
184 )?;
185 let poll_interval_seconds =
186 get_optional_u64(hash, "poll_interval_seconds", "profile.execution.runner")?;
187 let secrets = get_optional_string_list(hash, "secrets", "profile.execution.runner")?;
188 let workspace_url = get_optional_string(hash, "workspace_url", "profile.execution.runner")?;
189 let existing_cluster_id =
190 get_optional_string(hash, "existing_cluster_id", "profile.execution.runner")?;
191 let config_uri = get_optional_string(hash, "config_uri", "profile.execution.runner")?;
192 let python_file_uri = get_optional_string(hash, "python_file_uri", "profile.execution.runner")?;
193 let job_name = get_optional_string(hash, "job_name", "profile.execution.runner")?;
194 let auth = parse_runner_auth(hash_get(hash, "auth"))?;
195 let env_parameters = match hash_get(hash, "env_parameters") {
196 Some(value) => Some(extract_string_map(
197 yaml_hash(value, "profile.execution.runner.env_parameters")?,
198 "profile.execution.runner.env_parameters",
199 )?),
200 None => None,
201 };
202
203 Ok(ProfileRunner {
204 runner_type,
205 command,
206 args,
207 timeout_seconds,
208 ttl_seconds_after_finished,
209 poll_interval_seconds,
210 secrets,
211 workspace_url,
212 existing_cluster_id,
213 config_uri,
214 python_file_uri,
215 job_name,
216 auth,
217 env_parameters,
218 })
219}
220
221fn parse_runner_auth(value: Option<&Yaml>) -> FloeResult<Option<ProfileRunnerAuth>> {
222 let Some(value) = value else {
223 return Ok(None);
224 };
225
226 let hash = yaml_hash(value, "profile.execution.runner.auth")?;
227 validate_known_keys(
228 hash,
229 "profile.execution.runner.auth",
230 &["service_principal_oauth_ref"],
231 )?;
232
233 Ok(Some(ProfileRunnerAuth {
234 service_principal_oauth_ref: get_optional_string(
235 hash,
236 "service_principal_oauth_ref",
237 "profile.execution.runner.auth",
238 )?,
239 }))
240}
241
242fn parse_variables(value: &Yaml) -> FloeResult<HashMap<String, String>> {
243 let hash = yaml_hash(value, "profile.variables")?;
244 extract_string_map(hash, "profile.variables")
245}
246
247fn parse_validation(value: &Yaml) -> FloeResult<ProfileValidation> {
248 let hash = yaml_hash(value, "profile.validation")?;
249 validate_known_keys(hash, "profile.validation", &["strict"])?;
250
251 let strict = match hash_get(hash, "strict") {
252 Some(Yaml::Boolean(b)) => Some(*b),
253 Some(_) => {
254 return Err(Box::new(ConfigError(
255 "profile.validation.strict must be a boolean".to_string(),
256 )))
257 }
258 None => None,
259 };
260
261 Ok(ProfileValidation { strict })
262}
263
264fn get_required_string(hash: &Hash, key: &str, ctx: &str) -> FloeResult<String> {
265 let value = hash_get(hash, key).ok_or_else(|| {
266 Box::new(ConfigError(format!("{ctx}.{key} is required")))
267 as Box<dyn std::error::Error + Send + Sync>
268 })?;
269 yaml_string(value, &format!("{ctx}.{key}"))
270}
271
272fn get_optional_string(hash: &Hash, key: &str, ctx: &str) -> FloeResult<Option<String>> {
273 match hash_get(hash, key) {
274 None => Ok(None),
275 Some(value) => yaml_string(value, &format!("{ctx}.{key}")).map(Some),
276 }
277}
278
279fn get_optional_string_list(hash: &Hash, key: &str, ctx: &str) -> FloeResult<Option<Vec<String>>> {
280 match hash_get(hash, key) {
281 None => Ok(None),
282 Some(value) => {
283 let arr = yaml_array(value, &format!("{ctx}.{key}"))?;
284 let mut items = Vec::with_capacity(arr.len());
285 for item in arr {
286 items.push(yaml_string(item, &format!("{ctx}.{key}[]"))?);
287 }
288 Ok(Some(items))
289 }
290 }
291}
292
293fn get_optional_u64(hash: &Hash, key: &str, ctx: &str) -> FloeResult<Option<u64>> {
294 match hash_get(hash, key) {
295 None => Ok(None),
296 Some(Yaml::Integer(v)) if *v >= 0 => Ok(Some(*v as u64)),
297 Some(_) => Err(Box::new(ConfigError(format!(
298 "{ctx}.{key} must be a non-negative integer"
299 )))),
300 }
301}
302
303fn extract_string_map(hash: &Hash, context: &str) -> FloeResult<HashMap<String, String>> {
304 let mut map = HashMap::new();
305 for (key, value) in hash {
306 let key_str = yaml_string(key, context)?;
307 let value_str = yaml_string(value, context)?;
308 map.insert(key_str, value_str);
309 }
310 Ok(map)
311}