Skip to main content

code_system_graph_core/
config.rs

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/// Origin of an effective repository configuration value.
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
20#[serde(rename_all = "snake_case")]
21pub enum ConfigSource {
22    /// Explicit command-line override.
23    CliOverride,
24    /// Explicit workspace manifest value.
25    WorkspaceManifest,
26    /// Repository-local `.code-system-graph.yaml` value.
27    RepositoryLocal,
28    /// Deterministic filesystem auto-detection.
29    AutoDetected,
30    /// Safe built-in default.
31    Default,
32}
33
34/// Applies the highest-precedence command-line `OpenAPI` override.
35///
36/// # Errors
37///
38/// Returns [`ConfigError::EmptyField`] when the override is empty.
39pub 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/// Strict merged repository configuration after precedence resolution.
60#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
61pub struct EffectiveRepositoryConfig {
62    /// Effective automatic-discovery exclusion policy.
63    pub ignore_policy: IgnorePolicy,
64    /// Selected `OpenAPI` artifacts relative to the checkout root.
65    pub openapi: Vec<String>,
66    /// Source that selected the `OpenAPI` artifacts.
67    pub openapi_source: ConfigSource,
68    /// Selected explicit HTTP consumers.
69    pub http_consumers: Vec<HttpConsumerConfig>,
70    /// Source that selected the HTTP consumers.
71    pub http_consumers_source: ConfigSource,
72    /// Selected explicit cross-language integration tests.
73    pub integration_tests: Vec<IntegrationTestConfig>,
74    /// Source that selected integration tests.
75    pub integration_tests_source: ConfigSource,
76    /// Selected explicit contract implementation anchors.
77    pub implementations: Vec<ContractImplementationConfig>,
78    /// Source that selected implementation anchors.
79    pub implementations_source: ConfigSource,
80    /// Fingerprint of all effective values and relevant local configuration.
81    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/// Error returned while resolving repository configuration precedence.
97#[derive(Debug, Error)]
98pub enum ConfigError {
99    /// Repository-local configuration could not be read.
100    #[error("failed to read repository config `{path}`: {source}")]
101    Read {
102        /// Configuration path.
103        path: PathBuf,
104        /// Underlying filesystem error.
105        source: std::io::Error,
106    },
107    /// Repository-local configuration is malformed or contains unknown keys.
108    #[error("invalid repository config `{path}`: {source}")]
109    Invalid {
110        /// Configuration path.
111        path: PathBuf,
112        /// YAML decoding error.
113        source: Box<serde_saphyr::DeserializeError>,
114    },
115    /// Repository-local configuration schema version is unsupported.
116    #[error("unsupported repository config version {found} in `{path}`; expected version 1")]
117    UnsupportedVersion {
118        /// Configuration path.
119        path: PathBuf,
120        /// Version found in the file.
121        found: u32,
122    },
123    /// A configured value is empty.
124    #[error("repository config field `{field}` in `{path}` must not be empty")]
125    EmptyField {
126        /// Configuration path.
127        path: PathBuf,
128        /// Dot-style field location.
129        field: String,
130    },
131    /// Auto-detection found multiple plausible `OpenAPI` artifacts.
132    #[error("ambiguous OpenAPI auto-detection in `{root}`: {candidates:?}")]
133    AmbiguousOpenApi {
134        /// Repository checkout root.
135        root: PathBuf,
136        /// Candidate paths in deterministic order.
137        candidates: Vec<String>,
138    },
139    /// A repository-local discovery pattern is malformed or unsafe.
140    #[error("repository config field `{field}` in `{path}` is invalid: {source}")]
141    InvalidIgnorePattern {
142        /// Configuration path.
143        path: PathBuf,
144        /// Dot-style field location.
145        field: String,
146        /// Pattern validation failure.
147        #[source]
148        source: IgnorePatternError,
149    },
150    /// Repository-local configuration is a symbolic link or reparse point.
151    #[error("repository config `{path}` is a symbolic link or reparse point")]
152    Symlink {
153        /// Configuration path.
154        path: PathBuf,
155    },
156    /// Repository-local configuration is not a regular file.
157    #[error("repository config `{path}` is not a regular file")]
158    NotRegularFile {
159        /// Configuration path.
160        path: PathBuf,
161    },
162    /// Repository-local configuration exceeds the accepted size limit.
163    #[error("repository config `{path}` exceeds {limit} bytes")]
164    TooLarge {
165        /// Configuration path.
166        path: PathBuf,
167        /// Maximum accepted size in bytes.
168        limit: usize,
169    },
170    /// Repository-local configuration resolves outside the checkout root.
171    #[error("repository config `{path}` is outside checkout `{checkout}`")]
172    OutsideCheckout {
173        /// Configuration path.
174        path: PathBuf,
175        /// Canonical checkout root.
176        checkout: PathBuf,
177    },
178}
179
180/// Resolves workspace, repository-local, auto-detected, and default values in that order.
181///
182/// An explicit empty workspace `httpConsumers` list suppresses lower-precedence values.
183/// Auto-detection never chooses among multiple candidates.
184///
185/// # Errors
186///
187/// Returns [`ConfigError`] for unreadable or invalid local configuration, empty values, or
188/// ambiguous auto-detection.
189pub 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}