1use std::collections::BTreeMap;
10use std::path::{Path, PathBuf};
11
12use serde::{Deserialize, Serialize};
13use thiserror::Error;
14
15pub const WORKSPACE_FILE: &str = "rigg.yaml";
16pub const PROJECT_FILE: &str = "project.yaml";
17pub const PROJECTS_DIR: &str = "projects";
18pub const APIS_DIR: &str = "apis";
19pub const STATE_DIR: &str = ".rigg";
20
21#[derive(Debug, Error)]
22pub enum WorkspaceError {
23 #[error("no {WORKSPACE_FILE} found in {0} or any parent directory")]
24 NotFound(PathBuf),
25 #[error("failed to read {path}: {source}")]
26 Io {
27 path: PathBuf,
28 source: std::io::Error,
29 },
30 #[error("failed to parse {path}: {source}")]
31 Parse {
32 path: PathBuf,
33 source: serde_yaml::Error,
34 },
35 #[error("unknown project '{0}' (available: {1})")]
36 UnknownProject(String, String),
37 #[error("unknown environment '{0}' (available: {1})")]
38 UnknownEnvironment(String, String),
39 #[error(
40 "no default environment configured; pass --env or set `default: true` on one environment"
41 )]
42 NoDefaultEnvironment,
43 #[error(
44 "environment '{env}' has {count} {kind} connections; set `{kind}-connection` in project.yaml for project '{project}'"
45 )]
46 AmbiguousConnection {
47 env: String,
48 kind: &'static str,
49 count: usize,
50 project: String,
51 },
52 #[error("environment '{env}' has no {kind} connection (required by project '{project}')")]
53 MissingConnection {
54 env: String,
55 kind: &'static str,
56 project: String,
57 },
58 #[error(
59 "project '{project}' pins {kind} connection '{name}' which does not exist in environment '{env}'"
60 )]
61 UnknownConnection {
62 project: String,
63 kind: &'static str,
64 name: String,
65 env: String,
66 },
67}
68
69type Result<T> = std::result::Result<T, WorkspaceError>;
70
71#[derive(Debug, Clone, Default, Serialize, Deserialize)]
73#[serde(deny_unknown_fields)]
74pub struct WorkspaceConfig {
75 #[serde(default, skip_serializing_if = "Option::is_none")]
76 pub name: Option<String>,
77 #[serde(default)]
78 pub environments: BTreeMap<String, Environment>,
79 #[serde(default, skip_serializing_if = "Defaults::is_empty")]
80 pub defaults: Defaults,
81}
82
83#[derive(Debug, Clone, Default, Serialize, Deserialize)]
84#[serde(deny_unknown_fields)]
85pub struct Defaults {
86 #[serde(default, skip_serializing_if = "Option::is_none")]
88 pub identity: Option<String>,
89}
90
91impl Defaults {
92 fn is_empty(&self) -> bool {
93 self.identity.is_none()
94 }
95}
96
97#[derive(Debug, Clone, Default, Serialize, Deserialize)]
98#[serde(deny_unknown_fields)]
99pub struct Environment {
100 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
101 pub default: bool,
102 #[serde(default, skip_serializing_if = "ConnectionList::is_empty")]
103 pub search: ConnectionList<SearchConnection>,
104 #[serde(default, skip_serializing_if = "ConnectionList::is_empty")]
105 pub foundry: ConnectionList<FoundryConnection>,
106}
107
108#[derive(Debug, Clone, Serialize, Deserialize)]
110#[serde(untagged)]
111pub enum ConnectionList<T> {
112 One(T),
113 Many(Vec<T>),
114}
115
116impl<T> Default for ConnectionList<T> {
117 fn default() -> Self {
118 ConnectionList::Many(Vec::new())
119 }
120}
121
122impl<T> ConnectionList<T> {
123 pub fn as_slice(&self) -> &[T] {
124 match self {
125 ConnectionList::One(one) => std::slice::from_ref(one),
126 ConnectionList::Many(many) => many.as_slice(),
127 }
128 }
129
130 pub fn is_empty(&self) -> bool {
131 self.as_slice().is_empty()
132 }
133}
134
135#[derive(Debug, Clone, Serialize, Deserialize)]
136#[serde(deny_unknown_fields)]
137pub struct SearchConnection {
138 #[serde(default, skip_serializing_if = "Option::is_none")]
139 pub name: Option<String>,
140 pub service: String,
142 #[serde(default, skip_serializing_if = "Option::is_none")]
145 pub endpoint: Option<String>,
146 #[serde(
148 default,
149 rename = "api-version",
150 skip_serializing_if = "Option::is_none"
151 )]
152 pub api_version: Option<String>,
153 #[serde(
155 default,
156 rename = "preview-api-version",
157 skip_serializing_if = "Option::is_none"
158 )]
159 pub preview_api_version: Option<String>,
160}
161
162#[derive(Debug, Clone, Serialize, Deserialize)]
163#[serde(deny_unknown_fields)]
164pub struct FoundryConnection {
165 #[serde(default, skip_serializing_if = "Option::is_none")]
166 pub name: Option<String>,
167 pub account: String,
169 #[serde(default, skip_serializing_if = "Option::is_none")]
172 pub endpoint: Option<String>,
173 pub project: String,
175 #[serde(
177 default,
178 rename = "api-version",
179 skip_serializing_if = "Option::is_none"
180 )]
181 pub api_version: Option<String>,
182}
183
184#[derive(Debug, Clone, Default, Serialize, Deserialize)]
186#[serde(deny_unknown_fields)]
187pub struct ProjectManifest {
188 #[serde(default, skip_serializing_if = "Option::is_none")]
189 pub description: Option<String>,
190 #[serde(
192 default,
193 rename = "search-connection",
194 skip_serializing_if = "Option::is_none"
195 )]
196 pub search_connection: Option<String>,
197 #[serde(
198 default,
199 rename = "foundry-connection",
200 skip_serializing_if = "Option::is_none"
201 )]
202 pub foundry_connection: Option<String>,
203}
204
205#[derive(Debug, Clone)]
206pub struct Project {
207 pub name: String,
208 pub dir: PathBuf,
209 pub manifest: ProjectManifest,
210}
211
212#[derive(Debug, Clone)]
213pub struct Workspace {
214 pub root: PathBuf,
215 pub config: WorkspaceConfig,
216 pub projects: Vec<Project>,
217}
218
219#[derive(Debug, Clone)]
220pub struct ResolvedEnv {
221 pub name: String,
222 pub env: Environment,
223}
224
225impl Workspace {
226 pub fn discover(start: &Path) -> Result<Workspace> {
229 let start = if start.as_os_str().is_empty() {
230 Path::new(".")
231 } else {
232 start
233 };
234 let mut dir = start.canonicalize().map_err(|source| WorkspaceError::Io {
235 path: start.to_path_buf(),
236 source,
237 })?;
238 loop {
239 if dir.join(WORKSPACE_FILE).is_file() {
240 return Workspace::load(&dir);
241 }
242 if !dir.pop() {
243 return Err(WorkspaceError::NotFound(start.to_path_buf()));
244 }
245 }
246 }
247
248 pub fn load(root: &Path) -> Result<Workspace> {
250 let path = root.join(WORKSPACE_FILE);
251 let text = std::fs::read_to_string(&path).map_err(|source| WorkspaceError::Io {
252 path: path.clone(),
253 source,
254 })?;
255 let config: WorkspaceConfig =
256 serde_yaml::from_str(&text).map_err(|source| WorkspaceError::Parse { path, source })?;
257
258 let mut projects = Vec::new();
259 let projects_dir = root.join(PROJECTS_DIR);
260 if projects_dir.is_dir() {
261 let mut entries: Vec<_> = std::fs::read_dir(&projects_dir)
262 .map_err(|source| WorkspaceError::Io {
263 path: projects_dir.clone(),
264 source,
265 })?
266 .filter_map(|e| e.ok())
267 .map(|e| e.path())
268 .filter(|p| p.is_dir() && p.join(PROJECT_FILE).is_file())
269 .collect();
270 entries.sort();
271 for dir in entries {
272 let manifest_path = dir.join(PROJECT_FILE);
273 let text = std::fs::read_to_string(&manifest_path).map_err(|source| {
274 WorkspaceError::Io {
275 path: manifest_path.clone(),
276 source,
277 }
278 })?;
279 let manifest: ProjectManifest =
280 serde_yaml::from_str(&text).map_err(|source| WorkspaceError::Parse {
281 path: manifest_path,
282 source,
283 })?;
284 let name = dir
285 .file_name()
286 .expect("project dir has a name")
287 .to_string_lossy()
288 .into_owned();
289 projects.push(Project {
290 name,
291 dir,
292 manifest,
293 });
294 }
295 }
296
297 Ok(Workspace {
298 root: root.to_path_buf(),
299 config,
300 projects,
301 })
302 }
303
304 pub fn project(&self, name: &str) -> Result<&Project> {
305 self.projects
306 .iter()
307 .find(|p| p.name == name)
308 .ok_or_else(|| {
309 WorkspaceError::UnknownProject(
310 name.to_string(),
311 self.projects
312 .iter()
313 .map(|p| p.name.as_str())
314 .collect::<Vec<_>>()
315 .join(", "),
316 )
317 })
318 }
319
320 pub fn resolve_env(&self, selected: Option<&str>) -> Result<ResolvedEnv> {
322 let from_env = std::env::var("RIGG_ENV").ok();
323 let name = selected
324 .map(str::to_string)
325 .or(from_env)
326 .or_else(|| self.default_env_name().map(str::to_string))
327 .ok_or(WorkspaceError::NoDefaultEnvironment)?;
328 let env = self.config.environments.get(&name).ok_or_else(|| {
329 WorkspaceError::UnknownEnvironment(
330 name.clone(),
331 self.config
332 .environments
333 .keys()
334 .map(String::as_str)
335 .collect::<Vec<_>>()
336 .join(", "),
337 )
338 })?;
339 Ok(ResolvedEnv {
340 name,
341 env: env.clone(),
342 })
343 }
344
345 pub fn default_env_name(&self) -> Option<&str> {
346 self.config
347 .environments
348 .iter()
349 .find(|(_, e)| e.default)
350 .map(|(n, _)| n.as_str())
351 }
352
353 pub fn apis_dir(&self) -> PathBuf {
354 self.root.join(APIS_DIR)
355 }
356
357 pub fn state_dir(&self, env: &str, project: &str) -> PathBuf {
358 self.root.join(STATE_DIR).join(env).join(project)
359 }
360}
361
362impl ResolvedEnv {
363 pub fn search_for(&self, project: &Project) -> Result<&SearchConnection> {
364 pick_connection(
365 &self.name,
366 "search",
367 self.env.search.as_slice(),
368 project,
369 project.manifest.search_connection.as_deref(),
370 |c| c.name.as_deref(),
371 )
372 }
373
374 pub fn foundry_for(&self, project: &Project) -> Result<&FoundryConnection> {
375 pick_connection(
376 &self.name,
377 "foundry",
378 self.env.foundry.as_slice(),
379 project,
380 project.manifest.foundry_connection.as_deref(),
381 |c| c.name.as_deref(),
382 )
383 }
384
385 pub fn has_search(&self) -> bool {
386 !self.env.search.is_empty()
387 }
388
389 pub fn has_foundry(&self) -> bool {
390 !self.env.foundry.is_empty()
391 }
392}
393
394fn pick_connection<'a, T>(
395 env_name: &str,
396 kind: &'static str,
397 conns: &'a [T],
398 project: &Project,
399 pinned: Option<&str>,
400 name_of: impl Fn(&T) -> Option<&str>,
401) -> Result<&'a T> {
402 match (pinned, conns.len()) {
403 (_, 0) => Err(WorkspaceError::MissingConnection {
404 env: env_name.to_string(),
405 kind,
406 project: project.name.clone(),
407 }),
408 (Some(pin), _) => conns
409 .iter()
410 .find(|c| name_of(c) == Some(pin))
411 .ok_or_else(|| WorkspaceError::UnknownConnection {
412 project: project.name.clone(),
413 kind,
414 name: pin.to_string(),
415 env: env_name.to_string(),
416 }),
417 (None, 1) => Ok(&conns[0]),
418 (None, n) => Err(WorkspaceError::AmbiguousConnection {
419 env: env_name.to_string(),
420 kind,
421 count: n,
422 project: project.name.clone(),
423 }),
424 }
425}
426
427#[cfg(test)]
428mod tests {
429 use super::*;
430
431 fn ws_yaml_single() -> &'static str {
432 r#"
433name: demo
434environments:
435 dev:
436 default: true
437 search: { service: mklabsrch }
438 foundry: { account: mklabaifndr, project: proj-default }
439 prod:
440 search: { service: mklabsrch-prod, api-version: 2026-04-01 }
441"#
442 }
443
444 fn ws_yaml_multi() -> &'static str {
445 r#"
446environments:
447 dev:
448 default: true
449 search:
450 - name: primary
451 service: srch-a
452 - name: secondary
453 service: srch-b
454"#
455 }
456
457 fn make_ws(dir: &Path, yaml: &str, projects: &[(&str, &str)]) -> Workspace {
458 std::fs::write(dir.join(WORKSPACE_FILE), yaml).unwrap();
459 for (name, manifest) in projects {
460 let pdir = dir.join(PROJECTS_DIR).join(name);
461 std::fs::create_dir_all(&pdir).unwrap();
462 std::fs::write(pdir.join(PROJECT_FILE), manifest).unwrap();
463 }
464 Workspace::load(dir).unwrap()
465 }
466
467 #[test]
468 fn parses_single_connection_env() {
469 let tmp = tempfile::tempdir().unwrap();
470 let ws = make_ws(
471 tmp.path(),
472 ws_yaml_single(),
473 &[("p1", "description: test\n")],
474 );
475 let dev = ws.resolve_env(Some("dev")).unwrap();
476 let p1 = ws.project("p1").unwrap();
477 assert_eq!(dev.search_for(p1).unwrap().service, "mklabsrch");
478 let f = dev.foundry_for(p1).unwrap();
479 assert_eq!(
480 (f.account.as_str(), f.project.as_str()),
481 ("mklabaifndr", "proj-default")
482 );
483 }
484
485 #[test]
486 fn parses_multi_connection_env_and_requires_pin() {
487 let tmp = tempfile::tempdir().unwrap();
488 let ws = make_ws(
489 tmp.path(),
490 ws_yaml_multi(),
491 &[
492 ("pinned", "search-connection: secondary\n"),
493 ("unpinned", "{}\n"),
494 ],
495 );
496 let dev = ws.resolve_env(None).unwrap();
497 let pinned = ws.project("pinned").unwrap();
498 assert_eq!(dev.search_for(pinned).unwrap().service, "srch-b");
499 let unpinned = ws.project("unpinned").unwrap();
500 assert!(matches!(
501 dev.search_for(unpinned),
502 Err(WorkspaceError::AmbiguousConnection { count: 2, .. })
503 ));
504 }
505
506 #[test]
507 fn env_resolution_precedence() {
508 let tmp = tempfile::tempdir().unwrap();
509 let ws = make_ws(tmp.path(), ws_yaml_single(), &[]);
510 assert_eq!(ws.resolve_env(Some("prod")).unwrap().name, "prod");
512 assert_eq!(ws.resolve_env(None).unwrap().name, "dev");
514 assert!(matches!(
516 ws.resolve_env(Some("staging")),
517 Err(WorkspaceError::UnknownEnvironment(..))
518 ));
519 }
520
521 #[test]
522 fn no_default_env_errors() {
523 let tmp = tempfile::tempdir().unwrap();
524 let ws = make_ws(
525 tmp.path(),
526 "environments:\n a:\n search: { service: s }\n",
527 &[],
528 );
529 assert!(matches!(
530 ws.resolve_env(None),
531 Err(WorkspaceError::NoDefaultEnvironment)
532 ));
533 }
534
535 #[test]
536 fn discover_walks_up_and_finds_projects() {
537 let tmp = tempfile::tempdir().unwrap();
538 make_ws(
539 tmp.path(),
540 ws_yaml_single(),
541 &[("alpha", "{}\n"), ("beta", "description: b\n")],
542 );
543 let nested = tmp.path().join(PROJECTS_DIR).join("alpha").join("search");
544 std::fs::create_dir_all(&nested).unwrap();
545 let ws = Workspace::discover(&nested).unwrap();
546 assert_eq!(
547 ws.projects
548 .iter()
549 .map(|p| p.name.as_str())
550 .collect::<Vec<_>>(),
551 vec!["alpha", "beta"]
552 );
553 assert!(matches!(
554 ws.project("gamma"),
555 Err(WorkspaceError::UnknownProject(..))
556 ));
557 }
558
559 #[test]
560 fn missing_connection_errors() {
561 let tmp = tempfile::tempdir().unwrap();
562 let ws = make_ws(
563 tmp.path(),
564 "environments:\n dev:\n default: true\n search: { service: s }\n",
565 &[("p", "{}\n")],
566 );
567 let dev = ws.resolve_env(None).unwrap();
568 let p = ws.project("p").unwrap();
569 assert!(matches!(
570 dev.foundry_for(p),
571 Err(WorkspaceError::MissingConnection {
572 kind: "foundry",
573 ..
574 })
575 ));
576 }
577}