1use std::collections::BTreeMap;
2
3use code_system_graph_model::{EdgeKind, contains_unsafe_metadata_characters};
4use schemars::JsonSchema;
5use serde::{Deserialize, Serialize};
6use thiserror::Error;
7
8use crate::execution_policy::{ExecutionPolicy, ExecutionPolicyOverrides, InvalidExecutionPolicy};
9use crate::extraction_budget::{
10 ExtractionBudgetOverrides, ExtractionBudgets, InvalidExtractionBudget
11};
12use crate::ignore_policy::{IgnorePatternError, validate_excludes, validate_include_defaults};
13
14const MANUAL_ENDPOINT_MAX_BYTES: usize = 2_048;
15const MANUAL_CONTRACT_MAX_BYTES: usize = 1_024;
16const MANUAL_REASON_MAX_BYTES: usize = 4_096;
17
18#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
20#[serde(deny_unknown_fields)]
21pub struct WorkspaceManifest {
22 pub version: u32,
24 pub name: String,
26 #[serde(rename = "allowedRoots", default)]
28 pub allowed_roots: Vec<String>,
29 pub repos: BTreeMap<String, RepositoryConfig>,
31 #[serde(rename = "manualLinks", default)]
33 pub manual_links: Vec<ManualLinkConfig>,
34 #[serde(rename = "extractionBudgets", default)]
36 pub extraction_budgets: Option<ExtractionBudgetOverrides>,
37 #[serde(rename = "executionPolicy", default)]
39 pub execution_policy: Option<ExecutionPolicyOverrides>,
40}
41
42#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
44#[serde(rename_all = "camelCase", deny_unknown_fields)]
45pub struct RepositoryConfig {
46 pub path: String,
48 pub openapi: Option<String>,
50 pub http_consumers: Option<Vec<HttpConsumerConfig>>,
52 pub integration_tests: Option<Vec<IntegrationTestConfig>>,
54 pub implementations: Option<Vec<ContractImplementationConfig>>,
56 pub excludes: Option<Vec<String>>,
58 pub include_defaults: Option<Vec<String>>,
60}
61
62#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
64#[serde(deny_unknown_fields)]
65pub struct ManualLinkConfig {
66 pub from: String,
68 pub to: String,
70 pub relation: EdgeKind,
72 pub contract: Option<String>,
74 pub reason: String,
76 #[serde(default)]
78 pub suppress: bool,
79}
80
81#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
83#[serde(deny_unknown_fields)]
84pub struct HttpConsumerConfig {
85 pub method: String,
87 pub path: String,
89 pub source: String,
91}
92
93#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
95#[serde(deny_unknown_fields)]
96pub struct IntegrationTestConfig {
97 pub name: String,
99 pub path: String,
101 pub framework: String,
103 pub language: String,
105 pub validates: HttpContractConfig,
107}
108
109#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
111#[serde(deny_unknown_fields)]
112pub struct HttpContractConfig {
113 pub method: String,
115 pub path: String,
117}
118
119#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
121#[serde(deny_unknown_fields)]
122pub struct ContractImplementationConfig {
123 pub language: String,
125 pub path: String,
127 pub symbol: String,
129 pub implements: HttpContractConfig,
131}
132
133#[derive(Debug, Error)]
135pub enum ManifestError {
136 #[error("invalid workspace manifest: {0}")]
138 InvalidYaml(#[from] serde_saphyr::DeserializeError),
139 #[error("unsupported manifest version {found}; expected version 1")]
141 UnsupportedVersion {
142 found: u32,
144 },
145 #[error("manifest field `{field}` must not be empty")]
147 EmptyField {
148 field: String,
150 },
151 #[error("manifest field `{field}` contains unsafe control or bidirectional characters")]
153 UnsafeMetadata {
154 field: String,
156 },
157 #[error("manifest field `{field}` exceeds the {maximum}-byte limit")]
159 FieldTooLong {
160 field: String,
162 maximum: usize,
164 },
165 #[error("manifest field `{field}` is invalid: {source}")]
167 InvalidIgnorePattern {
168 field: String,
170 #[source]
172 source: IgnorePatternError,
173 },
174 #[error("invalid workspace manifest: {0}")]
176 InvalidExtractionBudget(#[from] InvalidExtractionBudget),
177 #[error("invalid workspace manifest: {0}")]
179 InvalidExecutionPolicy(#[from] InvalidExecutionPolicy),
180 #[error("manifest field `repos` must contain at least one repository")]
182 EmptyRepositories,
183 #[error("manifest field `manualLinks[{index}].relation` must be a concrete edge kind")]
185 NonConcreteManualRelation {
186 index: usize,
188 },
189 #[error("manual link at `manualLinks[{index}]` cannot link endpoint `{endpoint}` to itself")]
191 ManualSelfLink {
192 index: usize,
194 endpoint: String,
196 },
197 #[error(
199 "manual link at `manualLinks[{duplicate}]` duplicates `manualLinks[{first}]` for `{from}` -> `{to}` ({relation:?})"
200 )]
201 DuplicateManualLink {
202 first: usize,
204 duplicate: usize,
206 from: String,
208 to: String,
210 relation: EdgeKind,
212 },
213}
214
215pub fn parse_manifest(input: &str) -> Result<WorkspaceManifest, ManifestError> {
222 let manifest: WorkspaceManifest = crate::yaml::from_str(input)?;
223 if manifest.version != 1 {
224 return Err(ManifestError::UnsupportedVersion {
225 found: manifest.version,
226 });
227 }
228 validate_not_empty("name", &manifest.name)?;
229 for (index, root) in manifest.allowed_roots.iter().enumerate() {
230 validate_not_empty(&format!("allowedRoots[{index}]"), root)?;
231 }
232 if manifest.repos.is_empty() {
233 return Err(ManifestError::EmptyRepositories);
234 }
235 validate_manual_links(&manifest.manual_links)?;
236 ExtractionBudgets::resolve(manifest.extraction_budgets.as_ref())?;
237 ExecutionPolicy::resolve(manifest.execution_policy.as_ref())?;
238 for (alias, repository) in &manifest.repos {
239 validate_not_empty(&format!("repos.{alias}"), alias)?;
240 validate_not_empty(&format!("repos.{alias}.path"), &repository.path)?;
241 if let Some(openapi) = &repository.openapi {
242 validate_not_empty(&format!("repos.{alias}.openapi"), openapi)?;
243 }
244 for (index, consumer) in repository.http_consumers.iter().flatten().enumerate() {
245 validate_not_empty(
246 &format!("repos.{alias}.httpConsumers[{index}].method"),
247 &consumer.method,
248 )?;
249 validate_not_empty(
250 &format!("repos.{alias}.httpConsumers[{index}].path"),
251 &consumer.path,
252 )?;
253 validate_not_empty(
254 &format!("repos.{alias}.httpConsumers[{index}].source"),
255 &consumer.source,
256 )?;
257 }
258 for (index, test) in repository.integration_tests.iter().flatten().enumerate() {
259 for (field, value) in [
260 ("name", test.name.as_str()),
261 ("path", test.path.as_str()),
262 ("framework", test.framework.as_str()),
263 ("language", test.language.as_str()),
264 ("validates.method", test.validates.method.as_str()),
265 ("validates.path", test.validates.path.as_str()),
266 ] {
267 validate_not_empty(
268 &format!("repos.{alias}.integrationTests[{index}].{field}"),
269 value,
270 )?;
271 }
272 }
273 for (index, implementation) in repository.implementations.iter().flatten().enumerate() {
274 for (field, value) in [
275 ("language", implementation.language.as_str()),
276 ("path", implementation.path.as_str()),
277 ("symbol", implementation.symbol.as_str()),
278 (
279 "implements.method",
280 implementation.implements.method.as_str(),
281 ),
282 ("implements.path", implementation.implements.path.as_str()),
283 ] {
284 validate_not_empty(
285 &format!("repos.{alias}.implementations[{index}].{field}"),
286 value,
287 )?;
288 }
289 }
290 validate_repository_ignore_patterns(alias, repository)?;
291 }
292 Ok(manifest)
293}
294
295fn validate_repository_ignore_patterns(
296 alias: &str,
297 repository: &RepositoryConfig,
298) -> Result<(), ManifestError> {
299 if let Some(patterns) = &repository.excludes {
300 validate_excludes(patterns).map_err(|source| ManifestError::InvalidIgnorePattern {
301 field: format!("repos.{alias}.excludes"),
302 source,
303 })?;
304 }
305 if let Some(patterns) = &repository.include_defaults {
306 validate_include_defaults(patterns).map_err(|source| {
307 ManifestError::InvalidIgnorePattern {
308 field: format!("repos.{alias}.includeDefaults"),
309 source,
310 }
311 })?;
312 }
313 Ok(())
314}
315
316pub fn validate_manual_links(links: &[ManualLinkConfig]) -> Result<(), ManifestError> {
323 let mut identities = BTreeMap::new();
324 for (index, link) in links.iter().enumerate() {
325 validate_not_empty(&format!("manualLinks[{index}].from"), &link.from)?;
326 validate_not_empty(&format!("manualLinks[{index}].to"), &link.to)?;
327 validate_not_empty(&format!("manualLinks[{index}].reason"), &link.reason)?;
328 validate_maximum(
329 &format!("manualLinks[{index}].from"),
330 &link.from,
331 MANUAL_ENDPOINT_MAX_BYTES,
332 )?;
333 validate_maximum(
334 &format!("manualLinks[{index}].to"),
335 &link.to,
336 MANUAL_ENDPOINT_MAX_BYTES,
337 )?;
338 validate_maximum(
339 &format!("manualLinks[{index}].reason"),
340 &link.reason,
341 MANUAL_REASON_MAX_BYTES,
342 )?;
343 if let Some(contract) = &link.contract {
344 validate_not_empty(&format!("manualLinks[{index}].contract"), contract)?;
345 validate_maximum(
346 &format!("manualLinks[{index}].contract"),
347 contract,
348 MANUAL_CONTRACT_MAX_BYTES,
349 )?;
350 }
351 if link.relation == EdgeKind::ManualLink {
352 return Err(ManifestError::NonConcreteManualRelation { index });
353 }
354 if link.from == link.to {
355 return Err(ManifestError::ManualSelfLink {
356 index,
357 endpoint: link.from.clone(),
358 });
359 }
360 let identity = (link.from.clone(), link.to.clone(), link.relation);
361 if let Some(first) = identities.insert(identity, index) {
362 return Err(ManifestError::DuplicateManualLink {
363 first,
364 duplicate: index,
365 from: link.from.clone(),
366 to: link.to.clone(),
367 relation: link.relation,
368 });
369 }
370 }
371 Ok(())
372}
373
374fn validate_not_empty(field: &str, value: &str) -> Result<(), ManifestError> {
375 if value.trim().is_empty() {
376 return Err(ManifestError::EmptyField {
377 field: field.to_owned(),
378 });
379 }
380 if contains_unsafe_metadata_characters(value) {
381 return Err(ManifestError::UnsafeMetadata {
382 field: field.to_owned(),
383 });
384 }
385 Ok(())
386}
387
388fn validate_maximum(field: &str, value: &str, maximum: usize) -> Result<(), ManifestError> {
389 if value.len() > maximum {
390 return Err(ManifestError::FieldTooLong {
391 field: field.to_owned(),
392 maximum,
393 });
394 }
395 Ok(())
396}
397
398#[cfg(test)]
399mod tests {
400 use code_system_graph_model::EdgeKind;
401
402 use super::{MANUAL_REASON_MAX_BYTES, ManifestError, parse_manifest};
403 use crate::{ExecutionPolicy, ExtractionBudgets, IgnorePatternError};
404
405 const VALID: &str = r"
406version: 1
407name: commerce
408repos:
409 web:
410 path: ../web
411 httpConsumers:
412 - method: POST
413 path: /api/orders
414 source: src/checkout.ts
415";
416
417 #[test]
418 fn parse_manifest_should_accept_strict_valid_input() {
419 let result = parse_manifest(VALID);
420
421 assert!(result.is_ok(), "unexpected manifest error: {result:?}");
422 }
423
424 #[test]
425 fn extraction_budgets_should_be_optional_and_resolve_safe_defaults() {
426 let manifest = parse_manifest(VALID).expect("manifest without advanced budgets is valid");
427 let effective = ExtractionBudgets::resolve(manifest.extraction_budgets.as_ref())
428 .expect("safe defaults are valid");
429
430 assert_eq!(effective, ExtractionBudgets::default());
431 }
432
433 #[test]
434 fn extraction_budgets_should_accept_partial_higher_and_lower_overrides() {
435 let input = VALID.replace(
436 "name: commerce",
437 "name: commerce\nextractionBudgets:\n maxInputBytesPerArtifact: 1024\n maxStructuralDepthPerArtifact: 128",
438 );
439 let manifest = parse_manifest(&input).expect("partial override should be valid");
440 let effective = ExtractionBudgets::resolve(manifest.extraction_budgets.as_ref())
441 .expect("partial override should resolve");
442
443 assert_eq!(effective.max_input_bytes_per_artifact, 1_024);
444 assert_eq!(effective.max_structural_depth_per_artifact, 128);
445 assert_eq!(
446 effective.max_ast_depth_per_artifact,
447 ExtractionBudgets::default().max_ast_depth_per_artifact
448 );
449 }
450
451 #[test]
452 fn extraction_budgets_should_accept_a_complete_override() {
453 let input = VALID.replace(
454 "name: commerce",
455 "name: commerce\nextractionBudgets:\n maxInputBytesPerArtifact: 1\n maxStructuralDepthPerArtifact: 2\n maxAstDepthPerArtifact: 3\n maxWorkUnitsPerArtifact: 4\n maxTreeSitterNodesPerArtifact: 5\n maxObservationsPerArtifact: 6\n maxAccumulatedStringBytesPerArtifact: 7\n maxSerializedOutputBytesPerArtifact: 8\n maxStringBytesPerValue: 9\n maxPortablePathBytesPerValue: 10\n maxIdentifierBytesPerValue: 11\n maxStructuredWallTimeMsPerArtifact: 12\n maxTreeSitterWallTimeMsPerArtifact: 13",
456 );
457 let manifest = parse_manifest(&input).expect("complete override should be valid");
458 let effective = ExtractionBudgets::resolve(manifest.extraction_budgets.as_ref())
459 .expect("complete override should resolve");
460
461 assert_eq!(effective.max_input_bytes_per_artifact, 1);
462 assert_eq!(effective.max_structural_depth_per_artifact, 2);
463 assert_eq!(effective.max_ast_depth_per_artifact, 3);
464 assert_eq!(effective.max_work_units_per_artifact, 4);
465 assert_eq!(effective.max_tree_sitter_nodes_per_artifact, 5);
466 assert_eq!(effective.max_observations_per_artifact, 6);
467 assert_eq!(effective.max_accumulated_string_bytes_per_artifact, 7);
468 assert_eq!(effective.max_serialized_output_bytes_per_artifact, 8);
469 assert_eq!(effective.max_string_bytes_per_value, 9);
470 assert_eq!(effective.max_portable_path_bytes_per_value, 10);
471 assert_eq!(effective.max_identifier_bytes_per_value, 11);
472 assert_eq!(effective.max_structured_wall_time_ms_per_artifact, 12);
473 assert_eq!(effective.max_tree_sitter_wall_time_ms_per_artifact, 13);
474 }
475
476 #[test]
477 fn extraction_budgets_should_reject_zero_overflow_and_unknown_fields() {
478 let zero = VALID.replace(
479 "name: commerce",
480 "name: commerce\nextractionBudgets:\n maxWorkUnitsPerArtifact: 0",
481 );
482 let overflow = VALID.replace(
483 "name: commerce",
484 "name: commerce\nextractionBudgets:\n maxWorkUnitsPerArtifact: 18446744073709551616",
485 );
486 let unknown = VALID.replace(
487 "name: commerce",
488 "name: commerce\nextractionBudgets:\n maxWorkUnitsPerArtifact: 1\n maximumMagic: 2",
489 );
490
491 assert!(matches!(
492 parse_manifest(&zero),
493 Err(ManifestError::InvalidExtractionBudget(_))
494 ));
495 assert!(matches!(
496 parse_manifest(&overflow),
497 Err(ManifestError::InvalidYaml(_))
498 ));
499 assert!(matches!(
500 parse_manifest(&unknown),
501 Err(ManifestError::InvalidYaml(_))
502 ));
503 }
504
505 #[test]
506 fn execution_policy_should_resolve_defaults_and_partial_overrides() {
507 let manifest = parse_manifest(VALID).expect("manifest without execution policy is valid");
508 assert_eq!(
509 ExecutionPolicy::resolve(manifest.execution_policy.as_ref()).expect("defaults"),
510 ExecutionPolicy::default()
511 );
512
513 let input = VALID.replace(
514 "name: commerce",
515 "name: commerce\nexecutionPolicy:\n maxScanWallTimeMs: 28800000\n maxNoProgressTimeMs: 600000",
516 );
517 let manifest = parse_manifest(&input).expect("partial policy override is valid");
518 let effective = ExecutionPolicy::resolve(manifest.execution_policy.as_ref())
519 .expect("partial policy resolves");
520 assert_eq!(effective.max_scan_wall_time_ms, 28_800_000);
521 assert_eq!(effective.max_no_progress_time_ms, 600_000);
522 assert_eq!(
523 effective.max_worker_memory_bytes,
524 ExecutionPolicy::default().max_worker_memory_bytes
525 );
526 }
527
528 #[test]
529 fn execution_policy_should_reject_zero_overflow_unknown_and_invalid_relationships() {
530 let zero = VALID.replace(
531 "name: commerce",
532 "name: commerce\nexecutionPolicy:\n maxWorkerMemoryBytes: 0",
533 );
534 let overflow = VALID.replace(
535 "name: commerce",
536 "name: commerce\nexecutionPolicy:\n maxWorkerMemoryBytes: 18446744073709551616",
537 );
538 let unknown = VALID.replace(
539 "name: commerce",
540 "name: commerce\nexecutionPolicy:\n maximumMagic: 1",
541 );
542 let invalid = VALID.replace(
543 "name: commerce",
544 "name: commerce\nexecutionPolicy:\n maxScanWallTimeMs: 1000\n maxNoProgressTimeMs: 1001\n maxCodeGraphSyncWallTimeMsPerRepo: 1000\n gracefulTerminationMs: 1",
545 );
546
547 assert!(matches!(
548 parse_manifest(&zero),
549 Err(ManifestError::InvalidExecutionPolicy(_))
550 ));
551 assert!(matches!(
552 parse_manifest(&overflow),
553 Err(ManifestError::InvalidYaml(_))
554 ));
555 assert!(matches!(
556 parse_manifest(&unknown),
557 Err(ManifestError::InvalidYaml(_))
558 ));
559 let invalid_result = parse_manifest(&invalid);
560 assert!(
561 matches!(
562 invalid_result,
563 Err(ManifestError::InvalidExecutionPolicy(_))
564 ),
565 "unexpected invalid relationship result: {invalid_result:?}"
566 );
567 }
568
569 #[test]
570 fn parse_manifest_should_accept_repository_ignore_patterns() {
571 let input = VALID.replace(
572 " path: ../web",
573 " path: ../web\n excludes: [coverage/**]\n includeDefaults: [vendor/internal/**]",
574 );
575
576 let result = parse_manifest(&input);
577
578 assert!(result.is_ok(), "unexpected manifest error: {result:?}");
579 }
580
581 #[test]
582 fn parse_manifest_should_reject_protected_default_include() {
583 let input = VALID.replace(
584 " path: ../web",
585 " path: ../web\n includeDefaults: [.codegraph/**]",
586 );
587
588 let result = parse_manifest(&input);
589
590 assert!(matches!(
591 result,
592 Err(ManifestError::InvalidIgnorePattern { field, .. })
593 if field == "repos.web.includeDefaults"
594 ));
595 }
596
597 #[test]
598 fn parse_manifest_should_reject_unsupported_ignore_syntax() {
599 let input = VALID.replace(
600 " path: ../web",
601 " path: ../web\n excludes: [\"src/[ab]/**\"]",
602 );
603
604 let result = parse_manifest(&input);
605
606 assert!(matches!(
607 result,
608 Err(ManifestError::InvalidIgnorePattern {
609 source: IgnorePatternError::UnsupportedSyntax(_),
610 ..
611 })
612 ));
613 }
614
615 #[test]
616 fn parse_manifest_should_reject_unknown_keys() {
617 let result =
618 parse_manifest(&VALID.replace("name: commerce", "name: commerce\nextra: true"));
619
620 assert!(matches!(result, Err(ManifestError::InvalidYaml(_))));
621 }
622
623 #[test]
624 fn parse_manifest_should_reject_unsupported_version() {
625 let result = parse_manifest(&VALID.replace("version: 1", "version: 2"));
626
627 assert!(matches!(
628 result,
629 Err(ManifestError::UnsupportedVersion { found: 2 })
630 ));
631 }
632
633 #[test]
634 fn parse_manifest_should_reject_bidi_metadata() {
635 let result = parse_manifest(&VALID.replace("commerce", "commerce\u{202e}txt"));
636
637 assert!(matches!(
638 result,
639 Err(ManifestError::UnsafeMetadata { field }) if field == "name"
640 ));
641 }
642
643 #[test]
644 fn parse_manifest_should_accept_exact_manual_link() {
645 let input = format!(
646 "{VALID}manualLinks:\n - from: node:web\n to: service:api\n relation: consumes\n contract: POST /orders\n reason: Manual checkout boundary\n"
647 );
648
649 let result = parse_manifest(&input).map(|manifest| manifest.manual_links);
650
651 assert!(matches!(
652 result,
653 Ok(links)
654 if links.len() == 1
655 && links[0].relation == EdgeKind::Consumes
656 && !links[0].suppress
657 ));
658 }
659
660 #[test]
661 fn parse_manifest_should_reject_unknown_manual_link_fields() {
662 let input = format!(
663 "{VALID}manualLinks:\n - from: node:web\n to: service:api\n relation: consumes\n reason: Explicit dependency\n approximate: true\n"
664 );
665
666 let result = parse_manifest(&input);
667
668 assert!(matches!(result, Err(ManifestError::InvalidYaml(_))));
669 }
670
671 #[test]
672 fn parse_manifest_should_reject_empty_manual_link_reason() {
673 let input = format!(
674 "{VALID}manualLinks:\n - from: node:web\n to: service:api\n relation: consumes\n reason: ' '\n"
675 );
676
677 let result = parse_manifest(&input);
678
679 assert!(matches!(
680 result,
681 Err(ManifestError::EmptyField { field })
682 if field == "manualLinks[0].reason"
683 ));
684 }
685
686 #[test]
687 fn parse_manifest_should_reject_oversized_manual_link_reason() {
688 let input = format!(
689 "{VALID}manualLinks:\n - from: node:web\n to: service:api\n relation: consumes\n reason: {}\n",
690 "x".repeat(MANUAL_REASON_MAX_BYTES + 1)
691 );
692
693 let result = parse_manifest(&input);
694
695 assert!(matches!(
696 result,
697 Err(ManifestError::FieldTooLong { field, maximum })
698 if field == "manualLinks[0].reason" && maximum == MANUAL_REASON_MAX_BYTES
699 ));
700 }
701
702 #[test]
703 fn parse_manifest_should_reject_unsafe_manual_link_metadata() {
704 let input = format!(
705 "{VALID}manualLinks:\n - from: node:web\u{202e}\n to: service:api\n relation: consumes\n reason: Explicit dependency\n"
706 );
707
708 let result = parse_manifest(&input);
709
710 assert!(matches!(
711 result,
712 Err(ManifestError::UnsafeMetadata { field })
713 if field == "manualLinks[0].from"
714 ));
715 }
716
717 #[test]
718 fn parse_manifest_should_reject_duplicate_manual_link_identity() {
719 let declaration = " - from: node:web\n to: service:api\n relation: consumes\n reason: Explicit dependency\n";
720 let input = format!("{VALID}manualLinks:\n{declaration}{declaration}");
721
722 let result = parse_manifest(&input);
723
724 assert!(matches!(
725 result,
726 Err(ManifestError::DuplicateManualLink {
727 first: 0,
728 duplicate: 1,
729 ..
730 })
731 ));
732 }
733
734 #[test]
735 fn parse_manifest_should_reject_literal_manual_self_link() {
736 let input = format!(
737 "{VALID}manualLinks:\n - from: node:web\n to: node:web\n relation: consumes\n reason: Invalid self link\n"
738 );
739
740 let result = parse_manifest(&input);
741
742 assert!(matches!(
743 result,
744 Err(ManifestError::ManualSelfLink { index: 0, .. })
745 ));
746 }
747}