use std::fmt;
use crate::registry_core::identity::NodeId;
pub const GRAFT_PLAN_VERSION: u32 = 1;
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct GraftPlanDocument {
pub version: u32,
pub target: NodeId,
pub target_path: String,
pub graft: String,
pub full: bool,
}
impl GraftPlanDocument {
pub fn new(
target: NodeId,
target_path: impl Into<String>,
graft: impl Into<String>,
full: bool,
) -> Self {
Self {
version: GRAFT_PLAN_VERSION,
target,
target_path: target_path.into(),
graft: graft.into(),
full,
}
}
pub fn parse(source: &str) -> Result<Self, GraftPlanDocumentError> {
let mut version = None;
let mut target = None;
let mut target_path = None;
let mut graft = None;
let mut full = None;
let mut seen: Vec<&str> = Vec::new();
for (index, raw) in source.lines().enumerate() {
let line = raw.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let (key, value) =
line.split_once('=')
.ok_or_else(|| GraftPlanDocumentError::Malformed {
line: index + 1,
message: "expected `key=value`".to_owned(),
})?;
let key = key.trim();
let value = value.trim();
if seen.contains(&key) {
return Err(GraftPlanDocumentError::Malformed {
line: index + 1,
message: format!("duplicate key `{key}`"),
});
}
seen.push(key);
match key {
"version" => {
let parsed =
value
.parse::<u32>()
.map_err(|_| GraftPlanDocumentError::Malformed {
line: index + 1,
message: format!("version `{value}` is not a number"),
})?;
if parsed != GRAFT_PLAN_VERSION {
return Err(GraftPlanDocumentError::UnsupportedVersion(parsed));
}
version = Some(parsed);
}
"target" => {
let parsed =
value
.parse::<NodeId>()
.map_err(|_| GraftPlanDocumentError::Malformed {
line: index + 1,
message: format!("target `{value}` is not a node identity"),
})?;
target = Some(parsed);
}
"target_path" => target_path = Some(validate_path(value, index + 1)?),
"graft" => graft = Some(validate_selector(value, index + 1)?),
"full" => {
let parsed = match value {
"true" => true,
"false" => false,
_ => {
return Err(GraftPlanDocumentError::Malformed {
line: index + 1,
message: format!("full `{value}` must be true or false"),
});
}
};
full = Some(parsed);
}
other => return Err(GraftPlanDocumentError::UnknownKey(other.to_owned())),
}
}
Ok(Self {
version: version.ok_or(GraftPlanDocumentError::MissingKey("version"))?,
target: target.ok_or(GraftPlanDocumentError::MissingKey("target"))?,
target_path: target_path.ok_or(GraftPlanDocumentError::MissingKey("target_path"))?,
graft: graft.ok_or(GraftPlanDocumentError::MissingKey("graft"))?,
full: full.ok_or(GraftPlanDocumentError::MissingKey("full"))?,
})
}
pub fn render(&self) -> String {
format!(
"version={}\ntarget={}\ntarget_path={}\ngraft={}\nfull={}\n",
self.version,
self.target,
self.target_path,
self.graft,
if self.full { "true" } else { "false" }
)
}
pub fn declaration(&self) -> String {
format!(
"cut \"{}\"{} graft \"{}\",",
self.target_path,
if self.full { " full" } else { "" },
self.graft
)
}
}
fn validate_path(value: &str, line: usize) -> Result<String, GraftPlanDocumentError> {
if value.is_empty() || value.split('/').any(str::is_empty) || value.contains(['\\', '"']) {
return Err(GraftPlanDocumentError::Malformed {
line,
message: format!("target_path `{value}` is not a `/`-separated logical path"),
});
}
Ok(value.to_owned())
}
pub fn validate_graft_selector(selector: &str) -> Result<(), GraftPlanDocumentError> {
if selector.trim().is_empty() {
return Err(GraftPlanDocumentError::InvalidSelector(
"external graft name must be a non-empty selector".to_owned(),
));
}
if selector.contains(['/', '\\']) {
return Err(GraftPlanDocumentError::InvalidSelector(
"external graft name must not contain path separators".to_owned(),
));
}
if selector.chars().any(char::is_whitespace) || selector.contains('"') {
return Err(GraftPlanDocumentError::InvalidSelector(
"external graft name must be one word without quotes".to_owned(),
));
}
if selector.starts_with('.') {
return Err(GraftPlanDocumentError::InvalidSelector(
"external graft name must not start with `.`: it names a directory inside the record root"
.to_owned(),
));
}
Ok(())
}
fn validate_selector(value: &str, line: usize) -> Result<String, GraftPlanDocumentError> {
validate_graft_selector(value).map_err(|message| GraftPlanDocumentError::Malformed {
line,
message: format!("graft `{value}` is invalid: {message}"),
})?;
Ok(value.to_owned())
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum GraftPlanDocumentError {
UnsupportedVersion(u32),
InvalidSelector(String),
MissingKey(&'static str),
UnknownKey(String),
Malformed {
line: usize,
message: String,
},
}
impl fmt::Display for GraftPlanDocumentError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::UnsupportedVersion(version) => write!(
formatter,
"graft plan version `{version}` is not supported (expected {GRAFT_PLAN_VERSION})"
),
Self::InvalidSelector(message) => write!(formatter, "{message}"),
Self::MissingKey(key) => write!(formatter, "graft plan is missing `{key}`"),
Self::UnknownKey(key) => write!(formatter, "graft plan has an unknown key `{key}`"),
Self::Malformed { line, message } => {
write!(formatter, "graft plan line {line}: {message}")
}
}
}
}
impl std::error::Error for GraftPlanDocumentError {}
impl From<GraftPlanDocumentError> for String {
fn from(error: GraftPlanDocumentError) -> Self {
error.to_string()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_repeated_key_is_refused() {
let target = NodeId::from_path("control/object/button/button.rs", "Button");
let text = format!(
"version=1\ntarget={target}\ntarget_path=root/control/button\ngraft=first\ngraft=second\n"
);
let error = GraftPlanDocument::parse(&text).expect_err("a repeated key must be refused");
let message = error.to_string();
assert!(message.contains("duplicate"), "{message}");
assert!(message.contains("graft"), "{message}");
}
#[test]
fn selectors_that_could_leave_the_record_directory_are_refused() {
for refused in [".", "..", ".hidden"] {
assert!(
validate_graft_selector(refused).is_err(),
"`{refused}` must not name a directory"
);
}
validate_graft_selector("button_graft").expect("an ordinary selector is valid");
}
fn document() -> GraftPlanDocument {
GraftPlanDocument::new(
NodeId::from_path("control/object/button/button.rs", "Button"),
"root/control/button",
"button_fast",
false,
)
}
#[test]
fn a_document_round_trips_through_its_text_form() {
let document = document();
let text = document.render();
assert_eq!(
text,
format!(
"version=1\ntarget={}\ntarget_path=root/control/button\ngraft=button_fast\nfull=false\n",
document.target
)
);
assert_eq!(GraftPlanDocument::parse(&text).expect("parses"), document);
}
#[test]
fn a_subtree_document_round_trips_and_renders_the_full_clause() {
let mut document = document();
document.full = true;
assert_eq!(
GraftPlanDocument::parse(&document.render()).expect("parses"),
document
);
assert_eq!(
document.declaration(),
"cut \"root/control/button\" full graft \"button_fast\","
);
assert!(document.full);
}
#[test]
fn a_partial_document_omits_the_full_clause() {
let document = document();
assert_eq!(
document.declaration(),
"cut \"root/control/button\" graft \"button_fast\","
);
assert_eq!(document.target_path, "root/control/button");
assert_eq!(document.graft, "button_fast");
assert!(!document.full);
}
#[test]
fn key_order_and_comments_do_not_matter() {
let document = document();
let reordered = format!(
"# written by hand\ngraft=button_fast\nfull=false\n\ntarget_path=root/control/button\nversion=1\ntarget={}\n",
document.target
);
assert_eq!(
GraftPlanDocument::parse(&reordered).expect("parses"),
document
);
}
#[test]
fn an_unknown_version_is_refused_instead_of_guessed() {
let text = document().render().replace("version=1", "version=2");
assert_eq!(
GraftPlanDocument::parse(&text),
Err(GraftPlanDocumentError::UnsupportedVersion(2))
);
}
#[test]
fn missing_unknown_and_malformed_keys_are_reported() {
let missing = document().render().replace("full=false\n", "");
assert_eq!(
GraftPlanDocument::parse(&missing),
Err(GraftPlanDocumentError::MissingKey("full"))
);
let unknown = format!("{}editor=vscode\n", document().render());
assert_eq!(
GraftPlanDocument::parse(&unknown),
Err(GraftPlanDocumentError::UnknownKey("editor".to_owned()))
);
let malformed = document().render().replace("full=false", "full=yes");
assert!(matches!(
GraftPlanDocument::parse(&malformed),
Err(GraftPlanDocumentError::Malformed { line: 5, .. })
));
let selector = document()
.render()
.replace("graft=button_fast", "graft=a/b");
assert!(matches!(
GraftPlanDocument::parse(&selector),
Err(GraftPlanDocumentError::Malformed { line: 4, .. })
));
let path = document().render().replace(
"target_path=root/control/button",
"target_path=control//button",
);
assert!(matches!(
GraftPlanDocument::parse(&path),
Err(GraftPlanDocumentError::Malformed { line: 3, .. })
));
}
}