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