1use chrono::{DateTime, Duration, TimeZone, Utc};
10use organism_pack::{ExpiryAction, ForbiddenAction, IntentPacket, Reversibility};
11
12use crate::truths::{AuthorityBlock, ConstraintBlock, ExceptionBlock, IntentBlock, TruthDocument};
13
14#[derive(Debug, Clone, thiserror::Error)]
16pub enum CompileError {
17 #[error("truth document has no Intent: outcome or goal")]
20 MissingOutcome,
21
22 #[error("could not parse Authority.expires '{value}': {message}")]
25 ExpiryParse { value: String, message: String },
26}
27
28const DEFAULT_EXPIRY_HOURS: i64 = 24;
32
33pub fn compile_intent(doc: &TruthDocument) -> Result<IntentPacket, CompileError> {
59 let outcome = extract_outcome(doc.governance.intent.as_ref())?;
60 let expires = extract_expiry(doc.governance.authority.as_ref())?;
61 let authority = extract_authority(doc.governance.authority.as_ref());
62 let forbidden = extract_forbidden(
63 doc.governance.authority.as_ref(),
64 doc.governance.constraint.as_ref(),
65 );
66 let constraints = extract_constraints(
67 doc.governance.authority.as_ref(),
68 doc.governance.constraint.as_ref(),
69 );
70 let reversibility = extract_reversibility(&constraints);
71 let expiry_action = extract_expiry_action(doc.governance.exception.as_ref());
72
73 let packet = IntentPacket::new(outcome, expires)
74 .with_authority(authority)
75 .with_reversibility(reversibility)
76 .with_expiry_action(expiry_action);
77
78 Ok(IntentPacket {
79 constraints,
80 forbidden,
81 ..packet
82 })
83}
84
85pub fn compile_intent_from_source(source: &str) -> Result<IntentPacket, CompileFromSourceError> {
92 let doc =
93 crate::truths::parse_truth_document(source).map_err(CompileFromSourceError::ParseFailed)?;
94 compile_intent(&doc).map_err(CompileFromSourceError::CompileFailed)
95}
96
97#[derive(Debug, thiserror::Error)]
99pub enum CompileFromSourceError {
100 #[error("truth source did not parse: {0}")]
101 ParseFailed(crate::gherkin::ValidationError),
102 #[error("truth document did not compile: {0}")]
103 CompileFailed(CompileError),
104}
105
106fn extract_outcome(intent: Option<&IntentBlock>) -> Result<String, CompileError> {
107 let block = intent.ok_or(CompileError::MissingOutcome)?;
108 block
109 .outcome
110 .as_ref()
111 .or(block.goal.as_ref())
112 .map(|s| s.trim().to_string())
113 .filter(|s| !s.is_empty())
114 .ok_or(CompileError::MissingOutcome)
115}
116
117fn extract_expiry(authority: Option<&AuthorityBlock>) -> Result<DateTime<Utc>, CompileError> {
118 let Some(value) = authority.and_then(|a| a.expires.as_ref()) else {
119 return Ok(Utc::now() + Duration::hours(DEFAULT_EXPIRY_HOURS));
120 };
121 let trimmed = value.trim();
122 if let Ok(dt) = DateTime::parse_from_rfc3339(trimmed) {
123 return Ok(dt.with_timezone(&Utc));
124 }
125 if let Some(dt) = chrono::NaiveDate::parse_from_str(trimmed, "%Y-%m-%d")
126 .ok()
127 .and_then(|d| d.and_hms_opt(0, 0, 0))
128 .and_then(|naive| Utc.from_local_datetime(&naive).single())
129 {
130 return Ok(dt);
131 }
132 Err(CompileError::ExpiryParse {
133 value: value.clone(),
134 message: "expected RFC-3339 timestamp or YYYY-MM-DD date".into(),
135 })
136}
137
138fn extract_authority(authority: Option<&AuthorityBlock>) -> Vec<String> {
139 let Some(block) = authority else {
140 return Vec::new();
141 };
142 let mut entries: Vec<String> = block.may.iter().map(|s| s.trim().to_string()).collect();
143 if let Some(actor) = block.actor.as_ref() {
144 let actor = actor.trim();
145 if !actor.is_empty() {
146 entries.insert(0, format!("actor: {actor}"));
147 }
148 }
149 entries
150}
151
152fn extract_forbidden(
153 authority: Option<&AuthorityBlock>,
154 constraint: Option<&ConstraintBlock>,
155) -> Vec<ForbiddenAction> {
156 let mut forbidden: Vec<ForbiddenAction> = Vec::new();
157 let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
158
159 if let Some(auth) = authority {
160 for action in &auth.must_not {
161 let action = action.trim().to_string();
162 if !action.is_empty() && seen.insert(action.clone()) {
163 forbidden.push(ForbiddenAction {
164 action,
165 reason: "authority".into(),
166 });
167 }
168 }
169 }
170
171 if let Some(con) = constraint {
172 for action in &con.must_not {
173 let action = action.trim().to_string();
174 if !action.is_empty() && seen.insert(action.clone()) {
175 forbidden.push(ForbiddenAction {
176 action,
177 reason: "constraint".into(),
178 });
179 }
180 }
181 }
182
183 forbidden
184}
185
186fn extract_constraints(
187 authority: Option<&AuthorityBlock>,
188 constraint: Option<&ConstraintBlock>,
189) -> Vec<String> {
190 let mut entries: Vec<String> = Vec::new();
191 if let Some(con) = constraint {
192 entries.extend(con.budget.iter().map(|b| format!("budget: {}", b.trim())));
193 entries.extend(
194 con.cost_limit
195 .iter()
196 .map(|c| format!("cost_limit: {}", c.trim())),
197 );
198 }
199 if let Some(auth) = authority {
200 entries.extend(
201 auth.requires_approval
202 .iter()
203 .map(|a| format!("requires_approval: {}", a.trim())),
204 );
205 }
206 entries
207}
208
209fn extract_reversibility(constraints: &[String]) -> Reversibility {
210 for c in constraints {
211 let lower = c.to_lowercase();
212 if lower.contains("reversibility:") {
213 if lower.contains("irreversible") {
214 return Reversibility::Irreversible;
215 }
216 if lower.contains("partial") {
217 return Reversibility::Partial;
218 }
219 }
220 }
221 Reversibility::Reversible
222}
223
224fn extract_expiry_action(exception: Option<&ExceptionBlock>) -> ExpiryAction {
225 match exception {
226 Some(block) if !block.escalates_to.is_empty() || !block.requires.is_empty() => {
227 ExpiryAction::Escalate
228 }
229 _ => ExpiryAction::Halt,
230 }
231}
232
233#[cfg(test)]
234mod tests {
235 use super::*;
236 use crate::truths::TruthGovernance;
237
238 fn document(
239 intent: Option<IntentBlock>,
240 authority: Option<AuthorityBlock>,
241 constraint: Option<ConstraintBlock>,
242 exception: Option<ExceptionBlock>,
243 ) -> TruthDocument {
244 TruthDocument {
245 gherkin: String::new(),
246 governance: TruthGovernance {
247 intent,
248 authority,
249 constraint,
250 evidence: None,
251 exception,
252 },
253 }
254 }
255
256 #[test]
257 fn missing_intent_block_rejected() {
258 let doc = document(None, None, None, None);
259 assert!(matches!(
260 compile_intent(&doc),
261 Err(CompileError::MissingOutcome)
262 ));
263 }
264
265 #[test]
266 fn intent_with_only_whitespace_outcome_rejected() {
267 let doc = document(
268 Some(IntentBlock {
269 outcome: Some(" ".into()),
270 goal: None,
271 }),
272 None,
273 None,
274 None,
275 );
276 assert!(matches!(
277 compile_intent(&doc),
278 Err(CompileError::MissingOutcome)
279 ));
280 }
281
282 #[test]
283 fn outcome_taken_from_intent_outcome_field() {
284 let doc = document(
285 Some(IntentBlock {
286 outcome: Some("qualify inbound leads".into()),
287 goal: None,
288 }),
289 None,
290 None,
291 None,
292 );
293 let packet = compile_intent(&doc).expect("compiles");
294 assert_eq!(packet.outcome, "qualify inbound leads");
295 }
296
297 #[test]
298 fn outcome_falls_back_to_goal() {
299 let doc = document(
300 Some(IntentBlock {
301 outcome: None,
302 goal: Some("qualify inbound leads".into()),
303 }),
304 None,
305 None,
306 None,
307 );
308 let packet = compile_intent(&doc).expect("compiles");
309 assert_eq!(packet.outcome, "qualify inbound leads");
310 }
311
312 #[test]
313 fn authority_actor_prefixes_authority_list() {
314 let doc = document(
315 Some(IntentBlock {
316 outcome: Some("ship".into()),
317 goal: None,
318 }),
319 Some(AuthorityBlock {
320 actor: Some("revops_team".into()),
321 may: vec!["approve_lead".into(), "request_demo".into()],
322 must_not: vec![],
323 requires_approval: vec![],
324 expires: None,
325 }),
326 None,
327 None,
328 );
329 let packet = compile_intent(&doc).expect("compiles");
330 assert_eq!(
331 packet.authority,
332 vec!["actor: revops_team", "approve_lead", "request_demo"]
333 );
334 }
335
336 #[test]
337 fn forbidden_collects_authority_and_constraint_must_not() {
338 let doc = document(
339 Some(IntentBlock {
340 outcome: Some("ship".into()),
341 goal: None,
342 }),
343 Some(AuthorityBlock {
344 actor: None,
345 may: vec![],
346 must_not: vec!["delete_account".into()],
347 requires_approval: vec![],
348 expires: None,
349 }),
350 Some(ConstraintBlock {
351 budget: vec![],
352 cost_limit: vec![],
353 must_not: vec!["spend_over_500".into()],
354 }),
355 None,
356 );
357 let packet = compile_intent(&doc).expect("compiles");
358 assert_eq!(packet.forbidden.len(), 2);
359 assert_eq!(packet.forbidden[0].action, "delete_account");
360 assert_eq!(packet.forbidden[0].reason, "authority");
361 assert_eq!(packet.forbidden[1].action, "spend_over_500");
362 assert_eq!(packet.forbidden[1].reason, "constraint");
363 }
364
365 #[test]
366 fn forbidden_deduplicates_same_action_across_blocks() {
367 let doc = document(
368 Some(IntentBlock {
369 outcome: Some("ship".into()),
370 goal: None,
371 }),
372 Some(AuthorityBlock {
373 actor: None,
374 may: vec![],
375 must_not: vec!["delete_account".into()],
376 requires_approval: vec![],
377 expires: None,
378 }),
379 Some(ConstraintBlock {
380 budget: vec![],
381 cost_limit: vec![],
382 must_not: vec!["delete_account".into()],
383 }),
384 None,
385 );
386 let packet = compile_intent(&doc).expect("compiles");
387 assert_eq!(packet.forbidden.len(), 1);
388 assert_eq!(packet.forbidden[0].reason, "authority");
389 }
390
391 #[test]
392 fn constraints_carry_budget_cost_and_approval_lines() {
393 let doc = document(
394 Some(IntentBlock {
395 outcome: Some("ship".into()),
396 goal: None,
397 }),
398 Some(AuthorityBlock {
399 actor: None,
400 may: vec![],
401 must_not: vec![],
402 requires_approval: vec!["spend_over_1000".into()],
403 expires: None,
404 }),
405 Some(ConstraintBlock {
406 budget: vec!["$500".into()],
407 cost_limit: vec!["$100/lead".into()],
408 must_not: vec![],
409 }),
410 None,
411 );
412 let packet = compile_intent(&doc).expect("compiles");
413 assert!(packet.constraints.contains(&"budget: $500".to_string()));
414 assert!(
415 packet
416 .constraints
417 .contains(&"cost_limit: $100/lead".to_string())
418 );
419 assert!(
420 packet
421 .constraints
422 .contains(&"requires_approval: spend_over_1000".to_string())
423 );
424 }
425
426 #[test]
427 fn expiry_parses_rfc3339() {
428 let doc = document(
429 Some(IntentBlock {
430 outcome: Some("ship".into()),
431 goal: None,
432 }),
433 Some(AuthorityBlock {
434 actor: None,
435 may: vec![],
436 must_not: vec![],
437 requires_approval: vec![],
438 expires: Some("2027-01-15T12:00:00Z".into()),
439 }),
440 None,
441 None,
442 );
443 let packet = compile_intent(&doc).expect("compiles");
444 assert_eq!(packet.expires.to_rfc3339(), "2027-01-15T12:00:00+00:00");
445 }
446
447 #[test]
448 fn expiry_parses_yyyy_mm_dd_as_midnight_utc() {
449 let doc = document(
450 Some(IntentBlock {
451 outcome: Some("ship".into()),
452 goal: None,
453 }),
454 Some(AuthorityBlock {
455 actor: None,
456 may: vec![],
457 must_not: vec![],
458 requires_approval: vec![],
459 expires: Some("2027-01-15".into()),
460 }),
461 None,
462 None,
463 );
464 let packet = compile_intent(&doc).expect("compiles");
465 assert_eq!(packet.expires.to_rfc3339(), "2027-01-15T00:00:00+00:00");
466 }
467
468 #[test]
469 fn malformed_expiry_rejected() {
470 let doc = document(
471 Some(IntentBlock {
472 outcome: Some("ship".into()),
473 goal: None,
474 }),
475 Some(AuthorityBlock {
476 actor: None,
477 may: vec![],
478 must_not: vec![],
479 requires_approval: vec![],
480 expires: Some("not-a-date".into()),
481 }),
482 None,
483 None,
484 );
485 assert!(matches!(
486 compile_intent(&doc),
487 Err(CompileError::ExpiryParse { .. })
488 ));
489 }
490
491 #[test]
492 fn missing_expiry_uses_default_window() {
493 let before = Utc::now();
494 let doc = document(
495 Some(IntentBlock {
496 outcome: Some("ship".into()),
497 goal: None,
498 }),
499 None,
500 None,
501 None,
502 );
503 let packet = compile_intent(&doc).expect("compiles");
504 let after = Utc::now();
505 let expected_min = before + Duration::hours(DEFAULT_EXPIRY_HOURS);
506 let expected_max = after + Duration::hours(DEFAULT_EXPIRY_HOURS);
507 assert!(packet.expires >= expected_min && packet.expires <= expected_max);
508 }
509
510 #[test]
511 fn reversibility_irreversible_when_constraint_says_so() {
512 let doc = document(
513 Some(IntentBlock {
514 outcome: Some("ship".into()),
515 goal: None,
516 }),
517 None,
518 Some(ConstraintBlock {
519 budget: vec!["reversibility: irreversible".into()],
520 cost_limit: vec![],
521 must_not: vec![],
522 }),
523 None,
524 );
525 let packet = compile_intent(&doc).expect("compiles");
526 assert_eq!(packet.reversibility, Reversibility::Irreversible);
527 }
528
529 #[test]
530 fn reversibility_defaults_to_reversible() {
531 let doc = document(
532 Some(IntentBlock {
533 outcome: Some("ship".into()),
534 goal: None,
535 }),
536 None,
537 None,
538 None,
539 );
540 let packet = compile_intent(&doc).expect("compiles");
541 assert_eq!(packet.reversibility, Reversibility::Reversible);
542 }
543
544 #[test]
545 fn exception_block_flips_expiry_action_to_escalate() {
546 let doc = document(
547 Some(IntentBlock {
548 outcome: Some("ship".into()),
549 goal: None,
550 }),
551 None,
552 None,
553 Some(ExceptionBlock {
554 escalates_to: vec!["legal".into()],
555 requires: vec![],
556 }),
557 );
558 let packet = compile_intent(&doc).expect("compiles");
559 assert_eq!(packet.expiry_action, ExpiryAction::Escalate);
560 }
561
562 #[test]
563 fn no_exception_block_keeps_default_halt() {
564 let doc = document(
565 Some(IntentBlock {
566 outcome: Some("ship".into()),
567 goal: None,
568 }),
569 None,
570 None,
571 None,
572 );
573 let packet = compile_intent(&doc).expect("compiles");
574 assert_eq!(packet.expiry_action, ExpiryAction::Halt);
575 }
576
577 #[test]
578 fn round_trip_from_real_truth_source() {
579 let source = r#"Truth: lead qualification
580
581 Intent:
582 Outcome: qualify inbound leads end-to-end
583 Goal: convert tier-1 leads within SLA
584
585 Authority:
586 Actor: revops_team
587 May: approve_qualified_lead
588 May: request_demo
589 Must Not: approve_unverified_lead
590 Requires Approval: approve_enterprise_lead
591 Expires: 2027-01-15T12:00:00Z
592
593 Constraint:
594 Budget: 500_USD/week
595 Cost Limit: 50_USD/lead
596
597 Exception:
598 Escalates To: sales_director
599
600 @invariant @acceptance
601 Scenario: a basic lead arrives
602 Given a lead from "acme.com"
603 When the lead is qualified
604 Then the lead is marked as approved
605"#;
606 let packet = compile_intent_from_source(source).expect("source parses + compiles");
607 assert_eq!(packet.outcome, "qualify inbound leads end-to-end");
608 assert!(packet.authority.iter().any(|a| a.contains("revops_team")));
609 assert!(
610 packet
611 .authority
612 .iter()
613 .any(|a| a == "approve_qualified_lead")
614 );
615 assert!(
616 packet
617 .forbidden
618 .iter()
619 .any(|f| f.action == "approve_unverified_lead" && f.reason == "authority")
620 );
621 assert!(packet.constraints.iter().any(|c| c.starts_with("budget: ")));
622 assert_eq!(packet.expiry_action, ExpiryAction::Escalate);
623 }
624}