1use std::collections::BTreeMap;
11use std::path::{Path, PathBuf};
12
13use serde::{Deserialize, Serialize};
14use thiserror::Error;
15
16pub const WORKSPACE_FILE: &str = "rigg.yaml";
17pub const PROJECT_FILE: &str = "project.yaml";
18pub const PROJECTS_DIR: &str = "projects";
19pub const APIS_DIR: &str = "apis";
20pub const STATE_DIR: &str = ".rigg";
21pub const ENVS_DIR: &str = "envs";
24
25#[derive(Debug, Error)]
26pub enum WorkspaceError {
27 #[error("no {WORKSPACE_FILE} found in {0} or any parent directory")]
28 NotFound(PathBuf),
29 #[error("failed to read {path}: {source}")]
30 Io {
31 path: PathBuf,
32 source: std::io::Error,
33 },
34 #[error("failed to parse {path}: {source}")]
35 Parse {
36 path: PathBuf,
37 source: serde_yaml::Error,
38 },
39 #[error("unknown project '{0}' (available: {1})")]
40 UnknownProject(String, String),
41 #[error("unknown environment '{0}' (available: {1})")]
42 UnknownEnvironment(String, String),
43 #[error(
44 "no default environment configured; pass --env or set `default: true` on one environment"
45 )]
46 NoDefaultEnvironment,
47 #[error("environment '{env}' has an invalid dependency binding name '{name}': {reason}")]
48 InvalidBindingName {
49 path: PathBuf,
50 env: String,
51 name: String,
52 reason: String,
53 },
54}
55
56type Result<T> = std::result::Result<T, WorkspaceError>;
57
58#[derive(Debug, Clone, Default, Serialize, Deserialize)]
60#[serde(deny_unknown_fields)]
61pub struct WorkspaceConfig {
62 #[serde(default, skip_serializing_if = "Option::is_none")]
63 pub name: Option<String>,
64 #[serde(default, skip_serializing_if = "Option::is_none")]
68 pub root: Option<String>,
69 #[serde(default)]
70 pub environments: BTreeMap<String, Environment>,
71}
72
73#[derive(Debug, Clone, Default, Serialize, Deserialize)]
74#[serde(deny_unknown_fields)]
75pub struct Environment {
76 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
77 pub default: bool,
78 #[serde(default, skip_serializing_if = "Option::is_none")]
80 pub tenant: Option<String>,
81 #[serde(default, skip_serializing_if = "Option::is_none")]
83 pub subscription: Option<String>,
84 #[serde(default, skip_serializing_if = "Option::is_none")]
85 pub search: Option<SearchConnection>,
86 #[serde(default, skip_serializing_if = "Option::is_none")]
87 pub foundry: Option<FoundryConnection>,
88 #[serde(default, skip_serializing_if = "Policy::is_default")]
89 pub policy: Policy,
90 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
93 pub dependencies: BTreeMap<String, Binding>,
94}
95
96#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
100#[serde(deny_unknown_fields)]
101pub struct Policy {
102 #[serde(default)]
103 pub protected: bool,
104 #[serde(
107 default,
108 rename = "strict-bindings",
109 skip_serializing_if = "Option::is_none"
110 )]
111 pub strict_bindings: Option<bool>,
112}
113
114impl Policy {
115 fn is_default(&self) -> bool {
116 self == &Policy::default()
117 }
118
119 pub fn strict_bindings(&self) -> bool {
120 self.strict_bindings.unwrap_or(self.protected)
121 }
122}
123
124pub use crate::binding::{Binding, BindingType, validate_binding_name};
127
128#[derive(Debug, Clone, Default, Serialize, Deserialize)]
129#[serde(deny_unknown_fields)]
130pub struct SearchConnection {
131 #[serde(default, skip_serializing_if = "Option::is_none")]
132 pub name: Option<String>,
133 pub service: String,
135 #[serde(default, skip_serializing_if = "Option::is_none")]
138 pub endpoint: Option<String>,
139 #[serde(
141 default,
142 rename = "api-version",
143 skip_serializing_if = "Option::is_none"
144 )]
145 pub api_version: Option<String>,
146 #[serde(
148 default,
149 rename = "preview-api-version",
150 skip_serializing_if = "Option::is_none"
151 )]
152 pub preview_api_version: Option<String>,
153}
154
155impl SearchConnection {
156 pub fn url(&self) -> String {
159 match &self.endpoint {
160 Some(e) => e.trim_end_matches('/').to_string(),
161 None => format!("https://{}.search.windows.net", self.service),
162 }
163 }
164}
165
166#[derive(Debug, Clone, Default, Serialize, Deserialize)]
167#[serde(deny_unknown_fields)]
168pub struct FoundryConnection {
169 #[serde(default, skip_serializing_if = "Option::is_none")]
170 pub name: Option<String>,
171 pub account: String,
173 #[serde(default, skip_serializing_if = "Option::is_none")]
176 pub endpoint: Option<String>,
177 pub project: String,
179 #[serde(
181 default,
182 rename = "api-version",
183 skip_serializing_if = "Option::is_none"
184 )]
185 pub api_version: Option<String>,
186}
187
188impl FoundryConnection {
189 pub fn url(&self) -> String {
192 match &self.endpoint {
193 Some(e) => e.trim_end_matches('/').to_string(),
194 None => format!("https://{}.services.ai.azure.com", self.account),
195 }
196 }
197}
198
199#[derive(Debug, Clone, Default, Serialize, Deserialize)]
201#[serde(deny_unknown_fields)]
202pub struct ProjectManifest {
203 #[serde(default, skip_serializing_if = "Option::is_none")]
204 pub description: Option<String>,
205}
206
207#[derive(Debug, Clone)]
208pub struct Project {
209 pub name: String,
210 pub dir: PathBuf,
211 pub manifest: ProjectManifest,
212}
213
214#[derive(Debug, Clone)]
215pub struct Workspace {
216 pub root: PathBuf,
217 pub config: WorkspaceConfig,
218 pub projects: Vec<Project>,
219}
220
221#[derive(Debug, Clone)]
222pub struct ResolvedEnv {
223 pub name: String,
224 pub env: Environment,
225}
226
227impl Workspace {
228 pub fn discover(start: &Path) -> Result<Workspace> {
231 let start = if start.as_os_str().is_empty() {
232 Path::new(".")
233 } else {
234 start
235 };
236 let mut dir = start.canonicalize().map_err(|source| WorkspaceError::Io {
237 path: start.to_path_buf(),
238 source,
239 })?;
240 loop {
241 if dir.join(WORKSPACE_FILE).is_file() {
242 return Workspace::load(&dir);
243 }
244 if !dir.pop() {
245 return Err(WorkspaceError::NotFound(start.to_path_buf()));
246 }
247 }
248 }
249
250 pub fn load(root: &Path) -> Result<Workspace> {
252 let path = root.join(WORKSPACE_FILE);
253 let text = std::fs::read_to_string(&path).map_err(|source| WorkspaceError::Io {
254 path: path.clone(),
255 source,
256 })?;
257 let config: WorkspaceConfig =
258 serde_yaml::from_str(&text).map_err(|source| WorkspaceError::Parse {
259 path: path.clone(),
260 source,
261 })?;
262
263 for (env_name, env) in &config.environments {
264 for name in env.dependencies.keys() {
265 if let Err(reason) = validate_binding_name(name) {
266 return Err(WorkspaceError::InvalidBindingName {
267 path: path.clone(),
268 env: env_name.clone(),
269 name: name.clone(),
270 reason,
271 });
272 }
273 }
274 }
275
276 let files_root = match &config.root {
277 Some(sub) => root.join(sub),
278 None => root.to_path_buf(),
279 };
280 let mut projects = Vec::new();
281 let projects_dir = files_root.join(PROJECTS_DIR);
282 if projects_dir.is_dir() {
283 let mut entries: Vec<_> = std::fs::read_dir(&projects_dir)
284 .map_err(|source| WorkspaceError::Io {
285 path: projects_dir.clone(),
286 source,
287 })?
288 .filter_map(|e| e.ok())
289 .map(|e| e.path())
290 .filter(|p| p.is_dir() && p.join(PROJECT_FILE).is_file())
291 .collect();
292 entries.sort();
293 for dir in entries {
294 let manifest_path = dir.join(PROJECT_FILE);
295 let text = std::fs::read_to_string(&manifest_path).map_err(|source| {
296 WorkspaceError::Io {
297 path: manifest_path.clone(),
298 source,
299 }
300 })?;
301 let manifest: ProjectManifest =
302 serde_yaml::from_str(&text).map_err(|source| WorkspaceError::Parse {
303 path: manifest_path,
304 source,
305 })?;
306 let name = dir
307 .file_name()
308 .expect("project dir has a name")
309 .to_string_lossy()
310 .into_owned();
311 projects.push(Project {
312 name,
313 dir,
314 manifest,
315 });
316 }
317 }
318
319 Ok(Workspace {
320 root: root.to_path_buf(),
321 config,
322 projects,
323 })
324 }
325
326 pub fn project(&self, name: &str) -> Result<&Project> {
327 self.projects
328 .iter()
329 .find(|p| p.name == name)
330 .ok_or_else(|| {
331 WorkspaceError::UnknownProject(
332 name.to_string(),
333 self.projects
334 .iter()
335 .map(|p| p.name.as_str())
336 .collect::<Vec<_>>()
337 .join(", "),
338 )
339 })
340 }
341
342 pub fn resolve_env(&self, selected: Option<&str>) -> Result<ResolvedEnv> {
344 let from_env = std::env::var("RIGG_ENV").ok();
345 let name = selected
346 .map(str::to_string)
347 .or(from_env)
348 .or_else(|| self.default_env_name().map(str::to_string))
349 .ok_or(WorkspaceError::NoDefaultEnvironment)?;
350 let env = self.config.environments.get(&name).ok_or_else(|| {
351 WorkspaceError::UnknownEnvironment(
352 name.clone(),
353 self.config
354 .environments
355 .keys()
356 .map(String::as_str)
357 .collect::<Vec<_>>()
358 .join(", "),
359 )
360 })?;
361 Ok(ResolvedEnv {
362 name,
363 env: env.clone(),
364 })
365 }
366
367 pub fn default_env_name(&self) -> Option<&str> {
368 self.config
369 .environments
370 .iter()
371 .find(|(_, e)| e.default)
372 .map(|(n, _)| n.as_str())
373 }
374
375 pub fn files_root(&self) -> PathBuf {
378 match &self.config.root {
379 Some(sub) => self.root.join(sub),
380 None => self.root.clone(),
381 }
382 }
383
384 pub fn apis_dir(&self) -> PathBuf {
385 self.files_root().join(APIS_DIR)
386 }
387
388 pub fn state_dir(&self, env: &str, project: &str) -> PathBuf {
389 self.files_root().join(STATE_DIR).join(env).join(project)
390 }
391}
392
393impl ResolvedEnv {
394 pub fn search(&self) -> Option<&SearchConnection> {
395 self.env.search.as_ref()
396 }
397
398 pub fn foundry(&self) -> Option<&FoundryConnection> {
399 self.env.foundry.as_ref()
400 }
401
402 pub fn protected(&self) -> bool {
404 self.env.policy.protected
405 }
406
407 pub fn strict_bindings(&self) -> bool {
409 self.env.policy.strict_bindings()
410 }
411}
412
413#[cfg(test)]
414mod tests {
415 use super::*;
416
417 fn ws_yaml_single() -> &'static str {
418 r#"
419name: demo
420environments:
421 dev:
422 default: true
423 search: { service: mklabsrch }
424 foundry: { account: mklabaifndr, project: proj-default }
425 prod:
426 search: { service: mklabsrch-prod, api-version: 2026-04-01 }
427"#
428 }
429
430 fn ws_yaml_bindings() -> &'static str {
431 r#"
432environments:
433 dev:
434 default: true
435 tenant: 11111111-1111-1111-1111-111111111111
436 subscription: 00000000-0000-0000-0000-000000000000
437 search: { service: mklabsrch }
438 foundry: { account: mklabaifndr, project: proj-default }
439 dependencies:
440 docs-storage: { storage: mklabstorageacc }
441 enrich-fn: { function-app: mklab }
442 partner: { api: https://api.partner.example/v1 }
443 prod:
444 policy: { protected: true }
445 search: { service: mklabsrch-prod }
446 dependencies:
447 docs-storage: { storage: /subscriptions/0b1d/resourceGroups/rg/providers/Microsoft.Storage/storageAccounts/mklabstorageprod }
448"#
449 }
450
451 fn make_ws(dir: &Path, yaml: &str, projects: &[(&str, &str)]) -> Workspace {
452 std::fs::write(dir.join(WORKSPACE_FILE), yaml).unwrap();
453 for (name, manifest) in projects {
454 let pdir = dir.join(PROJECTS_DIR).join(name);
455 std::fs::create_dir_all(&pdir).unwrap();
456 std::fs::write(pdir.join(PROJECT_FILE), manifest).unwrap();
457 }
458 Workspace::load(dir).unwrap()
459 }
460
461 #[test]
462 fn parses_single_connection_env() {
463 let tmp = tempfile::tempdir().unwrap();
464 let ws = make_ws(
465 tmp.path(),
466 ws_yaml_single(),
467 &[("p1", "description: test\n")],
468 );
469 let dev = ws.resolve_env(Some("dev")).unwrap();
470 assert_eq!(dev.search().unwrap().service, "mklabsrch");
471 let f = dev.foundry().unwrap();
472 assert_eq!(
473 (f.account.as_str(), f.project.as_str()),
474 ("mklabaifndr", "proj-default")
475 );
476 }
477
478 #[test]
479 fn parses_targets_tenant_subscription_and_dependencies() {
480 let tmp = tempfile::tempdir().unwrap();
481 let ws = make_ws(tmp.path(), ws_yaml_bindings(), &[("p", "{}\n")]);
482 let dev = ws.resolve_env(Some("dev")).unwrap();
483 assert_eq!(dev.search().unwrap().service, "mklabsrch");
484 assert_eq!(dev.foundry().unwrap().project, "proj-default");
485 assert_eq!(
486 dev.env.subscription.as_deref(),
487 Some("00000000-0000-0000-0000-000000000000")
488 );
489 let b = &dev.env.dependencies["docs-storage"];
490 assert_eq!(b.kind, BindingType::Storage);
491 assert_eq!(b.value, "mklabstorageacc");
492 assert_eq!(dev.env.dependencies["partner"].kind, BindingType::Api);
493 let prod = ws.resolve_env(Some("prod")).unwrap();
494 assert!(prod.foundry().is_none());
495 assert!(
496 prod.protected() && prod.strict_bindings(),
497 "strict-bindings defaults to protected"
498 );
499 assert!(!dev.strict_bindings());
500 }
501
502 #[test]
503 fn binding_round_trips_as_a_one_key_map() {
504 let b: Binding = serde_yaml::from_str("key-vault: mklabkv").unwrap();
505 assert_eq!(b.kind, BindingType::KeyVault);
506 assert_eq!(
507 serde_yaml::to_string(&b).unwrap().trim(),
508 "key-vault: mklabkv"
509 );
510 assert!(
511 serde_yaml::from_str::<Binding>("cosmos: x").is_err(),
512 "unknown type rejected"
513 );
514 assert!(
515 serde_yaml::from_str::<Binding>("storage: a\nidentity: b").is_err(),
516 "exactly one key"
517 );
518 }
519
520 #[test]
521 fn binding_names_are_validated_and_reserved() {
522 assert!(validate_binding_name("docs-storage").is_ok());
523 assert!(validate_binding_name("Docs").is_err());
524 assert!(validate_binding_name("search").is_err());
525 assert!(validate_binding_name("foundry").is_err());
526 }
527
528 #[test]
529 fn list_form_targets_are_rejected() {
530 let tmp = tempfile::tempdir().unwrap();
531 std::fs::write(
532 tmp.path().join(WORKSPACE_FILE),
533 "environments:\n dev:\n search:\n - service: a\n",
534 )
535 .unwrap();
536 assert!(matches!(
537 Workspace::load(tmp.path()),
538 Err(WorkspaceError::Parse { .. })
539 ));
540 }
541
542 #[test]
543 fn env_resolution_precedence() {
544 let tmp = tempfile::tempdir().unwrap();
545 let ws = make_ws(tmp.path(), ws_yaml_single(), &[]);
546 assert_eq!(ws.resolve_env(Some("prod")).unwrap().name, "prod");
548 assert_eq!(ws.resolve_env(None).unwrap().name, "dev");
550 assert!(matches!(
552 ws.resolve_env(Some("staging")),
553 Err(WorkspaceError::UnknownEnvironment(..))
554 ));
555 }
556
557 #[test]
558 fn no_default_env_errors() {
559 let tmp = tempfile::tempdir().unwrap();
560 let ws = make_ws(
561 tmp.path(),
562 "environments:\n a:\n search: { service: s }\n",
563 &[],
564 );
565 assert!(matches!(
566 ws.resolve_env(None),
567 Err(WorkspaceError::NoDefaultEnvironment)
568 ));
569 }
570
571 #[test]
572 fn root_setting_relocates_file_trees() {
573 let tmp = tempfile::tempdir().unwrap();
574 let yaml =
575 "root: rag\nenvironments:\n dev:\n default: true\n search: { service: s }\n";
576 std::fs::write(tmp.path().join(WORKSPACE_FILE), yaml).unwrap();
577 let pdir = tmp.path().join("rag").join(PROJECTS_DIR).join("alpha");
578 std::fs::create_dir_all(&pdir).unwrap();
579 std::fs::write(pdir.join(PROJECT_FILE), "{}\n").unwrap();
580 let ws = Workspace::load(tmp.path()).unwrap();
581 assert_eq!(ws.root, tmp.path());
582 assert_eq!(ws.files_root(), tmp.path().join("rag"));
583 assert_eq!(ws.project("alpha").unwrap().dir, pdir);
584 assert_eq!(ws.apis_dir(), tmp.path().join("rag").join(APIS_DIR));
585 assert_eq!(
586 ws.state_dir("dev", "alpha"),
587 tmp.path()
588 .join("rag")
589 .join(STATE_DIR)
590 .join("dev")
591 .join("alpha")
592 );
593 }
594
595 #[test]
596 fn discover_walks_up_and_finds_projects() {
597 let tmp = tempfile::tempdir().unwrap();
598 make_ws(
599 tmp.path(),
600 ws_yaml_single(),
601 &[("alpha", "{}\n"), ("beta", "description: b\n")],
602 );
603 let nested = tmp.path().join(PROJECTS_DIR).join("alpha").join("search");
604 std::fs::create_dir_all(&nested).unwrap();
605 let ws = Workspace::discover(&nested).unwrap();
606 assert_eq!(
607 ws.projects
608 .iter()
609 .map(|p| p.name.as_str())
610 .collect::<Vec<_>>(),
611 vec!["alpha", "beta"]
612 );
613 assert!(matches!(
614 ws.project("gamma"),
615 Err(WorkspaceError::UnknownProject(..))
616 ));
617 }
618
619 #[test]
620 fn policy_protected_parses_and_defaults_unprotected() {
621 let tmp = tempfile::tempdir().unwrap();
622 let ws = make_ws(
623 tmp.path(),
624 "environments:\n dev:\n default: true\n search: { service: s }\n prod:\n policy: { protected: true }\n search: { service: p }\n",
625 &[],
626 );
627 assert!(!ws.resolve_env(Some("dev")).unwrap().protected());
628 assert!(ws.resolve_env(Some("prod")).unwrap().protected());
629 }
630}