1117 lines
32 KiB
Rust
1117 lines
32 KiB
Rust
//! 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<Path>) -> 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<Path>) -> Result<Self, TicketConfigError> {
|
|
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>,
|
|
path: impl AsRef<Path>,
|
|
content: &str,
|
|
) -> Result<Self, TicketConfigError> {
|
|
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<Self> {
|
|
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<Self> {
|
|
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<TicketRecordLanguage>,
|
|
}
|
|
|
|
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<String>) -> Result<Self, String> {
|
|
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<D>(deserializer: D) -> Result<Self, D::Error>
|
|
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<TicketRole, TicketRoleConfig>,
|
|
configured_roles: BTreeSet<TicketRole>,
|
|
profile_configured_roles: BTreeSet<TicketRole>,
|
|
}
|
|
|
|
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<Item = (TicketRole, &TicketRoleConfig)> {
|
|
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<PromptRef>,
|
|
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<String>) -> Result<Self, String> {
|
|
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<D>(deserializer: D) -> Result<Self, D::Error>
|
|
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<str> 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<String>) -> Result<Self, String> {
|
|
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<D>(deserializer: D) -> Result<Self, D::Error>
|
|
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<str> 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<String>) -> Result<Self, String> {
|
|
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<D>(deserializer: D) -> Result<Self, D::Error>
|
|
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<str> for WorkflowRef {
|
|
fn as_ref(&self) -> &str {
|
|
self.as_str()
|
|
}
|
|
}
|
|
|
|
fn normalized_non_empty(value: impl Into<String>, label: &str) -> Result<String, String> {
|
|
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<String, RawTicketRoleConfig>,
|
|
}
|
|
|
|
#[derive(Debug, Default, Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
struct RawTicketRecordConfig {
|
|
#[serde(default)]
|
|
language: Option<TicketRecordLanguage>,
|
|
}
|
|
|
|
impl RawTicketRecordConfig {
|
|
fn resolve(self) -> TicketRecordConfig {
|
|
TicketRecordConfig {
|
|
language: self.language,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl RawTicketConfig {
|
|
fn resolve(
|
|
self,
|
|
workspace_root: &Path,
|
|
path: &Path,
|
|
) -> Result<TicketConfig, TicketConfigError> {
|
|
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<String>,
|
|
#[serde(default)]
|
|
kind: Option<String>,
|
|
#[serde(default)]
|
|
root: Option<PathBuf>,
|
|
}
|
|
|
|
impl RawBackendConfig {
|
|
fn resolve(self, workspace_root: &Path) -> Result<TicketBackendConfig, String> {
|
|
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<ProfileSelectorRef>,
|
|
#[serde(default)]
|
|
launch_prompt: Option<PromptRef>,
|
|
#[serde(default)]
|
|
workflow: Option<WorkflowRef>,
|
|
}
|
|
|
|
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")
|
|
);
|
|
}
|
|
}
|