Skip to main content

ta_changeset/output_adapters/
mod.rs

1//! output_adapters — Pluggable output renderers for draft review (v0.2.3).
2//!
3//! Output adapters transform DraftPackage data into different formats for review:
4//! - **Terminal**: Colored inline diff with tiered display (default)
5//! - **Markdown**: GitHub-ready markdown with collapsible sections
6//! - **JSON**: Machine-readable structured output for CI/CD
7//! - **HTML**: Standalone review page with progressive disclosure
8
9use crate::draft_package::DraftPackage;
10use crate::error::ChangeSetError;
11
12pub mod html;
13pub mod json;
14pub mod markdown;
15pub mod terminal;
16
17/// Output format for PR rendering.
18#[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/// Detail level for rendering.
55#[derive(Debug, Clone, Copy, PartialEq, Eq)]
56pub enum DetailLevel {
57    /// Top: One-line summaries only.
58    Top,
59    /// Medium: Summary + explanation (no diffs). Default.
60    Medium,
61    /// Full: Everything including full diffs.
62    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/// Section filter for `ta draft view --section` (v0.14.7).
92#[derive(Debug, Clone, Copy, PartialEq, Eq)]
93pub enum SectionFilter {
94    /// Show only the summary section.
95    Summary,
96    /// Show only the Agent Decision Log.
97    Decisions,
98    /// Show only the validation evidence.
99    Validation,
100    /// Show only the changed files list.
101    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
132/// Context for rendering a PR package.
133pub struct RenderContext<'a> {
134    pub package: &'a DraftPackage,
135    pub detail_level: DetailLevel,
136    /// Optional: Filter to specific files matching these patterns (glob supported).
137    /// Empty vec = show all.
138    pub file_filters: Vec<String>,
139    /// Optional: Diff content provider (for fetching full diffs).
140    pub diff_provider: Option<&'a dyn DiffProvider>,
141    /// Optional: Show only one section of the draft view (v0.14.7).
142    pub section_filter: Option<SectionFilter>,
143}
144
145/// Trait for fetching diff content.
146///
147/// Adapters use this to lazily load full diffs when needed (DetailLevel::Full).
148pub trait DiffProvider {
149    fn get_diff(&self, diff_ref: &str) -> Result<String, ChangeSetError>;
150}
151
152/// Output adapter trait — renders draft packages in different formats.
153pub trait OutputAdapter {
154    /// Render the draft package to a string.
155    fn render(&self, ctx: &RenderContext) -> Result<String, ChangeSetError>;
156
157    /// Adapter name (for logging/debugging).
158    fn name(&self) -> &str;
159}
160
161/// Generate a sensible default summary when no explanation or rationale is provided.
162pub 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    // Lockfiles
166    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    // Config / manifest files
177    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    // Plan / docs
185    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    // By change type
193    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
201/// Check whether a resource URI matches any of the given file filter patterns.
202///
203/// Returns true if filters is empty (show all). Supports glob patterns
204/// (e.g. `"src/auth/*.rs"`) and falls back to substring matching for plain paths.
205pub fn matches_file_filters(uri: &str, filters: &[String]) -> bool {
206    if filters.is_empty() {
207        return true;
208    }
209    // Non-filesystem artifacts (e.g. ta://memory/...) are always shown regardless of
210    // file filters — filters target source-file paths, not synthetic TA artifacts.
211    if !uri.starts_with("fs://workspace/") {
212        return true;
213    }
214    // Extract path from URI: "fs://workspace/src/auth.rs" -> "src/auth.rs"
215    let path = uri.strip_prefix("fs://workspace/").unwrap_or(uri);
216    filters.iter().any(|pattern| {
217        // Try glob match first
218        if let Ok(pat) = glob::Pattern::new(pattern) {
219            if pat.matches(path) {
220                return true;
221            }
222        }
223        // Fall back to substring match (for plain paths without wildcards)
224        path.contains(pattern.as_str()) || uri.contains(pattern.as_str())
225    })
226}
227
228/// Get an adapter instance for the given format.
229///
230/// The `color` parameter controls ANSI color output for the terminal adapter.
231/// It is ignored for other formats.
232pub 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}