Skip to main content

ta_changeset/
explanation.rs

1//! explanation.rs — Parser for .diff.explanation.yaml sidecar files (v0.2.3).
2//!
3//! Agents write explanation sidecars alongside changes to provide tiered
4//! explanations for reviewers: summary → explanation → full diff.
5
6use std::fs;
7use std::path::Path;
8
9use serde::{Deserialize, Serialize};
10
11use crate::error::ChangeSetError;
12use crate::pr_package::ExplanationTiers;
13
14/// Schema for .diff.explanation.yaml sidecar files.
15///
16/// Example YAML:
17/// ```yaml
18/// file: src/auth/middleware.rs
19/// summary: "Refactored auth middleware to use JWT instead of session tokens"
20/// explanation: |
21///   Replaced session-based auth with JWT validation. The middleware now
22///   checks the Authorization header for a Bearer token, validates it
23///   against the JWKS endpoint, and extracts claims into the request context.
24///   This change touches 3 files: middleware.rs (core logic), config.rs
25///   (JWT settings), and tests/auth_test.rs (updated test fixtures).
26/// tags: [security, breaking-change]
27/// related_artifacts:
28///   - src/auth/config.rs
29///   - tests/auth_test.rs
30/// ```
31#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
32pub struct ExplanationSidecar {
33    /// The file this explanation applies to (relative path from workspace root).
34    pub file: String,
35    /// One-line summary.
36    pub summary: String,
37    /// Multi-line explanation of what changed and why.
38    pub explanation: String,
39    /// Optional tags for categorization.
40    #[serde(default)]
41    pub tags: Vec<String>,
42    /// Related artifacts (paths relative to workspace root).
43    #[serde(default)]
44    pub related_artifacts: Vec<String>,
45}
46
47impl ExplanationSidecar {
48    /// Parse an explanation sidecar from a YAML file.
49    pub fn from_file<P: AsRef<Path>>(path: P) -> Result<Self, ChangeSetError> {
50        let contents = fs::read_to_string(path.as_ref()).map_err(|e| {
51            ChangeSetError::InvalidData(format!(
52                "Failed to read explanation sidecar at {}: {}",
53                path.as_ref().display(),
54                e
55            ))
56        })?;
57
58        serde_yaml::from_str(&contents).map_err(|e| {
59            ChangeSetError::InvalidData(format!(
60                "Failed to parse explanation sidecar YAML at {}: {}",
61                path.as_ref().display(),
62                e
63            ))
64        })
65    }
66
67    /// Convert this sidecar into ExplanationTiers (for embedding in Artifact).
68    ///
69    /// Normalizes related_artifacts to URI format (fs://workspace/<path>).
70    pub fn into_tiers(self) -> ExplanationTiers {
71        ExplanationTiers {
72            summary: self.summary,
73            explanation: self.explanation,
74            tags: self.tags,
75            related_artifacts: self
76                .related_artifacts
77                .into_iter()
78                .map(|path| {
79                    if path.starts_with("fs://") {
80                        path
81                    } else {
82                        format!("fs://workspace/{}", path.trim_start_matches('/'))
83                    }
84                })
85                .collect(),
86        }
87    }
88
89    /// Find explanation sidecar for a given file path.
90    ///
91    /// Looks for: `<file_path>.diff.explanation.yaml`
92    ///
93    /// Returns None if the sidecar doesn't exist (this is not an error —
94    /// sidecars are optional).
95    pub fn find_for_file<P: AsRef<Path>>(file_path: P) -> Option<Self> {
96        let sidecar_path = format!("{}.diff.explanation.yaml", file_path.as_ref().display());
97        if Path::new(&sidecar_path).exists() {
98            Self::from_file(sidecar_path).ok()
99        } else {
100            None
101        }
102    }
103}
104
105#[cfg(test)]
106mod tests {
107    use super::*;
108    use std::io::Write;
109    use tempfile::NamedTempFile;
110
111    #[test]
112    fn parse_valid_yaml() {
113        let yaml = r#"
114file: src/auth/middleware.rs
115summary: "Refactored auth middleware to use JWT"
116explanation: |
117  Replaced session-based auth with JWT validation.
118  This improves security and scalability.
119tags:
120  - security
121  - breaking-change
122related_artifacts:
123  - src/auth/config.rs
124  - tests/auth_test.rs
125"#;
126        let mut file = NamedTempFile::new().unwrap();
127        file.write_all(yaml.as_bytes()).unwrap();
128        file.flush().unwrap();
129
130        let sidecar = ExplanationSidecar::from_file(file.path()).unwrap();
131        assert_eq!(sidecar.file, "src/auth/middleware.rs");
132        assert_eq!(sidecar.summary, "Refactored auth middleware to use JWT");
133        assert!(sidecar.explanation.contains("JWT validation"));
134        assert_eq!(sidecar.tags.len(), 2);
135        assert_eq!(sidecar.related_artifacts.len(), 2);
136    }
137
138    #[test]
139    fn parse_minimal_yaml() {
140        let yaml = r#"
141file: test.txt
142summary: "Added test file"
143explanation: "This is a test file for validation."
144"#;
145        let mut file = NamedTempFile::new().unwrap();
146        file.write_all(yaml.as_bytes()).unwrap();
147        file.flush().unwrap();
148
149        let sidecar = ExplanationSidecar::from_file(file.path()).unwrap();
150        assert_eq!(sidecar.file, "test.txt");
151        assert!(sidecar.tags.is_empty());
152        assert!(sidecar.related_artifacts.is_empty());
153    }
154
155    #[test]
156    fn into_tiers_normalizes_uris() {
157        let sidecar = ExplanationSidecar {
158            file: "src/main.rs".to_string(),
159            summary: "Test".to_string(),
160            explanation: "Test explanation".to_string(),
161            tags: vec![],
162            related_artifacts: vec![
163                "src/lib.rs".to_string(),
164                "fs://workspace/tests/test.rs".to_string(),
165            ],
166        };
167
168        let tiers = sidecar.into_tiers();
169        assert_eq!(tiers.related_artifacts.len(), 2);
170        assert_eq!(tiers.related_artifacts[0], "fs://workspace/src/lib.rs");
171        assert_eq!(tiers.related_artifacts[1], "fs://workspace/tests/test.rs");
172    }
173
174    #[test]
175    fn find_for_file_returns_none_when_missing() {
176        let result = ExplanationSidecar::find_for_file("/nonexistent/file.rs");
177        assert!(result.is_none());
178    }
179
180    #[test]
181    fn find_for_file_returns_sidecar_when_present() {
182        let yaml = r#"
183file: test.txt
184summary: "Test"
185explanation: "Test explanation"
186"#;
187        let mut file = NamedTempFile::new().unwrap();
188        file.write_all(yaml.as_bytes()).unwrap();
189        file.flush().unwrap();
190
191        // Create sidecar with expected naming convention
192        let base_path = file.path().parent().unwrap().join("test_file.rs");
193        let sidecar_path = format!("{}.diff.explanation.yaml", base_path.display());
194        fs::write(&sidecar_path, yaml).unwrap();
195
196        let result = ExplanationSidecar::find_for_file(&base_path);
197        assert!(result.is_some());
198        assert_eq!(result.unwrap().summary, "Test");
199
200        // Cleanup
201        fs::remove_file(&sidecar_path).ok();
202    }
203
204    #[test]
205    fn invalid_yaml_returns_error() {
206        let yaml = "this is not valid yaml: [unclosed";
207        let mut file = NamedTempFile::new().unwrap();
208        file.write_all(yaml.as_bytes()).unwrap();
209        file.flush().unwrap();
210
211        let result = ExplanationSidecar::from_file(file.path());
212        assert!(result.is_err());
213        assert!(result
214            .unwrap_err()
215            .to_string()
216            .contains("Failed to parse explanation sidecar YAML"));
217    }
218}