From 767870a4fbf12f942a8b270e1cc316d7f35d3ef6 Mon Sep 17 00:00:00 2001 From: Hare Date: Sat, 6 Jun 2026 03:33:40 +0900 Subject: [PATCH 1/2] ticket: add workspace ticket config --- Cargo.lock | 1 + crates/pod/src/feature/builtin/ticket.rs | 82 ++- crates/ticket/Cargo.toml | 1 + crates/ticket/src/config.rs | 626 +++++++++++++++++++++++ crates/ticket/src/lib.rs | 1 + package.nix | 2 +- 6 files changed, 710 insertions(+), 3 deletions(-) create mode 100644 crates/ticket/src/config.rs diff --git a/Cargo.lock b/Cargo.lock index e3d1ef95..ed391897 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3632,6 +3632,7 @@ dependencies = [ "tempfile", "thiserror 2.0.18", "tokio", + "toml", ] [[package]] diff --git a/crates/pod/src/feature/builtin/ticket.rs b/crates/pod/src/feature/builtin/ticket.rs index 462437f0..0910609c 100644 --- a/crates/pod/src/feature/builtin/ticket.rs +++ b/crates/pod/src/feature/builtin/ticket.rs @@ -6,7 +6,9 @@ use std::path::{Path, PathBuf}; -use ticket::{LocalTicketBackend, tool::TICKET_TOOL_NAMES, tool::ticket_tools}; +use ticket::{ + LocalTicketBackend, config::TicketConfig, tool::TICKET_TOOL_NAMES, tool::ticket_tools, +}; use crate::feature::{ FeatureDescriptor, FeatureDiagnostic, FeatureInstallContext, FeatureInstallError, @@ -22,17 +24,26 @@ const AUTHORITY_REASON: &str = "Use a configured local Ticket backend root for t #[derive(Clone, Debug)] pub struct TicketFeature { backend_root: PathBuf, + config_error: Option, } impl TicketFeature { pub fn new(backend_root: impl Into) -> Self { Self { backend_root: backend_root.into(), + config_error: None, } } pub fn for_workspace(workspace: impl AsRef) -> Self { - Self::new(workspace.as_ref().join("work-items")) + let workspace = workspace.as_ref(); + match TicketConfig::load_workspace(workspace) { + Ok(config) => Self::new(config.backend.root), + Err(error) => Self { + backend_root: workspace.join("work-items"), + config_error: Some(error.to_string()), + }, + } } pub fn backend_root(&self) -> &Path { @@ -80,6 +91,14 @@ impl FeatureModule for TicketFeature { } fn install(&self, context: &mut FeatureInstallContext<'_>) -> Result<(), FeatureInstallError> { + if let Some(error) = &self.config_error { + context + .diagnostics() + .push(FeatureDiagnostic::warning(format!( + "Ticket tools not registered: {error}" + ))); + return Ok(()); + } let usable_root = match self.usable_backend_root() { Ok(root) => root, Err(reason) => { @@ -142,6 +161,12 @@ mod tests { std::fs::create_dir_all(root.join("closed")).unwrap(); } + fn write_ticket_config(workspace: &Path, content: &str) { + let yoi_dir = workspace.join(".yoi"); + std::fs::create_dir_all(&yoi_dir).unwrap(); + std::fs::write(yoi_dir.join("ticket.config.toml"), content).unwrap(); + } + #[test] fn descriptor_declares_ticket_tools_and_backend_authority() { let temp = TempDir::new().unwrap(); @@ -182,6 +207,59 @@ mod tests { assert!(report.reports[0].skipped.is_empty()); } + #[test] + fn installs_ticket_tools_with_configured_backend_root() { + let temp = TempDir::new().unwrap(); + write_ticket_config( + temp.path(), + r#" +[backend] +root = "tickets" + +[roles.coder] +profile = "project:coder" +"#, + ); + make_work_items(&temp.path().join("tickets")); + + let feature = ticket_tools_feature(temp.path()); + assert_eq!(feature.backend_root(), temp.path().join("tickets")); + + let mut pending_tools = Vec::new(); + let mut hooks = HookRegistryBuilder::default(); + let report = FeatureRegistryBuilder::new() + .with_module(feature) + .install_into_pending(&mut pending_tools, &mut hooks); + + assert_eq!(pending_tools.len(), TICKET_TOOL_NAMES.len()); + assert!(report.reports[0].diagnostics.is_empty()); + } + + #[test] + fn malformed_ticket_config_fails_closed() { + let temp = TempDir::new().unwrap(); + make_work_items(&temp.path().join("work-items")); + write_ticket_config( + temp.path(), + r#" +[roles.operator] +profile = "inherit" +"#, + ); + let mut pending_tools = Vec::new(); + let mut hooks = HookRegistryBuilder::default(); + let report = FeatureRegistryBuilder::new() + .with_module(ticket_tools_feature(temp.path())) + .install_into_pending(&mut pending_tools, &mut hooks); + + assert!(pending_tools.is_empty()); + assert!(report.reports[0].installed_tools.is_empty()); + assert_eq!(report.reports[0].diagnostics.len(), 1); + let message = &report.reports[0].diagnostics[0].message; + assert!(message.contains("Ticket tools not registered")); + assert!(message.contains("unknown Ticket role `operator`")); + } + #[test] fn does_not_register_ticket_tools_when_root_is_missing() { let temp = TempDir::new().unwrap(); diff --git a/crates/ticket/Cargo.toml b/crates/ticket/Cargo.toml index 939291c2..73809b26 100644 --- a/crates/ticket/Cargo.toml +++ b/crates/ticket/Cargo.toml @@ -13,6 +13,7 @@ schemars = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } thiserror.workspace = true +toml = { workspace = true } [dev-dependencies] tempfile.workspace = true diff --git a/crates/ticket/src/config.rs b/crates/ticket/src/config.rs new file mode 100644 index 00000000..bf6cf427 --- /dev/null +++ b/crates/ticket/src/config.rs @@ -0,0 +1,626 @@ +//! 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; +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"; + +#[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 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), + 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 role(&self, role: TicketRole) -> &TicketRoleConfig { + self.roles.get(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 kind: TicketBackendKind, + pub root: PathBuf, +} + +impl TicketBackendConfig { + pub fn default_for_workspace(workspace_root: &Path) -> Self { + Self { + kind: TicketBackendKind::Local, + root: workspace_root.join("work-items"), + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum TicketBackendKind { + Local, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum TicketRole { + Intake, + Orchestrator, + Coder, + Reviewer, + Investigator, +} + +impl TicketRole { + pub const ALL: [TicketRole; 5] = [ + TicketRole::Intake, + TicketRole::Orchestrator, + TicketRole::Coder, + TicketRole::Reviewer, + TicketRole::Investigator, + ]; + + pub fn as_str(self) -> &'static str { + match self { + Self::Intake => "intake", + Self::Orchestrator => "orchestrator", + Self::Coder => "coder", + Self::Reviewer => "reviewer", + Self::Investigator => "investigator", + } + } + + pub fn parse(value: &str) -> Option { + match value { + "intake" => Some(Self::Intake), + "orchestrator" => Some(Self::Orchestrator), + "coder" => Some(Self::Coder), + "reviewer" => Some(Self::Reviewer), + "investigator" => Some(Self::Investigator), + _ => 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", + Self::Investigator => "ticket-orchestrator-routing", + } + } +} + +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 TicketRoleProfiles { + inner: BTreeMap, +} + +impl TicketRoleProfiles { + pub fn get(&self, role: TicketRole) -> &TicketRoleConfig { + self.inner + .get(&role) + .expect("TicketRoleProfiles always contains all fixed roles") + } + + 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 } + } +} + +#[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") + { + 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)] + roles: BTreeMap, +} + +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!("unknown Ticket role `{name}`"), + })?; + roles.inner.insert(role, raw_role.resolve(role)); + } + Ok(TicketConfig { + backend: self.backend.resolve(workspace_root), + roles, + }) + } +} + +#[derive(Debug, Default, Deserialize)] +#[serde(deny_unknown_fields)] +struct RawBackendConfig { + #[serde(default)] + kind: Option, + #[serde(default)] + root: Option, +} + +impl RawBackendConfig { + fn resolve(self, workspace_root: &Path) -> TicketBackendConfig { + let root = self.root.unwrap_or_else(|| PathBuf::from("work-items")); + TicketBackendConfig { + kind: self.kind.unwrap_or(TicketBackendKind::Local), + root: join_if_relative(workspace_root, &root), + } + } +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct RawTicketRoleConfig { + profile: ProfileSelectorRef, + #[serde(default)] + launch_prompt: Option, + #[serde(default)] + workflow: Option, +} + +impl RawTicketRoleConfig { + fn resolve(self, role: TicketRole) -> TicketRoleConfig { + TicketRoleConfig { + profile: self.profile, + 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.kind, TicketBackendKind::Local); + assert_eq!(config.backend.root, temp.path().join("work-items")); + 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] +kind = "local" +root = "custom-work-items" + +[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" + +[roles.investigator] +profile = "default" +launch_prompt = "$workspace/ticket/investigator/launch" +workflow = "ticket-orchestrator-routing" +"#, + ); + + let config = TicketConfig::load_workspace(temp.path()).unwrap(); + assert_eq!(config.backend.root, temp.path().join("custom-work-items")); + 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::Investigator).as_str(), + "ticket-orchestrator-routing" + ); + } + + #[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 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("unknown Ticket role `operator`")); + } + + #[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 unsupported_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("unknown variant")); + assert!(error.to_string().contains("github")); + } + + #[test] + fn relative_backend_root_resolves_against_workspace() { + let temp = TempDir::new().unwrap(); + write_config( + temp.path(), + r#" +[backend] +root = "nested/work-items" +"#, + ); + + let config = TicketConfig::load_workspace(temp.path()).unwrap(); + assert_eq!(config.backend_root(), temp.path().join("nested/work-items")); + } + + #[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") + ); + } +} diff --git a/crates/ticket/src/lib.rs b/crates/ticket/src/lib.rs index 8d2f30f7..73c498c2 100644 --- a/crates/ticket/src/lib.rs +++ b/crates/ticket/src/lib.rs @@ -14,6 +14,7 @@ use chrono::Utc; use fs4::fs_std::FileExt; use thiserror::Error; +pub mod config; pub mod tool; const STATUSES: [TicketStatus; 3] = [ diff --git a/package.nix b/package.nix index 84af53b3..c3113a2f 100644 --- a/package.nix +++ b/package.nix @@ -40,7 +40,7 @@ rustPlatform.buildRustPackage rec { filter = sourceFilter; }; - cargoHash = "sha256-UuKaulbFazTojfaCASnLHYMhmDSgX5LQ0ksJRndq+2w="; + cargoHash = "sha256-9/b7tbdMqXbhVpAcCEk+MBoDLhV0M7kIwYn6WUkrD4Y="; depsExtraArgs = { # Older fetchCargoVendor utilities used crates.io's API download endpoint, From 8fab67b5fcaa48a5ea5c3dcb8a5c3810630d45b3 Mon Sep 17 00:00:00 2001 From: Hare Date: Sat, 6 Jun 2026 03:43:37 +0900 Subject: [PATCH 2/2] ticket: reject nix profile selectors --- crates/ticket/src/config.rs | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/crates/ticket/src/config.rs b/crates/ticket/src/config.rs index bf6cf427..8b861bd7 100644 --- a/crates/ticket/src/config.rs +++ b/crates/ticket/src/config.rs @@ -230,6 +230,7 @@ impl ProfileSelectorRef { || 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()); } @@ -605,6 +606,25 @@ root = "nested/work-items" assert_eq!(config.backend_root(), temp.path().join("nested/work-items")); } + #[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();