|
| 1 | +// SPDX-License-Identifier: PMPL-1.0-or-later |
| 2 | +//! Canonical RSR skeleton generator (hyperpolymath/rsr-template-repo#48). |
| 3 | +//! |
| 4 | +//! `robot-repo-automaton` is the single source of truth for the RSR |
| 5 | +//! *required-files set*. `rsr-template-repo` is **derived** from this: its |
| 6 | +//! drift-CI runs `robot-repo-automaton skeleton check .` and fails if the |
| 7 | +//! checked-in template diverges from [`SKELETON`]. This makes hand-maintenance |
| 8 | +//! of the template structurally impossible to drift (#45/#47 closed the |
| 9 | +//! immediate sanitisation; this closes the stronger structural fix #48). |
| 10 | +//! |
| 11 | +//! Source of truth = the **post-#47 canonical layout**, not the prose in |
| 12 | +//! `standards` `REQUIRED-FILES.md` (which is stale: it still lists root |
| 13 | +//! `*.scm` + `Mustfile`, predating the estate-wide `.scm`→`.a2ml` migration |
| 14 | +//! and the `.machine_readable/6a2/` layout). Updating that doc to match is a |
| 15 | +//! separate `standards`-repo change, deliberately out of scope here. |
| 16 | +
|
| 17 | +use anyhow::{Context, Result}; |
| 18 | +use std::path::Path; |
| 19 | + |
| 20 | +/// `(repo-relative path, canonical content)`, embedded at compile time so the |
| 21 | +/// generator is hermetic and reproducible. The embedded copies under |
| 22 | +/// `templates/skeleton/` are themselves the canonical artefacts; on day one |
| 23 | +/// they are byte-identical to the post-#47 `rsr-template-repo`, so the drift |
| 24 | +/// check passes immediately and any future divergence fails CI. |
| 25 | +pub const SKELETON: &[(&str, &str)] = &[ |
| 26 | + ( |
| 27 | + ".gitignore", |
| 28 | + include_str!("../templates/skeleton/.gitignore"), |
| 29 | + ), |
| 30 | + ( |
| 31 | + ".gitattributes", |
| 32 | + include_str!("../templates/skeleton/.gitattributes"), |
| 33 | + ), |
| 34 | + ( |
| 35 | + ".editorconfig", |
| 36 | + include_str!("../templates/skeleton/.editorconfig"), |
| 37 | + ), |
| 38 | + ( |
| 39 | + ".tool-versions", |
| 40 | + include_str!("../templates/skeleton/.tool-versions"), |
| 41 | + ), |
| 42 | + ("Justfile", include_str!("../templates/skeleton/Justfile")), |
| 43 | + ( |
| 44 | + ".machine_readable/6a2/META.a2ml", |
| 45 | + include_str!("../templates/skeleton/.machine_readable/6a2/META.a2ml"), |
| 46 | + ), |
| 47 | + ( |
| 48 | + ".machine_readable/6a2/STATE.a2ml", |
| 49 | + include_str!("../templates/skeleton/.machine_readable/6a2/STATE.a2ml"), |
| 50 | + ), |
| 51 | + ( |
| 52 | + ".machine_readable/6a2/ECOSYSTEM.a2ml", |
| 53 | + include_str!("../templates/skeleton/.machine_readable/6a2/ECOSYSTEM.a2ml"), |
| 54 | + ), |
| 55 | + ( |
| 56 | + ".machine_readable/6a2/PLAYBOOK.a2ml", |
| 57 | + include_str!("../templates/skeleton/.machine_readable/6a2/PLAYBOOK.a2ml"), |
| 58 | + ), |
| 59 | + ( |
| 60 | + ".machine_readable/6a2/AGENTIC.a2ml", |
| 61 | + include_str!("../templates/skeleton/.machine_readable/6a2/AGENTIC.a2ml"), |
| 62 | + ), |
| 63 | + ( |
| 64 | + ".machine_readable/6a2/NEUROSYM.a2ml", |
| 65 | + include_str!("../templates/skeleton/.machine_readable/6a2/NEUROSYM.a2ml"), |
| 66 | + ), |
| 67 | +]; |
| 68 | + |
| 69 | +/// Write the canonical skeleton into `out`, creating parent directories. |
| 70 | +pub fn emit(out: &Path) -> Result<()> { |
| 71 | + for (rel, content) in SKELETON { |
| 72 | + let dst = out.join(rel); |
| 73 | + if let Some(parent) = dst.parent() { |
| 74 | + std::fs::create_dir_all(parent) |
| 75 | + .with_context(|| format!("creating {}", parent.display()))?; |
| 76 | + } |
| 77 | + std::fs::write(&dst, content).with_context(|| format!("writing {}", dst.display()))?; |
| 78 | + } |
| 79 | + Ok(()) |
| 80 | +} |
| 81 | + |
| 82 | +/// A single required file that has drifted from canonical. |
| 83 | +#[derive(Debug, PartialEq, Eq)] |
| 84 | +pub enum Drift { |
| 85 | + /// Required file absent in the target repo. |
| 86 | + Missing(String), |
| 87 | + /// Required file present but content differs from canonical. |
| 88 | + Differs(String), |
| 89 | +} |
| 90 | + |
| 91 | +/// Compare `repo`'s required files against [`SKELETON`]. Empty result == in |
| 92 | +/// sync. Comparison ignores only CRLF and trailing-final-newline noise so the |
| 93 | +/// check is robust across platforms/editors without being lax about content. |
| 94 | +pub fn check(repo: &Path) -> Result<Vec<Drift>> { |
| 95 | + let mut drift = Vec::new(); |
| 96 | + for (rel, content) in SKELETON { |
| 97 | + let path = repo.join(rel); |
| 98 | + match std::fs::read_to_string(&path) { |
| 99 | + Err(_) => drift.push(Drift::Missing((*rel).to_string())), |
| 100 | + Ok(actual) => { |
| 101 | + if normalize(&actual) != normalize(content) { |
| 102 | + drift.push(Drift::Differs((*rel).to_string())); |
| 103 | + } |
| 104 | + } |
| 105 | + } |
| 106 | + } |
| 107 | + Ok(drift) |
| 108 | +} |
| 109 | + |
| 110 | +fn normalize(s: &str) -> String { |
| 111 | + let mut t = s.replace("\r\n", "\n"); |
| 112 | + while t.ends_with('\n') { |
| 113 | + t.pop(); |
| 114 | + } |
| 115 | + t |
| 116 | +} |
| 117 | + |
| 118 | +#[cfg(test)] |
| 119 | +mod tests { |
| 120 | + use super::*; |
| 121 | + |
| 122 | + #[test] |
| 123 | + fn skeleton_is_non_empty_and_well_formed() { |
| 124 | + assert!(SKELETON.len() >= 11, "expected the full RSR required set"); |
| 125 | + for (rel, content) in SKELETON { |
| 126 | + assert!(!rel.is_empty(), "empty skeleton path"); |
| 127 | + assert!(!rel.starts_with('/'), "{rel} must be repo-relative"); |
| 128 | + assert!(!content.is_empty(), "{rel} canonical content is empty"); |
| 129 | + } |
| 130 | + } |
| 131 | + |
| 132 | + #[test] |
| 133 | + fn emit_then_check_roundtrips_clean() { |
| 134 | + let dir = tempfile::tempdir().unwrap(); |
| 135 | + emit(dir.path()).unwrap(); |
| 136 | + let drift = check(dir.path()).unwrap(); |
| 137 | + assert!(drift.is_empty(), "freshly emitted skeleton drifted: {drift:?}"); |
| 138 | + } |
| 139 | + |
| 140 | + #[test] |
| 141 | + fn check_flags_missing_and_differing() { |
| 142 | + let dir = tempfile::tempdir().unwrap(); |
| 143 | + emit(dir.path()).unwrap(); |
| 144 | + // Mutate one file and delete another. |
| 145 | + std::fs::write(dir.path().join(".tool-versions"), "tampered\n").unwrap(); |
| 146 | + std::fs::remove_file(dir.path().join(".gitignore")).unwrap(); |
| 147 | + let drift = check(dir.path()).unwrap(); |
| 148 | + assert!(drift.contains(&Drift::Differs(".tool-versions".into()))); |
| 149 | + assert!(drift.contains(&Drift::Missing(".gitignore".into()))); |
| 150 | + } |
| 151 | + |
| 152 | + #[test] |
| 153 | + fn normalize_ignores_only_eol_noise() { |
| 154 | + assert_eq!(normalize("a\r\nb\n\n"), normalize("a\nb")); |
| 155 | + assert_ne!(normalize("a b"), normalize("a b")); |
| 156 | + } |
| 157 | +} |
0 commit comments