Skip to main content

converge_tool/
jtbd.rs

1// Copyright 2024-2025 Aprio One AB, Sweden
2// Author: Kenneth Pernyer, kenneth@aprio.one
3// SPDX-License-Identifier: MIT
4// See LICENSE file in the project root for full license information.
5
6//! Jobs To Be Done (JTBD) parsing for Converge Truths.
7//!
8//! This module provides parsing and validation of JTBD metadata blocks
9//! embedded in `.truths` files as structured comments.
10//!
11//! # JTBD Format
12//!
13//! JTBD metadata is embedded as comment blocks using a consistent tag header.
14//! We support two formats:
15//!
16//! ## YAML-in-comments (recommended)
17//!
18//! ```gherkin
19//! Truth: Invoice issued after work and collected on time
20//!
21//!   # JTBD:
22//!   #   actor: Founder
23//!   #   job_functional: "Invoice customers and collect payment"
24//!   #   job_emotional: "Feel confident that every invoice gets sent"
25//!   #   job_relational: "Be seen as professional and reliable"
26//!   #   so_that: "Cash flows predictably"
27//! ```
28//!
29//! ## Plain structured text (human-friendly)
30//!
31//! ```gherkin
32//! Truth: Invoice issued after work and collected on time
33//!
34//!   # JTBD
35//!   # As: Founder
36//!   # Functional: Invoice customers and collect payment
37//!   # Emotional: Feel confident that every invoice gets sent
38//!   # Relational: Be seen as professional and reliable
39//!   # So that: Cash flows predictably
40//! ```
41
42use serde::{Deserialize, Serialize};
43
44/// JTBD metadata extracted from a Truth or Scenario.
45#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
46pub struct JTBDMetadata {
47    /// Role performing the job (required).
48    pub actor: String,
49    /// Functional job statement (required).
50    pub job_functional: String,
51    /// Emotional job statement (recommended).
52    pub job_emotional: Option<String>,
53    /// Relational job statement (recommended).
54    pub job_relational: Option<String>,
55    /// Outcome intent (required).
56    pub so_that: String,
57    /// Scope information.
58    pub scope: Option<Scope>,
59    /// Success metrics.
60    pub success_metrics: Vec<SuccessMetric>,
61    /// Failure modes.
62    pub failure_modes: Vec<String>,
63    /// Exceptions or edge cases.
64    pub exceptions: Vec<String>,
65    /// Evidence required.
66    pub evidence_required: Vec<String>,
67    /// Audit requirements.
68    pub audit_requirements: Vec<String>,
69    /// Links and references.
70    pub links: Vec<Link>,
71}
72
73/// Scope information for JTBD.
74#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
75pub struct Scope {
76    /// Pack name.
77    pub pack: Option<String>,
78    /// Business segment.
79    pub segment: Option<String>,
80    /// Canonical objects involved.
81    pub objects: Vec<String>,
82}
83
84/// Success metric definition.
85#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
86pub struct SuccessMetric {
87    /// Unique metric ID.
88    pub id: String,
89    /// Target value (e.g., "<= 0.05", ">= 0.95").
90    pub target: String,
91    /// Time window (e.g., "72h", "30d").
92    pub window: String,
93    /// Dimension: "functional" | "emotional" | "relational".
94    pub dimension: Option<String>,
95}
96
97/// Link or reference.
98#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
99pub struct Link {
100    /// URL or internal reference.
101    pub url: Option<String>,
102    /// Reference (e.g., "@`invariant:closed_period_readonly`").
103    pub ref_: Option<String>,
104    /// Label for the link.
105    pub label: Option<String>,
106}
107
108/// Error types for JTBD parsing.
109#[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
119/// Extracts JTBD metadata from a `.truths` file content.
120///
121/// Returns file-level JTBD if found, and scenario-level JTBD for each scenario.
122///
123/// # Example
124///
125/// ```rust,no_run
126/// use converge_tool::jtbd::extract_jtbd;
127///
128/// let content = r#"
129/// Truth: Example
130///   # JTBD:
131///   #   actor: Founder
132///   #   job_functional: "Do something"
133///   #   so_that: "Achieve outcome"
134/// "#;
135///
136/// let (file_jtbd, scenario_jtbds) = extract_jtbd(content).unwrap();
137/// ```
138pub 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    // Split content into lines for processing
143    let lines: Vec<&str> = content.lines().collect();
144    let mut i = 0;
145
146    while i < lines.len() {
147        // Look for file-level JTBD (after "Truth:" or "Feature:")
148        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        // Look for scenario-level JTBD (after "Scenario:")
157        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
171/// Parses a JTBD block starting at the given line index.
172///
173/// Returns `Some((JTBDMetadata, next_line_index))` if a JTBD block is found,
174/// or `None` if no block is found.
175fn 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    // Skip blank lines before the JTBD block
184    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    // Check if next line starts with "# JTBD:" (YAML format) or "# JTBD" (plain text)
193    let first_line = lines[start].trim();
194    if !first_line.starts_with("# JTBD") {
195        return Ok(None);
196    }
197
198    // Collect all comment lines in the JTBD block
199    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        // Stop if we hit a non-comment line (that's not empty)
206        if !line.is_empty() && !line.starts_with('#') {
207            break;
208        }
209
210        // Stop if we hit an empty line after the block has started
211        if line.is_empty() && !jtbd_lines.is_empty() {
212            // Check if next non-empty line is still part of JTBD
213            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    // Try to parse as YAML first, then fall back to plain text
234    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
243/// Parses YAML-format JTBD block.
244fn parse_yaml_jtbd(lines: &[&str]) -> Result<JTBDMetadata, JTBDError> {
245    // Remove "# " prefix and "JTBD:" header
246    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    // Parse YAML
265    let yaml_value: serde_yaml::Value =
266        serde_yaml::from_str(&yaml_content).map_err(|e| JTBDError::InvalidYaml(format!("{e}")))?;
267
268    // Extract fields
269    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    // Parse scope
303    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    // Parse success metrics
326    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    // Parse failure modes
348    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    // Parse exceptions
360    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    // Parse evidence_required
372    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    // Parse audit_requirements
384    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    // Parse links
396    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
427/// Parses plain text format JTBD block.
428fn 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        // Remove "# " prefix
451        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        // Parse key-value pairs
460        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                    // Handle scope, metrics, etc. with more complex parsing
472                    if key == "scope" {
473                        // Parse "pack.segment [object1, object2]"
474                        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                        // Simple metric parsing (can be enhanced)
489                        // Format: "Metric ID <= 0.05 (functional)"
490                        if let Some((id_target, dim)) = value.rsplit_once('(') {
491                            let dimension = dim.trim().trim_end_matches(')').to_string();
492                            // Parse id and target (simplified)
493                            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
541/// Validates JTBD metadata for completeness.
542///
543/// Returns a list of validation issues (warnings for missing recommended fields,
544/// errors for missing required fields).
545pub fn validate_jtbd(jtbd: &JTBDMetadata, strict: bool) -> Vec<JTBDValidationIssue> {
546    let mut issues = Vec::new();
547
548    // Required fields are already validated during parsing
549    // Check recommended fields
550    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    // Validate success metrics
575    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/// Validation issue for JTBD.
589#[derive(Debug, Clone, PartialEq)]
590pub struct JTBDValidationIssue {
591    /// Field that has the issue.
592    pub field: String,
593    /// Severity level.
594    pub severity: ValidationSeverity,
595    /// Human-readable message.
596    pub message: String,
597}
598
599/// Severity of a JTBD validation issue.
600#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
601pub enum ValidationSeverity {
602    /// Warning - recommended but not required.
603    Warning,
604    /// Error - must be fixed.
605    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); // Missing job_emotional and job_relational
674        assert!(
675            issues
676                .iter()
677                .all(|i| i.severity == ValidationSeverity::Warning)
678        );
679    }
680}