ta_changeset/output_adapters/
mod.rs1use crate::draft_package::DraftPackage;
10use crate::error::ChangeSetError;
11
12pub mod html;
13pub mod json;
14pub mod markdown;
15pub mod terminal;
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub enum OutputFormat {
20 Terminal,
21 Markdown,
22 Json,
23 Html,
24}
25
26impl std::str::FromStr for OutputFormat {
27 type Err = String;
28
29 fn from_str(s: &str) -> Result<Self, Self::Err> {
30 match s.to_lowercase().as_str() {
31 "terminal" => Ok(OutputFormat::Terminal),
32 "markdown" | "md" => Ok(OutputFormat::Markdown),
33 "json" => Ok(OutputFormat::Json),
34 "html" => Ok(OutputFormat::Html),
35 _ => Err(format!(
36 "Invalid output format: '{}'. Valid formats: terminal, markdown, json, html",
37 s
38 )),
39 }
40 }
41}
42
43impl std::fmt::Display for OutputFormat {
44 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
45 match self {
46 OutputFormat::Terminal => write!(f, "terminal"),
47 OutputFormat::Markdown => write!(f, "markdown"),
48 OutputFormat::Json => write!(f, "json"),
49 OutputFormat::Html => write!(f, "html"),
50 }
51 }
52}
53
54#[derive(Debug, Clone, Copy, PartialEq, Eq)]
56pub enum DetailLevel {
57 Top,
59 Medium,
61 Full,
63}
64
65impl std::str::FromStr for DetailLevel {
66 type Err = String;
67
68 fn from_str(s: &str) -> Result<Self, Self::Err> {
69 match s.to_lowercase().as_str() {
70 "top" => Ok(DetailLevel::Top),
71 "medium" | "med" => Ok(DetailLevel::Medium),
72 "full" => Ok(DetailLevel::Full),
73 _ => Err(format!(
74 "Invalid detail level: '{}'. Valid levels: top, medium, full",
75 s
76 )),
77 }
78 }
79}
80
81impl std::fmt::Display for DetailLevel {
82 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
83 match self {
84 DetailLevel::Top => write!(f, "top"),
85 DetailLevel::Medium => write!(f, "medium"),
86 DetailLevel::Full => write!(f, "full"),
87 }
88 }
89}
90
91#[derive(Debug, Clone, Copy, PartialEq, Eq)]
93pub enum SectionFilter {
94 Summary,
96 Decisions,
98 Validation,
100 Files,
102}
103
104impl std::str::FromStr for SectionFilter {
105 type Err = String;
106
107 fn from_str(s: &str) -> Result<Self, Self::Err> {
108 match s.to_lowercase().as_str() {
109 "summary" => Ok(SectionFilter::Summary),
110 "decisions" => Ok(SectionFilter::Decisions),
111 "validation" => Ok(SectionFilter::Validation),
112 "files" => Ok(SectionFilter::Files),
113 _ => Err(format!(
114 "Invalid section: '{}'. Valid sections: summary, decisions, validation, files",
115 s
116 )),
117 }
118 }
119}
120
121impl std::fmt::Display for SectionFilter {
122 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
123 match self {
124 SectionFilter::Summary => write!(f, "summary"),
125 SectionFilter::Decisions => write!(f, "decisions"),
126 SectionFilter::Validation => write!(f, "validation"),
127 SectionFilter::Files => write!(f, "files"),
128 }
129 }
130}
131
132pub struct RenderContext<'a> {
134 pub package: &'a DraftPackage,
135 pub detail_level: DetailLevel,
136 pub file_filters: Vec<String>,
139 pub diff_provider: Option<&'a dyn DiffProvider>,
141 pub section_filter: Option<SectionFilter>,
143}
144
145pub trait DiffProvider {
149 fn get_diff(&self, diff_ref: &str) -> Result<String, ChangeSetError>;
150}
151
152pub trait OutputAdapter {
154 fn render(&self, ctx: &RenderContext) -> Result<String, ChangeSetError>;
156
157 fn name(&self) -> &str;
159}
160
161pub fn default_summary<'a>(uri: &str, change_type: &crate::pr_package::ChangeType) -> &'a str {
163 let path = uri.strip_prefix("fs://workspace/").unwrap_or(uri);
164
165 if path.ends_with("Cargo.lock")
167 || path.ends_with("package-lock.json")
168 || path.ends_with("yarn.lock")
169 || path.ends_with("pnpm-lock.yaml")
170 || path.ends_with("Gemfile.lock")
171 || path.ends_with("poetry.lock")
172 {
173 return "lockfile updated (dependency changes)";
174 }
175
176 if path.ends_with("Cargo.toml")
178 || path.ends_with("package.json")
179 || path.ends_with("pyproject.toml")
180 {
181 return "project configuration updated";
182 }
183
184 if path.ends_with("PLAN.md") || path.ends_with("CHANGELOG.md") {
186 return "project documentation updated";
187 }
188 if path.ends_with("README.md") {
189 return "readme updated";
190 }
191
192 match change_type {
194 crate::pr_package::ChangeType::Add => "new file",
195 crate::pr_package::ChangeType::Delete => "file removed",
196 crate::pr_package::ChangeType::Rename => "file renamed",
197 crate::pr_package::ChangeType::Modify => "modified",
198 }
199}
200
201pub fn matches_file_filters(uri: &str, filters: &[String]) -> bool {
206 if filters.is_empty() {
207 return true;
208 }
209 if !uri.starts_with("fs://workspace/") {
212 return true;
213 }
214 let path = uri.strip_prefix("fs://workspace/").unwrap_or(uri);
216 filters.iter().any(|pattern| {
217 if let Ok(pat) = glob::Pattern::new(pattern) {
219 if pat.matches(path) {
220 return true;
221 }
222 }
223 path.contains(pattern.as_str()) || uri.contains(pattern.as_str())
225 })
226}
227
228pub fn get_adapter(format: OutputFormat, color: bool) -> Box<dyn OutputAdapter> {
233 match format {
234 OutputFormat::Terminal => Box::new(terminal::TerminalAdapter::with_color(color)),
235 OutputFormat::Markdown => Box::new(markdown::MarkdownAdapter::new()),
236 OutputFormat::Json => Box::new(json::JsonAdapter::new()),
237 OutputFormat::Html => Box::new(html::HtmlAdapter::new()),
238 }
239}
240
241#[cfg(test)]
242mod tests {
243 use super::*;
244
245 #[test]
246 fn output_format_from_str() {
247 assert_eq!(
248 "terminal".parse::<OutputFormat>().unwrap(),
249 OutputFormat::Terminal
250 );
251 assert_eq!(
252 "markdown".parse::<OutputFormat>().unwrap(),
253 OutputFormat::Markdown
254 );
255 assert_eq!(
256 "md".parse::<OutputFormat>().unwrap(),
257 OutputFormat::Markdown
258 );
259 assert_eq!("json".parse::<OutputFormat>().unwrap(), OutputFormat::Json);
260 assert_eq!("html".parse::<OutputFormat>().unwrap(), OutputFormat::Html);
261 assert!("invalid".parse::<OutputFormat>().is_err());
262 }
263
264 #[test]
265 fn detail_level_from_str() {
266 assert_eq!("top".parse::<DetailLevel>().unwrap(), DetailLevel::Top);
267 assert_eq!(
268 "medium".parse::<DetailLevel>().unwrap(),
269 DetailLevel::Medium
270 );
271 assert_eq!("med".parse::<DetailLevel>().unwrap(), DetailLevel::Medium);
272 assert_eq!("full".parse::<DetailLevel>().unwrap(), DetailLevel::Full);
273 assert!("invalid".parse::<DetailLevel>().is_err());
274 }
275
276 #[test]
277 fn output_format_display() {
278 assert_eq!(OutputFormat::Terminal.to_string(), "terminal");
279 assert_eq!(OutputFormat::Markdown.to_string(), "markdown");
280 assert_eq!(OutputFormat::Json.to_string(), "json");
281 assert_eq!(OutputFormat::Html.to_string(), "html");
282 }
283
284 #[test]
285 fn detail_level_display() {
286 assert_eq!(DetailLevel::Top.to_string(), "top");
287 assert_eq!(DetailLevel::Medium.to_string(), "medium");
288 assert_eq!(DetailLevel::Full.to_string(), "full");
289 }
290}