use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct SyncYaml {
#[serde(default)]
pub metadata: Metadata,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub body_markdown: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub comments: Vec<Comment>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub body: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub r#type: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub labels: Option<Vec<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub assignees: Option<Vec<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub category: Option<String>,
}
impl SyncYaml {
pub fn title(&self) -> &str {
self.title
.as_deref()
.or(self.metadata.title.as_deref())
.unwrap_or("Untitled")
}
pub fn body(&self) -> &str {
self.body
.as_deref()
.filter(|s| !s.is_empty())
.unwrap_or(&self.body_markdown)
}
pub fn item_type(&self) -> ItemType {
let type_str = self
.r#type
.as_deref()
.or(self.metadata.r#type.as_deref())
.unwrap_or("issue");
match type_str {
"idea" | "discussion" => ItemType::Idea,
_ => ItemType::Issue,
}
}
pub fn labels(&self) -> &[String] {
self.labels
.as_deref()
.unwrap_or(self.metadata.labels.as_slice())
}
pub fn assignees(&self) -> &[String] {
self.assignees
.as_deref()
.unwrap_or(self.metadata.assignees.as_slice())
}
pub fn category(&self) -> Option<&str> {
self.category
.as_deref()
.or(self.metadata.category.as_deref())
}
pub fn github_issue_number(&self) -> Option<u64> {
self.metadata.github_issue_number
}
pub fn github_discussion_id(&self) -> Option<&str> {
self.metadata.github_discussion_id.as_deref()
}
pub fn last_synced(&self) -> Option<&LastSynced> {
self.metadata.last_synced.as_ref()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ItemType {
Issue,
Idea, }
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct Metadata {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub r#type: Option<String>,
#[serde(default)]
pub labels: Vec<String>,
#[serde(default)]
pub assignees: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub state: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub category: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub github_issue_number: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub github_discussion_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub github_discussion_number: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub github_updated_at: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_synced: Option<LastSynced>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct LastSynced {
pub title: String,
pub body: String,
pub labels: Vec<String>,
pub updated_at: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub assignees: Option<Vec<String>>,
}
impl LastSynced {
pub fn new(
title: impl Into<String>,
body: impl Into<String>,
labels: Vec<String>,
updated_at: impl Into<String>,
assignees: Option<Vec<String>>,
) -> Self {
Self {
title: title.into(),
body: body.into(),
labels,
updated_at: updated_at.into(),
assignees,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Comment {
pub id: String,
pub author: String,
pub created_at: String,
pub body: String,
}
#[derive(Debug, Clone)]
pub struct GitHubIssue {
pub number: u64,
pub title: String,
pub body: Option<String>,
pub labels: Vec<String>,
pub assignees: Vec<String>,
pub state: String,
pub updated_at: String,
}
#[derive(Debug, Clone)]
pub struct GitHubDiscussion {
pub id: String, pub number: u64,
pub title: String,
pub body: Option<String>,
pub labels: Vec<String>,
pub category: String,
pub updated_at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GitHubLabel {
pub name: String,
pub color: String,
#[serde(default)]
pub description: String,
}
pub fn slugify(text: &str, max_len: usize) -> String {
text.to_lowercase()
.chars()
.map(|c| if c.is_alphanumeric() { c } else { '-' })
.collect::<String>()
.split('-')
.filter(|s| !s.is_empty())
.collect::<Vec<_>>()
.join("-")
.chars()
.take(max_len)
.collect()
}
pub fn yaml_filename(number: u64, title: &str) -> String {
let slug = slugify(title, 50);
format!("{}-{}.yaml", number, slug)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_slugify() {
assert_eq!(slugify("Hello World", 50), "hello-world");
assert_eq!(slugify("Fix: Bug #123", 50), "fix-bug-123");
assert_eq!(slugify(" Multiple Spaces ", 50), "multiple-spaces");
assert_eq!(slugify("Very Long Title That Exceeds", 10), "very-long-");
}
#[test]
fn test_yaml_filename() {
assert_eq!(
yaml_filename(11, "Port sync to pure Rust: GitHub Auth module"),
"11-port-sync-to-pure-rust-github-auth-module.yaml"
);
}
#[test]
fn test_item_type_parsing() {
let mut yaml = SyncYaml::default();
assert_eq!(yaml.item_type(), ItemType::Issue);
yaml.r#type = Some("idea".to_string());
assert_eq!(yaml.item_type(), ItemType::Idea);
yaml.r#type = Some("discussion".to_string());
assert_eq!(yaml.item_type(), ItemType::Idea);
yaml.r#type = Some("issue".to_string());
assert_eq!(yaml.item_type(), ItemType::Issue);
}
#[test]
fn test_field_resolution() {
let yaml = SyncYaml {
title: Some("Root Title".to_string()),
metadata: Metadata {
title: Some("Metadata Title".to_string()),
..Default::default()
},
..Default::default()
};
assert_eq!(yaml.title(), "Root Title");
let yaml2 = SyncYaml {
metadata: Metadata {
title: Some("Metadata Title".to_string()),
..Default::default()
},
..Default::default()
};
assert_eq!(yaml2.title(), "Metadata Title");
}
#[test]
fn test_deserialize_yaml() {
let yaml_str = r#"
metadata:
title: Test Issue
type: issue
labels:
- bug
- enhancement
assignees: []
state: open
github_issue_number: 42
body_markdown: |
This is the body.
comments: []
"#;
let parsed: SyncYaml = serde_yaml::from_str(yaml_str).unwrap();
assert_eq!(parsed.title(), "Test Issue");
assert_eq!(parsed.item_type(), ItemType::Issue);
assert_eq!(parsed.labels(), &["bug", "enhancement"]);
assert_eq!(parsed.github_issue_number(), Some(42));
assert!(parsed.body().contains("This is the body"));
}
}