//! Built-in Ticket feature adapter. //! //! The ticket crate owns Ticket domain logic and Tool implementations. This //! module only resolves the local backend root, declares the built-in feature, //! and contributes those tools through the normal feature registry path. use std::path::{Path, PathBuf}; use ticket::{ LocalTicketBackend, config::TicketConfig, tool::TICKET_TOOL_NAMES, tool::ticket_tools, }; use crate::feature::{ FeatureDescriptor, FeatureDiagnostic, FeatureInstallContext, FeatureInstallError, FeatureModule, HostAuthority, HostAuthorityRequest, ToolContribution, ToolDeclaration, }; const FEATURE_ID: &str = "ticket"; const FEATURE_NAME: &str = "Ticket tools"; const FEATURE_DESCRIPTION: &str = "Typed local Ticket work-item operations over a bounded backend root. \ The tools operate through the ticket crate backend and do not grant generic filesystem write scope."; const AUTHORITY_REASON: &str = "Use a configured local Ticket backend root for typed work-item operations without generic filesystem write authority."; #[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 { 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 { &self.backend_root } fn authority(&self) -> HostAuthority { HostAuthority::TicketBackend { root: self.backend_root.display().to_string(), } } fn usable_backend_root(&self) -> Result { let root = self .backend_root .canonicalize() .map_err(|error| format!("ticket backend root is not usable: {error}"))?; if !root.is_dir() { return Err("ticket backend root is not a directory".to_string()); } for status_dir in ["open", "pending", "closed"] { let dir = root.join(status_dir); if !dir.is_dir() { return Err(format!( "ticket backend root is missing required {status_dir}/ directory" )); } } Ok(root) } } impl FeatureModule for TicketFeature { fn descriptor(&self) -> FeatureDescriptor { let mut descriptor = FeatureDescriptor::builtin(FEATURE_ID, FEATURE_NAME) .with_description(FEATURE_DESCRIPTION) .with_host_authority(HostAuthorityRequest::required( self.authority(), AUTHORITY_REASON, )); for name in TICKET_TOOL_NAMES { descriptor = descriptor.with_tool(ToolDeclaration::new(name, tool_description(name))); } descriptor } 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) => { context .diagnostics() .push(FeatureDiagnostic::warning(format!( "Ticket tools not registered: {reason}; root={} ", self.backend_root.display() ))); return Ok(()); } }; let authority = self.authority(); let backend = LocalTicketBackend::new(usable_root); let mut tools = context.tools(); for definition in ticket_tools(backend) { let (meta, _) = definition(); let name = meta.name.clone(); tools.register( ToolContribution::new(name, definition) .with_required_host_authorities(vec![authority.clone()]), )?; } Ok(()) } } fn tool_description(name: &str) -> &'static str { match name { "TicketCreate" => "Create a Ticket through the typed local Ticket backend.", "TicketList" => "List Tickets through the typed local Ticket backend with bounded output.", "TicketShow" => { "Show one Ticket through the typed local Ticket backend with bounded output." } "TicketComment" => { "Append a comment/plan/decision/implementation_report event to a Ticket." } "TicketReview" => "Append an approve/request_changes review event to a Ticket.", "TicketStatus" => "Move a Ticket between open and pending; use TicketClose for closed.", "TicketClose" => "Close a Ticket with a resolution through the typed local Ticket backend.", "TicketDoctor" => "Run typed local Ticket backend consistency checks.", _ => "Typed Ticket backend tool.", } } pub fn ticket_tools_feature(workspace: impl AsRef) -> TicketFeature { TicketFeature::for_workspace(workspace) } #[cfg(test)] mod tests { use super::*; use crate::feature::{FeatureRegistryBuilder, FeatureRuntimeKind}; use crate::hook::HookRegistryBuilder; use tempfile::TempDir; fn make_work_items(root: &Path) { std::fs::create_dir_all(root.join("open")).unwrap(); std::fs::create_dir_all(root.join("pending")).unwrap(); 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(); let feature = ticket_tools_feature(temp.path()); let descriptor = feature.descriptor(); assert_eq!(descriptor.id.to_string(), "builtin:ticket"); assert_eq!(descriptor.runtime, FeatureRuntimeKind::Builtin); assert_eq!(descriptor.tools.len(), TICKET_TOOL_NAMES.len()); assert_eq!( descriptor .tools .iter() .map(|tool| tool.name.as_str()) .collect::>(), TICKET_TOOL_NAMES ); assert_eq!(descriptor.requested_host_authorities.len(), 1); assert!(matches!( descriptor.requested_host_authorities[0].authority, HostAuthority::TicketBackend { .. } )); } #[test] fn installs_ticket_tools_when_work_items_root_is_usable() { let temp = TempDir::new().unwrap(); make_work_items(&temp.path().join("work-items")); 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_eq!(pending_tools.len(), TICKET_TOOL_NAMES.len()); assert_eq!(report.reports.len(), 1); assert!(report.reports[0].installed); assert_eq!(report.reports[0].installed_tools, TICKET_TOOL_NAMES); 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(); 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_eq!(report.reports.len(), 1); assert!(report.reports[0].installed); assert!(report.reports[0].installed_tools.is_empty()); assert_eq!(report.reports[0].diagnostics.len(), 1); assert!( report.reports[0].diagnostics[0] .message .contains("Ticket tools not registered") ); } #[test] fn does_not_register_ticket_tools_when_root_lacks_status_dirs() { let temp = TempDir::new().unwrap(); std::fs::create_dir_all(temp.path().join("work-items")).unwrap(); 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!( report.reports[0].diagnostics[0] .message .contains("missing required open/ directory") ); } }