1use std::collections::BTreeMap;
2use std::fs;
3use std::path::{Path, PathBuf};
4
5use code_system_graph_model::stable_id;
6use schemars::JsonSchema;
7use serde::{Deserialize, Serialize};
8use thiserror::Error;
9
10use crate::{
11 CapabilityDir, CapabilityError, ContractImplementationConfig, HttpConsumerConfig, IgnorePatternError, IgnorePolicy, IntegrationTestConfig, MAX_REPOSITORY_CONFIG_BYTES, RegularFileEntry, RepositoryConfig, validate_excludes, validate_include_defaults
12};
13
14const LOCAL_CONFIG_NAME: &str = ".code-system-graph.yaml";
15const MAX_OPENAPI_DISCOVERY_DEPTH: usize = 8;
16const MAX_OPENAPI_CANDIDATES: usize = 32;
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
20#[serde(rename_all = "snake_case")]
21pub enum ConfigSource {
22 CliOverride,
24 WorkspaceManifest,
26 RepositoryLocal,
28 AutoDetected,
30 Default,
32}
33
34pub fn apply_openapi_override(
40 config: &mut EffectiveRepositoryConfig,
41 openapi: &str,
42) -> Result<(), ConfigError> {
43 if openapi.trim().is_empty() {
44 return Err(ConfigError::EmptyField {
45 path: PathBuf::from("<cli>"),
46 field: "repoOpenapi".to_owned(),
47 });
48 }
49 let previous_fingerprint = config.fingerprint.clone();
50 config.openapi = vec![openapi.to_owned()];
51 config.openapi_source = ConfigSource::CliOverride;
52 config.fingerprint = stable_id(
53 "repo-config",
54 &format!("base={previous_fingerprint};cli_openapi={openapi}"),
55 );
56 Ok(())
57}
58
59#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
61pub struct EffectiveRepositoryConfig {
62 pub ignore_policy: IgnorePolicy,
64 pub openapi: Vec<String>,
66 pub openapi_source: ConfigSource,
68 pub http_consumers: Vec<HttpConsumerConfig>,
70 pub http_consumers_source: ConfigSource,
72 pub integration_tests: Vec<IntegrationTestConfig>,
74 pub integration_tests_source: ConfigSource,
76 pub implementations: Vec<ContractImplementationConfig>,
78 pub implementations_source: ConfigSource,
80 pub fingerprint: String,
82}
83
84#[derive(Debug, Deserialize)]
85#[serde(rename_all = "camelCase", deny_unknown_fields)]
86struct RepositoryLocalConfig {
87 version: u32,
88 openapi: Option<String>,
89 http_consumers: Option<Vec<HttpConsumerConfig>>,
90 integration_tests: Option<Vec<IntegrationTestConfig>>,
91 implementations: Option<Vec<ContractImplementationConfig>>,
92 excludes: Option<Vec<String>>,
93 include_defaults: Option<Vec<String>>,
94}
95
96#[derive(Debug, Error)]
98pub enum ConfigError {
99 #[error("failed to read repository config `{path}`: {source}")]
101 Read {
102 path: PathBuf,
104 source: std::io::Error,
106 },
107 #[error("invalid repository config `{path}`: {source}")]
109 Invalid {
110 path: PathBuf,
112 source: Box<serde_saphyr::DeserializeError>,
114 },
115 #[error("unsupported repository config version {found} in `{path}`; expected version 1")]
117 UnsupportedVersion {
118 path: PathBuf,
120 found: u32,
122 },
123 #[error("repository config field `{field}` in `{path}` must not be empty")]
125 EmptyField {
126 path: PathBuf,
128 field: String,
130 },
131 #[error("ambiguous OpenAPI auto-detection in `{root}`: {candidates:?}")]
133 AmbiguousOpenApi {
134 root: PathBuf,
136 candidates: Vec<String>,
138 },
139 #[error("repository config field `{field}` in `{path}` is invalid: {source}")]
141 InvalidIgnorePattern {
142 path: PathBuf,
144 field: String,
146 #[source]
148 source: IgnorePatternError,
149 },
150 #[error("repository config `{path}` is a symbolic link or reparse point")]
152 Symlink {
153 path: PathBuf,
155 },
156 #[error("repository config `{path}` is not a regular file")]
158 NotRegularFile {
159 path: PathBuf,
161 },
162 #[error("repository config `{path}` exceeds {limit} bytes")]
164 TooLarge {
165 path: PathBuf,
167 limit: usize,
169 },
170 #[error("repository config `{path}` is outside checkout `{checkout}`")]
172 OutsideCheckout {
173 path: PathBuf,
175 checkout: PathBuf,
177 },
178}
179
180pub fn resolve_repository_config(
190 checkout_path: &Path,
191 workspace: &RepositoryConfig,
192) -> Result<EffectiveRepositoryConfig, ConfigError> {
193 let checkout = CapabilityDir::open(checkout_path)
194 .map_err(|error| map_capability_error(error, checkout_path, LOCAL_CONFIG_NAME))?;
195 let local_relative = Path::new(LOCAL_CONFIG_NAME);
196 let (local, local_source) = match checkout
197 .classify_regular_file_entry(local_relative)
198 .map_err(|error| map_capability_error(error, checkout_path, LOCAL_CONFIG_NAME))?
199 {
200 RegularFileEntry::Absent => (None, None),
201 RegularFileEntry::Regular => {
202 let local_path = checkout_path.join(LOCAL_CONFIG_NAME);
203 let source = checkout
204 .read_utf8_file_bounded(local_relative, MAX_REPOSITORY_CONFIG_BYTES)
205 .map_err(|error| map_capability_error(error, checkout_path, LOCAL_CONFIG_NAME))?;
206 let config: RepositoryLocalConfig =
207 crate::yaml::from_str(&source).map_err(|source| ConfigError::Invalid {
208 path: local_path.clone(),
209 source: Box::new(source),
210 })?;
211 validate_local(&local_path, &config)?;
212 (Some(config), Some(source))
213 }
214 };
215
216 let ignore_policy = resolve_ignore_policy(checkout_path, workspace, local.as_ref())?;
217 let (openapi, openapi_source) = if let Some(openapi) = &workspace.openapi {
218 (vec![openapi.clone()], ConfigSource::WorkspaceManifest)
219 } else if let Some(openapi) = local.as_ref().and_then(|config| config.openapi.clone()) {
220 (vec![openapi], ConfigSource::RepositoryLocal)
221 } else {
222 let candidates = discover_openapi_candidates(checkout_path, &ignore_policy)?;
223 if candidates.is_empty() {
224 (Vec::new(), ConfigSource::Default)
225 } else {
226 reject_competing_openapi_formats(checkout_path, &candidates)?;
227 (candidates, ConfigSource::AutoDetected)
228 }
229 };
230 let (http_consumers, http_consumers_source) = if let Some(consumers) = &workspace.http_consumers
231 {
232 (consumers.clone(), ConfigSource::WorkspaceManifest)
233 } else if let Some(consumers) = local
234 .as_ref()
235 .and_then(|config| config.http_consumers.clone())
236 {
237 (consumers, ConfigSource::RepositoryLocal)
238 } else {
239 (Vec::new(), ConfigSource::Default)
240 };
241 let (integration_tests, integration_tests_source) =
242 if let Some(tests) = &workspace.integration_tests {
243 (tests.clone(), ConfigSource::WorkspaceManifest)
244 } else if let Some(tests) = local
245 .as_ref()
246 .and_then(|config| config.integration_tests.clone())
247 {
248 (tests, ConfigSource::RepositoryLocal)
249 } else {
250 (Vec::new(), ConfigSource::Default)
251 };
252 let (implementations, implementations_source) =
253 if let Some(implementations) = &workspace.implementations {
254 (implementations.clone(), ConfigSource::WorkspaceManifest)
255 } else if let Some(implementations) = local
256 .as_ref()
257 .and_then(|config| config.implementations.clone())
258 {
259 (implementations, ConfigSource::RepositoryLocal)
260 } else {
261 (Vec::new(), ConfigSource::Default)
262 };
263 let fingerprint_material = format!(
264 "openapi={openapi:?};openapi_source={openapi_source:?};\
265 consumers={http_consumers:?};consumers_source={http_consumers_source:?};\
266 tests={integration_tests:?};tests_source={integration_tests_source:?};\
267 implementations={implementations:?};implementations_source={implementations_source:?};\
268 ignore_policy={};\
269 local={local_source:?}",
270 ignore_policy.fingerprint_material()
271 );
272 Ok(EffectiveRepositoryConfig {
273 ignore_policy,
274 openapi,
275 openapi_source,
276 http_consumers,
277 http_consumers_source,
278 integration_tests,
279 integration_tests_source,
280 implementations,
281 implementations_source,
282 fingerprint: stable_id("repo-config", &fingerprint_material),
283 })
284}
285
286fn map_capability_error(error: CapabilityError, checkout: &Path, config_name: &str) -> ConfigError {
287 let path = checkout.join(config_name);
288 match error {
289 CapabilityError::Io { path, source } => ConfigError::Read { path, source },
290 CapabilityError::Symlink { .. } => ConfigError::Symlink { path },
291 CapabilityError::NotRegularFile { .. } => ConfigError::NotRegularFile { path },
292 CapabilityError::TooLarge { limit, .. } => ConfigError::TooLarge { path, limit },
293 CapabilityError::OutsideRoot { root, .. } => ConfigError::OutsideCheckout {
294 path,
295 checkout: root,
296 },
297 CapabilityError::InvalidRelativePath { .. }
298 | CapabilityError::NotDirectory { .. }
299 | CapabilityError::InvalidUtf8 { .. } => ConfigError::Read {
300 path,
301 source: std::io::Error::new(std::io::ErrorKind::InvalidData, error.to_string()),
302 },
303 }
304}
305
306fn discover_openapi_candidates(
307 root: &Path,
308 ignore_policy: &IgnorePolicy,
309) -> Result<Vec<String>, ConfigError> {
310 let mut pending = vec![(root.to_path_buf(), 0_usize)];
311 let mut candidates = Vec::new();
312 while let Some((directory, depth)) = pending.pop() {
313 let entries = fs::read_dir(&directory).map_err(|source| ConfigError::Read {
314 path: directory.clone(),
315 source,
316 })?;
317 let mut entries =
318 entries
319 .collect::<Result<Vec<_>, _>>()
320 .map_err(|source| ConfigError::Read {
321 path: directory.clone(),
322 source,
323 })?;
324 entries.sort_by_key(fs::DirEntry::file_name);
325 for entry in entries {
326 let file_type = entry.file_type().map_err(|source| ConfigError::Read {
327 path: entry.path(),
328 source,
329 })?;
330 let name = entry.file_name().to_string_lossy().to_string();
331 let path = entry.path();
332 let relative = path.strip_prefix(root).unwrap_or(path.as_path());
333 if file_type.is_dir() {
334 if depth < MAX_OPENAPI_DISCOVERY_DEPTH && !ignore_policy.excludes(relative, true) {
335 pending.push((path, depth.saturating_add(1)));
336 }
337 continue;
338 }
339 if !file_type.is_file()
340 || ignore_policy.excludes(relative, false)
341 || !openapi_filename(&name)
342 {
343 continue;
344 }
345 let relative = relative.to_string_lossy().replace('\\', "/");
346 candidates.push(relative);
347 if candidates.len() >= MAX_OPENAPI_CANDIDATES {
348 break;
349 }
350 }
351 if candidates.len() >= MAX_OPENAPI_CANDIDATES {
352 break;
353 }
354 }
355 candidates.sort();
356 candidates.dedup();
357 Ok(candidates)
358}
359
360fn openapi_filename(name: &str) -> bool {
361 let lower = name.to_ascii_lowercase();
362 lower.contains("openapi")
363 && matches!(
364 Path::new(&lower)
365 .extension()
366 .and_then(|value| value.to_str()),
367 Some("json" | "yaml" | "yml")
368 )
369}
370
371fn reject_competing_openapi_formats(root: &Path, candidates: &[String]) -> Result<(), ConfigError> {
372 let mut by_stem = BTreeMap::<String, Vec<String>>::new();
373 for candidate in candidates {
374 let path = Path::new(candidate);
375 let parent = path.parent().unwrap_or_else(|| Path::new(""));
376 let stem = path
377 .file_stem()
378 .and_then(|value| value.to_str())
379 .unwrap_or(candidate);
380 by_stem
381 .entry(parent.join(stem).to_string_lossy().to_string())
382 .or_default()
383 .push(candidate.clone());
384 }
385 let ambiguous = by_stem
386 .into_values()
387 .filter(|values| values.len() > 1)
388 .flatten()
389 .collect::<Vec<_>>();
390 if ambiguous.is_empty() {
391 Ok(())
392 } else {
393 Err(ConfigError::AmbiguousOpenApi {
394 root: root.to_path_buf(),
395 candidates: ambiguous,
396 })
397 }
398}
399
400fn validate_local(path: &Path, config: &RepositoryLocalConfig) -> Result<(), ConfigError> {
401 if config.version != 1 {
402 return Err(ConfigError::UnsupportedVersion {
403 path: path.to_path_buf(),
404 found: config.version,
405 });
406 }
407 if config
408 .openapi
409 .as_ref()
410 .is_some_and(|value| value.trim().is_empty())
411 {
412 return Err(ConfigError::EmptyField {
413 path: path.to_path_buf(),
414 field: "openapi".to_owned(),
415 });
416 }
417 for (index, consumer) in config.http_consumers.iter().flatten().enumerate() {
418 for (field, value) in [
419 ("method", consumer.method.as_str()),
420 ("path", consumer.path.as_str()),
421 ("source", consumer.source.as_str()),
422 ] {
423 if value.trim().is_empty() {
424 return Err(ConfigError::EmptyField {
425 path: path.to_path_buf(),
426 field: format!("httpConsumers[{index}].{field}"),
427 });
428 }
429 }
430 }
431 for (index, test) in config.integration_tests.iter().flatten().enumerate() {
432 for (field, value) in [
433 ("name", test.name.as_str()),
434 ("path", test.path.as_str()),
435 ("framework", test.framework.as_str()),
436 ("language", test.language.as_str()),
437 ("validates.method", test.validates.method.as_str()),
438 ("validates.path", test.validates.path.as_str()),
439 ] {
440 if value.trim().is_empty() {
441 return Err(ConfigError::EmptyField {
442 path: path.to_path_buf(),
443 field: format!("integrationTests[{index}].{field}"),
444 });
445 }
446 }
447 }
448 for (index, implementation) in config.implementations.iter().flatten().enumerate() {
449 for (field, value) in [
450 ("language", implementation.language.as_str()),
451 ("path", implementation.path.as_str()),
452 ("symbol", implementation.symbol.as_str()),
453 (
454 "implements.method",
455 implementation.implements.method.as_str(),
456 ),
457 ("implements.path", implementation.implements.path.as_str()),
458 ] {
459 if value.trim().is_empty() {
460 return Err(ConfigError::EmptyField {
461 path: path.to_path_buf(),
462 field: format!("implementations[{index}].{field}"),
463 });
464 }
465 }
466 }
467 if let Some(patterns) = &config.excludes {
468 validate_excludes(patterns).map_err(|source| ConfigError::InvalidIgnorePattern {
469 path: path.to_path_buf(),
470 field: "excludes".to_owned(),
471 source,
472 })?;
473 }
474 if let Some(patterns) = &config.include_defaults {
475 validate_include_defaults(patterns).map_err(|source| {
476 ConfigError::InvalidIgnorePattern {
477 path: path.to_path_buf(),
478 field: "includeDefaults".to_owned(),
479 source,
480 }
481 })?;
482 }
483 Ok(())
484}
485
486fn select_patterns(
487 workspace: Option<&Vec<String>>,
488 local: Option<&Vec<String>>,
489) -> (Vec<String>, ConfigSource) {
490 if let Some(patterns) = workspace {
491 (patterns.clone(), ConfigSource::WorkspaceManifest)
492 } else if let Some(patterns) = local {
493 (patterns.clone(), ConfigSource::RepositoryLocal)
494 } else {
495 (Vec::new(), ConfigSource::Default)
496 }
497}
498
499fn resolve_ignore_policy(
500 checkout_path: &Path,
501 workspace: &RepositoryConfig,
502 local: Option<&RepositoryLocalConfig>,
503) -> Result<IgnorePolicy, ConfigError> {
504 let (excludes, excludes_source) = select_patterns(
505 workspace.excludes.as_ref(),
506 local.and_then(|config| config.excludes.as_ref()),
507 );
508 let (include_defaults, include_defaults_source) = select_patterns(
509 workspace.include_defaults.as_ref(),
510 local.and_then(|config| config.include_defaults.as_ref()),
511 );
512 IgnorePolicy::new(
513 excludes,
514 excludes_source,
515 include_defaults,
516 include_defaults_source,
517 )
518 .map_err(|source| ConfigError::InvalidIgnorePattern {
519 path: checkout_path.to_path_buf(),
520 field: "ignorePolicy".to_owned(),
521 source,
522 })
523}
524
525#[cfg(test)]
526mod tests {
527 use std::fs;
528
529 use super::{ConfigError, ConfigSource, resolve_repository_config};
530 use crate::{HttpConsumerConfig, RepositoryConfig};
531
532 #[test]
533 fn workspace_manifest_should_override_repository_local_values()
534 -> Result<(), Box<dyn std::error::Error>> {
535 let repository = tempfile::tempdir()?;
536 fs::write(
537 repository.path().join(".code-system-graph.yaml"),
538 "version: 1\nopenapi: local.yaml\nhttpConsumers: []\n",
539 )?;
540 let workspace = RepositoryConfig {
541 path: ".".to_owned(),
542 openapi: Some("workspace.yaml".to_owned()),
543 http_consumers: Some(vec![HttpConsumerConfig {
544 method: "GET".to_owned(),
545 path: "/health".to_owned(),
546 source: "client.rs".to_owned(),
547 }]),
548 integration_tests: None,
549 implementations: None,
550 excludes: None,
551 include_defaults: None,
552 };
553
554 let resolved = resolve_repository_config(repository.path(), &workspace)?;
555
556 assert_eq!(
557 (
558 resolved.openapi,
559 resolved.openapi_source,
560 resolved.http_consumers.len(),
561 resolved.http_consumers_source,
562 ),
563 (
564 vec!["workspace.yaml".to_owned()],
565 ConfigSource::WorkspaceManifest,
566 1,
567 ConfigSource::WorkspaceManifest,
568 )
569 );
570 Ok(())
571 }
572
573 #[test]
574 fn workspace_ignore_fields_should_override_repository_local_lists()
575 -> Result<(), Box<dyn std::error::Error>> {
576 let repository = tempfile::tempdir()?;
577 fs::write(
578 repository.path().join(".code-system-graph.yaml"),
579 "version: 1\nexcludes: [local/**]\nincludeDefaults: [vendor/local/**]\n",
580 )?;
581 let workspace = RepositoryConfig {
582 path: ".".to_owned(),
583 openapi: None,
584 http_consumers: None,
585 integration_tests: None,
586 implementations: None,
587 excludes: Some(vec!["workspace/**".to_owned()]),
588 include_defaults: Some(Vec::new()),
589 };
590
591 let resolved = resolve_repository_config(repository.path(), &workspace)?;
592
593 assert_eq!(
594 (
595 resolved.ignore_policy.configured_excludes().to_vec(),
596 resolved.ignore_policy.configured_excludes_source(),
597 resolved.ignore_policy.include_defaults().to_vec(),
598 resolved.ignore_policy.include_defaults_source(),
599 ),
600 (
601 vec!["workspace/**".to_owned()],
602 ConfigSource::WorkspaceManifest,
603 Vec::new(),
604 ConfigSource::WorkspaceManifest,
605 )
606 );
607 Ok(())
608 }
609
610 #[test]
611 fn canonical_equivalent_patterns_should_produce_the_same_repository_fingerprint()
612 -> Result<(), Box<dyn std::error::Error>> {
613 let repository = tempfile::tempdir()?;
614 let canonical = RepositoryConfig {
615 path: ".".to_owned(),
616 openapi: None,
617 http_consumers: None,
618 integration_tests: None,
619 implementations: None,
620 excludes: Some(vec!["coverage/**".to_owned()]),
621 include_defaults: Some(vec!["vendor/internal-sdk/**".to_owned()]),
622 };
623 let redundant = RepositoryConfig {
624 excludes: Some(vec!["./coverage//./**".to_owned()]),
625 include_defaults: Some(vec!["./vendor//internal-sdk/./**".to_owned()]),
626 ..canonical.clone()
627 };
628
629 let canonical = resolve_repository_config(repository.path(), &canonical)?;
630 let redundant = resolve_repository_config(repository.path(), &redundant)?;
631
632 assert_eq!(canonical.fingerprint, redundant.fingerprint);
633 Ok(())
634 }
635
636 #[test]
637 fn openapi_auto_detection_should_respect_reopened_default_subtree()
638 -> Result<(), Box<dyn std::error::Error>> {
639 let repository = tempfile::tempdir()?;
640 fs::create_dir_all(repository.path().join("vendor/internal-sdk"))?;
641 fs::create_dir_all(repository.path().join("vendor/external"))?;
642 fs::write(
643 repository.path().join("vendor/internal-sdk/openapi.yaml"),
644 "{}",
645 )?;
646 fs::write(repository.path().join("vendor/external/openapi.yaml"), "{}")?;
647 let workspace = RepositoryConfig {
648 path: ".".to_owned(),
649 openapi: None,
650 http_consumers: None,
651 integration_tests: None,
652 implementations: None,
653 excludes: None,
654 include_defaults: Some(vec!["vendor/internal-sdk/**".to_owned()]),
655 };
656
657 let resolved = resolve_repository_config(repository.path(), &workspace)?;
658
659 assert_eq!(
660 resolved.openapi,
661 vec!["vendor/internal-sdk/openapi.yaml".to_owned()]
662 );
663 Ok(())
664 }
665
666 #[test]
667 fn auto_detection_should_keep_distinct_nested_openapi_documents()
668 -> Result<(), Box<dyn std::error::Error>> {
669 let repository = tempfile::tempdir()?;
670 fs::create_dir_all(repository.path().join("backend/docs"))?;
671 fs::create_dir_all(repository.path().join("frontend/mockoon"))?;
672 fs::write(repository.path().join("backend/docs/openapi.json"), "{}")?;
673 fs::write(
674 repository
675 .path()
676 .join("frontend/mockoon/mock_openapi3.json"),
677 "{}",
678 )?;
679 let workspace = RepositoryConfig {
680 path: ".".to_owned(),
681 openapi: None,
682 http_consumers: None,
683 integration_tests: None,
684 implementations: None,
685 excludes: None,
686 include_defaults: None,
687 };
688
689 let resolved = resolve_repository_config(repository.path(), &workspace)?;
690
691 assert_eq!(
692 resolved.openapi,
693 vec![
694 "backend/docs/openapi.json".to_owned(),
695 "frontend/mockoon/mock_openapi3.json".to_owned(),
696 ]
697 );
698 assert_eq!(resolved.openapi_source, ConfigSource::AutoDetected);
699 Ok(())
700 }
701
702 #[test]
703 fn auto_detection_should_reject_multiple_openapi_candidates()
704 -> Result<(), Box<dyn std::error::Error>> {
705 let repository = tempfile::tempdir()?;
706 fs::write(repository.path().join("openapi.yaml"), "openapi: 3.1.0")?;
707 fs::write(repository.path().join("openapi.json"), "{}")?;
708 let workspace = RepositoryConfig {
709 path: ".".to_owned(),
710 openapi: None,
711 http_consumers: None,
712 integration_tests: None,
713 implementations: None,
714 excludes: None,
715 include_defaults: None,
716 };
717
718 let result = resolve_repository_config(repository.path(), &workspace);
719
720 assert!(matches!(result, Err(ConfigError::AmbiguousOpenApi { .. })));
721 Ok(())
722 }
723
724 #[test]
725 fn repository_local_config_should_reject_unknown_keys() -> Result<(), Box<dyn std::error::Error>>
726 {
727 let repository = tempfile::tempdir()?;
728 fs::write(
729 repository.path().join(".code-system-graph.yaml"),
730 "version: 1\nunknown: true\n",
731 )?;
732 let workspace = RepositoryConfig {
733 path: ".".to_owned(),
734 openapi: None,
735 http_consumers: None,
736 integration_tests: None,
737 implementations: None,
738 excludes: None,
739 include_defaults: None,
740 };
741
742 let result = resolve_repository_config(repository.path(), &workspace);
743
744 assert!(matches!(result, Err(ConfigError::Invalid { .. })));
745 Ok(())
746 }
747
748 #[test]
749 fn repository_local_config_should_reject_symlink() -> Result<(), Box<dyn std::error::Error>> {
750 let temporary = tempfile::tempdir()?;
751 let repository = temporary.path().join("repository");
752 let outside = temporary.path().join("outside.yaml");
753 std::fs::create_dir_all(&repository)?;
754 std::fs::write(&outside, "version: 1\n")?;
755 #[cfg(unix)]
756 std::os::unix::fs::symlink(&outside, repository.join(".code-system-graph.yaml"))?;
757 #[cfg(windows)]
758 std::os::windows::fs::symlink_file(&outside, repository.join(".code-system-graph.yaml"))?;
759 let workspace = RepositoryConfig {
760 path: ".".to_owned(),
761 openapi: None,
762 http_consumers: None,
763 integration_tests: None,
764 implementations: None,
765 excludes: None,
766 include_defaults: None,
767 };
768
769 let result = resolve_repository_config(&repository, &workspace);
770
771 assert!(matches!(
772 result,
773 Err(ConfigError::Symlink { .. }
774 | ConfigError::OutsideCheckout { .. }
775 | ConfigError::NotRegularFile { .. })
776 ));
777 Ok(())
778 }
779
780 #[test]
781 fn repository_local_config_should_reject_oversized_file()
782 -> Result<(), Box<dyn std::error::Error>> {
783 let repository = tempfile::tempdir()?;
784 std::fs::write(
785 repository.path().join(".code-system-graph.yaml"),
786 "x".repeat(crate::capability_dir::MAX_REPOSITORY_CONFIG_BYTES + 1),
787 )?;
788 let workspace = RepositoryConfig {
789 path: ".".to_owned(),
790 openapi: None,
791 http_consumers: None,
792 integration_tests: None,
793 implementations: None,
794 excludes: None,
795 include_defaults: None,
796 };
797
798 let result = resolve_repository_config(repository.path(), &workspace);
799
800 assert!(matches!(result, Err(ConfigError::TooLarge { .. })));
801 Ok(())
802 }
803
804 #[test]
805 fn repository_local_config_should_reject_directory_entry()
806 -> Result<(), Box<dyn std::error::Error>> {
807 let repository = tempfile::tempdir()?;
808 std::fs::create_dir_all(repository.path().join(".code-system-graph.yaml"))?;
809 let workspace = RepositoryConfig {
810 path: ".".to_owned(),
811 openapi: None,
812 http_consumers: None,
813 integration_tests: None,
814 implementations: None,
815 excludes: None,
816 include_defaults: None,
817 };
818
819 let result = resolve_repository_config(repository.path(), &workspace);
820
821 assert!(matches!(result, Err(ConfigError::NotRegularFile { .. })));
822 Ok(())
823 }
824
825 #[test]
826 fn cli_openapi_should_override_workspace_value() -> Result<(), Box<dyn std::error::Error>> {
827 let repository = tempfile::tempdir()?;
828 let workspace = RepositoryConfig {
829 path: ".".to_owned(),
830 openapi: Some("workspace.yaml".to_owned()),
831 http_consumers: None,
832 integration_tests: None,
833 implementations: None,
834 excludes: None,
835 include_defaults: None,
836 };
837 let mut resolved = resolve_repository_config(repository.path(), &workspace)?;
838
839 super::apply_openapi_override(&mut resolved, "cli.yaml")?;
840
841 assert_eq!(
842 (resolved.openapi, resolved.openapi_source),
843 (vec!["cli.yaml".to_owned()], ConfigSource::CliOverride)
844 );
845 Ok(())
846 }
847}