1use serde::{Deserialize, Serialize};
24
25#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
27pub struct Episode {
28 pub situation: String,
30 pub tools: Vec<String>,
32 pub outcome: String,
34 #[serde(default)]
36 pub tags: Vec<String>,
37 #[serde(default)]
44 pub skills: Vec<String>,
45 #[serde(default)]
51 pub success: Option<bool>,
52}
53
54const SITUATION_PREFIX: &str = "Situation: ";
55const TOOLS_PREFIX: &str = "Tools used: ";
56const SKILLS_PREFIX: &str = "Skills used: ";
57const RESULT_PREFIX: &str = "Result: ";
58const OUTCOME_PREFIX: &str = "Outcome: ";
59
60impl Episode {
61 pub fn new(situation: impl Into<String>, outcome: impl Into<String>) -> Self {
62 Self {
63 situation: situation.into(),
64 tools: Vec::new(),
65 outcome: outcome.into(),
66 tags: Vec::new(),
67 skills: Vec::new(),
68 success: None,
69 }
70 }
71
72 pub fn with_tools<I, S>(mut self, tools: I) -> Self
73 where
74 I: IntoIterator<Item = S>,
75 S: Into<String>,
76 {
77 self.tools = tools.into_iter().map(Into::into).collect();
78 self
79 }
80
81 pub fn with_tags<I, S>(mut self, tags: I) -> Self
82 where
83 I: IntoIterator<Item = S>,
84 S: Into<String>,
85 {
86 self.tags = tags.into_iter().map(Into::into).collect();
87 self
88 }
89
90 pub fn with_skills<I, S>(mut self, skills: I) -> Self
92 where
93 I: IntoIterator<Item = S>,
94 S: Into<String>,
95 {
96 self.skills = skills.into_iter().map(Into::into).collect();
97 self
98 }
99
100 pub fn with_success(mut self, success: bool) -> Self {
102 self.success = Some(success);
103 self
104 }
105
106 pub fn succeeded(&self) -> bool {
109 self.success == Some(true)
110 }
111
112 pub fn render(&self) -> String {
118 let tools = if self.tools.is_empty() {
119 "(none)".to_string()
120 } else {
121 self.tools.join(", ")
122 };
123 let mut s = format!(
124 "{SITUATION_PREFIX}{}\n{TOOLS_PREFIX}{tools}\n",
125 self.situation.trim(),
126 );
127 if !self.skills.is_empty() {
128 s.push_str(&format!("{SKILLS_PREFIX}{}\n", self.skills.join(", ")));
129 }
130 if let Some(ok) = self.success {
131 s.push_str(&format!(
132 "{RESULT_PREFIX}{}\n",
133 if ok { "success" } else { "failure" }
134 ));
135 }
136 s.push_str(&format!("{OUTCOME_PREFIX}{}", self.outcome.trim()));
137 s
138 }
139
140 pub fn parse(text: &str) -> Option<Episode> {
146 let situation = line_after(text, SITUATION_PREFIX)?;
147 let tools = comma_list(line_after(text, TOOLS_PREFIX).unwrap_or_default());
148 let skills = comma_list(line_after(text, SKILLS_PREFIX).unwrap_or_default());
149 let success = line_after(text, RESULT_PREFIX).and_then(|v| match v.trim() {
150 "success" => Some(true),
151 "failure" => Some(false),
152 _ => None,
153 });
154 let outcome = text
156 .find(OUTCOME_PREFIX)
157 .map(|i| text[i + OUTCOME_PREFIX.len()..].trim().to_string())
158 .unwrap_or_default();
159 Some(Episode {
160 situation,
161 tools,
162 outcome,
163 tags: Vec::new(),
164 skills,
165 success,
166 })
167 }
168}
169
170fn comma_list(line: String) -> Vec<String> {
173 if line.trim().is_empty() || line.trim() == "(none)" {
174 return Vec::new();
175 }
176 line.split(',')
177 .map(|s| s.trim().to_string())
178 .filter(|s| !s.is_empty())
179 .collect()
180}
181
182fn line_after(text: &str, prefix: &str) -> Option<String> {
184 text.lines()
185 .find(|l| l.starts_with(prefix))
186 .map(|l| l[prefix.len()..].trim().to_string())
187}
188
189#[cfg(test)]
190mod tests {
191 use super::*;
192
193 #[test]
194 fn render_parse_roundtrip() {
195 let ep = Episode::new(
196 "user asked to deploy the site",
197 "ran deploy.sh, site is live",
198 )
199 .with_tools(["read_file", "shell", "shell"]);
200 let text = ep.render();
201 assert!(text.contains("Tools used: read_file, shell, shell"));
202 let back = Episode::parse(&text).unwrap();
203 assert_eq!(back.situation, "user asked to deploy the site");
204 assert_eq!(back.tools, vec!["read_file", "shell", "shell"]);
205 assert_eq!(back.outcome, "ran deploy.sh, site is live");
206 }
207
208 #[test]
209 fn parse_no_tools() {
210 let ep = Episode::new("simple question", "answered directly");
211 let back = Episode::parse(&ep.render()).unwrap();
212 assert!(back.tools.is_empty());
213 }
214
215 #[test]
220 fn old_format_text_still_round_trips() {
221 const OLD: &str = "Situation: user asked to deploy the site\n\
222 Tools used: read_file, shell, shell\n\
223 Outcome: ran deploy.sh, site is live";
224 let back = Episode::parse(OLD).expect("legacy render must still parse");
225 assert_eq!(back.situation, "user asked to deploy the site");
226 assert_eq!(back.tools, vec!["read_file", "shell", "shell"]);
227 assert_eq!(back.outcome, "ran deploy.sh, site is live");
228 assert!(back.skills.is_empty(), "absent field → empty, not garbage");
229 assert_eq!(back.success, None, "absent field → unknown, not false");
230
231 assert_eq!(back.render(), OLD);
234 }
235
236 #[test]
238 fn old_format_json_still_deserializes() {
239 let json = r#"{"situation":"s","tools":["a"],"outcome":"o","tags":[]}"#;
240 let ep: Episode = serde_json::from_str(json).unwrap();
241 assert!(ep.skills.is_empty());
242 assert_eq!(ep.success, None);
243 }
244
245 #[test]
246 fn skills_and_success_round_trip() {
247 let ep = Episode::new("ship the release", "tagged and pushed")
248 .with_tools(["shell", "read_file"])
249 .with_skills(["release-runbook", "changelog-writing"])
250 .with_success(false);
251 let text = ep.render();
252 assert!(text.trim_end().ends_with("tagged and pushed"));
254 let back = Episode::parse(&text).unwrap();
255 assert_eq!(back.skills, vec!["release-runbook", "changelog-writing"]);
256 assert_eq!(back.success, Some(false));
257 assert_eq!(back.outcome, "tagged and pushed");
258 assert!(!back.succeeded());
259 }
260
261 #[test]
265 fn new_format_is_readable_by_the_old_parser() {
266 let ep = Episode::new("ship the release", "tagged and pushed")
267 .with_tools(["shell"])
268 .with_skills(["release-runbook"])
269 .with_success(true);
270 let text = ep.render();
271
272 let legacy_situation = line_after(&text, "Situation: ").unwrap();
273 let legacy_tools = line_after(&text, "Tools used: ").unwrap();
274 let legacy_outcome = text
275 .find("Outcome: ")
276 .map(|i| text[i + "Outcome: ".len()..].trim().to_string())
277 .unwrap();
278 assert_eq!(legacy_situation, "ship the release");
279 assert_eq!(legacy_tools, "shell");
280 assert_eq!(legacy_outcome, "tagged and pushed");
281 }
282}