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