use std::path::Path;
use crate::graph::tree::{ArtifactId, ArtifactKind, normalize_persisted_reference};
use crate::core::error::SpecmanError;
use crate::metadata::frontmatter::{
self, ArtifactFrontMatter, ArtifactIdentityFields, DependencyEntry, FrontMatterKind,
ImplementationFrontMatter, ReferenceEntry, ScratchFrontMatter, SpecificationFrontMatter,
};
use crate::metadata::update_model::{
FrontMatterUpdate, IdentityUpdate, ImplementationUpdate, ScratchUpdate, SpecificationUpdate,
};
use crate::workspace::WorkspacePaths;
pub fn apply_front_matter_update(
artifact: &ArtifactId,
artifact_path: &Path,
workspace: &WorkspacePaths,
raw_document: &str,
update: &FrontMatterUpdate,
persist: bool,
) -> Result<(String, bool), SpecmanError> {
let parent_dir = artifact_path.parent().ok_or_else(|| {
SpecmanError::Workspace(format!(
"artifact {} has no parent directory",
artifact_path.display()
))
})?;
let split = frontmatter::split_front_matter(raw_document)?;
let mut front: ArtifactFrontMatter = ArtifactFrontMatter::from_yaml_str(split.yaml)?;
ensure_kind_matches(artifact, &front)?;
let mut mutated = false;
match (&mut front, update) {
(ArtifactFrontMatter::Specification(fm), FrontMatterUpdate::Specification(up)) => {
mutated |= update_spec(fm, up, parent_dir, workspace)?;
}
(ArtifactFrontMatter::Implementation(fm), FrontMatterUpdate::Implementation(up)) => {
mutated |= update_impl(fm, up, parent_dir, workspace)?;
}
(ArtifactFrontMatter::Scratch(fm), FrontMatterUpdate::Scratch(up)) => {
mutated |= update_scratch(fm, up, parent_dir, workspace)?;
}
_ => {
return Err(SpecmanError::Template(
"FrontMatterUpdate kind does not match artifact kind".into(),
));
}
}
if mutated || persist {
if persist {
crate::metadata::mutation::write_artifact_front_matter(artifact_path, &front)?;
}
let yaml_str = serde_yaml::to_string(&front)
.map_err(|e| SpecmanError::Serialization(e.to_string()))?;
let yaml_clean = yaml_str.trim_start_matches("---").trim_start();
let updated_doc = format!("---\n{}\n---\n{}", yaml_clean.trim_end(), split.body);
return Ok((updated_doc, mutated));
}
Ok((raw_document.to_string(), false))
}
fn update_identity(front: &mut ArtifactIdentityFields, update: &IdentityUpdate) -> bool {
let mut changed = false;
if let Some(val) = &update.name {
if front.name.as_ref() != Some(val) {
front.name = Some(val.clone());
changed = true;
}
}
if let Some(val) = &update.title {
if front.title.as_ref() != Some(val) {
front.title = Some(val.clone());
changed = true;
}
}
if let Some(val) = &update.description {
if front.description.as_ref() != Some(val) {
front.description = Some(val.clone());
changed = true;
}
}
if let Some(val) = &update.version {
if front.version.as_ref() != Some(val) {
front.version = Some(val.clone());
changed = true;
}
}
if let Some(val) = &update.tags {
if &front.tags != val {
front.tags = val.clone();
changed = true;
}
}
changed
}
fn update_spec(
front: &mut SpecificationFrontMatter,
update: &SpecificationUpdate,
parent: &Path,
workspace: &WorkspacePaths,
) -> Result<bool, SpecmanError> {
let mut changed = update_identity(&mut front.identity, &update.identity);
if let Some(val) = update.requires_implementation {
if front.requires_implementation != Some(val) {
front.requires_implementation = Some(val);
changed = true;
}
}
if let Some(deps) = &update.dependencies {
let normalized = normalize_dependencies(deps, parent, workspace)?;
if dependencies_changed(&front.dependencies, &normalized) {
front.dependencies = normalized;
changed = true;
}
}
Ok(changed)
}
fn update_impl(
front: &mut ImplementationFrontMatter,
update: &ImplementationUpdate,
parent: &Path,
workspace: &WorkspacePaths,
) -> Result<bool, SpecmanError> {
let mut changed = update_identity(&mut front.identity, &update.identity);
if let Some(val) = &update.spec {
let normalized = normalize_persisted_reference(val, parent, workspace)?;
if front.spec.as_ref() != Some(&normalized) {
front.spec = Some(normalized);
changed = true;
}
}
if let Some(val) = &update.location {
if front.location.as_ref() != Some(val) {
front.location = Some(val.clone());
changed = true;
}
}
if let Some(deps) = &update.dependencies {
let normalized = normalize_dependencies(deps, parent, workspace)?;
if dependencies_changed(&front.dependencies, &normalized) {
front.dependencies = normalized;
changed = true;
}
}
if let Some(refs) = &update.references {
let normalized = normalize_references(refs, parent, workspace)?;
if references_changed(&front.references, &normalized) {
front.references = normalized;
changed = true;
}
}
Ok(changed)
}
fn update_scratch(
front: &mut ScratchFrontMatter,
update: &ScratchUpdate,
_parent: &Path,
workspace: &WorkspacePaths,
) -> Result<bool, SpecmanError> {
let mut changed = update_identity(&mut front.identity, &update.identity);
if let Some(val) = &update.branch {
if front.branch.as_ref() != Some(val) {
front.branch = Some(val.clone());
changed = true;
}
}
if let Some(val) = &update.work_type {
let old_json = serde_json::to_value(&front.work_type).unwrap_or_default();
let new_json = serde_json::to_value(&Some(val.clone())).unwrap_or_default();
if old_json != new_json {
front.work_type = Some(val.clone());
changed = true;
}
}
if let Some(deps) = &update.dependencies {
let normalized = normalize_dependencies(deps, workspace.root(), workspace)?;
if dependencies_changed(&front.dependencies, &normalized) {
front.dependencies = normalized;
changed = true;
}
}
Ok(changed)
}
fn normalize_dependencies(
deps: &[DependencyEntry],
base: &Path,
workspace: &WorkspacePaths,
) -> Result<Vec<DependencyEntry>, SpecmanError> {
let mut normalized = Vec::with_capacity(deps.len());
for dep in deps {
match dep {
DependencyEntry::Simple(s) => {
let r = normalize_persisted_reference(s, base, workspace)?;
normalized.push(DependencyEntry::Simple(r));
}
DependencyEntry::Detailed(obj) => {
let r = normalize_persisted_reference(&obj.reference, base, workspace)?;
let mut new_obj = obj.clone();
new_obj.reference = r;
normalized.push(DependencyEntry::Detailed(new_obj));
}
}
}
Ok(normalized)
}
fn normalize_references(
refs: &[ReferenceEntry],
base: &Path,
workspace: &WorkspacePaths,
) -> Result<Vec<ReferenceEntry>, SpecmanError> {
let mut normalized = Vec::with_capacity(refs.len());
for r in refs {
let ref_path = normalize_persisted_reference(&r.reference, base, workspace)?;
let mut new_r = r.clone();
new_r.reference = ref_path;
normalized.push(new_r);
}
Ok(normalized)
}
fn dependencies_changed(old: &[DependencyEntry], new: &[DependencyEntry]) -> bool {
serde_json::to_string(old).unwrap_or_default() != serde_json::to_string(new).unwrap_or_default()
}
fn references_changed(old: &[ReferenceEntry], new: &[ReferenceEntry]) -> bool {
serde_json::to_string(old).unwrap_or_default() != serde_json::to_string(new).unwrap_or_default()
}
fn ensure_kind_matches(
artifact: &ArtifactId,
front: &ArtifactFrontMatter,
) -> Result<(), SpecmanError> {
let fm_kind = front.kind();
if fm_kind != extract_front_matter_kind(&artifact.kind) {
return Err(SpecmanError::Template(format!(
"artifact kind mismatch: id={:?}, front_matter={:?}",
artifact.kind, fm_kind
)));
}
Ok(())
}
fn extract_front_matter_kind(kind: &ArtifactKind) -> FrontMatterKind {
match kind {
ArtifactKind::Specification => FrontMatterKind::Specification,
ArtifactKind::Implementation => FrontMatterKind::Implementation,
ArtifactKind::ScratchPad => FrontMatterKind::ScratchPad,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::graph::tree::{ArtifactId, ArtifactKind};
use crate::metadata::frontmatter::ReferenceEntry;
use crate::metadata::update_model::{
IdentityUpdate, ImplementationUpdate, SpecificationUpdate,
};
use std::path::PathBuf;
#[test]
fn test_update_spec_identity() {
let doc = "---\nname: old-name\n---\nbody content";
let artifact_id = ArtifactId {
kind: ArtifactKind::Specification,
name: "old-name".to_string(),
};
let path = PathBuf::from("/tmp/specman/spec/old-name/spec.md");
let root = PathBuf::from("/tmp/specman");
let workspace = WorkspacePaths::new(root.clone(), root.join(".specman"));
let update = FrontMatterUpdate::Specification(SpecificationUpdate {
identity: IdentityUpdate {
name: Some("new-name".to_string()),
..Default::default()
},
..Default::default()
});
let (new_doc, mutated) = apply_front_matter_update(
&artifact_id,
&path,
&workspace, doc,
&update,
false
).expect("update");
assert!(mutated);
assert!(new_doc.contains("name: new-name"));
assert!(!new_doc.contains("name: old-name"));
assert!(new_doc.contains("body content"));
}
#[test]
fn test_update_mismatch() {
let doc = "---\nname: spec\n---\nbody";
let artifact_id = ArtifactId {
kind: ArtifactKind::Specification,
name: "spec".to_string(),
};
let path = PathBuf::from("/tmp/specman/spec/spec.md");
let root = PathBuf::from("/tmp/specman");
let workspace = WorkspacePaths::new(root.clone(), root.join(".specman"));
let update = FrontMatterUpdate::Scratch(Default::default());
let err = apply_front_matter_update(
&artifact_id,
&path,
&workspace, doc,
&update,
false
).unwrap_err();
match err {
SpecmanError::Template(msg) => assert!(msg.contains("kind does not match")),
_ => panic!("unexpected error: {:?}", err),
}
}
#[test]
fn test_update_impl_references_and_location() {
let doc = "---\nname: impl-a\nspec: ../spec/a/spec.md\nlocation: src\nreferences:\n - ref: ../spec/a/spec.md\n type: specification\n---\nbody";
let artifact_id = ArtifactId {
kind: ArtifactKind::Implementation,
name: "impl-a".to_string(),
};
let path = PathBuf::from("/tmp/specman/impl/impl-a/impl.md");
let root = PathBuf::from("/tmp/specman");
let workspace = WorkspacePaths::new(root.clone(), root.join(".specman"));
std::fs::create_dir_all(root.join("spec/b")).expect("create referenced spec dir");
std::fs::write(root.join("spec/b/spec.md"), "---\nname: b\n---\n# B\n")
.expect("write referenced spec");
let update = FrontMatterUpdate::Implementation(ImplementationUpdate {
location: Some("src/crates/specman".to_string()),
references: Some(vec![ReferenceEntry {
reference: "../../spec/b/spec.md".to_string(),
reference_type: Some("specification".to_string()),
optional: Some(true),
}]),
..Default::default()
});
let (new_doc, mutated) = apply_front_matter_update(
&artifact_id,
&path,
&workspace,
doc,
&update,
false,
)
.expect("update impl fields");
assert!(mutated);
assert!(new_doc.contains("location: src/crates/specman"));
assert!(new_doc.contains("../spec/b/spec.md"));
}
}