use rto_graph::{BlobRef, GitError, GraphSource, Repo};
use crate::adr::AdrDoc;
use crate::annotate::Annotation;
use crate::blueprint::BlueprintDoc;
use crate::check::{Violation, ViolationKind};
use crate::site::SitePage;
pub type BlobReader<'a, E> = dyn Fn(&BlobRef) -> Result<Option<Vec<u8>>, E> + 'a;
#[derive(Debug, Default)]
pub struct AuthoredLayer {
pub docs: Vec<AdrDoc>,
pub blueprints: Vec<BlueprintDoc>,
pub annotations: Vec<Annotation>,
pub malformed: Vec<Violation>,
}
pub fn authored_blobs(repo: &Repo, source: GraphSource) -> Result<Vec<BlobRef>, GitError> {
match source {
GraphSource::Index => repo.index_files(),
GraphSource::Committed => repo.walk_blobs(),
GraphSource::Worktree => {
let mut blobs = repo.walk_blobs()?;
blobs.extend(repo.untracked_files()?.into_iter().map(|path| BlobRef {
path,
oid: String::new(),
}));
Ok(blobs)
}
}
}
#[derive(Debug, Default)]
pub struct AuthoredDocs {
pub layer: AuthoredLayer,
pub site: Vec<SitePage>,
}
pub fn authored_layer_from<E>(
blobs: Vec<BlobRef>,
read: &BlobReader<'_, E>,
) -> Result<AuthoredLayer, E> {
Ok(authored_docs_from(blobs, read)?.layer)
}
pub fn authored_layer(repo: &Repo, source: GraphSource) -> Result<AuthoredLayer, GitError> {
Ok(authored_docs(repo, source)?.layer)
}
pub fn authored_docs(repo: &Repo, source: GraphSource) -> Result<AuthoredDocs, GitError> {
authored_docs_from(authored_blobs(repo, source)?, &|blob| {
repo.read_source(blob, source)
})
}
pub fn authored_docs_from<E>(
blobs: Vec<BlobRef>,
read: &BlobReader<'_, E>,
) -> Result<AuthoredDocs, E> {
let mut out = AuthoredDocs::default();
let layer = &mut out.layer;
for blob in blobs {
let Some(bytes) = read(&blob)? else {
continue;
};
let text = String::from_utf8_lossy(&bytes);
let file = std::path::Path::new(&blob.path);
let is_md = file
.extension()
.and_then(|e| e.to_str())
.is_some_and(|e| e.eq_ignore_ascii_case("md"));
let name = file
.file_name()
.and_then(|n| n.to_str())
.unwrap_or_default();
let is_adr = blob.path.starts_with("docs/adr/") && is_md && name != "README.md";
if is_adr {
match crate::adr::parse_adr(&blob.path, &text) {
Ok(doc) => layer.docs.push(doc),
Err(e) => layer.malformed.push(Violation {
kind: ViolationKind::MalformedAdr,
message: format!("{}: cannot parse ADR: {e}", blob.path),
}),
}
} else if is_md && crate::site::is_site_page(&text) {
match crate::site::parse_site_page(&blob.path, &text) {
Ok(page) => out.site.push(page),
Err(e) => layer.malformed.push(Violation {
kind: ViolationKind::MalformedSitePage,
message: format!("{}: cannot parse site page: {e}", blob.path),
}),
}
} else if is_md && crate::blueprint::is_blueprint(&blob.path, &text) {
layer
.blueprints
.push(crate::blueprint::parse_blueprint(&blob.path, &text));
} else {
layer
.annotations
.extend(crate::annotate::scan_annotations(&blob.path, &text));
}
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::authored_docs_from;
use rto_graph::BlobRef;
fn classify(files: &[(&str, &str)]) -> super::AuthoredDocs {
let blobs: Vec<BlobRef> = files
.iter()
.map(|(path, _)| BlobRef {
path: (*path).to_owned(),
oid: String::new(),
})
.collect();
authored_docs_from(blobs, &|blob: &BlobRef| -> Result<Option<Vec<u8>>, ()> {
Ok(files
.iter()
.find(|(p, _)| *p == blob.path)
.map(|(_, text)| text.as_bytes().to_vec()))
})
.expect("classify")
}
#[test]
fn publication_is_a_declaration_and_survives_living_outside_docs_site() {
let layer = classify(&[
(
"docs/OFFLINE_SETUP.md",
"---\nsite-page: offline-setup\n---\n\n# Offline setup\n",
),
(
"docs/REVIEW_CHECKLIST.md",
"# Review checklist\n\nInternal.\n",
),
("docs/BUILD_PLAN_V2.md", "# Build Plan V2\n\nInternal.\n"),
]);
let published: Vec<&str> = layer.site.iter().map(|p| p.path.as_str()).collect();
assert_eq!(published, ["docs/OFFLINE_SETUP.md"]);
assert_eq!(layer.site[0].slug, "offline-setup");
assert!(
layer.layer.malformed.is_empty(),
"{:?}",
layer.layer.malformed
);
}
#[test]
fn an_adr_is_still_an_adr_and_a_page_outranks_the_blueprint_rule() {
let layer = classify(&[
(
"docs/adr/0001-x.md",
"---\nadr-id: \"0001\"\nstatus: Accepted\nsite-page: sneaky\n---\n\n# ADR-0001\n",
),
(
"docs/blueprint/landing.md",
"---\nsite-page: index\n---\n\n# Roteiro\n",
),
(
"docs/blueprint/roteiro.md",
"# Roteiro — Technical Implementation Plan\n",
),
]);
assert_eq!(layer.layer.docs.len(), 1, "the ADR is still an ADR");
let pages: Vec<&str> = layer.site.iter().map(|p| p.slug.as_str()).collect();
assert_eq!(pages, ["index"]);
assert_eq!(layer.layer.blueprints.len(), 1);
assert_eq!(layer.layer.blueprints[0].path, "docs/blueprint/roteiro.md");
}
#[test]
fn a_page_that_declares_itself_and_fails_to_parse_is_drift_not_silence() {
let layer = classify(&[("docs/site/x.md", "---\nsite-page: Not A Slug\n---\n\n# X\n")]);
assert!(layer.site.is_empty());
assert_eq!(layer.layer.malformed.len(), 1);
assert_eq!(
layer.layer.malformed[0].kind,
crate::check::ViolationKind::MalformedSitePage
);
assert!(
layer.layer.malformed[0].message.contains("docs/site/x.md"),
"names the file: {}",
layer.layer.malformed[0].message
);
}
#[test]
fn the_three_field_entry_point_drops_pages_rather_than_misfiling_them() {
let files = [(
"docs/OFFLINE_SETUP.md",
"---\nsite-page: offline-setup\n---\n\n# Offline setup\n\n// @rto:0001\n",
)];
let blobs = vec![BlobRef {
path: files[0].0.to_owned(),
oid: String::new(),
}];
let layer =
super::authored_layer_from(blobs, &|_: &BlobRef| -> Result<Option<Vec<u8>>, ()> {
Ok(Some(files[0].1.as_bytes().to_vec()))
})
.expect("classify");
assert!(layer.docs.is_empty());
assert!(layer.blueprints.is_empty());
assert!(
layer.annotations.is_empty(),
"a published page is not an annotation carrier: {:?}",
layer.annotations
);
assert_eq!(classify(&files).site.len(), 1);
}
#[test]
fn a_non_page_still_contributes_its_annotations() {
let layer = classify(&[("src/store.rs", "//! @rto:0001\n")]);
assert!(layer.site.is_empty());
assert_eq!(layer.layer.annotations.len(), 1);
}
}