diff --git a/rivet-cli/src/main.rs b/rivet-cli/src/main.rs index 434ee3f..c47db4a 100644 --- a/rivet-cli/src/main.rs +++ b/rivet-cli/src/main.rs @@ -455,6 +455,12 @@ enum Command { #[arg(long)] force: bool, }, + + /// Apply a batch of mutations from a YAML file + Batch { + /// Path to the batch YAML file + file: PathBuf, + }, } #[derive(Subcommand)] @@ -720,6 +726,7 @@ fn run(cli: Cli) -> Result { set_fields, ), Command::Remove { id, force } => cmd_remove(&cli, id, *force), + Command::Batch { file } => cmd_batch(&cli, file), } } @@ -1300,7 +1307,10 @@ fn cmd_validate( }) }) .collect(); + let total_errors = errors + cross_errors; + let result_str = if total_errors > 0 { "FAIL" } else { "PASS" }; let output = serde_json::json!({ + "result": result_str, "command": "validate", "errors": errors, "warnings": warnings, @@ -1543,7 +1553,9 @@ fn cmd_validate_incremental(cli: &Cli, format: &str, verify: bool) -> Result 0 { "FAIL" } else { "PASS" }; let output = serde_json::json!({ + "result": result_str, "command": "validate", "incremental": true, "errors": errors, @@ -1690,10 +1702,35 @@ fn cmd_coverage(cli: &Cli, format: &str, fail_under: Option<&f64>) -> Result = report + .entries + .iter() + .map(|e| { + serde_json::json!({ + "name": e.rule_name, + "description": e.description, + "source_type": e.source_type, + "link_type": e.link_type, + "direction": e.direction, + "covered": e.covered, + "total": e.total, + "percentage": (e.percentage() * 10.0).round() / 10.0, + "uncovered_ids": e.uncovered_ids, + }) + }) + .collect(); + let total: usize = report.entries.iter().map(|e| e.total).sum(); + let covered: usize = report.entries.iter().map(|e| e.covered).sum(); + let overall_pct = (report.overall_coverage() * 10.0).round() / 10.0; + let output = serde_json::json!({ + "rules": rules_json, + "overall": { + "covered": covered, + "total": total, + "percentage": overall_pct, + }, + }); + println!("{}", serde_json::to_string_pretty(&output).unwrap()); } else { println!("Traceability Coverage Report\n"); println!( @@ -3557,6 +3594,26 @@ impl ProjectContext { } } + // Load external project artifacts so cross-repo references resolve + if let Some(ref externals) = config.externals { + if !externals.is_empty() { + match rivet_core::externals::load_all_externals(externals, &cli.project) { + Ok(resolved) => { + for ext in resolved { + for mut artifact in ext.artifacts { + // Prefix external artifact IDs so they don't collide + artifact.id = format!("{}:{}", ext.prefix, artifact.id); + store.upsert(artifact); + } + } + } + Err(e) => { + log::warn!("could not load externals: {e}"); + } + } + } + } + let graph = LinkGraph::build(&store, &schema); Ok(Self { config, @@ -3913,6 +3970,317 @@ fn cmd_remove(cli: &Cli, id: &str, force: bool) -> Result { Ok(true) } +// ── Batch types and command ────────────────────────────────────────────── + +/// A batch file containing multiple mutations to apply atomically. +#[derive(Debug, serde::Deserialize)] +struct BatchFile { + mutations: Vec, +} + +/// A single mutation within a batch file. +#[derive(Debug, serde::Deserialize)] +#[serde(tag = "action", rename_all = "lowercase")] +enum BatchMutation { + Add { + #[serde(rename = "type")] + artifact_type: String, + title: String, + #[serde(default)] + description: Option, + #[serde(default)] + status: Option, + #[serde(default)] + tags: Vec, + #[serde(default)] + links: Vec, + #[serde(default)] + fields: std::collections::BTreeMap, + }, + Link { + source: String, + link_type: String, + target: String, + }, + Modify { + id: String, + #[serde(default)] + set_status: Option, + #[serde(default)] + set_title: Option, + #[serde(default)] + set_fields: Vec, + }, +} + +/// A link entry within a batch add mutation. +#[derive(Debug, serde::Deserialize)] +struct BatchLink { + #[serde(rename = "type")] + link_type: String, + target: String, +} + +/// A field assignment within a batch modify mutation. +#[derive(Debug, serde::Deserialize)] +struct BatchFieldSet { + key: String, + value: String, +} + +/// Apply a batch of mutations from a YAML file. +fn cmd_batch(cli: &Cli, file: &std::path::Path) -> Result { + use rivet_core::model::{Artifact, Link}; + use rivet_core::mutate; + + let content = std::fs::read_to_string(file) + .with_context(|| format!("reading batch file {}", file.display()))?; + + let batch: BatchFile = serde_yaml::from_str(&content).with_context(|| "parsing batch file")?; + + if batch.mutations.is_empty() { + println!("batch: no mutations to apply"); + return Ok(true); + } + + let ctx = ProjectContext::load(cli)?; + let mut store = ctx.store; + let schema = ctx.schema; + + // Track the ID generated by the most recent "add" for $prev substitution. + let mut prev_id: Option = None; + + // ── Phase 1: validate all mutations before applying any ───────────── + // We clone the store so validation can see the effects of earlier adds + // in the batch without modifying the real files yet. + let mut validation_store = store.clone(); + let mut planned_ids: Vec> = Vec::new(); + let mut validation_prev: Option = None; + + for (i, mutation) in batch.mutations.iter().enumerate() { + match mutation { + BatchMutation::Add { + artifact_type, + title, + description, + status, + tags, + links, + fields, + } => { + let prefix = mutate::prefix_for_type(artifact_type, &validation_store); + let id = mutate::next_id(&validation_store, &prefix); + + let link_vec: Vec = links + .iter() + .map(|l| { + let target = substitute_prev(&l.target, &validation_prev); + Link { + link_type: l.link_type.clone(), + target, + } + }) + .collect(); + + let artifact = Artifact { + id: id.clone(), + artifact_type: artifact_type.clone(), + title: title.clone(), + description: description.clone(), + status: Some(status.as_deref().unwrap_or("draft").to_string()), + tags: tags.clone(), + links: link_vec, + fields: fields.clone(), + source_file: None, + }; + + mutate::validate_add(&artifact, &validation_store, &schema) + .with_context(|| format!("batch mutation #{}: add '{}'", i + 1, id))?; + + // Insert into validation store so subsequent mutations can reference it + validation_store.upsert(artifact); + validation_prev = Some(id.clone()); + planned_ids.push(Some(id)); + } + BatchMutation::Link { + source, + link_type, + target, + } => { + let source = substitute_prev(source, &validation_prev); + let target = substitute_prev(target, &validation_prev); + mutate::validate_link(&source, link_type, &target, &validation_store, &schema) + .with_context(|| { + format!( + "batch mutation #{}: link {} --[{}]--> {}", + i + 1, + source, + link_type, + target + ) + })?; + planned_ids.push(None); + } + BatchMutation::Modify { + id, + set_status, + set_title, + set_fields, + } => { + let id = substitute_prev(id, &validation_prev); + let params = mutate::ModifyParams { + set_status: set_status.clone(), + set_title: set_title.clone(), + set_fields: set_fields + .iter() + .map(|f| (f.key.clone(), f.value.clone())) + .collect(), + ..Default::default() + }; + mutate::validate_modify(&id, ¶ms, &validation_store, &schema) + .with_context(|| format!("batch mutation #{}: modify '{}'", i + 1, id))?; + planned_ids.push(None); + } + } + } + + // ── Phase 2: apply all mutations ──────────────────────────────────── + for (i, mutation) in batch.mutations.iter().enumerate() { + match mutation { + BatchMutation::Add { + artifact_type, + title, + description, + status, + tags, + links, + fields, + } => { + let prefix = mutate::prefix_for_type(artifact_type, &store); + let id = mutate::next_id(&store, &prefix); + + let link_vec: Vec = links + .iter() + .map(|l| { + let target = substitute_prev(&l.target, &prev_id); + Link { + link_type: l.link_type.clone(), + target, + } + }) + .collect(); + + let artifact = Artifact { + id: id.clone(), + artifact_type: artifact_type.clone(), + title: title.clone(), + description: description.clone(), + status: Some(status.as_deref().unwrap_or("draft").to_string()), + tags: tags.clone(), + links: link_vec, + fields: fields.clone(), + source_file: None, + }; + + // Find target file + let target_file = + mutate::find_file_for_type(artifact_type, &store).ok_or_else(|| { + anyhow::anyhow!( + "batch mutation #{}: no existing file found for type '{}'. \ + Add an artifact of this type first or use `rivet add --file`.", + i + 1, + artifact_type + ) + })?; + + mutate::append_artifact_to_file(&artifact, &target_file) + .with_context(|| format!("writing to {}", target_file.display()))?; + + println!("added {}", id); + store.upsert(artifact); + prev_id = Some(id); + } + BatchMutation::Link { + source, + link_type, + target, + } => { + let source = substitute_prev(source, &prev_id); + let target = substitute_prev(target, &prev_id); + + let source_file = mutate::find_source_file(&source, &store).ok_or_else(|| { + anyhow::anyhow!( + "batch mutation #{}: cannot determine source file for '{}'", + i + 1, + source + ) + })?; + + let link = Link { + link_type: link_type.clone(), + target: target.clone(), + }; + + mutate::add_link_to_file(&source, &link, &source_file) + .with_context(|| format!("updating {}", source_file.display()))?; + + println!("linked {} --[{}]--> {}", source, link_type, target); + } + BatchMutation::Modify { + id, + set_status, + set_title, + set_fields, + } => { + let id = substitute_prev(id, &prev_id); + + let params = mutate::ModifyParams { + set_status: set_status.clone(), + set_title: set_title.clone(), + set_fields: set_fields + .iter() + .map(|f| (f.key.clone(), f.value.clone())) + .collect(), + ..Default::default() + }; + + let source_file = mutate::find_source_file(&id, &store).ok_or_else(|| { + anyhow::anyhow!( + "batch mutation #{}: cannot determine source file for '{}'", + i + 1, + id + ) + })?; + + mutate::modify_artifact_in_file(&id, ¶ms, &source_file, &store) + .with_context(|| format!("updating {}", source_file.display()))?; + + println!("modified {}", id); + } + } + } + + println!( + "\nbatch: applied {} mutation(s) successfully", + batch.mutations.len() + ); + Ok(true) +} + +/// Substitute `$prev` in a string with the most recently generated ID. +fn substitute_prev(s: &str, prev: &Option) -> String { + if s == "$prev" { + prev.as_deref().unwrap_or("$prev").to_string() + } else if s.contains("$prev") { + match prev { + Some(id) => s.replace("$prev", id), + None => s.to_string(), + } + } else { + s.to_string() + } +} + fn print_diagnostics(diagnostics: &[validate::Diagnostic]) { if diagnostics.is_empty() { println!("\nNo issues found."); diff --git a/rivet-core/src/db.rs b/rivet-core/src/db.rs index 33eadc1..362c6a6 100644 --- a/rivet-core/src/db.rs +++ b/rivet-core/src/db.rs @@ -144,7 +144,7 @@ pub fn evaluate_conditional_rules( for rule in &schema.conditional_rules { for artifact in store.iter() { if rule.when.matches_artifact(artifact) { - diagnostics.extend(rule.then.check(artifact, &rule.name, rule.severity.clone())); + diagnostics.extend(rule.then.check(artifact, &rule.name, rule.severity)); } } } diff --git a/rivet-core/src/externals.rs b/rivet-core/src/externals.rs index b25f0d6..6f36fa1 100644 --- a/rivet-core/src/externals.rs +++ b/rivet-core/src/externals.rs @@ -526,6 +526,8 @@ pub fn check_baseline_tag( let output = Command::new("git") .args(["rev-parse", "--verify", &format!("refs/tags/{tag}")]) .current_dir(repo_dir) + .env_remove("GIT_DIR") + .env_remove("GIT_WORK_TREE") .output() .map_err(|e| crate::error::Error::Io(format!("git rev-parse: {e}")))?; @@ -542,6 +544,8 @@ pub fn list_baseline_tags(repo_dir: &Path) -> Result, crate::error:: let output = Command::new("git") .args(["tag", "--list", "baseline/*"]) .current_dir(repo_dir) + .env_remove("GIT_DIR") + .env_remove("GIT_WORK_TREE") .output() .map_err(|e| crate::error::Error::Io(format!("git tag list: {e}")))?; @@ -1278,48 +1282,32 @@ externals: #[serial] fn check_baseline_tag_in_git_repo() { let dir = tempfile::tempdir().unwrap(); + + // Helper: run git in the temp repo, clearing GIT_DIR / GIT_WORK_TREE + // so the command targets the freshly-init'd repo rather than an + // enclosing worktree that may share tags. + let git = |args: &[&str]| { + std::process::Command::new("git") + .args(args) + .current_dir(dir.path()) + .env_remove("GIT_DIR") + .env_remove("GIT_WORK_TREE") + .env_remove("GIT_COMMON_DIR") + .output() + .unwrap() + }; + // Init a git repo with a baseline tag - std::process::Command::new("git") - .args(["init"]) - .current_dir(dir.path()) - .output() - .unwrap(); - std::process::Command::new("git") - .args(["config", "user.email", "test@test.com"]) - .current_dir(dir.path()) - .output() - .unwrap(); - std::process::Command::new("git") - .args(["config", "user.name", "Test"]) - .current_dir(dir.path()) - .output() - .unwrap(); - std::process::Command::new("git") - .args(["config", "tag.forceSignAnnotated", "false"]) - .current_dir(dir.path()) - .output() - .unwrap(); - std::process::Command::new("git") - .args(["config", "tag.gpgSign", "false"]) - .current_dir(dir.path()) - .output() - .unwrap(); + git(&["init"]); + git(&["config", "user.email", "test@test.com"]); + git(&["config", "user.name", "Test"]); + git(&["config", "tag.forceSignAnnotated", "false"]); + git(&["config", "tag.gpgSign", "false"]); + git(&["config", "commit.gpgSign", "false"]); std::fs::write(dir.path().join("file.txt"), "hello").unwrap(); - std::process::Command::new("git") - .args(["add", "."]) - .current_dir(dir.path()) - .output() - .unwrap(); - std::process::Command::new("git") - .args(["commit", "-m", "init"]) - .current_dir(dir.path()) - .output() - .unwrap(); - std::process::Command::new("git") - .args(["tag", "baseline/v1.0"]) - .current_dir(dir.path()) - .output() - .unwrap(); + git(&["add", "."]); + git(&["commit", "-m", "init"]); + git(&["tag", "baseline/v1.0"]); let status = check_baseline_tag(dir.path(), "v1.0").unwrap(); assert!(status.is_present()); @@ -1333,52 +1321,30 @@ externals: #[serial] fn list_baseline_tags_finds_tags() { let dir = tempfile::tempdir().unwrap(); - std::process::Command::new("git") - .args(["init"]) - .current_dir(dir.path()) - .output() - .unwrap(); - std::process::Command::new("git") - .args(["config", "user.email", "test@test.com"]) - .current_dir(dir.path()) - .output() - .unwrap(); - std::process::Command::new("git") - .args(["config", "user.name", "Test"]) - .current_dir(dir.path()) - .output() - .unwrap(); - std::process::Command::new("git") - .args(["config", "tag.forceSignAnnotated", "false"]) - .current_dir(dir.path()) - .output() - .unwrap(); - std::process::Command::new("git") - .args(["config", "tag.gpgSign", "false"]) - .current_dir(dir.path()) - .output() - .unwrap(); + + // Helper: run git in the temp repo, clearing inherited env vars. + let git = |args: &[&str]| { + std::process::Command::new("git") + .args(args) + .current_dir(dir.path()) + .env_remove("GIT_DIR") + .env_remove("GIT_WORK_TREE") + .env_remove("GIT_COMMON_DIR") + .output() + .unwrap() + }; + + git(&["init"]); + git(&["config", "user.email", "test@test.com"]); + git(&["config", "user.name", "Test"]); + git(&["config", "tag.forceSignAnnotated", "false"]); + git(&["config", "tag.gpgSign", "false"]); + git(&["config", "commit.gpgSign", "false"]); std::fs::write(dir.path().join("file.txt"), "hello").unwrap(); - std::process::Command::new("git") - .args(["add", "."]) - .current_dir(dir.path()) - .output() - .unwrap(); - std::process::Command::new("git") - .args(["commit", "-m", "init"]) - .current_dir(dir.path()) - .output() - .unwrap(); - std::process::Command::new("git") - .args(["tag", "baseline/v1.0"]) - .current_dir(dir.path()) - .output() - .unwrap(); - std::process::Command::new("git") - .args(["tag", "baseline/v2.0"]) - .current_dir(dir.path()) - .output() - .unwrap(); + git(&["add", "."]); + git(&["commit", "-m", "init"]); + git(&["tag", "baseline/v1.0"]); + git(&["tag", "baseline/v2.0"]); let tags = list_baseline_tags(dir.path()).unwrap(); assert!(tags.contains(&"v1.0".to_string())); diff --git a/rivet-core/src/links.rs b/rivet-core/src/links.rs index 5e2e28c..0c31c5b 100644 --- a/rivet-core/src/links.rs +++ b/rivet-core/src/links.rs @@ -46,11 +46,12 @@ pub struct LinkGraph { impl LinkGraph { /// Build the link graph from a store and schema. pub fn build(store: &Store, schema: &Schema) -> Self { - let mut forward: HashMap> = HashMap::new(); - let mut backward: HashMap> = HashMap::new(); + let n = store.len(); + let mut forward: HashMap> = HashMap::with_capacity(n); + let mut backward: HashMap> = HashMap::with_capacity(n); let mut broken = Vec::new(); - let mut graph = DiGraph::new(); - let mut node_map: HashMap = HashMap::new(); + let mut graph = DiGraph::with_capacity(n, n * 2); + let mut node_map: HashMap = HashMap::with_capacity(n); // Create nodes for all artifacts for artifact in store.iter() { @@ -61,18 +62,16 @@ impl LinkGraph { // Create edges for all links for artifact in store.iter() { for link in &artifact.links { - let resolved = ResolvedLink { - source: artifact.id.clone(), - target: link.target.clone(), - link_type: link.link_type.clone(), - }; - if store.contains(&link.target) { // Valid link — add forward, backward, and graph edge forward .entry(artifact.id.clone()) .or_default() - .push(resolved); + .push(ResolvedLink { + source: artifact.id.clone(), + target: link.target.clone(), + link_type: link.link_type.clone(), + }); let inverse_type = schema.inverse_of(&link.link_type).map(|s| s.to_string()); backward @@ -90,7 +89,11 @@ impl LinkGraph { graph.add_edge(src, dst, link.link_type.clone()); } } else { - broken.push(resolved); + broken.push(ResolvedLink { + source: artifact.id.clone(), + target: link.target.clone(), + link_type: link.link_type.clone(), + }); } } } @@ -105,21 +108,25 @@ impl LinkGraph { } /// Access the underlying petgraph directed graph. + #[inline] pub fn graph(&self) -> &DiGraph { &self.graph } /// Access the mapping from artifact ID to petgraph node index. + #[inline] pub fn node_map(&self) -> &HashMap { &self.node_map } /// Get forward links from an artifact. + #[inline] pub fn links_from(&self, id: &str) -> &[ResolvedLink] { self.forward.get(id).map(|v| v.as_slice()).unwrap_or(&[]) } /// Get backlinks (incoming links) to an artifact. + #[inline] pub fn backlinks_to(&self, id: &str) -> &[Backlink] { self.backward.get(id).map(|v| v.as_slice()).unwrap_or(&[]) } diff --git a/rivet-core/src/model.rs b/rivet-core/src/model.rs index fec03c1..1f62e32 100644 --- a/rivet-core/src/model.rs +++ b/rivet-core/src/model.rs @@ -65,6 +65,7 @@ pub struct Artifact { impl Artifact { /// Return all link targets of a given link type. + #[inline] pub fn links_of_type(&self, link_type: &str) -> Vec<&ArtifactId> { self.links .iter() @@ -74,6 +75,7 @@ impl Artifact { } /// Check whether this artifact has any link of the given type. + #[inline] pub fn has_link_type(&self, link_type: &str) -> bool { self.links.iter().any(|l| l.link_type == link_type) } diff --git a/rivet-core/src/schema.rs b/rivet-core/src/schema.rs index a9381b3..63aa275 100644 --- a/rivet-core/src/schema.rs +++ b/rivet-core/src/schema.rs @@ -122,7 +122,7 @@ pub struct TraceabilityRule { pub severity: Severity, } -#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default, PartialEq, Eq)] #[serde(rename_all = "lowercase")] pub enum Severity { Info, @@ -220,6 +220,7 @@ impl TryFrom for Condition { // Manual Serialize implementation for Condition → flat YAML output impl Condition { /// Check whether an artifact satisfies this condition. + #[inline] pub fn matches_artifact(&self, artifact: &Artifact) -> bool { match self { Condition::Equals { field, value } => { @@ -237,6 +238,7 @@ impl Condition { } /// Get a string value for a field from an artifact, checking base fields first. +#[inline] fn get_field_value(artifact: &Artifact, field: &str) -> Option { match field { "status" => artifact.status.clone(), @@ -326,7 +328,7 @@ impl Requirement { let has_field = get_field_value(artifact, field_name).is_some(); if !has_field { diags.push(crate::validate::Diagnostic { - severity: severity.clone(), + severity, artifact_id: Some(artifact.id.clone()), rule: rule_name.to_string(), message: format!( @@ -341,7 +343,7 @@ impl Requirement { for lt in link_types { if !artifact.has_link_type(lt) { diags.push(crate::validate::Diagnostic { - severity: severity.clone(), + severity, artifact_id: Some(artifact.id.clone()), rule: rule_name.to_string(), message: format!( @@ -532,16 +534,19 @@ impl Schema { } /// Look up an artifact type definition by name. + #[inline] pub fn artifact_type(&self, name: &str) -> Option<&ArtifactTypeDef> { self.artifact_types.get(name) } /// Look up a link type definition by name. + #[inline] pub fn link_type(&self, name: &str) -> Option<&LinkTypeDef> { self.link_types.get(name) } /// Get the inverse link type name, if one is defined. + #[inline] pub fn inverse_of(&self, link_type: &str) -> Option<&str> { self.inverse_map.get(link_type).map(|s| s.as_str()) } diff --git a/rivet-core/src/store.rs b/rivet-core/src/store.rs index 4f2f03c..2a04092 100644 --- a/rivet-core/src/store.rs +++ b/rivet-core/src/store.rs @@ -56,11 +56,13 @@ impl Store { } /// Look up an artifact by ID. + #[inline] pub fn get(&self, id: &str) -> Option<&Artifact> { self.artifacts.get(id) } /// Get all artifact IDs of a given type. + #[inline] pub fn by_type(&self, artifact_type: &str) -> &[ArtifactId] { self.by_type .get(artifact_type) @@ -69,15 +71,18 @@ impl Store { } /// Iterate over all artifacts. + #[inline] pub fn iter(&self) -> impl Iterator { self.artifacts.values() } /// Total number of artifacts. + #[inline] pub fn len(&self) -> usize { self.artifacts.len() } + #[inline] pub fn is_empty(&self) -> bool { self.artifacts.is_empty() } @@ -96,6 +101,7 @@ impl Store { } /// Check whether an artifact ID exists in the store. + #[inline] pub fn contains(&self, id: &str) -> bool { self.artifacts.contains_key(id) } diff --git a/rivet-core/src/validate.rs b/rivet-core/src/validate.rs index a007b66..b236fc8 100644 --- a/rivet-core/src/validate.rs +++ b/rivet-core/src/validate.rs @@ -47,7 +47,7 @@ pub fn validate(store: &Store, schema: &Schema, graph: &LinkGraph) -> Vec // 1. Check that every artifact has a known type for artifact in store.iter() { - if schema.artifact_type(&artifact.artifact_type).is_none() { + let Some(type_def) = schema.artifact_type(&artifact.artifact_type) else { diagnostics.push(Diagnostic { severity: Severity::Error, artifact_id: Some(artifact.id.clone()), @@ -74,9 +74,7 @@ pub fn validate_structural(store: &Store, schema: &Schema, graph: &LinkGraph) -> message: format!("unknown artifact type '{}'", artifact.artifact_type), }); continue; - } - - let type_def = schema.artifact_type(&artifact.artifact_type).unwrap(); + }; // 2. Check required fields for field in &type_def.fields { @@ -219,16 +217,12 @@ pub fn validate_structural(store: &Store, schema: &Schema, graph: &LinkGraph) -> }); if !has_link { diagnostics.push(Diagnostic { - severity: rule.severity.clone(), + severity: rule.severity, artifact_id: Some(id.clone()), rule: rule.name.clone(), message: format!( - "{}: {}", - rule.description, - format_args!( - "missing '{}' link to {:?}", - required_link, rule.target_types - ) + "{}: missing '{}' link to {:?}", + rule.description, required_link, rule.target_types ), }); } @@ -244,7 +238,7 @@ pub fn validate_structural(store: &Store, schema: &Schema, graph: &LinkGraph) -> }); if !has_backlink { diagnostics.push(Diagnostic { - severity: rule.severity.clone(), + severity: rule.severity, artifact_id: Some(id.clone()), rule: rule.name.clone(), message: rule.description.clone(),