1use serde::{Deserialize, Serialize};
43
44#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
46pub struct JTBDMetadata {
47 pub actor: String,
49 pub job_functional: String,
51 pub job_emotional: Option<String>,
53 pub job_relational: Option<String>,
55 pub so_that: String,
57 pub scope: Option<Scope>,
59 pub success_metrics: Vec<SuccessMetric>,
61 pub failure_modes: Vec<String>,
63 pub exceptions: Vec<String>,
65 pub evidence_required: Vec<String>,
67 pub audit_requirements: Vec<String>,
69 pub links: Vec<Link>,
71}
72
73#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
75pub struct Scope {
76 pub pack: Option<String>,
78 pub segment: Option<String>,
80 pub objects: Vec<String>,
82}
83
84#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
86pub struct SuccessMetric {
87 pub id: String,
89 pub target: String,
91 pub window: String,
93 pub dimension: Option<String>,
95}
96
97#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
99pub struct Link {
100 pub url: Option<String>,
102 pub ref_: Option<String>,
104 pub label: Option<String>,
106}
107
108#[derive(Debug, Clone, thiserror::Error)]
110pub enum JTBDError {
111 #[error("Missing required field: {0}")]
112 MissingRequiredField(String),
113 #[error("Invalid YAML: {0}")]
114 InvalidYaml(String),
115 #[error("Parse error: {0}")]
116 ParseError(String),
117}
118
119pub fn extract_jtbd(content: &str) -> Result<(Option<JTBDMetadata>, Vec<JTBDMetadata>), JTBDError> {
139 let mut file_jtbd = None;
140 let mut scenario_jtbds = Vec::new();
141
142 let lines: Vec<&str> = content.lines().collect();
144 let mut i = 0;
145
146 while i < lines.len() {
147 if lines[i].trim().starts_with("Truth:") || lines[i].trim().starts_with("Feature:") {
149 if let Some((jtbd, next_i)) = parse_jtbd_block(&lines, i + 1)? {
150 file_jtbd = Some(jtbd);
151 i = next_i;
152 continue;
153 }
154 }
155
156 if lines[i].trim().starts_with("Scenario:") {
158 if let Some((jtbd, next_i)) = parse_jtbd_block(&lines, i + 1)? {
159 scenario_jtbds.push(jtbd);
160 i = next_i;
161 continue;
162 }
163 }
164
165 i += 1;
166 }
167
168 Ok((file_jtbd, scenario_jtbds))
169}
170
171fn parse_jtbd_block(
176 lines: &[&str],
177 start: usize,
178) -> Result<Option<(JTBDMetadata, usize)>, JTBDError> {
179 if start >= lines.len() {
180 return Ok(None);
181 }
182
183 let mut start = start;
185 while start < lines.len() && lines[start].trim().is_empty() {
186 start += 1;
187 }
188 if start >= lines.len() {
189 return Ok(None);
190 }
191
192 let first_line = lines[start].trim();
194 if !first_line.starts_with("# JTBD") {
195 return Ok(None);
196 }
197
198 let mut jtbd_lines = Vec::new();
200 let mut i = start;
201
202 while i < lines.len() {
203 let line = lines[i].trim();
204
205 if !line.is_empty() && !line.starts_with('#') {
207 break;
208 }
209
210 if line.is_empty() && !jtbd_lines.is_empty() {
212 let mut peek = i + 1;
214 while peek < lines.len() && lines[peek].trim().is_empty() {
215 peek += 1;
216 }
217 if peek < lines.len() && !lines[peek].trim().starts_with('#') {
218 break;
219 }
220 }
221
222 if line.starts_with('#') {
223 jtbd_lines.push(line);
224 }
225
226 i += 1;
227 }
228
229 if jtbd_lines.is_empty() {
230 return Ok(None);
231 }
232
233 let jtbd = if jtbd_lines[0].contains("JTBD:") {
235 parse_yaml_jtbd(&jtbd_lines)?
236 } else {
237 parse_plain_text_jtbd(&jtbd_lines)?
238 };
239
240 Ok(Some((jtbd, i)))
241}
242
243fn parse_yaml_jtbd(lines: &[&str]) -> Result<JTBDMetadata, JTBDError> {
245 let yaml_lines: Vec<String> = lines
247 .iter()
248 .map(|line| {
249 let trimmed = line.trim();
250 if trimmed == "# JTBD:" {
251 "jtbd:".to_string()
252 } else if let Some(rest) = trimmed.strip_prefix("# ") {
253 rest.to_string()
254 } else if let Some(rest) = trimmed.strip_prefix('#') {
255 rest.to_string()
256 } else {
257 trimmed.to_string()
258 }
259 })
260 .collect();
261
262 let yaml_content = yaml_lines.join("\n");
263
264 let yaml_value: serde_yaml::Value =
266 serde_yaml::from_str(&yaml_content).map_err(|e| JTBDError::InvalidYaml(format!("{e}")))?;
267
268 let actor = yaml_value
270 .get("jtbd")
271 .and_then(|j| j.get("actor"))
272 .and_then(|v| v.as_str())
273 .ok_or_else(|| JTBDError::MissingRequiredField("actor".to_string()))?
274 .to_string();
275
276 let job_functional = yaml_value
277 .get("jtbd")
278 .and_then(|j| j.get("job_functional"))
279 .and_then(|v| v.as_str())
280 .ok_or_else(|| JTBDError::MissingRequiredField("job_functional".to_string()))?
281 .to_string();
282
283 let job_emotional = yaml_value
284 .get("jtbd")
285 .and_then(|j| j.get("job_emotional"))
286 .and_then(|v| v.as_str())
287 .map(String::from);
288
289 let job_relational = yaml_value
290 .get("jtbd")
291 .and_then(|j| j.get("job_relational"))
292 .and_then(|v| v.as_str())
293 .map(String::from);
294
295 let so_that = yaml_value
296 .get("jtbd")
297 .and_then(|j| j.get("so_that"))
298 .and_then(|v| v.as_str())
299 .ok_or_else(|| JTBDError::MissingRequiredField("so_that".to_string()))?
300 .to_string();
301
302 let scope = yaml_value
304 .get("jtbd")
305 .and_then(|j| j.get("scope"))
306 .map(|s| {
307 let pack = s.get("pack").and_then(|v| v.as_str()).map(String::from);
308 let segment = s.get("segment").and_then(|v| v.as_str()).map(String::from);
309 let objects = s
310 .get("objects")
311 .and_then(|v| v.as_sequence())
312 .map(|seq| {
313 seq.iter()
314 .filter_map(|v| v.as_str().map(String::from))
315 .collect()
316 })
317 .unwrap_or_default();
318 Scope {
319 pack,
320 segment,
321 objects,
322 }
323 });
324
325 let success_metrics = yaml_value
327 .get("jtbd")
328 .and_then(|j| j.get("success_metrics"))
329 .and_then(|v| v.as_sequence())
330 .map(|seq| {
331 seq.iter()
332 .filter_map(|m| {
333 Some(SuccessMetric {
334 id: m.get("id")?.as_str()?.to_string(),
335 target: m.get("target")?.as_str()?.to_string(),
336 window: m.get("window")?.as_str()?.to_string(),
337 dimension: m
338 .get("dimension")
339 .and_then(|v| v.as_str())
340 .map(String::from),
341 })
342 })
343 .collect()
344 })
345 .unwrap_or_default();
346
347 let failure_modes = yaml_value
349 .get("jtbd")
350 .and_then(|j| j.get("failure_modes"))
351 .and_then(|v| v.as_sequence())
352 .map(|seq| {
353 seq.iter()
354 .filter_map(|v| v.as_str().map(String::from))
355 .collect()
356 })
357 .unwrap_or_default();
358
359 let exceptions = yaml_value
361 .get("jtbd")
362 .and_then(|j| j.get("exceptions"))
363 .and_then(|v| v.as_sequence())
364 .map(|seq| {
365 seq.iter()
366 .filter_map(|v| v.as_str().map(String::from))
367 .collect()
368 })
369 .unwrap_or_default();
370
371 let evidence_required = yaml_value
373 .get("jtbd")
374 .and_then(|j| j.get("evidence_required"))
375 .and_then(|v| v.as_sequence())
376 .map(|seq| {
377 seq.iter()
378 .filter_map(|v| v.as_str().map(String::from))
379 .collect()
380 })
381 .unwrap_or_default();
382
383 let audit_requirements = yaml_value
385 .get("jtbd")
386 .and_then(|j| j.get("audit_requirements"))
387 .and_then(|v| v.as_sequence())
388 .map(|seq| {
389 seq.iter()
390 .filter_map(|v| v.as_str().map(String::from))
391 .collect()
392 })
393 .unwrap_or_default();
394
395 let links = yaml_value
397 .get("jtbd")
398 .and_then(|j| j.get("links"))
399 .and_then(|v| v.as_sequence())
400 .map(|seq| {
401 seq.iter()
402 .map(|l| Link {
403 url: l.get("url").and_then(|v| v.as_str()).map(String::from),
404 ref_: l.get("ref").and_then(|v| v.as_str()).map(String::from),
405 label: l.get("label").and_then(|v| v.as_str()).map(String::from),
406 })
407 .collect()
408 })
409 .unwrap_or_default();
410
411 Ok(JTBDMetadata {
412 actor,
413 job_functional,
414 job_emotional,
415 job_relational,
416 so_that,
417 scope,
418 success_metrics,
419 failure_modes,
420 exceptions,
421 evidence_required,
422 audit_requirements,
423 links,
424 })
425}
426
427fn parse_plain_text_jtbd(lines: &[&str]) -> Result<JTBDMetadata, JTBDError> {
429 let mut actor = None;
430 let mut job_functional = None;
431 let mut job_emotional = None;
432 let mut job_relational = None;
433 let mut so_that = None;
434 let mut scope_pack = None;
435 let mut scope_segment = None;
436 let mut scope_objects = Vec::new();
437 let mut success_metrics = Vec::new();
438 let mut failure_modes = Vec::new();
439 let mut exceptions = Vec::new();
440 let mut evidence_required = Vec::new();
441 let mut audit_requirements = Vec::new();
442 let links = Vec::new();
443
444 for line in lines {
445 let trimmed = line.trim();
446 if trimmed.is_empty() || trimmed == "# JTBD" {
447 continue;
448 }
449
450 let content = if let Some(rest) = trimmed.strip_prefix("# ") {
452 rest
453 } else if let Some(rest) = trimmed.strip_prefix('#') {
454 rest
455 } else {
456 continue;
457 };
458
459 if let Some((key, value)) = content.split_once(':') {
461 let key = key.trim().to_lowercase();
462 let value = value.trim().trim_matches('"').to_string();
463
464 match key.as_str() {
465 "as" => actor = Some(value),
466 "functional" | "job_functional" => job_functional = Some(value),
467 "emotional" | "job_emotional" => job_emotional = Some(value),
468 "relational" | "job_relational" => job_relational = Some(value),
469 "so that" | "so_that" => so_that = Some(value),
470 _ => {
471 if key == "scope" {
473 if let Some((pack_seg, objects_str)) = value.split_once('[') {
475 let parts: Vec<&str> = pack_seg.split('.').collect();
476 if !parts.is_empty() {
477 scope_pack = Some(parts[0].trim().to_string());
478 }
479 if parts.len() >= 2 {
480 scope_segment = Some(parts[1].trim().to_string());
481 }
482 if let Some(objs) = objects_str.strip_suffix(']') {
483 scope_objects =
484 objs.split(',').map(|s| s.trim().to_string()).collect();
485 }
486 }
487 } else if key.starts_with("metric") || key.contains("metric") {
488 if let Some((id_target, dim)) = value.rsplit_once('(') {
491 let dimension = dim.trim().trim_end_matches(')').to_string();
492 if let Some((id, target)) = id_target.split_once(' ') {
494 success_metrics.push(SuccessMetric {
495 id: id.trim().to_string(),
496 target: target.trim().to_string(),
497 window: "unknown".to_string(),
498 dimension: Some(dimension),
499 });
500 }
501 }
502 } else if key == "failure mode" || key == "failure_mode" {
503 failure_modes.push(value);
504 } else if key == "exception" {
505 exceptions.push(value);
506 } else if key == "evidence" {
507 evidence_required.push(value);
508 } else if key == "audit" {
509 audit_requirements.push(value);
510 }
511 }
512 }
513 }
514 }
515
516 Ok(JTBDMetadata {
517 actor: actor.ok_or_else(|| JTBDError::MissingRequiredField("actor".to_string()))?,
518 job_functional: job_functional
519 .ok_or_else(|| JTBDError::MissingRequiredField("job_functional".to_string()))?,
520 job_emotional,
521 job_relational,
522 so_that: so_that.ok_or_else(|| JTBDError::MissingRequiredField("so_that".to_string()))?,
523 scope: if scope_pack.is_some() || scope_segment.is_some() || !scope_objects.is_empty() {
524 Some(Scope {
525 pack: scope_pack,
526 segment: scope_segment,
527 objects: scope_objects,
528 })
529 } else {
530 None
531 },
532 success_metrics,
533 failure_modes,
534 exceptions,
535 evidence_required,
536 audit_requirements,
537 links,
538 })
539}
540
541pub fn validate_jtbd(jtbd: &JTBDMetadata, strict: bool) -> Vec<JTBDValidationIssue> {
546 let mut issues = Vec::new();
547
548 if jtbd.job_emotional.is_none() {
551 issues.push(JTBDValidationIssue {
552 field: "job_emotional".to_string(),
553 severity: if strict {
554 ValidationSeverity::Error
555 } else {
556 ValidationSeverity::Warning
557 },
558 message: "Missing recommended field: job_emotional".to_string(),
559 });
560 }
561
562 if jtbd.job_relational.is_none() {
563 issues.push(JTBDValidationIssue {
564 field: "job_relational".to_string(),
565 severity: if strict {
566 ValidationSeverity::Error
567 } else {
568 ValidationSeverity::Warning
569 },
570 message: "Missing recommended field: job_relational".to_string(),
571 });
572 }
573
574 let metric_ids: Vec<&str> = jtbd.success_metrics.iter().map(|m| m.id.as_str()).collect();
576 let unique_ids: std::collections::HashSet<&str> = metric_ids.iter().copied().collect();
577 if metric_ids.len() != unique_ids.len() {
578 issues.push(JTBDValidationIssue {
579 field: "success_metrics".to_string(),
580 severity: ValidationSeverity::Error,
581 message: "Duplicate success metric IDs found".to_string(),
582 });
583 }
584
585 issues
586}
587
588#[derive(Debug, Clone, PartialEq)]
590pub struct JTBDValidationIssue {
591 pub field: String,
593 pub severity: ValidationSeverity,
595 pub message: String,
597}
598
599#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
601pub enum ValidationSeverity {
602 Warning,
604 Error,
606}
607
608#[cfg(test)]
609mod tests {
610 use super::*;
611
612 #[test]
613 fn test_parse_yaml_jtbd() {
614 let content = r#"
615Truth: Invoice issued after work and collected on time
616
617 # JTBD:
618 # actor: Founder
619 # job_functional: "Invoice customers and collect payment"
620 # job_emotional: "Feel confident that every invoice gets sent"
621 # so_that: "Cash flows predictably"
622"#;
623
624 let (file_jtbd, _) = extract_jtbd(content).unwrap();
625 let jtbd = file_jtbd.unwrap();
626
627 assert_eq!(jtbd.actor, "Founder");
628 assert_eq!(jtbd.job_functional, "Invoice customers and collect payment");
629 assert_eq!(
630 jtbd.job_emotional,
631 Some("Feel confident that every invoice gets sent".to_string())
632 );
633 assert_eq!(jtbd.so_that, "Cash flows predictably");
634 }
635
636 #[test]
637 fn test_parse_plain_text_jtbd() {
638 let content = r#"
639Truth: Example
640
641 # JTBD
642 # As: Founder
643 # Functional: Invoice customers
644 # So that: Cash flows predictably
645"#;
646
647 let (file_jtbd, _) = extract_jtbd(content).unwrap();
648 let jtbd = file_jtbd.unwrap();
649
650 assert_eq!(jtbd.actor, "Founder");
651 assert_eq!(jtbd.job_functional, "Invoice customers");
652 assert_eq!(jtbd.so_that, "Cash flows predictably");
653 }
654
655 #[test]
656 fn test_validate_jtbd() {
657 let jtbd = JTBDMetadata {
658 actor: "Founder".to_string(),
659 job_functional: "Do something".to_string(),
660 job_emotional: None,
661 job_relational: None,
662 so_that: "Achieve outcome".to_string(),
663 scope: None,
664 success_metrics: Vec::new(),
665 failure_modes: Vec::new(),
666 exceptions: Vec::new(),
667 evidence_required: Vec::new(),
668 audit_requirements: Vec::new(),
669 links: Vec::new(),
670 };
671
672 let issues = validate_jtbd(&jtbd, false);
673 assert_eq!(issues.len(), 2); assert!(
675 issues
676 .iter()
677 .all(|i| i.severity == ValidationSeverity::Warning)
678 );
679 }
680}