Skip to main content

code_system_graph_core/
manifest.rs

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/// Strict workspace manifest accepted by the initial registry slice.
19#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
20#[serde(deny_unknown_fields)]
21pub struct WorkspaceManifest {
22    /// Manifest schema version.
23    pub version: u32,
24    /// Stable workspace name.
25    pub name: String,
26    /// Additional filesystem roots repositories may resolve beneath.
27    #[serde(rename = "allowedRoots", default)]
28    pub allowed_roots: Vec<String>,
29    /// Repositories indexed by unique alias.
30    pub repos: BTreeMap<String, RepositoryConfig>,
31    /// Versioned exact relationship declarations and suppressions.
32    #[serde(rename = "manualLinks", default)]
33    pub manual_links: Vec<ManualLinkConfig>,
34    /// Optional operator-owned extraction safety limit overrides.
35    #[serde(rename = "extractionBudgets", default)]
36    pub extraction_budgets: Option<ExtractionBudgetOverrides>,
37    /// Optional operator-owned supervised-execution policy overrides.
38    #[serde(rename = "executionPolicy", default)]
39    pub execution_policy: Option<ExecutionPolicyOverrides>,
40}
41
42/// Repository registration and boundary inputs.
43#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
44#[serde(rename_all = "camelCase", deny_unknown_fields)]
45pub struct RepositoryConfig {
46    /// Native path as written in the manifest.
47    pub path: String,
48    /// Optional `OpenAPI` artifact relative to the repository root.
49    pub openapi: Option<String>,
50    /// Explicit HTTP consumers that cannot yet be extracted from source.
51    pub http_consumers: Option<Vec<HttpConsumerConfig>>,
52    /// Explicit cross-language integration tests and validated HTTP contracts.
53    pub integration_tests: Option<Vec<IntegrationTestConfig>>,
54    /// Explicit contract-to-source implementation anchors.
55    pub implementations: Option<Vec<ContractImplementationConfig>>,
56    /// Additional repository-relative globs omitted from automatic discovery.
57    pub excludes: Option<Vec<String>>,
58    /// Repository-relative exceptions to reactivable built-in exclusions.
59    pub include_defaults: Option<Vec<String>>,
60}
61
62/// Exact manual relationship or automatic-link suppression.
63#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
64#[serde(deny_unknown_fields)]
65pub struct ManualLinkConfig {
66    /// Exact source node identifier or stable key.
67    pub from: String,
68    /// Exact target node identifier or stable key.
69    pub to: String,
70    /// Concrete graph relationship.
71    pub relation: EdgeKind,
72    /// Optional contract identity retained for audit context.
73    pub contract: Option<String>,
74    /// Mandatory human explanation for the override.
75    pub reason: String,
76    /// Whether to remove the exact automatic relationship instead of creating one.
77    #[serde(default)]
78    pub suppress: bool,
79}
80
81/// Explicit HTTP consumer boundary.
82#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
83#[serde(deny_unknown_fields)]
84pub struct HttpConsumerConfig {
85    /// Upper- or lower-case HTTP method.
86    pub method: String,
87    /// HTTP path template.
88    pub path: String,
89    /// Repository-relative evidence path.
90    pub source: String,
91}
92
93/// Explicit test case that validates an HTTP contract across a repository boundary.
94#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
95#[serde(deny_unknown_fields)]
96pub struct IntegrationTestConfig {
97    /// Test function or scenario name.
98    pub name: String,
99    /// Repository-relative test source path.
100    pub path: String,
101    /// Test framework, such as `pytest`.
102    pub framework: String,
103    /// Source language, such as `python`.
104    pub language: String,
105    /// HTTP contract validated by this test.
106    pub validates: HttpContractConfig,
107}
108
109/// Canonicalizable HTTP contract target.
110#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
111#[serde(deny_unknown_fields)]
112pub struct HttpContractConfig {
113    /// HTTP method.
114    pub method: String,
115    /// HTTP path template.
116    pub path: String,
117}
118
119/// Explicit source symbol that implements an HTTP contract.
120#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
121#[serde(deny_unknown_fields)]
122pub struct ContractImplementationConfig {
123    /// Source language, such as `rust`.
124    pub language: String,
125    /// Repository-relative implementation source path.
126    pub path: String,
127    /// Qualified or local symbol name.
128    pub symbol: String,
129    /// HTTP contract implemented by the symbol.
130    pub implements: HttpContractConfig,
131}
132
133/// Error returned while validating a workspace manifest.
134#[derive(Debug, Error)]
135pub enum ManifestError {
136    /// YAML could not be decoded according to the strict schema.
137    #[error("invalid workspace manifest: {0}")]
138    InvalidYaml(#[from] serde_saphyr::DeserializeError),
139    /// The manifest uses an unsupported schema version.
140    #[error("unsupported manifest version {found}; expected version 1")]
141    UnsupportedVersion {
142        /// Version supplied by the user.
143        found: u32,
144    },
145    /// A required string is empty after trimming.
146    #[error("manifest field `{field}` must not be empty")]
147    EmptyField {
148        /// Dot-style field location.
149        field: String,
150    },
151    /// A metadata field contains terminal control or bidirectional formatting characters.
152    #[error("manifest field `{field}` contains unsafe control or bidirectional characters")]
153    UnsafeMetadata {
154        /// Dot-style field location.
155        field: String,
156    },
157    /// A bounded manifest metadata field exceeds its hard byte limit.
158    #[error("manifest field `{field}` exceeds the {maximum}-byte limit")]
159    FieldTooLong {
160        /// Dot-style field location.
161        field: String,
162        /// Non-overridable maximum UTF-8 byte length.
163        maximum: usize,
164    },
165    /// A repository discovery pattern is malformed or unsafe.
166    #[error("manifest field `{field}` is invalid: {source}")]
167    InvalidIgnorePattern {
168        /// Dot-style field location.
169        field: String,
170        /// Pattern validation failure.
171        #[source]
172        source: IgnorePatternError,
173    },
174    /// An extraction budget is zero or cannot be represented internally.
175    #[error("invalid workspace manifest: {0}")]
176    InvalidExtractionBudget(#[from] InvalidExtractionBudget),
177    /// An execution policy value or relationship is invalid.
178    #[error("invalid workspace manifest: {0}")]
179    InvalidExecutionPolicy(#[from] InvalidExecutionPolicy),
180    /// The workspace does not register any repositories.
181    #[error("manifest field `repos` must contain at least one repository")]
182    EmptyRepositories,
183    /// A manual relationship uses the reserved generic relationship kind.
184    #[error("manifest field `manualLinks[{index}].relation` must be a concrete edge kind")]
185    NonConcreteManualRelation {
186        /// Zero-based declaration index.
187        index: usize,
188    },
189    /// A manual relationship names the same literal endpoint twice.
190    #[error("manual link at `manualLinks[{index}]` cannot link endpoint `{endpoint}` to itself")]
191    ManualSelfLink {
192        /// Zero-based declaration index.
193        index: usize,
194        /// Repeated endpoint literal.
195        endpoint: String,
196    },
197    /// Two manual declarations target the same relationship identity.
198    #[error(
199        "manual link at `manualLinks[{duplicate}]` duplicates `manualLinks[{first}]` for `{from}` -> `{to}` ({relation:?})"
200    )]
201    DuplicateManualLink {
202        /// Index of the first declaration.
203        first: usize,
204        /// Index of the repeated declaration.
205        duplicate: usize,
206        /// Exact source endpoint literal.
207        from: String,
208        /// Exact target endpoint literal.
209        to: String,
210        /// Repeated relationship.
211        relation: EdgeKind,
212    },
213}
214
215/// Parses and semantically validates a strict workspace manifest.
216///
217/// # Errors
218///
219/// Returns [`ManifestError`] for malformed YAML, unknown keys, unsupported versions, empty
220/// fields, or an empty repository registry.
221pub 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
316/// Validates manually constructed relationship declarations.
317///
318/// # Errors
319///
320/// Returns [`ManifestError`] for empty or unsafe metadata, reserved relationship kinds,
321/// self-links, or duplicate source/target/relation declarations.
322pub 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}