//! Workspace-local Ticket orchestration configuration. //! //! The config file lives at `.yoi/ticket.config.toml` under a workspace root. //! It intentionally stores lightweight string references for Profile selectors, //! launch prompts, and workflows so this crate remains independent from `pod` //! and `manifest` runtime resolution. use std::collections::{BTreeMap, BTreeSet}; use std::fmt; use std::fs; use std::path::{Path, PathBuf}; use serde::{Deserialize, Serialize}; use thiserror::Error; pub const TICKET_CONFIG_RELATIVE_PATH: &str = ".yoi/ticket.config.toml"; /// Workspace-relative default root for the built-in local Ticket backend. pub const DEFAULT_TICKET_BACKEND_RELATIVE_PATH: &str = ".yoi/tickets"; /// Return the explicit workspace Ticket config scaffold written by `yoi ticket init`. /// /// The scaffold intentionally configures every fixed Ticket role with a concrete /// profile so strict role launch planning can validate the config without runtime /// fallback. pub fn ticket_config_scaffold() -> String { let mut out = String::from("[backend]\n"); out.push_str(&format!( "provider = \"{}\"\n", TicketBackendProvider::BuiltinYoiLocal.as_str() )); out.push_str(&format!( "root = \"{}\"\n", DEFAULT_TICKET_BACKEND_RELATIVE_PATH )); out.push_str( "\n# Optional durable Ticket record language. When unset, generated Ticket text keeps current defaults.\n# [ticket]\n# language = \"Japanese\"\n", ); for role in TicketRole::ALL { out.push_str(&format!( "\n[roles.{role}]\nprofile = \"{}\"\nworkflow = \"{}\"\n", role.default_profile(), role.default_workflow() )); } out } #[derive(Debug, Error)] pub enum TicketConfigError { #[error("failed to read Ticket config {}: {source}", .path.display())] Read { path: PathBuf, #[source] source: std::io::Error, }, #[error("failed to parse Ticket config {}: {source}", .path.display())] Parse { path: PathBuf, #[source] source: toml::de::Error, }, #[error("invalid Ticket config {}: {message}", .path.display())] Invalid { path: PathBuf, message: String }, } #[derive(Debug, Clone, PartialEq, Eq)] pub struct TicketConfig { pub backend: TicketBackendConfig, pub ticket: TicketRecordConfig, pub roles: TicketRoleProfiles, } impl TicketConfig { pub fn default_for_workspace(workspace_root: impl AsRef) -> Self { let workspace_root = workspace_root.as_ref(); Self { backend: TicketBackendConfig::default_for_workspace(workspace_root), ticket: TicketRecordConfig::default(), roles: TicketRoleProfiles::default(), } } pub fn load_workspace(workspace_root: impl AsRef) -> Result { let workspace_root = workspace_root.as_ref(); let path = workspace_root.join(TICKET_CONFIG_RELATIVE_PATH); let content = match fs::read_to_string(&path) { Ok(content) => content, Err(source) if source.kind() == std::io::ErrorKind::NotFound => { return Ok(Self::default_for_workspace(workspace_root)); } Err(source) => return Err(TicketConfigError::Read { path, source }), }; Self::from_toml(workspace_root, &path, &content) } pub fn from_toml( workspace_root: impl AsRef, path: impl AsRef, content: &str, ) -> Result { let workspace_root = workspace_root.as_ref(); let path = path.as_ref(); let raw: RawTicketConfig = toml::from_str(content).map_err(|source| TicketConfigError::Parse { path: path.to_path_buf(), source, })?; raw.resolve(workspace_root, path) } pub fn backend_root(&self) -> &Path { self.backend.root.as_path() } pub fn ticket_record_language(&self) -> Option<&str> { self.ticket .language .as_ref() .map(TicketRecordLanguage::as_str) } pub fn role(&self, role: TicketRole) -> &TicketRoleConfig { self.roles.get(role) } pub fn role_launch_config( &self, role: TicketRole, ) -> Result<&TicketRoleConfig, TicketRoleLaunchConfigError> { self.roles.launch_config(role) } pub fn profile_for(&self, role: TicketRole) -> &ProfileSelectorRef { &self.role(role).profile } pub fn launch_prompt_for(&self, role: TicketRole) -> Option<&PromptRef> { self.role(role).launch_prompt.as_ref() } pub fn workflow_for(&self, role: TicketRole) -> &WorkflowRef { &self.role(role).workflow } } #[derive(Debug, Clone, PartialEq, Eq)] pub struct TicketBackendConfig { pub provider: TicketBackendProvider, pub root: PathBuf, } impl TicketBackendConfig { pub fn default_for_workspace(workspace_root: &Path) -> Self { Self { provider: TicketBackendProvider::BuiltinYoiLocal, root: workspace_root.join(DEFAULT_TICKET_BACKEND_RELATIVE_PATH), } } } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum TicketBackendProvider { #[serde(rename = "builtin:yoi_local")] BuiltinYoiLocal, } impl TicketBackendProvider { pub fn as_str(self) -> &'static str { match self { Self::BuiltinYoiLocal => "builtin:yoi_local", } } pub fn parse(value: &str) -> Option { match value { "builtin:yoi_local" => Some(Self::BuiltinYoiLocal), _ => None, } } } impl fmt::Display for TicketBackendProvider { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.write_str(self.as_str()) } } #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum TicketRole { Intake, Orchestrator, Coder, Reviewer, } impl TicketRole { pub const ALL: [TicketRole; 4] = [ TicketRole::Intake, TicketRole::Orchestrator, TicketRole::Coder, TicketRole::Reviewer, ]; pub fn supported_names() -> Vec<&'static str> { Self::ALL.iter().map(|role| role.as_str()).collect() } pub fn as_str(self) -> &'static str { match self { Self::Intake => "intake", Self::Orchestrator => "orchestrator", Self::Coder => "coder", Self::Reviewer => "reviewer", } } pub fn parse(value: &str) -> Option { match value { "intake" => Some(Self::Intake), "orchestrator" => Some(Self::Orchestrator), "coder" => Some(Self::Coder), "reviewer" => Some(Self::Reviewer), _ => None, } } pub fn default_workflow(self) -> &'static str { match self { Self::Intake => "ticket-intake-workflow", Self::Orchestrator => "ticket-orchestrator-routing", Self::Coder | Self::Reviewer => "multi-agent-workflow", } } pub fn default_profile(self) -> &'static str { match self { Self::Intake => "builtin:intake", Self::Orchestrator => "builtin:orchestrator", Self::Coder => "builtin:coder", Self::Reviewer => "builtin:reviewer", } } } impl fmt::Display for TicketRole { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.write_str(self.as_str()) } } #[derive(Debug, Clone, PartialEq, Eq)] pub struct TicketRecordConfig { pub language: Option, } impl Default for TicketRecordConfig { fn default() -> Self { Self { language: None } } } #[derive(Debug, Clone, PartialEq, Eq)] pub struct TicketRecordLanguage(String); impl TicketRecordLanguage { pub fn new(language: impl Into) -> Result { let language = normalized_non_empty(language, "ticket record language")?; Ok(Self(language)) } pub fn as_str(&self) -> &str { self.0.as_str() } } impl<'de> Deserialize<'de> for TicketRecordLanguage { fn deserialize(deserializer: D) -> Result where D: serde::Deserializer<'de>, { let value = String::deserialize(deserializer)?; TicketRecordLanguage::new(value).map_err(serde::de::Error::custom) } } #[derive(Debug, Clone, PartialEq, Eq)] pub struct TicketRoleProfiles { inner: BTreeMap, configured_roles: BTreeSet, profile_configured_roles: BTreeSet, } impl TicketRoleProfiles { pub fn get(&self, role: TicketRole) -> &TicketRoleConfig { self.inner .get(&role) .expect("TicketRoleProfiles always contains all fixed roles") } pub fn role_is_configured(&self, role: TicketRole) -> bool { self.configured_roles.contains(&role) } pub fn profile_is_configured(&self, role: TicketRole) -> bool { self.profile_configured_roles.contains(&role) } pub fn launch_config( &self, role: TicketRole, ) -> Result<&TicketRoleConfig, TicketRoleLaunchConfigError> { if !self.role_is_configured(role) { return Err(TicketRoleLaunchConfigError::MissingRoleTable { role }); } if !self.profile_is_configured(role) { return Err(TicketRoleLaunchConfigError::MissingProfile { role }); } let config = self.get(role); if config.profile.as_str() == "inherit" { return Err(TicketRoleLaunchConfigError::InheritProfile { role }); } Ok(config) } pub fn iter(&self) -> impl Iterator { TicketRole::ALL .into_iter() .map(|role| (role, self.get(role))) } } impl Default for TicketRoleProfiles { fn default() -> Self { let inner = TicketRole::ALL .into_iter() .map(|role| (role, TicketRoleConfig::default_for_role(role))) .collect(); Self { inner, configured_roles: BTreeSet::new(), profile_configured_roles: BTreeSet::new(), } } } #[derive(Debug, Clone, PartialEq, Eq, Error)] pub enum TicketRoleLaunchConfigError { #[error( "Ticket role `{role}` is not launch-configured; add `[roles.{role}]` with the role builtin profile or another executable concrete profile selector" )] MissingRoleTable { role: TicketRole }, #[error( "Ticket role `{role}` has no launch profile; set `[roles.{role}].profile` to the role builtin profile or another executable concrete profile selector" )] MissingProfile { role: TicketRole }, #[error( "Ticket role `{role}` uses `profile = \"inherit\"`; top-level Ticket role launch requires an explicit executable profile selector such as the role builtin profile or a project/user profile" )] InheritProfile { role: TicketRole }, } #[derive(Debug, Clone, PartialEq, Eq)] pub struct TicketRoleConfig { pub profile: ProfileSelectorRef, pub launch_prompt: Option, pub workflow: WorkflowRef, } impl TicketRoleConfig { pub fn default_for_role(role: TicketRole) -> Self { Self { profile: ProfileSelectorRef::inherit(), launch_prompt: None, workflow: WorkflowRef::from_static(role.default_workflow()), } } } #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)] pub struct ProfileSelectorRef(String); impl ProfileSelectorRef { pub fn new(value: impl Into) -> Result { let value = normalized_non_empty(value, "profile selector")?; if value.starts_with("path:") || value.starts_with('.') || value.contains('/') || value.ends_with(".lua") || value.ends_with(".nix") { return Err("profile selector must be `inherit`, `default`, a source-qualified registry selector, or an unqualified registry selector; path selectors are not supported".to_string()); } if let Some((source, name)) = value.split_once(':') { if !matches!(source, "builtin" | "user" | "project") { return Err( "profile selector source must be one of `builtin`, `user`, or `project`" .to_string(), ); } if name.trim().is_empty() { return Err("profile selector registry name must not be empty".to_string()); } } Ok(Self(value)) } pub fn inherit() -> Self { Self("inherit".to_string()) } pub fn as_str(&self) -> &str { self.0.as_str() } } impl<'de> Deserialize<'de> for ProfileSelectorRef { fn deserialize(deserializer: D) -> Result where D: serde::Deserializer<'de>, { let value = String::deserialize(deserializer)?; Self::new(value).map_err(serde::de::Error::custom) } } impl fmt::Display for ProfileSelectorRef { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.write_str(self.as_str()) } } impl AsRef for ProfileSelectorRef { fn as_ref(&self) -> &str { self.as_str() } } #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)] pub struct PromptRef(String); impl PromptRef { pub fn new(value: impl Into) -> Result { normalized_non_empty(value, "launch prompt ref").map(Self) } pub fn as_str(&self) -> &str { self.0.as_str() } } impl<'de> Deserialize<'de> for PromptRef { fn deserialize(deserializer: D) -> Result where D: serde::Deserializer<'de>, { let value = String::deserialize(deserializer)?; Self::new(value).map_err(serde::de::Error::custom) } } impl fmt::Display for PromptRef { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.write_str(self.as_str()) } } impl AsRef for PromptRef { fn as_ref(&self) -> &str { self.as_str() } } #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)] pub struct WorkflowRef(String); impl WorkflowRef { pub fn new(value: impl Into) -> Result { normalized_non_empty(value, "workflow ref").map(Self) } pub fn from_static(value: &'static str) -> Self { Self(value.to_string()) } pub fn as_str(&self) -> &str { self.0.as_str() } } impl<'de> Deserialize<'de> for WorkflowRef { fn deserialize(deserializer: D) -> Result where D: serde::Deserializer<'de>, { let value = String::deserialize(deserializer)?; Self::new(value).map_err(serde::de::Error::custom) } } impl fmt::Display for WorkflowRef { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.write_str(self.as_str()) } } impl AsRef for WorkflowRef { fn as_ref(&self) -> &str { self.as_str() } } fn normalized_non_empty(value: impl Into, label: &str) -> Result { let value = value.into(); let trimmed = value.trim(); if trimmed.is_empty() { Err(format!("{label} must not be empty")) } else { Ok(trimmed.to_string()) } } #[derive(Debug, Default, Deserialize)] #[serde(deny_unknown_fields)] struct RawTicketConfig { #[serde(default)] backend: RawBackendConfig, #[serde(default)] ticket: RawTicketRecordConfig, #[serde(default)] roles: BTreeMap, } #[derive(Debug, Default, Deserialize)] #[serde(deny_unknown_fields)] struct RawTicketRecordConfig { #[serde(default)] language: Option, } impl RawTicketRecordConfig { fn resolve(self) -> TicketRecordConfig { TicketRecordConfig { language: self.language, } } } impl RawTicketConfig { fn resolve( self, workspace_root: &Path, path: &Path, ) -> Result { let mut roles = TicketRoleProfiles::default(); for (name, raw_role) in self.roles { let role = TicketRole::parse(&name).ok_or_else(|| TicketConfigError::Invalid { path: path.to_path_buf(), message: format!( "unsupported Ticket role `{name}`; supported fixed roles: {}", TicketRole::supported_names().join(", ") ), })?; let profile_configured = raw_role.profile.is_some(); roles.inner.insert(role, raw_role.resolve(role)); roles.configured_roles.insert(role); if profile_configured { roles.profile_configured_roles.insert(role); } } Ok(TicketConfig { backend: self.backend.resolve(workspace_root).map_err(|message| { TicketConfigError::Invalid { path: path.to_path_buf(), message, } })?, ticket: self.ticket.resolve(), roles, }) } } #[derive(Debug, Default, Deserialize)] #[serde(deny_unknown_fields)] struct RawBackendConfig { #[serde(default)] provider: Option, #[serde(default)] kind: Option, #[serde(default)] root: Option, } impl RawBackendConfig { fn resolve(self, workspace_root: &Path) -> Result { let provider = match (self.provider, self.kind) { (Some(provider), None) => TicketBackendProvider::parse(&provider).ok_or_else(|| { format!( "unsupported Ticket backend provider `{provider}`; supported provider: `builtin:yoi_local`" ) })?, (None, Some(kind)) if kind == "local" => TicketBackendProvider::BuiltinYoiLocal, (None, Some(kind)) => { return Err(format!( "unsupported legacy Ticket backend kind `{kind}`; use provider = \"builtin:yoi_local\"" )); } (None, None) => TicketBackendProvider::BuiltinYoiLocal, (Some(_), Some(_)) => { return Err( "backend.provider and legacy backend.kind are mutually exclusive; use provider = \"builtin:yoi_local\"" .to_string(), ); } }; let root = self .root .unwrap_or_else(|| PathBuf::from(DEFAULT_TICKET_BACKEND_RELATIVE_PATH)); Ok(TicketBackendConfig { provider, root: join_if_relative(workspace_root, &root), }) } } #[derive(Debug, Deserialize)] #[serde(deny_unknown_fields)] struct RawTicketRoleConfig { #[serde(default)] profile: Option, #[serde(default)] launch_prompt: Option, #[serde(default)] workflow: Option, } impl RawTicketRoleConfig { fn resolve(self, role: TicketRole) -> TicketRoleConfig { TicketRoleConfig { profile: self.profile.unwrap_or_else(ProfileSelectorRef::inherit), launch_prompt: self.launch_prompt, workflow: self .workflow .unwrap_or_else(|| WorkflowRef::from_static(role.default_workflow())), } } } fn join_if_relative(base: &Path, path: &Path) -> PathBuf { if path.is_absolute() { path.to_path_buf() } else { base.join(path) } } #[cfg(test)] mod tests { use super::*; use tempfile::TempDir; fn write_config(workspace: &Path, content: &str) { let dir = workspace.join(".yoi"); std::fs::create_dir_all(&dir).unwrap(); std::fs::write(dir.join("ticket.config.toml"), content).unwrap(); } #[test] fn missing_config_returns_documented_defaults() { let temp = TempDir::new().unwrap(); let config = TicketConfig::load_workspace(temp.path()).unwrap(); assert_eq!( config.backend.provider, TicketBackendProvider::BuiltinYoiLocal ); assert_eq!( config.backend.root, temp.path().join(DEFAULT_TICKET_BACKEND_RELATIVE_PATH) ); assert_eq!(config.ticket_record_language(), None); for role in TicketRole::ALL { let role_config = config.role(role); assert_eq!(role_config.profile.as_str(), "inherit"); assert!(role_config.launch_prompt.is_none()); assert_eq!(role_config.workflow.as_str(), role.default_workflow()); } } #[test] fn full_config_parses_fixed_role_refs() { let temp = TempDir::new().unwrap(); write_config( temp.path(), r#" [backend] provider = "builtin:yoi_local" root = "custom-tickets" [ticket] language = "Japanese" [roles.intake] profile = "project:intake" launch_prompt = "$workspace/ticket/intake/launch" workflow = "ticket-intake-workflow" [roles.orchestrator] profile = "project:orchestrator" launch_prompt = "$workspace/ticket/orchestrator/launch" workflow = "ticket-orchestrator-routing" [roles.coder] profile = "inherit" launch_prompt = "$workspace/ticket/coder/launch" workflow = "multi-agent-workflow" [roles.reviewer] profile = "project:reviewer" launch_prompt = "$workspace/ticket/reviewer/launch" workflow = "multi-agent-workflow" "#, ); let config = TicketConfig::load_workspace(temp.path()).unwrap(); assert_eq!( config.backend.provider, TicketBackendProvider::BuiltinYoiLocal ); assert_eq!(config.backend.root, temp.path().join("custom-tickets")); assert_eq!(config.ticket_record_language(), Some("Japanese")); assert_eq!( config.profile_for(TicketRole::Intake).as_str(), "project:intake" ); assert_eq!( config .launch_prompt_for(TicketRole::Reviewer) .unwrap() .as_str(), "$workspace/ticket/reviewer/launch" ); assert_eq!( config.workflow_for(TicketRole::Reviewer).as_str(), "multi-agent-workflow" ); } #[test] fn scaffold_config_includes_backend_and_all_fixed_roles() { let temp = TempDir::new().unwrap(); let scaffold = ticket_config_scaffold(); assert!(scaffold.contains("[backend]\n")); assert!(scaffold.contains("provider = \"builtin:yoi_local\"")); assert!(scaffold.contains("root = \".yoi/tickets\"")); assert!(scaffold.contains("# [ticket]\n# language = \"Japanese\"")); for role in TicketRole::ALL { assert!(scaffold.contains(&format!("[roles.{role}]"))); assert!(scaffold.contains(&format!( "[roles.{role}]\nprofile = \"{}\"\nworkflow = \"{}\"", role.default_profile(), role.default_workflow() ))); } assert!(!scaffold.contains("[roles.investigator]")); let config = TicketConfig::from_toml( temp.path(), temp.path().join(TICKET_CONFIG_RELATIVE_PATH), &scaffold, ) .unwrap(); assert_eq!(config.backend_root(), temp.path().join(".yoi/tickets")); for role in TicketRole::ALL { let role_config = config.role_launch_config(role).unwrap(); assert_eq!(role_config.profile.as_str(), role.default_profile()); assert_eq!(role_config.workflow.as_str(), role.default_workflow()); } } #[test] fn partial_role_config_keeps_role_defaults() { let temp = TempDir::new().unwrap(); write_config( temp.path(), r#" [roles.coder] profile = "project:coder" "#, ); let config = TicketConfig::load_workspace(temp.path()).unwrap(); let coder = config.role(TicketRole::Coder); assert_eq!(coder.profile.as_str(), "project:coder"); assert!(coder.launch_prompt.is_none()); assert_eq!(coder.workflow.as_str(), "multi-agent-workflow"); assert_eq!(config.profile_for(TicketRole::Reviewer).as_str(), "inherit"); } #[test] fn backend_only_config_is_not_role_launch_ready() { let temp = TempDir::new().unwrap(); write_config( temp.path(), r#" [backend] provider = "builtin:yoi_local" root = ".yoi/tickets" "#, ); let config = TicketConfig::load_workspace(temp.path()).unwrap(); assert_eq!(config.backend.root, temp.path().join(".yoi/tickets")); assert_eq!( config.role_launch_config(TicketRole::Intake).unwrap_err(), TicketRoleLaunchConfigError::MissingRoleTable { role: TicketRole::Intake } ); } #[test] fn partial_role_config_only_marks_configured_roles_launch_ready() { let temp = TempDir::new().unwrap(); write_config( temp.path(), r#" [roles.intake] profile = "builtin:default" "#, ); let config = TicketConfig::load_workspace(temp.path()).unwrap(); assert_eq!( config .role_launch_config(TicketRole::Intake) .unwrap() .profile .as_str(), "builtin:default" ); assert_eq!( config .role_launch_config(TicketRole::Orchestrator) .unwrap_err(), TicketRoleLaunchConfigError::MissingRoleTable { role: TicketRole::Orchestrator } ); } #[test] fn role_table_without_profile_is_not_role_launch_ready() { let temp = TempDir::new().unwrap(); write_config( temp.path(), r#" [roles.orchestrator] workflow = "ticket-orchestrator-routing" "#, ); let config = TicketConfig::load_workspace(temp.path()).unwrap(); assert_eq!( config .role_launch_config(TicketRole::Orchestrator) .unwrap_err(), TicketRoleLaunchConfigError::MissingProfile { role: TicketRole::Orchestrator } ); } #[test] fn inherit_profile_is_not_role_launch_ready() { let temp = TempDir::new().unwrap(); write_config( temp.path(), r#" [roles.intake] profile = "inherit" "#, ); let config = TicketConfig::load_workspace(temp.path()).unwrap(); assert_eq!( config.role_launch_config(TicketRole::Intake).unwrap_err(), TicketRoleLaunchConfigError::InheritProfile { role: TicketRole::Intake } ); } #[test] fn unknown_roles_are_rejected() { let temp = TempDir::new().unwrap(); write_config( temp.path(), r#" [roles.operator] profile = "inherit" "#, ); let error = TicketConfig::load_workspace(temp.path()).unwrap_err(); assert!( error .to_string() .contains("unsupported Ticket role `operator`") ); assert!( error .to_string() .contains("intake, orchestrator, coder, reviewer") ); } #[test] fn stale_investigator_role_config_is_rejected() { let temp = TempDir::new().unwrap(); write_config( temp.path(), r#" [roles.investigator] profile = "builtin:default" workflow = "ticket-orchestrator-routing" "#, ); let error = TicketConfig::load_workspace(temp.path()).unwrap_err(); assert!( error .to_string() .contains("unsupported Ticket role `investigator`") ); assert!( error .to_string() .contains("intake, orchestrator, coder, reviewer") ); } #[test] fn unknown_fields_are_rejected() { let temp = TempDir::new().unwrap(); write_config( temp.path(), r#" [roles.coder] profile = "inherit" system_instruction = "$workspace/not-supported" "#, ); let error = TicketConfig::load_workspace(temp.path()).unwrap_err(); assert!(error.to_string().contains("unknown field")); assert!(error.to_string().contains("system_instruction")); } #[test] fn legacy_backend_kind_local_is_transitional_alias() { let temp = TempDir::new().unwrap(); write_config( temp.path(), r#" [backend] kind = "local" root = "legacy-tickets" "#, ); let config = TicketConfig::load_workspace(temp.path()).unwrap(); assert_eq!( config.backend.provider, TicketBackendProvider::BuiltinYoiLocal ); assert_eq!(config.backend_root(), temp.path().join("legacy-tickets")); } #[test] fn rejects_empty_ticket_record_language() { let temp = TempDir::new().unwrap(); write_config( temp.path(), r#" [backend] provider = "builtin:yoi_local" root = ".yoi/tickets" [ticket] language = " " "#, ); let error = TicketConfig::load_workspace(temp.path()).unwrap_err(); assert!( error .to_string() .contains("ticket record language must not be empty") ); } #[test] fn unsupported_backend_provider_is_rejected() { let temp = TempDir::new().unwrap(); write_config( temp.path(), r#" [backend] provider = "github" "#, ); let error = TicketConfig::load_workspace(temp.path()).unwrap_err(); assert!( error .to_string() .contains("unsupported Ticket backend provider `github`") ); assert!(error.to_string().contains("builtin:yoi_local")); } #[test] fn unsupported_legacy_backend_kind_is_rejected() { let temp = TempDir::new().unwrap(); write_config( temp.path(), r#" [backend] kind = "github" "#, ); let error = TicketConfig::load_workspace(temp.path()).unwrap_err(); assert!( error .to_string() .contains("unsupported legacy Ticket backend kind `github`") ); } #[test] fn backend_provider_and_legacy_kind_are_mutually_exclusive() { let temp = TempDir::new().unwrap(); write_config( temp.path(), r#" [backend] provider = "builtin:yoi_local" kind = "local" "#, ); let error = TicketConfig::load_workspace(temp.path()).unwrap_err(); assert!( error .to_string() .contains("backend.provider and legacy backend.kind are mutually exclusive") ); } #[test] fn relative_backend_root_resolves_against_workspace() { let temp = TempDir::new().unwrap(); write_config( temp.path(), r#" [backend] root = "nested/tickets" "#, ); let config = TicketConfig::load_workspace(temp.path()).unwrap(); assert_eq!(config.backend_root(), temp.path().join("nested/tickets")); } #[test] fn nix_profile_selector_refs_are_rejected() { let temp = TempDir::new().unwrap(); write_config( temp.path(), r#" [roles.coder] profile = "legacy.nix" "#, ); let error = TicketConfig::load_workspace(temp.path()).unwrap_err(); assert!( error .to_string() .contains("path selectors are not supported") ); } #[test] fn malformed_refs_are_reported() { let temp = TempDir::new().unwrap(); write_config( temp.path(), r#" [roles.coder] profile = "./coder.lua" "#, ); let error = TicketConfig::load_workspace(temp.path()).unwrap_err(); assert!( error .to_string() .contains("path selectors are not supported") ); } }