use std::fs; use std::path::{Path, PathBuf}; use project_record::validate_record_id; use serde::{Deserialize, Serialize}; use ticket::{LocalTicketBackend, TicketFilter, TicketIdOrSlug}; use crate::{Error, Result}; const DETAIL_BODY_LIMIT: usize = 64 * 1024; const SUMMARY_BODY_LIMIT: usize = 240; #[derive(Debug, Clone)] pub struct LocalProjectRecordReader { workspace_root: PathBuf, ticket_backend: LocalTicketBackend, } impl LocalProjectRecordReader { pub fn new(workspace_root: impl Into) -> Self { let workspace_root = workspace_root.into(); let ticket_root = workspace_root.join(".yoi/tickets"); Self { workspace_root, ticket_backend: LocalTicketBackend::new(ticket_root), } } pub fn workspace_root(&self) -> &Path { self.workspace_root.as_path() } pub fn list_tickets(&self, limit: usize) -> Result> { let partial = self.ticket_backend.list_partial(TicketFilter::all())?; let mut items = partial .tickets .into_iter() .map(|item| TicketSummary { id: item.id, title: item.title, state: item.workflow_state.as_str().to_string(), priority: item.priority, updated_at: item.updated_at, queued_by: item.queued_by, queued_at: item.queued_at, record_source: "local_yoi_ticket".to_string(), }) .collect::>(); items.sort_by(|a, b| { b.updated_at .cmp(&a.updated_at) .then_with(|| a.id.cmp(&b.id)) }); items.truncate(limit.min(200)); Ok(ProjectRecordList { items, invalid_records: partial .invalid_records .into_iter() .map(|record| InvalidProjectRecord { label: record.label, reason: record.reason, }) .collect(), record_authority: "local_yoi_project_records".to_string(), }) } pub fn ticket(&self, id: &str) -> Result { validate_project_id(id)?; let partial = self .ticket_backend .show_partial(TicketIdOrSlug::Id(id.to_string()))?; let ticket = partial.ticket; let (body, body_truncated) = truncate_body(ticket.document.body.as_str(), DETAIL_BODY_LIMIT); Ok(TicketDetail { id: ticket.meta.id, title: ticket.meta.title, state: ticket.meta.workflow_state.as_str().to_string(), priority: ticket.meta.priority, created_at: ticket.meta.created_at, updated_at: ticket.meta.updated_at, queued_by: ticket.meta.queued_by, queued_at: ticket.meta.queued_at, risk_flags: ticket.meta.risk_flags, body, body_truncated, event_count: ticket.events.len(), artifact_count: ticket.artifacts.len(), record_source: "local_yoi_ticket".to_string(), }) } pub fn list_objectives(&self, limit: usize) -> Result> { let mut items = Vec::new(); let mut invalid_records = Vec::new(); let root = self.workspace_root.join(".yoi/objectives"); if !root.exists() { return Ok(ProjectRecordList { items, invalid_records, record_authority: "local_yoi_project_records".to_string(), }); } for entry in fs::read_dir(&root)? { let entry = entry?; let path = entry.path(); if !path.is_dir() { continue; } let id = entry.file_name().to_string_lossy().to_string(); match read_objective_summary(&path, &id) { Ok(item) => items.push(item), Err(error) => invalid_records.push(InvalidProjectRecord { label: id, reason: error.to_string(), }), } } items.sort_by(|a, b| { b.updated_at .cmp(&a.updated_at) .then_with(|| a.id.cmp(&b.id)) }); items.truncate(limit.min(200)); Ok(ProjectRecordList { items, invalid_records, record_authority: "local_yoi_project_records".to_string(), }) } pub fn objective(&self, id: &str) -> Result { validate_project_id(id)?; let path = self.workspace_root.join(".yoi/objectives").join(id); let raw = fs::read_to_string(path.join("item.md"))?; let (frontmatter, body) = split_frontmatter(&raw, id)?; let meta: ObjectiveFrontmatter = serde_yaml::from_str(frontmatter)?; let (body, body_truncated) = truncate_body(body, DETAIL_BODY_LIMIT); Ok(ObjectiveDetail { id: id.to_string(), title: meta.title, state: meta.state, created_at: meta.created_at, updated_at: meta.updated_at, linked_tickets: meta.linked_tickets, body, body_truncated, record_source: "local_yoi_objective".to_string(), }) } } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct ProjectRecordList { pub items: Vec, pub invalid_records: Vec, pub record_authority: String, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct InvalidProjectRecord { pub label: String, pub reason: String, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct TicketSummary { pub id: String, pub title: String, pub state: String, pub priority: String, pub updated_at: Option, pub queued_by: Option, pub queued_at: Option, pub record_source: String, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct TicketDetail { pub id: String, pub title: String, pub state: String, pub priority: String, pub created_at: Option, pub updated_at: Option, pub queued_by: Option, pub queued_at: Option, pub risk_flags: Vec, pub body: String, pub body_truncated: bool, pub event_count: usize, pub artifact_count: usize, pub record_source: String, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct ObjectiveSummary { pub id: String, pub title: String, pub state: String, pub updated_at: Option, pub summary: String, pub linked_tickets: Vec, pub record_source: String, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct ObjectiveDetail { pub id: String, pub title: String, pub state: String, pub created_at: Option, pub updated_at: Option, pub linked_tickets: Vec, pub body: String, pub body_truncated: bool, pub record_source: String, } #[derive(Debug, Deserialize)] struct ObjectiveFrontmatter { title: String, state: String, #[serde(default)] created_at: Option, #[serde(default)] updated_at: Option, #[serde(default)] linked_tickets: Vec, } fn read_objective_summary(path: &Path, id: &str) -> Result { validate_project_id(id)?; let raw = fs::read_to_string(path.join("item.md"))?; let (frontmatter, body) = split_frontmatter(&raw, id)?; let meta: ObjectiveFrontmatter = serde_yaml::from_str(frontmatter)?; Ok(ObjectiveSummary { id: id.to_string(), title: meta.title, state: meta.state, updated_at: meta.updated_at, summary: summarize_body(body), linked_tickets: meta.linked_tickets, record_source: "local_yoi_objective".to_string(), }) } fn split_frontmatter<'a>(raw: &'a str, label: &str) -> Result<(&'a str, &'a str)> { let rest = raw .strip_prefix("---\n") .ok_or_else(|| Error::MissingFrontmatter(label.to_string()))?; let Some((frontmatter, body)) = rest.split_once("\n---\n") else { return Err(Error::MissingFrontmatter(label.to_string())); }; Ok((frontmatter, body)) } fn validate_project_id(id: &str) -> Result<()> { validate_record_id(id).map_err(|_| Error::InvalidRecordId(id.to_string())) } fn summarize_body(body: &str) -> String { let summary = body .lines() .map(str::trim) .find(|line| !line.is_empty() && !line.starts_with('#')) .unwrap_or_default(); let (summary, truncated) = truncate_body(summary, SUMMARY_BODY_LIMIT); if truncated { format!("{summary}…") } else { summary } } fn truncate_body(body: &str, limit: usize) -> (String, bool) { if body.len() <= limit { return (body.to_string(), false); } let mut end = limit; while !body.is_char_boundary(end) { end -= 1; } (body[..end].to_string(), true) } #[cfg(test)] mod tests { use super::*; #[test] fn reads_local_yoi_ticket_and_objective_records_without_migration() { let dir = tempfile::tempdir().unwrap(); write_ticket(dir.path(), "00000000001J2", "Read bridge", "ready"); write_objective(dir.path(), "00000000001J3", "Control plane", "active"); let reader = LocalProjectRecordReader::new(dir.path()); let tickets = reader.list_tickets(20).unwrap(); assert_eq!(tickets.record_authority, "local_yoi_project_records"); assert_eq!(tickets.items[0].id, "00000000001J2"); assert_eq!(tickets.items[0].state, "ready"); let ticket = reader.ticket("00000000001J2").unwrap(); assert!(ticket.body.contains("Ticket body")); let objectives = reader.list_objectives(20).unwrap(); assert_eq!(objectives.items[0].id, "00000000001J3"); assert_eq!(objectives.items[0].linked_tickets, vec!["00000000001J2"]); let objective = reader.objective("00000000001J3").unwrap(); assert!(objective.body.contains("Objective body")); } fn write_ticket(root: &Path, id: &str, title: &str, state: &str) { let ticket_dir = root.join(".yoi/tickets").join(id); fs::create_dir_all(&ticket_dir).unwrap(); fs::write( ticket_dir.join("item.md"), format!( r#"--- title: "{title}" state: "{state}" created_at: "2026-01-01T00:00:00Z" updated_at: "2026-01-02T00:00:00Z" --- Ticket body. "#, ), ) .unwrap(); fs::write(ticket_dir.join("thread.md"), "").unwrap(); } fn write_objective(root: &Path, id: &str, title: &str, state: &str) { let objective_dir = root.join(".yoi/objectives").join(id); fs::create_dir_all(&objective_dir).unwrap(); fs::write( objective_dir.join("item.md"), format!( r#"--- title: "{title}" state: "{state}" created_at: "2026-01-01T00:00:00Z" updated_at: "2026-01-02T00:00:00Z" linked_tickets: ["00000000001J2"] --- Objective body. "#, ), ) .unwrap(); } }