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
188impl SearchConnection {
189 pub fn url(&self) -> String {
192 match &self.endpoint {
193 Some(e) => e.trim_end_matches('/').to_string(),
194 None => format!("https://{}.search.windows.net", self.service),
195 }
196 }
197}
198
199#[derive(Debug, Clone, Serialize, Deserialize)]
200#[serde(deny_unknown_fields)]
201pub struct FoundryConnection {
202 #[serde(default, skip_serializing_if = "Option::is_none")]
203 pub name: Option<String>,
204 pub account: String,
206 #[serde(default, skip_serializing_if = "Option::is_none")]
209 pub endpoint: Option<String>,
210 pub project: String,
212 #[serde(
214 default,
215 rename = "api-version",
216 skip_serializing_if = "Option::is_none"
217 )]
218 pub api_version: Option<String>,
219}
220
221impl FoundryConnection {
222 pub fn url(&self) -> String {
225 match &self.endpoint {
226 Some(e) => e.trim_end_matches('/').to_string(),
227 None => format!("https://{}.services.ai.azure.com", self.account),
228 }
229 }
230}
231
232#[derive(Debug, Clone, Default, Serialize, Deserialize)]
234#[serde(deny_unknown_fields)]
235pub struct ProjectManifest {
236 #[serde(default, skip_serializing_if = "Option::is_none")]
237 pub description: Option<String>,
238 #[serde(
240 default,
241 rename = "search-connection",
242 skip_serializing_if = "Option::is_none"
243 )]
244 pub search_connection: Option<String>,
245 #[serde(
246 default,
247 rename = "foundry-connection",
248 skip_serializing_if = "Option::is_none"
249 )]
250 pub foundry_connection: Option<String>,
251}
252
253#[derive(Debug, Clone)]
254pub struct Project {
255 pub name: String,
256 pub dir: PathBuf,
257 pub manifest: ProjectManifest,
258}
259
260#[derive(Debug, Clone)]
261pub struct Workspace {
262 pub root: PathBuf,
263 pub config: WorkspaceConfig,
264 pub projects: Vec<Project>,
265}
266
267#[derive(Debug, Clone)]
268pub struct ResolvedEnv {
269 pub name: String,
270 pub env: Environment,
271}
272
273impl Workspace {
274 pub fn discover(start: &Path) -> Result<Workspace> {
277 let start = if start.as_os_str().is_empty() {
278 Path::new(".")
279 } else {
280 start
281 };
282 let mut dir = start.canonicalize().map_err(|source| WorkspaceError::Io {
283 path: start.to_path_buf(),
284 source,
285 })?;
286 loop {
287 if dir.join(WORKSPACE_FILE).is_file() {
288 return Workspace::load(&dir);
289 }
290 if !dir.pop() {
291 return Err(WorkspaceError::NotFound(start.to_path_buf()));
292 }
293 }
294 }
295
296 pub fn load(root: &Path) -> Result<Workspace> {
298 let path = root.join(WORKSPACE_FILE);
299 let text = std::fs::read_to_string(&path).map_err(|source| WorkspaceError::Io {
300 path: path.clone(),
301 source,
302 })?;
303 let config: WorkspaceConfig =
304 serde_yaml::from_str(&text).map_err(|source| WorkspaceError::Parse { path, source })?;
305
306 let files_root = match &config.root {
307 Some(sub) => root.join(sub),
308 None => root.to_path_buf(),
309 };
310 let mut projects = Vec::new();
311 let projects_dir = files_root.join(PROJECTS_DIR);
312 if projects_dir.is_dir() {
313 let mut entries: Vec<_> = std::fs::read_dir(&projects_dir)
314 .map_err(|source| WorkspaceError::Io {
315 path: projects_dir.clone(),
316 source,
317 })?
318 .filter_map(|e| e.ok())
319 .map(|e| e.path())
320 .filter(|p| p.is_dir() && p.join(PROJECT_FILE).is_file())
321 .collect();
322 entries.sort();
323 for dir in entries {
324 let manifest_path = dir.join(PROJECT_FILE);
325 let text = std::fs::read_to_string(&manifest_path).map_err(|source| {
326 WorkspaceError::Io {
327 path: manifest_path.clone(),
328 source,
329 }
330 })?;
331 let manifest: ProjectManifest =
332 serde_yaml::from_str(&text).map_err(|source| WorkspaceError::Parse {
333 path: manifest_path,
334 source,
335 })?;
336 let name = dir
337 .file_name()
338 .expect("project dir has a name")
339 .to_string_lossy()
340 .into_owned();
341 projects.push(Project {
342 name,
343 dir,
344 manifest,
345 });
346 }
347 }
348
349 Ok(Workspace {
350 root: root.to_path_buf(),
351 config,
352 projects,
353 })
354 }
355
356 pub fn project(&self, name: &str) -> Result<&Project> {
357 self.projects
358 .iter()
359 .find(|p| p.name == name)
360 .ok_or_else(|| {
361 WorkspaceError::UnknownProject(
362 name.to_string(),
363 self.projects
364 .iter()
365 .map(|p| p.name.as_str())
366 .collect::<Vec<_>>()
367 .join(", "),
368 )
369 })
370 }
371
372 pub fn resolve_env(&self, selected: Option<&str>) -> Result<ResolvedEnv> {
374 let from_env = std::env::var("RIGG_ENV").ok();
375 let name = selected
376 .map(str::to_string)
377 .or(from_env)
378 .or_else(|| self.default_env_name().map(str::to_string))
379 .ok_or(WorkspaceError::NoDefaultEnvironment)?;
380 let env = self.config.environments.get(&name).ok_or_else(|| {
381 WorkspaceError::UnknownEnvironment(
382 name.clone(),
383 self.config
384 .environments
385 .keys()
386 .map(String::as_str)
387 .collect::<Vec<_>>()
388 .join(", "),
389 )
390 })?;
391 Ok(ResolvedEnv {
392 name,
393 env: env.clone(),
394 })
395 }
396
397 pub fn default_env_name(&self) -> Option<&str> {
398 self.config
399 .environments
400 .iter()
401 .find(|(_, e)| e.default)
402 .map(|(n, _)| n.as_str())
403 }
404
405 pub fn files_root(&self) -> PathBuf {
408 match &self.config.root {
409 Some(sub) => self.root.join(sub),
410 None => self.root.clone(),
411 }
412 }
413
414 pub fn apis_dir(&self) -> PathBuf {
415 self.files_root().join(APIS_DIR)
416 }
417
418 pub fn state_dir(&self, env: &str, project: &str) -> PathBuf {
419 self.files_root().join(STATE_DIR).join(env).join(project)
420 }
421}
422
423impl ResolvedEnv {
424 pub fn search_for(&self, project: &Project) -> Result<&SearchConnection> {
425 pick_connection(
426 &self.name,
427 "search",
428 self.env.search.as_slice(),
429 project,
430 project.manifest.search_connection.as_deref(),
431 |c| c.name.as_deref(),
432 )
433 }
434
435 pub fn foundry_for(&self, project: &Project) -> Result<&FoundryConnection> {
436 pick_connection(
437 &self.name,
438 "foundry",
439 self.env.foundry.as_slice(),
440 project,
441 project.manifest.foundry_connection.as_deref(),
442 |c| c.name.as_deref(),
443 )
444 }
445
446 pub fn has_search(&self) -> bool {
447 !self.env.search.is_empty()
448 }
449
450 pub fn has_foundry(&self) -> bool {
451 !self.env.foundry.is_empty()
452 }
453
454 pub fn protected(&self) -> bool {
456 self.env.policy.protected
457 }
458}
459
460fn pick_connection<'a, T>(
461 env_name: &str,
462 kind: &'static str,
463 conns: &'a [T],
464 project: &Project,
465 pinned: Option<&str>,
466 name_of: impl Fn(&T) -> Option<&str>,
467) -> Result<&'a T> {
468 match (pinned, conns.len()) {
469 (_, 0) => Err(WorkspaceError::MissingConnection {
470 env: env_name.to_string(),
471 kind,
472 project: project.name.clone(),
473 }),
474 (Some(pin), _) => conns
475 .iter()
476 .find(|c| name_of(c) == Some(pin))
477 .ok_or_else(|| WorkspaceError::UnknownConnection {
478 project: project.name.clone(),
479 kind,
480 name: pin.to_string(),
481 env: env_name.to_string(),
482 }),
483 (None, 1) => Ok(&conns[0]),
484 (None, n) => Err(WorkspaceError::AmbiguousConnection {
485 env: env_name.to_string(),
486 kind,
487 count: n,
488 project: project.name.clone(),
489 }),
490 }
491}
492
493#[cfg(test)]
494mod tests {
495 use super::*;
496
497 fn ws_yaml_single() -> &'static str {
498 r#"
499name: demo
500environments:
501 dev:
502 default: true
503 search: { service: mklabsrch }
504 foundry: { account: mklabaifndr, project: proj-default }
505 prod:
506 search: { service: mklabsrch-prod, api-version: 2026-04-01 }
507"#
508 }
509
510 fn ws_yaml_multi() -> &'static str {
511 r#"
512environments:
513 dev:
514 default: true
515 search:
516 - name: primary
517 service: srch-a
518 - name: secondary
519 service: srch-b
520"#
521 }
522
523 fn make_ws(dir: &Path, yaml: &str, projects: &[(&str, &str)]) -> Workspace {
524 std::fs::write(dir.join(WORKSPACE_FILE), yaml).unwrap();
525 for (name, manifest) in projects {
526 let pdir = dir.join(PROJECTS_DIR).join(name);
527 std::fs::create_dir_all(&pdir).unwrap();
528 std::fs::write(pdir.join(PROJECT_FILE), manifest).unwrap();
529 }
530 Workspace::load(dir).unwrap()
531 }
532
533 #[test]
534 fn parses_single_connection_env() {
535 let tmp = tempfile::tempdir().unwrap();
536 let ws = make_ws(
537 tmp.path(),
538 ws_yaml_single(),
539 &[("p1", "description: test\n")],
540 );
541 let dev = ws.resolve_env(Some("dev")).unwrap();
542 let p1 = ws.project("p1").unwrap();
543 assert_eq!(dev.search_for(p1).unwrap().service, "mklabsrch");
544 let f = dev.foundry_for(p1).unwrap();
545 assert_eq!(
546 (f.account.as_str(), f.project.as_str()),
547 ("mklabaifndr", "proj-default")
548 );
549 }
550
551 #[test]
552 fn parses_multi_connection_env_and_requires_pin() {
553 let tmp = tempfile::tempdir().unwrap();
554 let ws = make_ws(
555 tmp.path(),
556 ws_yaml_multi(),
557 &[
558 ("pinned", "search-connection: secondary\n"),
559 ("unpinned", "{}\n"),
560 ],
561 );
562 let dev = ws.resolve_env(None).unwrap();
563 let pinned = ws.project("pinned").unwrap();
564 assert_eq!(dev.search_for(pinned).unwrap().service, "srch-b");
565 let unpinned = ws.project("unpinned").unwrap();
566 assert!(matches!(
567 dev.search_for(unpinned),
568 Err(WorkspaceError::AmbiguousConnection { count: 2, .. })
569 ));
570 }
571
572 #[test]
573 fn env_resolution_precedence() {
574 let tmp = tempfile::tempdir().unwrap();
575 let ws = make_ws(tmp.path(), ws_yaml_single(), &[]);
576 assert_eq!(ws.resolve_env(Some("prod")).unwrap().name, "prod");
578 assert_eq!(ws.resolve_env(None).unwrap().name, "dev");
580 assert!(matches!(
582 ws.resolve_env(Some("staging")),
583 Err(WorkspaceError::UnknownEnvironment(..))
584 ));
585 }
586
587 #[test]
588 fn no_default_env_errors() {
589 let tmp = tempfile::tempdir().unwrap();
590 let ws = make_ws(
591 tmp.path(),
592 "environments:\n a:\n search: { service: s }\n",
593 &[],
594 );
595 assert!(matches!(
596 ws.resolve_env(None),
597 Err(WorkspaceError::NoDefaultEnvironment)
598 ));
599 }
600
601 #[test]
602 fn root_setting_relocates_file_trees() {
603 let tmp = tempfile::tempdir().unwrap();
604 let yaml =
605 "root: rag\nenvironments:\n dev:\n default: true\n search: { service: s }\n";
606 std::fs::write(tmp.path().join(WORKSPACE_FILE), yaml).unwrap();
607 let pdir = tmp.path().join("rag").join(PROJECTS_DIR).join("alpha");
608 std::fs::create_dir_all(&pdir).unwrap();
609 std::fs::write(pdir.join(PROJECT_FILE), "{}\n").unwrap();
610 let ws = Workspace::load(tmp.path()).unwrap();
611 assert_eq!(ws.root, tmp.path());
612 assert_eq!(ws.files_root(), tmp.path().join("rag"));
613 assert_eq!(ws.project("alpha").unwrap().dir, pdir);
614 assert_eq!(ws.apis_dir(), tmp.path().join("rag").join(APIS_DIR));
615 assert_eq!(
616 ws.state_dir("dev", "alpha"),
617 tmp.path()
618 .join("rag")
619 .join(STATE_DIR)
620 .join("dev")
621 .join("alpha")
622 );
623 }
624
625 #[test]
626 fn discover_walks_up_and_finds_projects() {
627 let tmp = tempfile::tempdir().unwrap();
628 make_ws(
629 tmp.path(),
630 ws_yaml_single(),
631 &[("alpha", "{}\n"), ("beta", "description: b\n")],
632 );
633 let nested = tmp.path().join(PROJECTS_DIR).join("alpha").join("search");
634 std::fs::create_dir_all(&nested).unwrap();
635 let ws = Workspace::discover(&nested).unwrap();
636 assert_eq!(
637 ws.projects
638 .iter()
639 .map(|p| p.name.as_str())
640 .collect::<Vec<_>>(),
641 vec!["alpha", "beta"]
642 );
643 assert!(matches!(
644 ws.project("gamma"),
645 Err(WorkspaceError::UnknownProject(..))
646 ));
647 }
648
649 #[test]
650 fn policy_protected_parses_and_defaults_unprotected() {
651 let tmp = tempfile::tempdir().unwrap();
652 let ws = make_ws(
653 tmp.path(),
654 "environments:\n dev:\n default: true\n search: { service: s }\n prod:\n policy: { protected: true }\n search: { service: p }\n",
655 &[],
656 );
657 assert!(!ws.resolve_env(Some("dev")).unwrap().protected());
658 assert!(ws.resolve_env(Some("prod")).unwrap().protected());
659 }
660
661 #[test]
662 fn missing_connection_errors() {
663 let tmp = tempfile::tempdir().unwrap();
664 let ws = make_ws(
665 tmp.path(),
666 "environments:\n dev:\n default: true\n search: { service: s }\n",
667 &[("p", "{}\n")],
668 );
669 let dev = ws.resolve_env(None).unwrap();
670 let p = ws.project("p").unwrap();
671 assert!(matches!(
672 dev.foundry_for(p),
673 Err(WorkspaceError::MissingConnection {
674 kind: "foundry",
675 ..
676 })
677 ));
678 }
679}