1use serde::{Deserialize, Serialize};
14use thiserror::Error;
15
16use crate::domain::version::CanonVersion;
17use crate::plan::decision::{AnswerSchema, Choice, Decision, Selections};
18use crate::plan::readiness::{Evaluation, Precondition, Requirement};
19
20pub const INDEX_PATH: &str = "guidance/index.toml";
22
23pub const INDEX_SCHEMA: &str = "sdd.guidance-index/1";
25
26pub const SCHEMA: &str = "sdd.guidance/1";
28
29pub const NONE: &str = "none";
31
32#[derive(Debug, Error, PartialEq, Eq)]
34pub enum GuidanceError {
35 #[error("{path} does not parse: {reason}")]
37 Malformed {
38 path: String,
40 reason: String,
42 },
43
44 #[error("{path} declares schema {found}, and this engine reads {expected}")]
46 UnknownSchema {
47 path: String,
49 found: String,
51 expected: String,
53 },
54
55 #[error("{path} is inconsistent: {reason}")]
57 Inconsistent {
58 path: String,
60 reason: String,
62 },
63}
64
65#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
67#[serde(rename_all = "kebab-case")]
68pub enum StepKind {
69 SeedAdded,
71 RuleRetired,
73 ManagedChanged,
75 DeclarationKeyAdded,
77 GateWidened,
79}
80
81#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
83#[serde(rename_all = "kebab-case")]
84pub enum Actor {
85 Plan,
87 Operator,
89}
90
91pub const DESTINATIONS: &[&str] = &[
96 "specs",
97 "decisions",
98 "reference",
99 "guides",
100 "declaration",
101 "debt",
102 "markdownlint",
103 "hooks-config",
104 "agents-digest",
105];
106
107#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
109#[serde(deny_unknown_fields)]
110pub struct Step {
111 pub id: String,
113 pub kind: StepKind,
115 pub breaking: bool,
117 pub destinations: Vec<String>,
119 pub actor: Actor,
121 pub text: String,
123}
124
125impl Step {
126 #[must_use]
131 pub fn decision_id(&self, release: &CanonVersion) -> String {
132 format!("guidance:{release}:{}", self.id)
133 }
134}
135
136#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
138#[serde(deny_unknown_fields)]
139pub struct Guidance {
140 pub schema: String,
142 pub release: CanonVersion,
144 pub steps: Vec<Step>,
146}
147
148impl Guidance {
149 pub fn parse(path: &str, bytes: &[u8], bodies: &[String]) -> Result<Self, GuidanceError> {
157 let malformed = |reason: String| GuidanceError::Malformed {
158 path: path.to_string(),
159 reason,
160 };
161 let text = std::str::from_utf8(bytes).map_err(|source| malformed(source.to_string()))?;
162 let held: Self = toml::from_str(text).map_err(|source| malformed(source.to_string()))?;
163 if held.schema != SCHEMA {
164 return Err(GuidanceError::UnknownSchema {
165 path: path.to_string(),
166 found: held.schema,
167 expected: SCHEMA.to_string(),
168 });
169 }
170 let inconsistent = |reason: String| GuidanceError::Inconsistent {
171 path: path.to_string(),
172 reason,
173 };
174 let mut seen: Vec<&str> = Vec::new();
175 for step in &held.steps {
176 if seen.contains(&step.id.as_str()) {
177 return Err(inconsistent(format!("{} appears twice", step.id)));
178 }
179 seen.push(&step.id);
180 if step.destinations.is_empty() {
181 return Err(inconsistent(format!("{} names no destination", step.id)));
182 }
183 for destination in &step.destinations {
184 if !DESTINATIONS.contains(&destination.as_str()) {
185 return Err(inconsistent(format!(
186 "{} names the destination {destination}, which is not one this engine filters against",
187 step.id
188 )));
189 }
190 }
191 let body = format!("guidance/{}/{}", held.release, step.text);
192 if !bodies.contains(&body) {
193 return Err(inconsistent(format!(
194 "{} names the body {body}, which the bundle does not carry",
195 step.id
196 )));
197 }
198 }
199 Ok(held)
200 }
201
202 #[must_use]
204 pub fn filtered(&self, held: &[String]) -> Vec<&Step> {
205 self.steps
206 .iter()
207 .filter(|step| {
208 step.destinations
209 .iter()
210 .any(|destination| held.contains(destination))
211 })
212 .collect()
213 }
214}
215
216#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
218#[serde(deny_unknown_fields)]
219pub struct IndexEntry {
220 pub version: CanonVersion,
222 pub guidance: String,
224}
225
226#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
228#[serde(deny_unknown_fields)]
229pub struct Index {
230 pub schema: String,
232 pub capability_floor: CanonVersion,
234 pub releases: Vec<IndexEntry>,
236}
237
238impl Index {
239 pub fn parse(bytes: &[u8]) -> Result<Self, GuidanceError> {
246 let malformed = |reason: String| GuidanceError::Malformed {
247 path: INDEX_PATH.to_string(),
248 reason,
249 };
250 let text = std::str::from_utf8(bytes).map_err(|source| malformed(source.to_string()))?;
251 let held: Self = toml::from_str(text).map_err(|source| malformed(source.to_string()))?;
252 if held.schema != INDEX_SCHEMA {
253 return Err(GuidanceError::UnknownSchema {
254 path: INDEX_PATH.to_string(),
255 found: held.schema,
256 expected: INDEX_SCHEMA.to_string(),
257 });
258 }
259 let mut seen: Vec<CanonVersion> = Vec::new();
260 for entry in &held.releases {
261 if seen.contains(&entry.version) {
262 return Err(GuidanceError::Inconsistent {
263 path: INDEX_PATH.to_string(),
264 reason: format!("{} appears twice", entry.version),
265 });
266 }
267 seen.push(entry.version);
268 }
269 Ok(held)
270 }
271
272 #[must_use]
274 pub fn entry(&self, version: CanonVersion) -> Option<&IndexEntry> {
275 self.releases.iter().find(|entry| entry.version == version)
276 }
277
278 #[must_use]
280 pub fn interval(&self, from: Option<CanonVersion>, to: CanonVersion) -> Vec<&IndexEntry> {
281 let mut found: Vec<&IndexEntry> = self
282 .releases
283 .iter()
284 .filter(|entry| from.is_none_or(|held| entry.version > held) && entry.version <= to)
285 .collect();
286 found.sort_by_key(|entry| entry.version);
287 found
288 }
289}
290
291#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
293#[serde(rename_all = "kebab-case")]
294pub enum Coverage {
295 Complete,
297 Partial,
299}
300
301#[must_use]
303pub fn coverage(index: &Index, from: Option<CanonVersion>) -> Coverage {
304 match from {
305 Some(recorded) if recorded < index.capability_floor => Coverage::Partial,
306 _ => Coverage::Complete,
307 }
308}
309
310#[derive(Debug, Clone, Default)]
312pub struct Briefing {
313 pub preconditions: Vec<Precondition>,
315 pub decisions: Vec<Decision>,
317 pub applicable: Vec<String>,
319 pub excluded: usize,
321}
322
323#[must_use]
330pub fn brief(
331 index: &Index,
332 files: &[(CanonVersion, Guidance)],
333 from: Option<CanonVersion>,
334 destinations: &[String],
335 selections: &Selections,
336) -> Briefing {
337 let mut briefing = Briefing::default();
338 for (release, guidance) in files {
339 let applicable = guidance.filtered(destinations);
340 briefing.excluded += guidance.steps.len() - applicable.len();
341 for step in applicable {
342 let id = step.decision_id(release);
343 briefing.applicable.push(id.clone());
344 if !step.breaking {
345 continue;
346 }
347 let selected = selections.get(&id).cloned();
348 briefing.decisions.push(Decision {
349 question: format!("{release} asks: {}", step.id.replace('-', " ")),
350 schema: AnswerSchema::Choice {
351 choices: vec![
352 Choice {
353 id: "accepted".to_string(),
354 consequence: format!(
355 "read guidance/{release}/{} and take the step",
356 step.text
357 ),
358 },
359 Choice {
360 id: "not-applicable".to_string(),
361 consequence: "this target does not carry what the step is about"
362 .to_string(),
363 },
364 ],
365 },
366 depends_on: Vec::new(),
367 selected,
368 id,
369 });
370 }
371 }
372 if coverage(index, from) == Coverage::Partial {
373 let id = "guidance-coverage".to_string();
374 let selected = selections.get(&id).cloned();
375 briefing.preconditions.push(Precondition {
376 id: "guidance-is-covered".to_string(),
377 statement: "every release in the interval carries its guidance".to_string(),
378 requirement: Requirement::DecisionRequired,
379 evaluation: selected.as_ref().map_or_else(
380 || Evaluation::NotObserved {
381 reason: format!(
382 "the target records a release below {}, which this engine does not brief",
383 index.capability_floor
384 ),
385 },
386 |_| Evaluation::Satisfied,
387 ),
388 resolved_by: Some(id.clone()),
389 evidence_refs: vec!["release".to_string()],
390 });
391 briefing.decisions.push(Decision {
392 question: format!(
393 "guidance starts at {}; proceed without what came before?",
394 index.capability_floor
395 ),
396 schema: AnswerSchema::Choice {
397 choices: vec![
398 Choice {
399 id: "accepted".to_string(),
400 consequence: "the plan proceeds with the guidance it has".to_string(),
401 },
402 Choice {
403 id: "refuse".to_string(),
404 consequence: "nothing lands; read the changelog first".to_string(),
405 },
406 ],
407 },
408 depends_on: Vec::new(),
409 selected,
410 id,
411 });
412 }
413 for decision in &briefing.decisions {
414 if decision.id == "guidance-coverage" {
415 continue;
416 }
417 briefing.preconditions.push(Precondition {
418 id: format!("step:{}", decision.id),
419 statement: decision.question.clone(),
420 requirement: Requirement::DecisionRequired,
421 evaluation: decision.selected.as_ref().map_or_else(
422 || Evaluation::Unsatisfied {
423 reason: "the operator has not accepted this step".to_string(),
424 },
425 |_| Evaluation::Satisfied,
426 ),
427 resolved_by: Some(decision.id.clone()),
428 evidence_refs: vec!["release".to_string()],
429 });
430 }
431 briefing
432}
433
434#[cfg(test)]
435mod tests {
436 #![allow(
437 clippy::unwrap_used,
438 reason = "a test panics as its failure signal, not as control flow"
439 )]
440
441 use super::*;
442
443 fn version(value: &str) -> CanonVersion {
444 value.parse().unwrap()
445 }
446
447 fn bodies() -> Vec<String> {
448 vec!["guidance/0.7.0/one.md".to_string()]
449 }
450
451 const ONE: &str = r#"
452schema = "sdd.guidance/1"
453release = "0.7.0"
454
455[[steps]]
456id = "one"
457kind = "rule-retired"
458breaking = true
459destinations = ["specs"]
460actor = "operator"
461text = "one.md"
462"#;
463
464 #[test]
465 fn a_step_declares_every_part_and_parses() {
466 let held = Guidance::parse("guidance/0.7.0.toml", ONE.as_bytes(), &bodies()).unwrap();
467 assert_eq!(held.release, version("0.7.0"));
468 assert_eq!(held.steps[0].kind, StepKind::RuleRetired);
469 assert_eq!(held.steps[0].actor, Actor::Operator);
470 assert!(held.steps[0].breaking);
471 }
472
473 #[test]
474 fn a_decision_id_derives_from_the_release_and_the_step_id() {
475 let held = Guidance::parse("guidance/0.7.0.toml", ONE.as_bytes(), &bodies()).unwrap();
476 assert_eq!(
477 held.steps[0].decision_id(&version("0.7.0")),
478 "guidance:0.7.0:one"
479 );
480 let reworded = ONE.replace("kind = \"rule-retired\"", "kind = \"gate-widened\"");
483 let again = Guidance::parse("guidance/0.7.0.toml", reworded.as_bytes(), &bodies()).unwrap();
484 assert_eq!(
485 again.steps[0].decision_id(&version("0.7.0")),
486 held.steps[0].decision_id(&version("0.7.0"))
487 );
488 }
489
490 #[test]
491 fn guidance_refuses_what_it_cannot_filter_or_resolve() {
492 let cases = [
493 (
494 ONE.replace("kind = \"rule-retired\"", "kind = \"invented\""),
495 "parse",
496 ),
497 (
498 ONE.replace("destinations = [\"specs\"]", "destinations = []"),
499 "destination",
500 ),
501 (
502 ONE.replace("destinations = [\"specs\"]", "destinations = [\"nowhere\"]"),
503 "destination",
504 ),
505 (
506 ONE.replace("text = \"one.md\"", "text = \"absent.md\""),
507 "body",
508 ),
509 (format!("{ONE}extra = 1\n"), "parse"),
510 (format!("{ONE}{ONE}"), "parse"),
511 ];
512 for (text, _) in cases {
513 assert!(
514 Guidance::parse("guidance/0.7.0.toml", text.as_bytes(), &bodies()).is_err(),
515 "{text}"
516 );
517 }
518 }
519
520 #[test]
521 fn a_duplicate_step_identifier_refuses() {
522 let doubled = format!(
523 "{ONE}\n[[steps]]\nid = \"one\"\nkind = \"seed-added\"\nbreaking = false\ndestinations = [\"specs\"]\nactor = \"plan\"\ntext = \"one.md\"\n"
524 );
525 let error =
526 Guidance::parse("guidance/0.7.0.toml", doubled.as_bytes(), &bodies()).unwrap_err();
527 assert!(error.to_string().contains("twice"), "{error}");
528 }
529
530 #[test]
531 fn a_step_is_filtered_against_the_targets_destinations() {
532 let held = Guidance::parse("guidance/0.7.0.toml", ONE.as_bytes(), &bodies()).unwrap();
533 assert_eq!(held.filtered(&["specs".to_string()]).len(), 1);
534 assert_eq!(held.filtered(&["debt".to_string()]).len(), 0);
535 }
536
537 fn index() -> Index {
538 Index::parse(
539 br#"
540schema = "sdd.guidance-index/1"
541capability_floor = "0.6.6"
542
543[[releases]]
544version = "0.6.6"
545guidance = "none"
546
547[[releases]]
548version = "0.7.0"
549guidance = "0.7.0.toml"
550
551[[releases]]
552version = "0.7.1"
553guidance = "none"
554"#,
555 )
556 .unwrap()
557 }
558
559 #[test]
560 fn the_interval_is_derived_from_the_ledger_alone() {
561 let held = index();
562 let found: Vec<String> = held
563 .interval(Some(version("0.6.6")), version("0.7.1"))
564 .iter()
565 .map(|entry| entry.version.to_string())
566 .collect();
567 assert_eq!(found, ["0.7.0", "0.7.1"]);
568 assert_eq!(held.interval(None, version("0.6.6")).len(), 1);
569 assert_eq!(held.entry(version("0.7.0")).unwrap().guidance, "0.7.0.toml");
570 assert_eq!(held.entry(version("0.7.1")).unwrap().guidance, NONE);
571 assert!(held.entry(version("9.9.9")).is_none());
572 }
573
574 #[test]
575 fn a_release_below_the_floor_is_partial_coverage() {
576 let held = index();
577 assert_eq!(coverage(&held, Some(version("0.6.5"))), Coverage::Partial);
578 assert_eq!(coverage(&held, Some(version("0.6.6"))), Coverage::Complete);
579 assert_eq!(coverage(&held, None), Coverage::Complete);
580 }
581
582 #[test]
583 fn an_additive_upgrade_needs_no_decision_and_a_breaking_one_does() {
584 let additive = ONE.replace("breaking = true", "breaking = false");
585 let held = Guidance::parse("guidance/0.7.0.toml", additive.as_bytes(), &bodies()).unwrap();
586 let briefing = brief(
587 &index(),
588 &[(version("0.7.0"), held)],
589 Some(version("0.6.6")),
590 &["specs".to_string()],
591 &Selections::new(),
592 );
593 assert!(briefing.decisions.is_empty());
594 assert_eq!(briefing.applicable, ["guidance:0.7.0:one"]);
595
596 let breaking = Guidance::parse("guidance/0.7.0.toml", ONE.as_bytes(), &bodies()).unwrap();
597 let briefing = brief(
598 &index(),
599 &[(version("0.7.0"), breaking.clone())],
600 Some(version("0.6.6")),
601 &["specs".to_string()],
602 &Selections::new(),
603 );
604 assert_eq!(briefing.decisions.len(), 1);
605 assert_eq!(briefing.preconditions.len(), 1);
606 assert_eq!(
607 briefing.preconditions[0].verdict(),
608 crate::plan::readiness::Readiness::NeedsDecision
609 );
610
611 let mut selections = Selections::new();
612 selections.insert("guidance:0.7.0:one".to_string(), "accepted".to_string());
613 let briefing = brief(
614 &index(),
615 &[(version("0.7.0"), breaking)],
616 Some(version("0.6.6")),
617 &["specs".to_string()],
618 &selections,
619 );
620 assert_eq!(
621 briefing.preconditions[0].verdict(),
622 crate::plan::readiness::Readiness::Ready
623 );
624 }
625
626 #[test]
627 fn a_filtered_out_step_is_counted_rather_than_hidden() {
628 let held = Guidance::parse("guidance/0.7.0.toml", ONE.as_bytes(), &bodies()).unwrap();
629 let briefing = brief(
630 &index(),
631 &[(version("0.7.0"), held)],
632 Some(version("0.6.6")),
633 &["debt".to_string()],
634 &Selections::new(),
635 );
636 assert_eq!(briefing.excluded, 1);
637 assert!(briefing.applicable.is_empty());
638 assert!(briefing.decisions.is_empty());
639 }
640
641 #[test]
642 fn partial_coverage_is_a_decision_a_selection_resolves() {
643 let briefing = brief(
644 &index(),
645 &[],
646 Some(version("0.6.5")),
647 &["specs".to_string()],
648 &Selections::new(),
649 );
650 assert_eq!(briefing.preconditions.len(), 1);
651 assert_eq!(
652 briefing.preconditions[0].verdict(),
653 crate::plan::readiness::Readiness::NeedsDecision
654 );
655 let mut selections = Selections::new();
656 selections.insert("guidance-coverage".to_string(), "accepted".to_string());
657 let briefing = brief(
658 &index(),
659 &[],
660 Some(version("0.6.5")),
661 &["specs".to_string()],
662 &selections,
663 );
664 assert_eq!(
665 briefing.preconditions[0].verdict(),
666 crate::plan::readiness::Readiness::Ready
667 );
668 }
669}