feat: add ticket record language config

This commit is contained in:
2026-06-08 17:02:16 +09:00
parent 17ac5b04f0
commit fb261bb4f6
9 changed files with 310 additions and 28 deletions
+94
View File
@@ -34,6 +34,9 @@ pub fn ticket_config_scaffold() -> String {
"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",
@@ -65,6 +68,7 @@ pub enum TicketConfigError {
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TicketConfig {
pub backend: TicketBackendConfig,
pub ticket: TicketRecordConfig,
pub roles: TicketRoleProfiles,
}
@@ -73,6 +77,7 @@ impl TicketConfig {
let workspace_root = workspace_root.as_ref();
Self {
backend: TicketBackendConfig::default_for_workspace(workspace_root),
ticket: TicketRecordConfig::default(),
roles: TicketRoleProfiles::default(),
}
}
@@ -109,6 +114,13 @@ impl TicketConfig {
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)
}
@@ -231,6 +243,41 @@ impl fmt::Display for TicketRole {
}
}
#[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>,
@@ -473,9 +520,26 @@ 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,
@@ -502,6 +566,7 @@ impl RawTicketConfig {
message,
}
})?,
ticket: self.ticket.resolve(),
roles,
})
}
@@ -605,6 +670,7 @@ mod tests {
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");
@@ -623,6 +689,9 @@ mod tests {
provider = "builtin:yoi_local"
root = "custom-tickets"
[ticket]
language = "Japanese"
[roles.intake]
profile = "project:intake"
launch_prompt = "$workspace/ticket/intake/launch"
@@ -656,6 +725,7 @@ workflow = "ticket-orchestrator-routing"
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"
@@ -681,6 +751,7 @@ workflow = "ticket-orchestrator-routing"
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!(
@@ -868,6 +939,29 @@ root = "legacy-tickets"
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();