ta_changeset/
explanation.rs1use std::fs;
7use std::path::Path;
8
9use serde::{Deserialize, Serialize};
10
11use crate::error::ChangeSetError;
12use crate::pr_package::ExplanationTiers;
13
14#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
32pub struct ExplanationSidecar {
33 pub file: String,
35 pub summary: String,
37 pub explanation: String,
39 #[serde(default)]
41 pub tags: Vec<String>,
42 #[serde(default)]
44 pub related_artifacts: Vec<String>,
45}
46
47impl ExplanationSidecar {
48 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 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 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 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 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}