Skip to main content

xianyun_core/config/
merge.rs

1//! Port of `internal/config/merge.go`: `extends:`/local-override composition,
2//! RFC 7386-style deep merge with append-list semantics for a handful of
3//! keys, and the `caproni-project.yaml` per-repository fragment machinery.
4//!
5//! ## Remote HTTP fragment resolution: `ureq` (blocking), not `reqwest`/async
6//!
7//! The Go original threads a `*http.Client` through `resolveExtends`, doing
8//! ordinary blocking HTTP calls — Go's goroutine model makes a blocking call
9//! cheap and doesn't force the whole call graph onto a runtime. Every other
10//! function in this crate is synchronous (`load_caproni_config` and
11//! everything it calls), so introducing an async HTTP client (`reqwest`)
12//! here would mean either colouring `load_caproni_config` and its ~15
13//! callers `async fn` for the sake of one HTTP call three layers deep, or
14//! spinning up a throwaway Tokio runtime inside a sync function just to
15//! `.block_on()` an async request — both worse than the alternative.
16//! `ureq` (a small, dependency-light blocking HTTP client with its own
17//! rustls-backed TLS) keeps `resolve_extends`/`load_caproni_config` fully
18//! synchronous, matching the Go original's call shape exactly: a
19//! `RemoteFetcher` trait object is threaded through the same call chain the
20//! Go original threads `*http.Client` through, with [`UreqFetcher`] as the
21//! production implementation.
22//!
23//! **Testing shape**: `RemoteFetcher` is itself the injection seam
24//! `UreqFetcher`'s HTTP logic sits behind, so exercising
25//! `resolve_extends`/`fetch_deployer_files` doesn't require a real network
26//! call at all — an earlier draft of this module planned a `wiremock`+
27//! `tokio` mock-HTTP-server setup (rewriting the `https://`-only scheme
28//! check's URL to a real `http://127.0.0.1:<port>` mock address before
29//! dispatch, wrapping each test in `tokio::task::spawn_blocking` since
30//! `wiremock`'s server is async and this crate's HTTP calls are blocking),
31//! but every remote-fragment test in `merge.rs`'s test module instead uses
32//! an in-process `FakeFetcher`: an in-memory `HashMap<String, Vec<u8>>`
33//! keyed by the exact `https://fake-remote.test/...` URL a test's
34//! `caproni.yaml` fixture embeds. This tests the same call-graph behavior
35//! (`resolve_extends` dispatching to `RemoteFetcher::fetch`, size-limit and
36//! not-found handling, caching) without a real socket, TLS story, or async
37//! runtime anywhere in the crate — `wiremock`/`tokio` were dropped from
38//! `dev-dependencies` once this was confirmed sufficient.
39//!
40//! **Caching, scoped down from the Go original**: the Go original threads a
41//! `filecache.Cache` (a persistent, on-disk, TTL-based cache with a
42//! stale-fallback path) through the same call chain, shared across a whole
43//! `caproni` invocation so the same URL is fetched at most once per
44//! process. [`UreqFetcher`] reproduces only the "fetched at most once per
45//! `UreqFetcher` instance" behavior (an in-memory `HashMap` keyed by URL,
46//! populated on first fetch) — no on-disk persistence, no TTL, no
47//! stale-fallback. Nothing in this crate's test suite exercises staleness
48//! or cross-process caching (`TestLoadCaproniConfig_ExtendsRemoteFragment`'s
49//! `"caching"` subtest only asserts that two `load_caproni_config` calls
50//! sharing one fetcher instance issue one HTTP request between them), so
51//! the full `filecache` port is deferred rather than built against no test
52//! that needs it.
53
54use std::io::Read as _;
55use std::sync::Mutex;
56
57use serde_json::Value;
58
59use super::errors::ConfigError;
60
61/// Maximum size, in bytes, of a fetched remote fragment or downloaded
62/// deployer file. Port of `maxFragmentBytes`.
63pub(super) const MAX_FRAGMENT_BYTES: u64 = 10 * 1024 * 1024;
64
65/// The only URL scheme `extends:`/deployer-file remote references accept.
66/// Port of `remoteFragmentScheme`.
67const REMOTE_FRAGMENT_SCHEME: &str = "https://";
68
69/// A deployer's remote-fragment source URL and the file references the
70/// fragment itself declared. `fetch_deployer_files` compares the merged
71/// deployer against these values: a field a higher-precedence layer
72/// replaced no longer refers to the fragment's location and is left to
73/// resolve locally. Port of `remoteElement`.
74#[derive(Debug, Clone, Default)]
75pub(super) struct RemoteElement {
76    pub url: String,
77    pub values_file: String,
78    pub kustomize_path: String,
79}
80
81/// Reports whether `source` is a remote (`https://`) reference. Port of
82/// `isRemoteSource`.
83pub(super) fn is_remote_source(source: &str) -> bool {
84    source.starts_with(REMOTE_FRAGMENT_SCHEME)
85}
86
87/// Fetches a fragment or file's raw bytes from a URL. Injected through
88/// `resolve_extends`/`load_caproni_config` so tests can substitute a
89/// `wiremock`-backed double for the real [`UreqFetcher`]; see this module's
90/// doc comment for the full design rationale.
91pub trait RemoteFetcher: Send + Sync {
92    /// Fetches `url`'s body, enforcing the `MAX_FRAGMENT_BYTES` size limit
93    /// and a `200 OK` status. Port of `fetchRemoteFile`.
94    fn fetch(&self, url: &str) -> Result<Vec<u8>, ConfigError>;
95}
96
97/// The production [`RemoteFetcher`]: a blocking `ureq` GET per URL, with an
98/// in-memory fetch-at-most-once-per-instance cache. See this module's doc
99/// comment for why `ureq` and why the cache is scoped down from the Go
100/// original's persistent `filecache.Cache`.
101#[derive(Default)]
102pub struct UreqFetcher {
103    cache: Mutex<std::collections::HashMap<String, Vec<u8>>>,
104}
105
106impl UreqFetcher {
107    #[must_use]
108    pub fn new() -> Self {
109        Self::default()
110    }
111}
112
113impl RemoteFetcher for UreqFetcher {
114    fn fetch(&self, url: &str) -> Result<Vec<u8>, ConfigError> {
115        if let Some(cached) = self.cache.lock().unwrap().get(url) {
116            return Ok(cached.clone());
117        }
118
119        let body = fetch_remote_file_via_ureq(url)?;
120        self.cache
121            .lock()
122            .unwrap()
123            .insert(url.to_string(), body.clone());
124        Ok(body)
125    }
126}
127
128fn fetch_remote_file_via_ureq(url: &str) -> Result<Vec<u8>, ConfigError> {
129    let mut response = ureq::get(url)
130        .call()
131        .map_err(|err| ConfigError::RemoteFragmentFetch(format!("{url}: {err}")))?;
132
133    if response.status() != 200 {
134        return Err(ConfigError::RemoteFragmentFetch(format!(
135            "{url}: status {}",
136            response.status()
137        )));
138    }
139
140    // Limit set one byte over MAX_FRAGMENT_BYTES so a response of exactly
141    // the limit succeeds (matching Go's `io.LimitReader(resp.Body,
142    // maxFragmentBytes+1)`), while a larger response either trips ureq's
143    // own `BodyExceedsLimit` mid-read or is caught by the length check
144    // below once the (still oversized-by-one) read completes.
145    let mut body = Vec::new();
146    response
147        .body_mut()
148        .as_reader()
149        .take(MAX_FRAGMENT_BYTES + 1)
150        .read_to_end(&mut body)
151        .map_err(|err| ConfigError::RemoteFragmentFetch(format!("{url}: {err}")))?;
152
153    if body.len() as u64 > MAX_FRAGMENT_BYTES {
154        return Err(ConfigError::RemoteFragmentFetch(format!(
155            "{url}: response exceeds {MAX_FRAGMENT_BYTES} bytes"
156        )));
157    }
158
159    Ok(body)
160}
161
162const LIFECYCLE_HOOKS_KEY: &str = "lifecycle_hooks";
163const ADDITIONAL_HOSTNAMES_KEY: &str = "additional_hostnames";
164const DYNAMIC_OVERRIDES_KEY: &str = "dynamic_overrides";
165/// Primary local-override filename; `caproni.local.yaml` is supported as
166/// a compatibility alias (see [`resolve_local_override_path`]).
167const LOCAL_OVERRIDE_FILENAME: &str = "xianyun.local.yaml";
168const LOCAL_OVERRIDE_FILENAME_ALIAS: &str = "caproni.local.yaml";
169
170/// Resolves the local-override path next to `config_path`: `xianyun.
171/// local.yaml` if it exists, else the `caproni.local.yaml` compatibility
172/// alias, else the primary name (so callers get a consistent path for
173/// "no override exists" bookkeeping, matching the pre-aliasing behavior).
174fn resolve_local_override_path(config_path: &std::path::Path) -> std::path::PathBuf {
175    let dir = config_path
176        .parent()
177        .unwrap_or_else(|| std::path::Path::new("."));
178    let primary = dir.join(LOCAL_OVERRIDE_FILENAME);
179    if primary.is_file() {
180        return primary;
181    }
182    let alias = dir.join(LOCAL_OVERRIDE_FILENAME_ALIAS);
183    if alias.is_file() {
184        return alias;
185    }
186    primary
187}
188
189/// Deep-merges `src` into `dst` (RFC 7386-ish): maps merge recursively, a
190/// handful of well-known list keys (`additional_hostnames`,
191/// `lifecycle_hooks`, `dynamic_overrides`) append rather than replace, other
192/// lists replace wholesale, and an explicit `null` in `src` deletes the key
193/// from the result at any depth. `dst` is not modified; the merged result is
194/// returned as a new value.
195pub fn deep_merge(dst: &Value, src: &Value) -> Value {
196    let (Some(dst_map), Some(src_map)) = (dst.as_object(), src.as_object()) else {
197        return src.clone();
198    };
199
200    let mut result = dst_map.clone();
201
202    for (key, src_val) in src_map {
203        // An explicit null overlay deletes the key (no-op if it was absent).
204        if src_val.is_null() {
205            result.remove(key);
206            continue;
207        }
208
209        let Some(dst_val) = result.get(key) else {
210            result.insert(key.clone(), src_val.clone());
211            continue;
212        };
213
214        if dst_val.is_object() && src_val.is_object() {
215            let merged = deep_merge(dst_val, src_val);
216            result.insert(key.clone(), merged);
217            continue;
218        }
219
220        if matches!(
221            key.as_str(),
222            LIFECYCLE_HOOKS_KEY | ADDITIONAL_HOSTNAMES_KEY | DYNAMIC_OVERRIDES_KEY
223        ) && let (Some(dst_arr), Some(src_arr)) = (dst_val.as_array(), src_val.as_array())
224        {
225            let mut combined = dst_arr.clone();
226            combined.extend(src_arr.clone());
227            result.insert(key.clone(), Value::Array(combined));
228            continue;
229        }
230
231        result.insert(key.clone(), src_val.clone());
232    }
233
234    Value::Object(result)
235}
236
237/// Reports whether `path` matches the fragment-name convention:
238/// `xianyun.yaml`/`caproni.yaml` or `xianyun.<name>.yaml`/
239/// `caproni.<name>.yaml`, optionally under subdirectories that do not
240/// start with a dot. Port of `fragmentNamePattern`'s regex, hand-rolled
241/// to avoid adding a `regex` dependency for one pattern (see `naming.rs`'s
242/// precedent, noted in `PORT_NOTES.md`). Recognizes both the primary
243/// `xianyun` prefix and the pre-rename `caproni` prefix, so an existing
244/// project's `extends:` list naming its fragments `caproni.<name>.yaml`
245/// still validates.
246fn is_valid_fragment_name(path: &str) -> bool {
247    let mut parts: Vec<&str> = path.split('/').collect();
248    let Some(filename) = parts.pop() else {
249        return false;
250    };
251
252    if !is_caproni_fragment_filename(filename, "xianyun")
253        && !is_caproni_fragment_filename(filename, "caproni")
254    {
255        return false;
256    }
257
258    parts
259        .iter()
260        .all(|d| *d == "." || (!d.is_empty() && !d.starts_with('.')))
261}
262
263fn is_caproni_fragment_filename(name: &str, prefix: &str) -> bool {
264    let Some(rest) = name.strip_prefix(prefix) else {
265        return false;
266    };
267
268    if rest == ".yaml" {
269        return true;
270    }
271
272    let Some(mid) = rest.strip_prefix('.') else {
273        return false;
274    };
275
276    mid.strip_suffix(".yaml")
277        .is_some_and(|base| !base.is_empty() && !base.contains('/'))
278}
279
280/// Port of `extractVersion`.
281fn extract_version(
282    cfg: &serde_json::Map<String, Value>,
283    fallback: i64,
284) -> Result<i64, ConfigError> {
285    match cfg.get("version") {
286        None => Ok(fallback),
287        Some(v) => v.as_i64().ok_or(ConfigError::InvalidVersion),
288    }
289}
290
291/// Port of `ensureSameVersion`.
292fn ensure_same_version(
293    base: &serde_json::Map<String, Value>,
294    override_map: &serde_json::Map<String, Value>,
295) -> Result<(), ConfigError> {
296    let base_version = extract_version(base, 2)?;
297    let override_version = extract_version(override_map, base_version)?;
298
299    if base_version != override_version {
300        return Err(ConfigError::OverrideVersionMismatch);
301    }
302
303    Ok(())
304}
305
306/// Port of `parseExtendsPaths`.
307fn parse_extends_paths(val: &Value) -> Result<Vec<String>, ConfigError> {
308    let items = val.as_array().ok_or(ConfigError::ExtendsNotList)?;
309
310    items
311        .iter()
312        .map(|item| {
313            item.as_str()
314                .map(String::from)
315                .ok_or(ConfigError::ExtendsItemNotString)
316        })
317        .collect()
318}
319
320/// Port of `loadFragment`/`parseFragment`.
321fn load_fragment(abs_path: &std::path::Path) -> Result<Value, ConfigError> {
322    let data = std::fs::read(abs_path)
323        .map_err(|_| ConfigError::ConfigFileNotFound(abs_path.display().to_string()))?;
324
325    serde_yaml::from_slice(&data).map_err(|err| ConfigError::InvalidYAML(err.to_string()))
326}
327
328/// Port of `resolveExtends`. `fetcher` resolves `https://` fragment
329/// references (see this module's doc comment); `element_sources`, when
330/// present, records which remote fragment each top-level deployer came
331/// from, for `fetch_deployer_files` to later download referenced files
332/// against.
333#[allow(clippy::too_many_arguments)]
334pub(super) fn resolve_extends(
335    config_path: &std::path::Path,
336    raw_config: Value,
337    is_fragment: bool,
338    visited: &mut std::collections::HashSet<std::path::PathBuf>,
339    used_fragments: &mut std::collections::HashMap<String, String>,
340    fetcher: &dyn RemoteFetcher,
341    mut element_sources: Option<&mut std::collections::HashMap<String, RemoteElement>>,
342) -> Result<Value, ConfigError> {
343    let Value::Object(mut base_map) = raw_config else {
344        return Ok(raw_config);
345    };
346
347    let Some(extends_val) = base_map.get("extends").cloned() else {
348        return Ok(Value::Object(base_map));
349    };
350
351    if is_fragment {
352        return Err(ConfigError::FragmentHasExtends);
353    }
354
355    let extends_paths = parse_extends_paths(&extends_val)?;
356
357    let base_dir = config_path
358        .parent()
359        .unwrap_or_else(|| std::path::Path::new("."));
360
361    let mut merged = Value::Object(serde_json::Map::new());
362
363    for rel_path in &extends_paths {
364        let fragment = if rel_path.starts_with(REMOTE_FRAGMENT_SCHEME) {
365            let url = url_parse(rel_path).ok_or(ConfigError::InvalidFragmentName)?;
366            // Port of `fragmentNamePattern.MatchString(path.Base(parsedURL.Path))`:
367            // only the URL path's basename is checked, unlike a local
368            // fragment reference's full relative path.
369            let basename = url.path.rsplit('/').next().unwrap_or("");
370            if !is_caproni_fragment_filename(basename, "xianyun")
371                && !is_caproni_fragment_filename(basename, "caproni")
372            {
373                return Err(ConfigError::InvalidFragmentName);
374            }
375
376            resolve_remote_fragment(
377                config_path,
378                rel_path,
379                visited,
380                used_fragments,
381                fetcher,
382                element_sources.as_deref_mut(),
383            )?
384        } else if rel_path.contains("://") {
385            return Err(ConfigError::RemoteFragmentFetch(format!(
386                "{rel_path}: only https:// is supported"
387            )));
388        } else {
389            resolve_local_fragment(
390                config_path,
391                base_dir,
392                rel_path,
393                visited,
394                used_fragments,
395                fetcher,
396            )?
397        };
398
399        merged = deep_merge(&merged, &fragment);
400    }
401
402    base_map.remove("extends");
403
404    Ok(deep_merge(&merged, &Value::Object(base_map)))
405}
406
407/// A minimal `scheme://host/path` split, enough to validate a remote
408/// fragment reference's path component against the fragment-name
409/// convention. No `url` crate dependency was added for this one call site
410/// (see `predefined.rs`'s `host_port` precedent, noted in
411/// `PORT_NOTES.md`).
412struct ParsedUrl {
413    path: String,
414}
415
416fn url_parse(url: &str) -> Option<ParsedUrl> {
417    let rest = url.strip_prefix(REMOTE_FRAGMENT_SCHEME)?;
418    let path_start = rest.find('/')?;
419    let path_and_query = &rest[path_start..];
420    let path = path_and_query.split(['?', '#']).next().unwrap_or("");
421    Some(ParsedUrl {
422        path: path.to_string(),
423    })
424}
425
426/// Port of `resolveLocalFragment`.
427fn resolve_local_fragment(
428    config_path: &std::path::Path,
429    base_dir: &std::path::Path,
430    rel_path: &str,
431    visited: &mut std::collections::HashSet<std::path::PathBuf>,
432    used_fragments: &mut std::collections::HashMap<String, String>,
433    fetcher: &dyn RemoteFetcher,
434) -> Result<Value, ConfigError> {
435    let abs_frag_path = base_dir.join(rel_path);
436
437    let rel = pathdiff(&abs_frag_path, base_dir);
438    if rel.starts_with("..") {
439        return Err(ConfigError::FragmentOutsideDir);
440    }
441
442    if !is_valid_fragment_name(rel_path) {
443        return Err(ConfigError::InvalidFragmentName);
444    }
445
446    let canonical = abs_frag_path
447        .canonicalize()
448        .unwrap_or_else(|_| abs_frag_path.clone());
449    let canonical_key = canonical.display().to_string();
450
451    if used_fragments.contains_key(&canonical_key) {
452        return Err(ConfigError::DuplicateExtends(rel_path.to_string()));
453    }
454
455    used_fragments.insert(
456        canonical_key,
457        config_path
458            .file_name()
459            .map(|n| n.to_string_lossy().into_owned())
460            .unwrap_or_default(),
461    );
462
463    if visited.contains(&canonical) {
464        return Err(ConfigError::CircularExtends);
465    }
466
467    let frag_map = load_fragment(&abs_frag_path)?;
468
469    // Fragments are not allowed to extend further (isFragment=true), so no
470    // `element_sources` are recorded for a local fragment's own deployers
471    // (matching the Go original's `resolveLocalFragment`, which never
472    // threads `elementSources` at all — only a *remote* fragment's
473    // deployers can later need a file downloaded from that fragment's URL).
474    resolve_extends(
475        &abs_frag_path,
476        frag_map,
477        true,
478        visited,
479        used_fragments,
480        fetcher,
481        None,
482    )
483}
484
485/// Port of `resolveRemoteFragment`.
486fn resolve_remote_fragment(
487    config_path: &std::path::Path,
488    url: &str,
489    visited: &mut std::collections::HashSet<std::path::PathBuf>,
490    used_fragments: &mut std::collections::HashMap<String, String>,
491    fetcher: &dyn RemoteFetcher,
492    element_sources: Option<&mut std::collections::HashMap<String, RemoteElement>>,
493) -> Result<Value, ConfigError> {
494    if used_fragments.contains_key(url) {
495        return Err(ConfigError::DuplicateExtends(url.to_string()));
496    }
497
498    used_fragments.insert(
499        url.to_string(),
500        config_path
501            .file_name()
502            .map(|n| n.to_string_lossy().into_owned())
503            .unwrap_or_default(),
504    );
505
506    let data = fetcher.fetch(url)?;
507
508    let frag_map: Value =
509        serde_yaml::from_slice(&data).map_err(|err| ConfigError::InvalidYAML(err.to_string()))?;
510
511    // A remote fragment cannot itself carry `extends:` (isFragment=true, and
512    // `element_sources=None` for the same reason as a local fragment's own
513    // nested resolution — see `resolve_local_fragment`'s comment).
514    let frag_map = resolve_extends(
515        std::path::Path::new(url),
516        frag_map,
517        true,
518        visited,
519        used_fragments,
520        fetcher,
521        None,
522    )?;
523
524    if let Some(sources) = element_sources {
525        record_element_sources(sources, &frag_map, url);
526    }
527
528    Ok(frag_map)
529}
530
531/// Port of `recordElementSources`.
532fn record_element_sources(
533    sources: &mut std::collections::HashMap<String, RemoteElement>,
534    frag_map: &Value,
535    url: &str,
536) {
537    let Some(deployers) = frag_map.get("deployers").and_then(Value::as_object) else {
538        return;
539    };
540
541    for (name, deployer) in deployers {
542        let Some(d) = deployer.as_object() else {
543            continue;
544        };
545
546        let values_file = d
547            .get("helm")
548            .and_then(|h| h.get("values_file"))
549            .and_then(Value::as_str)
550            .unwrap_or_default()
551            .to_string();
552        let kustomize_path = d
553            .get("kustomize")
554            .and_then(|k| k.get("path"))
555            .and_then(Value::as_str)
556            .unwrap_or_default()
557            .to_string();
558
559        sources.insert(
560            format!("deployers/{name}"),
561            RemoteElement {
562                url: url.to_string(),
563                values_file,
564                kustomize_path,
565            },
566        );
567    }
568}
569
570/// A minimal relative-path computation (`filepath.Rel` equivalent), scoped to
571/// what fragment-outside-directory detection needs: whether `path` (already
572/// joined onto `base`) escapes `base` via `..` segments. No `pathdiff` crate
573/// dependency was added for this single call site.
574fn pathdiff(path: &std::path::Path, base: &std::path::Path) -> std::path::PathBuf {
575    use std::path::Component;
576
577    fn normalize(p: &std::path::Path) -> Vec<Component<'_>> {
578        let mut out: Vec<Component<'_>> = Vec::new();
579        for c in p.components() {
580            match c {
581                Component::CurDir => {}
582                Component::ParentDir => {
583                    if matches!(out.last(), Some(Component::Normal(_))) {
584                        out.pop();
585                    } else {
586                        out.push(c);
587                    }
588                }
589                other => out.push(other),
590            }
591        }
592        out
593    }
594
595    let path_parts = normalize(path);
596    let base_parts = normalize(base);
597
598    let common = path_parts
599        .iter()
600        .zip(base_parts.iter())
601        .take_while(|(a, b)| a == b)
602        .count();
603
604    let mut result = std::path::PathBuf::new();
605    for _ in common..base_parts.len() {
606        result.push("..");
607    }
608    for part in &path_parts[common..] {
609        result.push(part.as_os_str());
610    }
611
612    if result.as_os_str().is_empty() {
613        result.push(".");
614    }
615
616    result
617}
618
619/// Port of `loadLocalOverride`: reads and parses `xianyun.local.yaml`
620/// (or, as a compatibility alias, `caproni.local.yaml`) from the same
621/// directory as `config_path`. Returns `None` (no error) if neither file
622/// exists.
623pub(super) fn load_local_override(
624    config_path: &std::path::Path,
625) -> Result<Option<serde_json::Map<String, Value>>, ConfigError> {
626    let local_path = resolve_local_override_path(config_path);
627
628    let data = match std::fs::read(&local_path) {
629        Ok(d) => d,
630        Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None),
631        Err(err) => {
632            return Err(ConfigError::InvalidYAML(format!(
633                "reading {}: {err}",
634                local_path.display()
635            )));
636        }
637    };
638
639    let value: Value =
640        serde_yaml::from_slice(&data).map_err(|err| ConfigError::InvalidYAML(err.to_string()))?;
641
642    match value {
643        Value::Object(map) => Ok(Some(map)),
644        Value::Null => Ok(Some(serde_json::Map::new())),
645        _ => Err(ConfigError::InvalidYAML(format!(
646            "{} must be a YAML mapping",
647            local_path.display()
648        ))),
649    }
650}
651
652/// Port of `resolveLocalOverride`: reads `caproni.local.yaml` and resolves
653/// the `extends:` it may carry. `used_fragments` is shared with the base
654/// config's extends resolution so that a fragment cannot be extended from
655/// both `caproni.yaml` and `caproni.local.yaml`.
656pub(super) fn resolve_local_override(
657    abs_path: &std::path::Path,
658    used_fragments: &mut std::collections::HashMap<String, String>,
659    fetcher: &dyn RemoteFetcher,
660    element_sources: Option<&mut std::collections::HashMap<String, RemoteElement>>,
661) -> Result<Option<Value>, ConfigError> {
662    let Some(local_override) = load_local_override(abs_path)? else {
663        return Ok(None);
664    };
665
666    let local_abs_path = resolve_local_override_path(abs_path);
667
668    let mut visited = std::collections::HashSet::new();
669    visited.insert(
670        abs_path
671            .canonicalize()
672            .unwrap_or_else(|_| abs_path.to_path_buf()),
673    );
674    visited.insert(
675        local_abs_path
676            .canonicalize()
677            .unwrap_or_else(|_| local_abs_path.clone()),
678    );
679
680    let resolved = resolve_extends(
681        &local_abs_path,
682        Value::Object(local_override),
683        false,
684        &mut visited,
685        used_fragments,
686        fetcher,
687        element_sources,
688    )?;
689
690    Ok(Some(resolved))
691}
692
693/// Port of `applyLocalOverride`: merges `override_val` onto `raw_config` and
694/// returns the merged value. If `raw_config` is not a mapping, the override
695/// is ignored and `raw_config` is returned unchanged (matching the Go
696/// original's warn-and-ignore behavior, minus the log line).
697pub(super) fn apply_local_override(
698    raw_config: Value,
699    override_val: Value,
700) -> Result<Value, ConfigError> {
701    let (Value::Object(base_map), Value::Object(override_map)) = (&raw_config, &override_val)
702    else {
703        return Ok(raw_config);
704    };
705
706    ensure_same_version(base_map, override_map)?;
707
708    Ok(deep_merge(&raw_config, &override_val))
709}
710
711/// Primary filename read from each edit-mode repository directory. Port
712/// of `caproniProjectFilename`. `caproni-project.yaml` is supported as a
713/// compatibility alias when `xianyun-project.yaml` is absent.
714const CAPRONI_PROJECT_FILENAME: &str = "xianyun-project.yaml";
715const CAPRONI_PROJECT_FILENAME_ALIAS: &str = "caproni-project.yaml";
716const RELOADERS_KEY: &str = "reloaders";
717
718/// Compiles and validates `frag` against `schema/caproni-project.schema.json`.
719/// That schema's `reloaders`/`lifecycle_hooks` properties `$ref` into
720/// `schema/config.v2.schema.json`, so the v2 schema is registered as a
721/// dependency resource under the URL the relative `$ref` resolves against
722/// (`https://gitlab.com/schemas/config.v2.schema.json` — not the v2 schema's
723/// own `$id`, matching the Go original's `compileProjectSchema`). No caching
724/// across calls, for the same reason `validate_schema_v2` has none: see its
725/// doc comment.
726pub(super) fn validate_project_file_schema(frag: &Value) -> Result<(), ConfigError> {
727    const V2_SCHEMA_JSON: &str = include_str!("../../../../schema/config.v2.schema.json");
728    const PROJECT_SCHEMA_JSON: &str =
729        include_str!("../../../../schema/caproni-project.schema.json");
730
731    const V2_SCHEMA_URL: &str = "https://gitlab.com/schemas/config.v2.schema.json";
732
733    let v2_doc: Value = serde_json::from_str(V2_SCHEMA_JSON)
734        .map_err(|err| ConfigError::SchemaValidation(format!("parsing config schema v2: {err}")))?;
735    let project_doc: Value = serde_json::from_str(PROJECT_SCHEMA_JSON).map_err(|err| {
736        ConfigError::SchemaValidation(format!("parsing caproni-project schema: {err}"))
737    })?;
738
739    let v2_resource = jsonschema::Resource::from_contents(v2_doc).map_err(|err| {
740        ConfigError::SchemaValidation(format!(
741            "adding v2 schema resource for project schema: {err}"
742        ))
743    })?;
744
745    let compiled = jsonschema::options()
746        .with_resource(V2_SCHEMA_URL, v2_resource)
747        .build(&project_doc)
748        .map_err(|err| {
749            ConfigError::SchemaValidation(format!("compiling caproni-project schema: {err}"))
750        })?;
751
752    let errors: Vec<String> = compiled
753        .iter_errors(frag)
754        .map(|e| {
755            let path = e.instance_path.to_string();
756            if path.is_empty() {
757                e.to_string()
758            } else {
759                format!("{path}: {e}")
760            }
761        })
762        .collect();
763
764    if errors.is_empty() {
765        Ok(())
766    } else {
767        Err(ConfigError::SchemaValidation(errors.join("; ")))
768    }
769}
770
771/// Checks that every reloader in `frag` references `repo_name`. Structural
772/// key validation is handled by the JSON schema
773/// ([`validate_project_file_schema`]). Port of `validateRepoDeployersFragment`.
774pub(super) fn validate_repo_deployers_fragment(
775    frag: &Value,
776    repo_name: &str,
777) -> Result<(), ConfigError> {
778    let Some(reloaders_val) = frag.get(RELOADERS_KEY) else {
779        return Ok(());
780    };
781
782    let Some(reloaders) = reloaders_val.as_object() else {
783        return Err(ConfigError::RepoFragmentOnlyReloaders);
784    };
785
786    for (name, val) in reloaders {
787        let Some(entry) = val.as_object() else {
788            continue;
789        };
790
791        let repo = entry
792            .get("repository")
793            .and_then(Value::as_str)
794            .unwrap_or("");
795        if repo != repo_name {
796            return Err(ConfigError::RepoFragmentWrongRepository {
797                name: name.clone(),
798                repo: repo.to_string(),
799                expected: repo_name.to_string(),
800            });
801        }
802    }
803
804    Ok(())
805}
806
807/// Reads and parses `xianyun-project.yaml` (or, as a compatibility
808/// alias, `caproni-project.yaml`) from `dir`. Returns `Ok(None)` (no
809/// error) if neither file exists. Port of `loadRepoDeployersFragment`.
810fn load_repo_deployers_fragment(dir: &std::path::Path) -> Result<Option<Value>, ConfigError> {
811    let primary_path = dir.join(CAPRONI_PROJECT_FILENAME);
812    let frag_path = if primary_path.is_file() {
813        primary_path
814    } else {
815        dir.join(CAPRONI_PROJECT_FILENAME_ALIAS)
816    };
817
818    let data = match std::fs::read(&frag_path) {
819        Ok(d) => d,
820        Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None),
821        Err(err) => {
822            return Err(ConfigError::InvalidYAML(format!(
823                "reading {}: {err}",
824                frag_path.display()
825            )));
826        }
827    };
828
829    let mut frag: Value =
830        serde_yaml::from_slice(&data).map_err(|err| ConfigError::InvalidYAML(err.to_string()))?;
831
832    // Drop version so it cannot mask or conflict with the base config's version.
833    if let Value::Object(map) = &mut frag {
834        map.remove("version");
835    }
836
837    Ok(Some(frag))
838}
839
840/// Loads `caproni-project.yaml` from each repository directory that exists
841/// on disk, and merges them as lower-precedence fragments beneath
842/// `base_config` (`base_config` keys always win). Repositories are
843/// processed in alphabetical order for determinism.
844///
845/// Each fragment may define `reloaders` (which must reference the owning
846/// repository, and names must be unique across all repo fragments) and/or
847/// `lifecycle_hooks` (which are appended to the owning repository's
848/// `lifecycle_hooks` in `base_config`). Port of `applyCaproniProject`.
849pub(super) fn apply_caproni_project(
850    base_config: Value,
851    config_dir: &std::path::Path,
852) -> Result<Value, ConfigError> {
853    let Some(repos) = base_config.get("repositories").and_then(Value::as_object) else {
854        return Ok(base_config);
855    };
856
857    let mut repo_names: Vec<&String> = repos.keys().collect();
858    repo_names.sort();
859
860    let mut all_reloaders = serde_json::Map::new();
861    let mut repo_hooks: Vec<(String, Vec<Value>)> = Vec::new();
862
863    for repo_name in repo_names {
864        let Some(repo) = repos.get(repo_name).and_then(Value::as_object) else {
865            continue;
866        };
867
868        let edit_mode = repo
869            .get("edit_mode")
870            .and_then(Value::as_bool)
871            .unwrap_or(false);
872        if !edit_mode {
873            continue;
874        }
875
876        let dir_str = repo.get("directory").and_then(Value::as_str).unwrap_or("");
877        if dir_str.is_empty() {
878            continue;
879        }
880
881        let dir_path = std::path::Path::new(dir_str);
882        let dir = if dir_path.is_absolute() {
883            dir_path.to_path_buf()
884        } else {
885            config_dir.join(dir_path)
886        };
887
888        let Some(frag) = load_repo_deployers_fragment(&dir)? else {
889            continue;
890        };
891
892        let is_empty = frag.as_object().is_none_or(serde_json::Map::is_empty);
893        if is_empty {
894            continue;
895        }
896
897        validate_project_file_schema(&frag)?;
898        validate_repo_deployers_fragment(&frag, repo_name)?;
899
900        if let Some(reloaders) = frag.get(RELOADERS_KEY).and_then(Value::as_object) {
901            for (name, val) in reloaders {
902                if all_reloaders.contains_key(name) {
903                    return Err(ConfigError::RepoFragmentDuplicateReloader(name.clone()));
904                }
905
906                all_reloaders.insert(name.clone(), val.clone());
907            }
908        }
909
910        if let Some(hooks) = frag.get(LIFECYCLE_HOOKS_KEY).and_then(Value::as_array)
911            && !hooks.is_empty()
912        {
913            repo_hooks.push((repo_name.clone(), hooks.clone()));
914        }
915    }
916
917    if all_reloaders.is_empty() && repo_hooks.is_empty() {
918        return Ok(base_config);
919    }
920
921    let mut merged = base_config;
922
923    if !all_reloaders.is_empty() {
924        let reloaders_overlay = Value::Object(serde_json::Map::from_iter([(
925            RELOADERS_KEY.to_string(),
926            Value::Object(all_reloaders),
927        )]));
928        merged = deep_merge(&reloaders_overlay, &merged);
929    }
930
931    // Append project-file lifecycle_hooks to each repo's existing hooks.
932    // deep_merge(base, fragment) puts base hooks first, project hooks
933    // second (via LIFECYCLE_HOOKS_KEY's append-list handling above).
934    if !repo_hooks.is_empty() {
935        let mut repo_fragment = serde_json::Map::new();
936        for (repo_name, hooks) in repo_hooks {
937            repo_fragment.insert(
938                repo_name,
939                Value::Object(serde_json::Map::from_iter([(
940                    LIFECYCLE_HOOKS_KEY.to_string(),
941                    Value::Array(hooks),
942                )])),
943            );
944        }
945
946        let hooks_overlay = Value::Object(serde_json::Map::from_iter([(
947            "repositories".to_string(),
948            Value::Object(repo_fragment),
949        )]));
950        merged = deep_merge(&merged, &hooks_overlay);
951    }
952
953    Ok(merged)
954}
955
956/// Names a kustomize path that references an overlay embedded in the
957/// caproni binary, not a directory to fetch from a remote fragment's URL.
958/// Port of `builtinKustomizeScheme`/`isBuiltinKustomizePath`.
959const BUILTIN_KUSTOMIZE_SCHEME: &str = "builtin:";
960
961fn is_builtin_kustomize_path(path: &str) -> bool {
962    path.starts_with(BUILTIN_KUSTOMIZE_SCHEME)
963}
964
965/// Reports whether `path` is a kustomize remote target: a URL whose scheme
966/// kustomize can resolve itself, so it is not fetched from the fragment's
967/// URL either. Port of `kustomize.IsRemoteTarget`/`URLScheme`, duplicated
968/// here for the same reason the Go original duplicates it in `caproni.go`
969/// rather than importing the `kustomize` backend package: the config layer
970/// must not depend on a deployer implementation.
971fn is_kustomize_remote_target(path: &str) -> bool {
972    let Some(scheme_end) = path.find("://") else {
973        return false;
974    };
975
976    matches!(
977        path[..scheme_end].to_ascii_lowercase().as_str(),
978        "http" | "https" | "ssh" | "file"
979    )
980}
981
982/// Port of `fetchDeployerFiles`: downloads the values file a remote
983/// fragment's deployer references, unless a higher-precedence layer (the
984/// base config, a local fragment, or `caproni.local.yaml`) already replaced
985/// that reference — in which case it belongs to the user's own config and
986/// resolves against the config directory like any local deployer.
987pub(super) fn fetch_deployer_files(
988    cfg: &mut super::latest::CaproniConfig,
989    element_sources: &std::collections::HashMap<String, RemoteElement>,
990    fetcher: &dyn RemoteFetcher,
991) -> Result<(), ConfigError> {
992    let Some(deployers) = cfg.deployers.as_mut() else {
993        return Ok(());
994    };
995
996    let names: Vec<String> = deployers.iter().map(|(k, _)| k.clone()).collect();
997
998    for name in names {
999        let Some(src) = element_sources.get(&format!("deployers/{name}")) else {
1000            continue;
1001        };
1002
1003        if !is_remote_source(&src.url) {
1004            continue;
1005        }
1006
1007        {
1008            let dep = deployers.get(&name).expect("name came from this map");
1009            if let Some(helm) = &dep.helm
1010                && helm.values_file != src.values_file
1011            {
1012                continue;
1013            }
1014            if let Some(kustomize) = &dep.kustomize
1015                && kustomize.path != src.kustomize_path
1016            {
1017                continue;
1018            }
1019        }
1020
1021        let dep = deployers.get_mut(&name).expect("name came from this map");
1022        fetch_deployer_files_from_remote(&name, dep, &src.url, fetcher)?;
1023    }
1024
1025    Ok(())
1026}
1027
1028/// Port of `fetchDeployerFilesFromRemote`. Receives the fragment's own file
1029/// references, so a kustomize path that is a local directory is rejected:
1030/// it lives on the fragment's host and cannot be downloaded.
1031fn fetch_deployer_files_from_remote(
1032    _name: &str,
1033    deployer: &mut super::latest::DeployerConfig,
1034    frag_url: &str,
1035    fetcher: &dyn RemoteFetcher,
1036) -> Result<(), ConfigError> {
1037    if let Some(kustomize) = &deployer.kustomize
1038        && !is_builtin_kustomize_path(&kustomize.path)
1039        && !is_kustomize_remote_target(&kustomize.path)
1040    {
1041        return Err(ConfigError::RemoteFragmentDirRef);
1042    }
1043
1044    let Some(helm) = &mut deployer.helm else {
1045        return Ok(());
1046    };
1047
1048    if helm.values_file.is_empty() || std::path::Path::new(&helm.values_file).is_absolute() {
1049        return Ok(());
1050    }
1051
1052    helm.values_file = fetch_relative_file(fetcher, frag_url, &helm.values_file)?;
1053
1054    Ok(())
1055}
1056
1057/// A minimal `base.ResolveReference(ref)` equivalent, scoped to what
1058/// resolving a fragment-relative file reference needs: replace the base
1059/// URL's final path segment with `rel_path`. No `url` crate dependency was
1060/// added for this one call site.
1061fn resolve_relative_url(base: &str, rel_path: &str) -> Result<String, ConfigError> {
1062    if rel_path.contains("://") {
1063        return Ok(rel_path.to_string());
1064    }
1065
1066    let last_slash = base
1067        .rfind('/')
1068        .ok_or_else(|| ConfigError::RemoteFragmentFetch(base.to_string()))?;
1069
1070    Ok(format!(
1071        "{}/{}",
1072        &base[..last_slash],
1073        rel_path.trim_start_matches("./")
1074    ))
1075}
1076
1077/// A deterministic on-disk path for a fetched remote file, keyed by its URL.
1078/// Stands in for the Go original's `filecache.Cache`-managed path (see this
1079/// module's doc comment on the caching scope reduction).
1080fn cache_file_path(url: &str) -> std::path::PathBuf {
1081    use sha2::{Digest, Sha256};
1082
1083    let mut hasher = Sha256::new();
1084    hasher.update(url.as_bytes());
1085    let hash = hasher.finalize();
1086    let hex: String = hash.iter().map(|b| format!("{b:02x}")).collect();
1087
1088    std::env::temp_dir()
1089        .join("xianyun-remote-fragments")
1090        .join(hex)
1091}
1092
1093/// Port of `fetchRelativeFile`: fetches a file relative to a fragment URL
1094/// and persists it to a local path, returning that path.
1095///
1096/// **Divergence, reproducing Go's own observable quirk rather than fixing
1097/// it**: the Go original's `filecache.Cache.GetFile` cannot distinguish "no
1098/// file has ever been cached for this key" from "a stale file exists and
1099/// refreshing it failed" — both return the would-be cache path with no
1100/// error, on the theory that a stale-but-present file is still usable. On a
1101/// cold cache (the only case this crate's fetcher reproduces — see the
1102/// module doc comment), that means a *failed* fetch still returns a path,
1103/// just one nothing was ever written to. The caller (this port's
1104/// `fetch_deployer_files_from_remote`) has no way to tell the difference
1105/// either, exactly as in Go, so `deployer.Helm.ValuesFile` ends up pointing
1106/// at a nonexistent path — and this port's own `validate_semantics`
1107/// "values file not found" check is what actually surfaces the failure,
1108/// not this function. `TestLoadCaproniConfig_ExtendsRemoteFragmentMissingValuesFile_Fails`
1109/// pins exactly this externally-observable chain.
1110fn fetch_relative_file(
1111    fetcher: &dyn RemoteFetcher,
1112    frag_url: &str,
1113    rel_path: &str,
1114) -> Result<String, ConfigError> {
1115    let file_url = resolve_relative_url(frag_url, rel_path)?;
1116    let path = cache_file_path(&file_url);
1117
1118    if let Ok(data) = fetcher.fetch(&file_url) {
1119        if let Some(parent) = path.parent() {
1120            std::fs::create_dir_all(parent).map_err(|err| {
1121                ConfigError::RemoteFragmentFetch(format!("persisting cache entry: {err}"))
1122            })?;
1123        }
1124        std::fs::write(&path, &data).map_err(|err| {
1125            ConfigError::RemoteFragmentFetch(format!("persisting cache entry: {err}"))
1126        })?;
1127    }
1128
1129    Ok(path.to_string_lossy().into_owned())
1130}
1131
1132#[cfg(test)]
1133mod tests {
1134    use super::*;
1135    use crate::config::global::GlobalConfig;
1136    use serde_json::json;
1137    use std::fs;
1138
1139    fn write_test_config(content: &str) -> (tempfile::TempDir, String) {
1140        let dir = tempfile::tempdir().unwrap();
1141        let path = dir.path().join("caproni.yaml");
1142        fs::write(&path, content).unwrap();
1143        (dir, path.to_string_lossy().into_owned())
1144    }
1145
1146    /// Port of the Go `writeLocalOverride` helper.
1147    fn write_local_override(config_path: &str, content: &str) {
1148        let dir = std::path::Path::new(config_path).parent().unwrap();
1149        fs::write(dir.join("caproni.local.yaml"), content).unwrap();
1150    }
1151
1152    /// Port of the Go `writeFragment` helper (used by extends: tests).
1153    fn write_fragment(dir: &std::path::Path, name: &str, content: &str) {
1154        fs::write(dir.join(name), content).unwrap();
1155    }
1156
1157    fn load(path: String) -> Result<super::super::latest::CaproniConfig, ConfigError> {
1158        let gc = GlobalConfig {
1159            config_file: path,
1160            ..Default::default()
1161        };
1162        super::super::caproni::load_caproni_config(&gc)
1163    }
1164
1165    // Port of TestDeepMerge (table-driven).
1166    #[test]
1167    fn deep_merge_cases() {
1168        struct Case {
1169            name: &'static str,
1170            dst: Value,
1171            src: Value,
1172            want: Value,
1173        }
1174
1175        let cases = vec![
1176            Case {
1177                name: "scalar override",
1178                dst: json!({"a": "original", "b": "keep"}),
1179                src: json!({"a": "override"}),
1180                want: json!({"a": "override", "b": "keep"}),
1181            },
1182            Case {
1183                name: "new key added",
1184                dst: json!({"a": "original"}),
1185                src: json!({"b": "new"}),
1186                want: json!({"a": "original", "b": "new"}),
1187            },
1188            Case {
1189                name: "nested map merge",
1190                dst: json!({"outer": {"keep": "kept", "override": "original"}}),
1191                src: json!({"outer": {"override": "new", "added": "yes"}}),
1192                want: json!({"outer": {"keep": "kept", "override": "new", "added": "yes"}}),
1193            },
1194            Case {
1195                name: "slice replaces",
1196                dst: json!({"items": ["a", "b"]}),
1197                src: json!({"items": ["c"]}),
1198                want: json!({"items": ["c"]}),
1199            },
1200            Case {
1201                name: "additional_hostnames appended",
1202                dst: json!({"additional_hostnames": ["base.local"]}),
1203                src: json!({"additional_hostnames": ["fragment.local"]}),
1204                want: json!({"additional_hostnames": ["base.local", "fragment.local"]}),
1205            },
1206            Case {
1207                name: "lifecycle_hooks appended",
1208                dst: json!({"lifecycle_hooks": [{"type": "edit_mode_start", "script": "base.sh"}]}),
1209                src: json!({"lifecycle_hooks": [{"type": "edit_mode_start", "script": "override.sh"}]}),
1210                want: json!({"lifecycle_hooks": [
1211                    {"type": "edit_mode_start", "script": "base.sh"},
1212                    {"type": "edit_mode_start", "script": "override.sh"},
1213                ]}),
1214            },
1215            Case {
1216                name: "dynamic_overrides appended",
1217                dst: json!({"dynamic_overrides": [{"type": "inline", "value_map": {"crds.enabled": true}}]}),
1218                src: json!({"dynamic_overrides": [{"type": "inline", "value_map": {"prometheus.enabled": true}}]}),
1219                want: json!({"dynamic_overrides": [
1220                    {"type": "inline", "value_map": {"crds.enabled": true}},
1221                    {"type": "inline", "value_map": {"prometheus.enabled": true}},
1222                ]}),
1223            },
1224        ];
1225
1226        for case in cases {
1227            let result = deep_merge(&case.dst, &case.src);
1228            assert_eq!(result, case.want, "{}", case.name);
1229        }
1230    }
1231
1232    // Port of TestDeepMerge_NullDeletes.
1233    #[test]
1234    fn deep_merge_null_deletes_cases() {
1235        struct Case {
1236            name: &'static str,
1237            dst: Value,
1238            src: Value,
1239            want: Value,
1240        }
1241
1242        let cases = vec![
1243            Case {
1244                name: "null overlay deletes existing scalar key",
1245                dst: json!({"a": "original", "b": "keep"}),
1246                src: json!({"a": null}),
1247                want: json!({"b": "keep"}),
1248            },
1249            Case {
1250                name: "null overlay deletes existing map key",
1251                dst: json!({"traefik": {"type": "helm"}, "keep": "kept"}),
1252                src: json!({"traefik": null}),
1253                want: json!({"keep": "kept"}),
1254            },
1255            Case {
1256                name: "null overlay on absent key is a harmless no-op",
1257                dst: json!({"a": "original"}),
1258                src: json!({"b": null}),
1259                want: json!({"a": "original"}),
1260            },
1261            Case {
1262                name: "nested null deletes only the inner key",
1263                dst: json!({"deployers": {"traefik": {"type": "helm"}, "gitlab": {"type": "helm"}}}),
1264                src: json!({"deployers": {"traefik": null}}),
1265                want: json!({"deployers": {"gitlab": {"type": "helm"}}}),
1266            },
1267            Case {
1268                name: "null deletes an append-slice key (lifecycle_hooks)",
1269                dst: json!({"lifecycle_hooks": [{"type": "edit_mode_start"}]}),
1270                src: json!({"lifecycle_hooks": null}),
1271                want: json!({}),
1272            },
1273            Case {
1274                name: "null drops every inherited dynamic_overrides entry",
1275                dst: json!({"dynamic_overrides": [{"type": "inline"}]}),
1276                src: json!({"dynamic_overrides": null}),
1277                want: json!({}),
1278            },
1279            Case {
1280                name: "empty-map overlay on existing map is a no-op",
1281                dst: json!({"traefik": {"type": "helm"}}),
1282                src: json!({"traefik": {}}),
1283                want: json!({"traefik": {"type": "helm"}}),
1284            },
1285        ];
1286
1287        for case in cases {
1288            let result = deep_merge(&case.dst, &case.src);
1289            assert_eq!(result, case.want, "{}", case.name);
1290        }
1291    }
1292
1293    // Port of TestDeepMerge_DstNotModified.
1294    #[test]
1295    fn deep_merge_dst_not_modified() {
1296        let dst = json!({"a": "original"});
1297        let src = json!({"a": "override", "b": "new"});
1298
1299        let _ = deep_merge(&dst, &src);
1300
1301        assert_eq!(dst["a"], json!("original"));
1302        assert!(dst.get("b").is_none());
1303    }
1304
1305    // Port of TestLoadCaproniConfig_LocalOverrideNullDeployerRemoves.
1306    #[test]
1307    fn local_override_null_deployer_removes() {
1308        let base = "
1309version: 2
1310cluster:
1311  driver: colima
1312  network:
1313    hostname: test.local
1314  colima:
1315    memory: 4GiB
1316    cores: 2
1317repositories: {}
1318deployers:
1319  extra-ns:
1320    type: namespace
1321    namespace:
1322      namespaces:
1323        - extra
1324  keep-ns:
1325    type: namespace
1326    namespace:
1327      namespaces:
1328        - keep
1329";
1330        let (_dir, config_path) = write_test_config(base);
1331        write_local_override(&config_path, "deployers:\n  extra-ns: null\n");
1332
1333        let cfg = load(config_path).expect("load should succeed");
1334        let deployers = cfg.deployers.unwrap();
1335        assert!(
1336            !deployers.has(&"extra-ns".to_string()),
1337            "null override removes the deployer"
1338        );
1339        assert!(
1340            deployers.has(&"keep-ns".to_string()),
1341            "sibling deployer survives"
1342        );
1343    }
1344
1345    // Port of TestLoadCaproniConfig_LocalOverrideDeletesExtendedDeployer.
1346    #[test]
1347    fn local_override_deletes_extended_deployer() {
1348        let tmp_dir = tempfile::tempdir().unwrap();
1349
1350        write_fragment(
1351            tmp_dir.path(),
1352            "caproni.infra.yaml",
1353            "
1354deployers:
1355  shared-ns:
1356    type: namespace
1357    namespace:
1358      namespaces:
1359        - shared
1360  other-ns:
1361    type: namespace
1362    namespace:
1363      namespaces:
1364        - other
1365",
1366        );
1367
1368        let config_path = tmp_dir.path().join("caproni.yaml");
1369        fs::write(
1370            &config_path,
1371            "
1372version: 2
1373extends:
1374  - ./caproni.infra.yaml
1375cluster:
1376  driver: colima
1377  network:
1378    hostname: test.local
1379  colima:
1380    memory: 4GiB
1381    cores: 2
1382repositories: {}
1383deployers:
1384  my-app:
1385    type: namespace
1386    namespace:
1387      namespaces:
1388        - my-app
1389",
1390        )
1391        .unwrap();
1392
1393        let config_path = config_path.to_string_lossy().into_owned();
1394        write_local_override(&config_path, "deployers:\n  shared-ns: null\n");
1395
1396        let cfg = load(config_path).expect("load should succeed");
1397        let deployers = cfg.deployers.unwrap();
1398        assert!(
1399            !deployers.has(&"shared-ns".to_string()),
1400            "a null in caproni.local.yaml must delete a deployer the extended fragment supplies"
1401        );
1402        assert!(
1403            deployers.has(&"other-ns".to_string()),
1404            "deleting one extended deployer must not affect its siblings"
1405        );
1406        assert!(
1407            deployers.has(&"my-app".to_string()),
1408            "the deployer caproni.yaml declares itself survives the override merge"
1409        );
1410    }
1411
1412    // Port of TestLoadCaproniConfig_LocalOverrideEmptyMapDeployerIsNoOp.
1413    #[test]
1414    fn local_override_empty_map_deployer_is_noop() {
1415        let base = "
1416version: 2
1417cluster:
1418  driver: colima
1419  network:
1420    hostname: test.local
1421  colima:
1422    memory: 4GiB
1423    cores: 2
1424repositories: {}
1425deployers:
1426  extra-ns:
1427    type: namespace
1428    namespace:
1429      namespaces:
1430        - extra
1431";
1432        let (_dir, config_path) = write_test_config(base);
1433        write_local_override(&config_path, "deployers:\n  extra-ns: {}\n");
1434
1435        let cfg = load(config_path).expect("load should succeed");
1436        let deployers = cfg.deployers.unwrap();
1437        assert!(deployers.has(&"extra-ns".to_string()));
1438
1439        let dep = deployers.get(&"extra-ns".to_string()).unwrap();
1440        let ns = dep
1441            .namespace
1442            .as_ref()
1443            .expect("empty-map override left the namespace config intact");
1444        assert_eq!(ns.namespaces, vec!["extra".to_string()]);
1445    }
1446
1447    // The remote-fragment tests below (`TestLoadCaproniConfig_ExtendsRemote*`)
1448    // are all ported with real bodies using `FakeFetcher`; see this module's
1449    // doc comment.
1450
1451    // Port of TestLoadCaproniConfig_LocalOverrideAbsent.
1452    #[test]
1453    fn local_override_absent() {
1454        let base = "
1455version: 2
1456cluster:
1457  driver: colima
1458  network:
1459    hostname: test.local
1460  colima:
1461    memory: 4GiB
1462    cores: 2
1463repositories: {}
1464";
1465        let (_dir, config_path) = write_test_config(base);
1466        let cfg = load(config_path).expect("load should succeed");
1467        assert_eq!(cfg.cluster.driver, "colima");
1468    }
1469
1470    // Port of TestLoadCaproniConfig_LocalOverrideScalar.
1471    #[test]
1472    fn local_override_scalar() {
1473        let base = "
1474version: 2
1475cluster:
1476  driver: colima
1477  network:
1478    hostname: test.local
1479  colima:
1480    memory: 4GiB
1481    cores: 2
1482repositories: {}
1483";
1484        let (_dir, config_path) = write_test_config(base);
1485        write_local_override(&config_path, "cluster:\n  colima:\n    memory: 16GiB\n");
1486
1487        let cfg = load(config_path).expect("load should succeed");
1488        assert_eq!(cfg.cluster.colima.as_ref().unwrap().memory, "16GiB");
1489        assert_eq!(
1490            cfg.cluster.colima.as_ref().unwrap().cores,
1491            2,
1492            "cores unchanged by override"
1493        );
1494    }
1495
1496    // Port of TestLoadCaproniConfig_LocalOverrideAddsReloader.
1497    #[test]
1498    fn local_override_adds_reloader() {
1499        let base = "
1500version: 2
1501cluster:
1502  driver: colima
1503  network:
1504    hostname: test.local
1505  colima:
1506    memory: 4GiB
1507    cores: 2
1508repositories:
1509  app:
1510    repository: https://example.com/app.git
1511    directory: ./app/
1512";
1513        let (_dir, config_path) = write_test_config(base);
1514        write_local_override(
1515            &config_path,
1516            "
1517reloaders:
1518  app:
1519    type: mirror
1520    repository: app
1521    edit_mode: true
1522    mirror:
1523      target:
1524        namespace: default
1525        kubernetes_type: deployment
1526        name: app-deployment
1527      edit_mode_processes:
1528        server:
1529          command: bin/server
1530",
1531        );
1532
1533        let cfg = load(config_path).expect("load should succeed");
1534        let reloaders = cfg.reloaders.unwrap();
1535        assert_eq!(reloaders.len(), 1);
1536        let reloader = reloaders.get(&"app".to_string()).unwrap();
1537        assert!(reloader.edit_mode);
1538    }
1539
1540    // Port of TestLoadCaproniConfig_VersionMismatch.
1541    #[test]
1542    fn version_mismatch() {
1543        let base = "
1544version: 2
1545cluster:
1546  driver: colima
1547  network:
1548    hostname: test.local
1549  colima:
1550    memory: 4GiB
1551    cores: 2
1552repositories:
1553  app:
1554    repository: https://example.com/app.git
1555    directory: ./app/
1556";
1557        let (_dir, config_path) = write_test_config(base);
1558        write_local_override(&config_path, "version: 1\n");
1559
1560        let err = load(config_path).expect_err("version mismatch should fail");
1561        assert!(matches!(err, ConfigError::OverrideVersionMismatch));
1562        assert_eq!(
1563            err.to_string(),
1564            "tried merging configs with different versions"
1565        );
1566    }
1567
1568    // Port of TestLoadCaproniConfig_LocalOverrideLifecycleHooksAppend.
1569    #[test]
1570    fn local_override_lifecycle_hooks_append() {
1571        let base = "
1572version: 2
1573cluster:
1574  driver: colima
1575  network:
1576    hostname: test.local
1577  colima:
1578    memory: 4GiB
1579    cores: 2
1580repositories:
1581  app:
1582    repository: https://example.com/app.git
1583    directory: ./app/
1584    lifecycle_hooks:
1585      - type: edit_mode_start
1586        script: ./base-hook.sh
1587";
1588        let (_dir, config_path) = write_test_config(base);
1589        write_local_override(
1590            &config_path,
1591            "
1592repositories:
1593  app:
1594    lifecycle_hooks:
1595      - type: edit_mode_start
1596        script: ./local-hook.sh
1597",
1598        );
1599
1600        let cfg = load(config_path).expect("load should succeed");
1601        let repos = cfg.repositories.unwrap();
1602        let repo = repos.get(&"app".to_string()).unwrap();
1603        assert_eq!(repo.lifecycle_hooks.len(), 2);
1604        assert_eq!(repo.lifecycle_hooks[0].script, "./base-hook.sh");
1605        assert_eq!(repo.lifecycle_hooks[1].script, "./local-hook.sh");
1606    }
1607
1608    // Port of TestLoadCaproniConfig_LocalOverrideInvalidYAML.
1609    #[test]
1610    fn local_override_invalid_yaml() {
1611        let base = "
1612version: 2
1613cluster:
1614  driver: colima
1615  network:
1616    hostname: test.local
1617  colima:
1618    memory: 4GiB
1619    cores: 2
1620repositories: {}
1621";
1622        let (_dir, config_path) = write_test_config(base);
1623        write_local_override(&config_path, "invalid: [yaml");
1624
1625        let err = load(config_path).expect_err("invalid yaml should fail");
1626        assert!(matches!(err, ConfigError::InvalidYAML(_)));
1627    }
1628
1629    // Port of TestLoadCaproniConfig_LocalOverrideNonMapping.
1630    #[test]
1631    fn local_override_non_mapping() {
1632        let base = "
1633version: 2
1634cluster:
1635  driver: colima
1636  network:
1637    hostname: test.local
1638  colima:
1639    memory: 4GiB
1640    cores: 2
1641repositories: {}
1642";
1643        let (_dir, config_path) = write_test_config(base);
1644        write_local_override(&config_path, "- item1\n- item2\n");
1645
1646        let err = load(config_path).expect_err("non-mapping override should fail");
1647        assert!(matches!(err, ConfigError::InvalidYAML(_)));
1648    }
1649
1650    // Port of TestLoadCaproniConfig_ExtendsAddsRepository.
1651    #[test]
1652    fn extends_adds_repository() {
1653        let tmp_dir = tempfile::tempdir().unwrap();
1654        write_fragment(
1655            tmp_dir.path(),
1656            "caproni.extra.yaml",
1657            "
1658repositories:
1659  extra:
1660    repository: https://example.com/extra.git
1661    directory: ./extra/
1662",
1663        );
1664
1665        let config_path = tmp_dir.path().join("caproni.yaml");
1666        fs::write(
1667            &config_path,
1668            "
1669version: 2
1670extends:
1671  - ./caproni.extra.yaml
1672cluster:
1673  driver: colima
1674  network:
1675    hostname: test.local
1676  colima:
1677    memory: 4GiB
1678    cores: 2
1679repositories:
1680  main:
1681    repository: https://example.com/main.git
1682    directory: ./main/
1683",
1684        )
1685        .unwrap();
1686
1687        let cfg = load(config_path.to_string_lossy().into_owned()).expect("load should succeed");
1688        let repos = cfg.repositories.unwrap();
1689        assert_eq!(repos.len(), 2);
1690        assert!(repos.has(&"main".to_string()));
1691        assert!(repos.has(&"extra".to_string()));
1692    }
1693
1694    // Port of TestLoadCaproniConfig_ExtendsBaseWinsOnConflict.
1695    #[test]
1696    fn extends_base_wins_on_conflict() {
1697        let tmp_dir = tempfile::tempdir().unwrap();
1698        write_fragment(
1699            tmp_dir.path(),
1700            "caproni.frag.yaml",
1701            "
1702cluster:
1703  colima:
1704    memory: 32GiB
1705    cores: 8
1706",
1707        );
1708
1709        let config_path = tmp_dir.path().join("caproni.yaml");
1710        fs::write(
1711            &config_path,
1712            "
1713version: 2
1714extends:
1715  - ./caproni.frag.yaml
1716cluster:
1717  driver: colima
1718  network:
1719    hostname: test.local
1720  colima:
1721    memory: 4GiB
1722    cores: 2
1723repositories: {}
1724",
1725        )
1726        .unwrap();
1727
1728        let cfg = load(config_path.to_string_lossy().into_owned()).expect("load should succeed");
1729        assert_eq!(cfg.cluster.colima.as_ref().unwrap().memory, "4GiB");
1730        assert_eq!(cfg.cluster.colima.as_ref().unwrap().cores, 2);
1731    }
1732
1733    // Port of TestLoadCaproniConfig_ExtendsMultipleFragmentsLeftToRight.
1734    #[test]
1735    fn extends_multiple_fragments_left_to_right() {
1736        let tmp_dir = tempfile::tempdir().unwrap();
1737        write_fragment(
1738            tmp_dir.path(),
1739            "caproni.a.yaml",
1740            "
1741repositories:
1742  a:
1743    repository: https://example.com/a.git
1744    directory: ./a/
1745  shared:
1746    repository: https://example.com/shared-from-a.git
1747    directory: ./shared/
1748",
1749        );
1750        write_fragment(
1751            tmp_dir.path(),
1752            "caproni.b.yaml",
1753            "
1754repositories:
1755  b:
1756    repository: https://example.com/b.git
1757    directory: ./b/
1758  shared:
1759    repository: https://example.com/shared-from-b.git
1760    directory: ./shared/
1761",
1762        );
1763
1764        let config_path = tmp_dir.path().join("caproni.yaml");
1765        fs::write(
1766            &config_path,
1767            "
1768version: 2
1769extends:
1770  - ./caproni.a.yaml
1771  - ./caproni.b.yaml
1772cluster:
1773  driver: colima
1774  network:
1775    hostname: test.local
1776  colima:
1777    memory: 4GiB
1778    cores: 2
1779repositories: {}
1780",
1781        )
1782        .unwrap();
1783
1784        let cfg = load(config_path.to_string_lossy().into_owned()).expect("load should succeed");
1785        let repos = cfg.repositories.unwrap();
1786        assert!(repos.has(&"a".to_string()));
1787        assert!(repos.has(&"b".to_string()));
1788        let shared = repos.get(&"shared".to_string()).unwrap();
1789        assert_eq!(shared.repository, "https://example.com/shared-from-b.git");
1790    }
1791
1792    // Port of TestLoadCaproniConfig_ExtendsFragmentHasExtends_Fails.
1793    #[test]
1794    fn extends_fragment_has_extends_fails() {
1795        let tmp_dir = tempfile::tempdir().unwrap();
1796        write_fragment(
1797            tmp_dir.path(),
1798            "caproni.frag.yaml",
1799            "
1800extends:
1801  - ./caproni.other.yaml
1802repositories:
1803  frag:
1804    repository: https://example.com/frag.git
1805    directory: ./frag/
1806",
1807        );
1808
1809        let config_path = tmp_dir.path().join("caproni.yaml");
1810        fs::write(
1811            &config_path,
1812            "
1813version: 2
1814extends:
1815  - ./caproni.frag.yaml
1816cluster:
1817  driver: colima
1818  network:
1819    hostname: test.local
1820  colima:
1821    memory: 4GiB
1822    cores: 2
1823repositories: {}
1824",
1825        )
1826        .unwrap();
1827
1828        let err = load(config_path.to_string_lossy().into_owned())
1829            .expect_err("fragment with extends should fail");
1830        assert!(matches!(err, ConfigError::FragmentHasExtends));
1831    }
1832
1833    // Port of TestLoadCaproniConfig_ExtendsMainConfig_Fails.
1834    #[test]
1835    fn extends_main_config_fails() {
1836        let tmp_dir = tempfile::tempdir().unwrap();
1837        let config_path = tmp_dir.path().join("caproni.yaml");
1838        fs::write(
1839            &config_path,
1840            "
1841version: 2
1842extends:
1843  - ./caproni.yaml
1844cluster:
1845  driver: colima
1846  network:
1847    hostname: test.local
1848  colima:
1849    memory: 4GiB
1850    cores: 2
1851repositories: {}
1852",
1853        )
1854        .unwrap();
1855
1856        let err =
1857            load(config_path.to_string_lossy().into_owned()).expect_err("self-extends should fail");
1858        assert!(matches!(err, ConfigError::CircularExtends));
1859    }
1860
1861    // Port of TestLoadCaproniConfig_ExtendsLocalOverride_Fails.
1862    #[test]
1863    fn extends_local_override_fails() {
1864        let tmp_dir = tempfile::tempdir().unwrap();
1865        let config_path = tmp_dir.path().join("caproni.yaml");
1866        fs::write(
1867            &config_path,
1868            "
1869version: 2
1870cluster:
1871  driver: colima
1872  network:
1873    hostname: test.local
1874  colima:
1875    memory: 4GiB
1876    cores: 2
1877repositories: {}
1878",
1879        )
1880        .unwrap();
1881
1882        write_local_override(
1883            &config_path.to_string_lossy(),
1884            "
1885extends:
1886  - ./caproni.local.yaml
1887",
1888        );
1889
1890        let err = load(config_path.to_string_lossy().into_owned())
1891            .expect_err("local override extending itself should fail");
1892        assert!(matches!(err, ConfigError::CircularExtends));
1893    }
1894
1895    // Port of TestLoadCaproniConfig_ExtendsMissingFragment_Fails.
1896    #[test]
1897    fn extends_missing_fragment_fails() {
1898        let tmp_dir = tempfile::tempdir().unwrap();
1899        let config_path = tmp_dir.path().join("caproni.yaml");
1900        fs::write(
1901            &config_path,
1902            "
1903version: 2
1904extends:
1905  - ./caproni.nonexistent.yaml
1906cluster:
1907  driver: colima
1908  network:
1909    hostname: test.local
1910  colima:
1911    memory: 4GiB
1912    cores: 2
1913repositories: {}
1914",
1915        )
1916        .unwrap();
1917
1918        let err = load(config_path.to_string_lossy().into_owned())
1919            .expect_err("missing fragment should fail");
1920        assert!(matches!(err, ConfigError::ConfigFileNotFound(_)));
1921    }
1922
1923    // Port of TestLoadCaproniConfig_ExtendsFragmentInvalidYAML_Fails.
1924    #[test]
1925    fn extends_fragment_invalid_yaml_fails() {
1926        let tmp_dir = tempfile::tempdir().unwrap();
1927        write_fragment(tmp_dir.path(), "caproni.bad.yaml", "invalid: [yaml");
1928
1929        let config_path = tmp_dir.path().join("caproni.yaml");
1930        fs::write(
1931            &config_path,
1932            "
1933version: 2
1934extends:
1935  - ./caproni.bad.yaml
1936cluster:
1937  driver: colima
1938  network:
1939    hostname: test.local
1940  colima:
1941    memory: 4GiB
1942    cores: 2
1943repositories: {}
1944",
1945        )
1946        .unwrap();
1947
1948        let err = load(config_path.to_string_lossy().into_owned())
1949            .expect_err("invalid fragment yaml should fail");
1950        assert!(matches!(err, ConfigError::InvalidYAML(_)));
1951    }
1952
1953    // Port of TestLoadCaproniConfig_ExtendsWithLocalOverride.
1954    #[test]
1955    fn extends_with_local_override() {
1956        let tmp_dir = tempfile::tempdir().unwrap();
1957        write_fragment(
1958            tmp_dir.path(),
1959            "caproni.frag.yaml",
1960            "
1961reloaders:
1962  app:
1963    type: mirror
1964    repository: app
1965    mirror:
1966      target:
1967        namespace: default
1968        kubernetes_type: deployment
1969        name: app
1970      edit_mode_processes:
1971        server:
1972          command: bin/server
1973",
1974        );
1975
1976        let config_path = tmp_dir.path().join("caproni.yaml");
1977        fs::write(
1978            &config_path,
1979            "
1980version: 2
1981extends:
1982  - ./caproni.frag.yaml
1983cluster:
1984  driver: colima
1985  network:
1986    hostname: test.local
1987  colima:
1988    memory: 4GiB
1989    cores: 2
1990repositories:
1991  app:
1992    repository: https://example.com/app.git
1993    directory: ./app/
1994",
1995        )
1996        .unwrap();
1997
1998        write_local_override(
1999            &config_path.to_string_lossy(),
2000            "
2001reloaders:
2002  app:
2003    edit_mode: true
2004",
2005        );
2006
2007        let cfg = load(config_path.to_string_lossy().into_owned()).expect("load should succeed");
2008        let reloaders = cfg.reloaders.unwrap();
2009        let reloader = reloaders.get(&"app".to_string()).unwrap();
2010        assert!(
2011            reloader.edit_mode,
2012            "local override should set edit_mode=true"
2013        );
2014    }
2015
2016    // Port of TestLoadCaproniConfig_LocalOverrideExtendsFragment.
2017    #[test]
2018    fn local_override_extends_fragment() {
2019        let tmp_dir = tempfile::tempdir().unwrap();
2020        write_fragment(
2021            tmp_dir.path(),
2022            "caproni.local-extra.yaml",
2023            "
2024reloaders:
2025  app:
2026    type: mirror
2027    repository: app
2028    edit_mode: true
2029    mirror:
2030      target:
2031        namespace: default
2032        kubernetes_type: deployment
2033        name: app
2034      edit_mode_processes:
2035        server:
2036          command: bin/server
2037",
2038        );
2039
2040        let config_path = tmp_dir.path().join("caproni.yaml");
2041        fs::write(
2042            &config_path,
2043            "
2044version: 2
2045cluster:
2046  driver: colima
2047  network:
2048    hostname: test.local
2049  colima:
2050    memory: 4GiB
2051    cores: 2
2052repositories:
2053  app:
2054    repository: https://example.com/app.git
2055    directory: ./app/
2056",
2057        )
2058        .unwrap();
2059
2060        write_local_override(
2061            &config_path.to_string_lossy(),
2062            "
2063extends:
2064  - ./caproni.local-extra.yaml
2065",
2066        );
2067
2068        let cfg = load(config_path.to_string_lossy().into_owned()).expect("load should succeed");
2069        let reloaders = cfg.reloaders.unwrap();
2070        let reloader = reloaders.get(&"app".to_string()).unwrap();
2071        assert!(reloader.edit_mode);
2072    }
2073
2074    // Port of TestLoadCaproniConfig_SameFragmentInBaseAndLocal_Fails.
2075    #[test]
2076    fn same_fragment_in_base_and_local_fails() {
2077        let tmp_dir = tempfile::tempdir().unwrap();
2078        write_fragment(
2079            tmp_dir.path(),
2080            "caproni.frag.yaml",
2081            "
2082repositories:
2083  extra:
2084    repository: https://example.com/extra.git
2085    directory: ./extra/
2086",
2087        );
2088
2089        let config_path = tmp_dir.path().join("caproni.yaml");
2090        fs::write(
2091            &config_path,
2092            "
2093version: 2
2094extends:
2095  - ./caproni.frag.yaml
2096cluster:
2097  driver: colima
2098  network:
2099    hostname: test.local
2100  colima:
2101    memory: 4GiB
2102    cores: 2
2103repositories: {}
2104",
2105        )
2106        .unwrap();
2107
2108        write_local_override(
2109            &config_path.to_string_lossy(),
2110            "
2111extends:
2112  - ./caproni.frag.yaml
2113",
2114        );
2115
2116        let err = load(config_path.to_string_lossy().into_owned())
2117            .expect_err("using the same fragment twice should fail");
2118        assert!(matches!(err, ConfigError::DuplicateExtends(_)));
2119    }
2120
2121    // Port of TestLoadCaproniConfig_LocalOverrideEmpty.
2122    #[test]
2123    fn local_override_empty() {
2124        let base = "
2125version: 2
2126cluster:
2127  driver: colima
2128  network:
2129    hostname: test.local
2130  colima:
2131    memory: 4GiB
2132    cores: 2
2133repositories: {}
2134";
2135        let (_dir, config_path) = write_test_config(base);
2136        write_local_override(&config_path, "");
2137
2138        let cfg = load(config_path).expect("load should succeed");
2139        assert_eq!(cfg.cluster.driver, "colima");
2140    }
2141
2142    // Port of TestLoadCaproniConfig_ExtendsLifecycleHooksAppend.
2143    #[test]
2144    fn extends_lifecycle_hooks_append() {
2145        let tmp_dir = tempfile::tempdir().unwrap();
2146        write_fragment(
2147            tmp_dir.path(),
2148            "caproni.hooks.yaml",
2149            "
2150repositories:
2151  app:
2152    repository: https://example.com/app.git
2153    directory: ./app/
2154    lifecycle_hooks:
2155      - type: edit_mode_start
2156        script: ./fragment-hook.sh
2157",
2158        );
2159
2160        let config_path = tmp_dir.path().join("caproni.yaml");
2161        fs::write(
2162            &config_path,
2163            "
2164version: 2
2165extends:
2166  - ./caproni.hooks.yaml
2167cluster:
2168  driver: colima
2169  network:
2170    hostname: test.local
2171  colima:
2172    memory: 4GiB
2173    cores: 2
2174repositories:
2175  app:
2176    repository: https://example.com/app.git
2177    directory: ./app/
2178    lifecycle_hooks:
2179      - type: edit_mode_start
2180        script: ./base-hook.sh
2181",
2182        )
2183        .unwrap();
2184
2185        let cfg = load(config_path.to_string_lossy().into_owned()).expect("load should succeed");
2186        let repos = cfg.repositories.unwrap();
2187        let repo = repos.get(&"app".to_string()).unwrap();
2188        assert_eq!(repo.lifecycle_hooks.len(), 2);
2189        assert_eq!(repo.lifecycle_hooks[0].script, "./fragment-hook.sh");
2190        assert_eq!(repo.lifecycle_hooks[1].script, "./base-hook.sh");
2191    }
2192
2193    // Port of TestLoadCaproniConfig_LocalOverrideAdditionalHostnamesAppend.
2194    #[test]
2195    fn local_override_additional_hostnames_append() {
2196        let base = "
2197version: 2
2198cluster:
2199  driver: colima
2200  network:
2201    hostname: test.local
2202    additional_hostnames:
2203      - base.local
2204  colima:
2205    memory: 4GiB
2206    cores: 2
2207repositories:
2208  app:
2209    repository: https://example.com/app.git
2210    directory: ./app/
2211";
2212        let (_dir, config_path) = write_test_config(base);
2213        write_local_override(
2214            &config_path,
2215            "
2216cluster:
2217  network:
2218    additional_hostnames:
2219      - local-override.local
2220",
2221        );
2222
2223        let cfg = load(config_path).expect("load should succeed");
2224        let network = cfg.cluster.network.as_ref().unwrap();
2225        assert_eq!(
2226            network.additional_hostnames,
2227            vec!["base.local".to_string(), "local-override.local".to_string()]
2228        );
2229    }
2230
2231    // Port of TestLoadCaproniConfig_ExtendsAdditionalHostnamesAppend.
2232    #[test]
2233    fn extends_additional_hostnames_append() {
2234        let tmp_dir = tempfile::tempdir().unwrap();
2235        write_fragment(
2236            tmp_dir.path(),
2237            "caproni.hostnames.yaml",
2238            "
2239cluster:
2240  network:
2241    additional_hostnames:
2242      - fragment.local
2243",
2244        );
2245
2246        let config_path = tmp_dir.path().join("caproni.yaml");
2247        fs::write(
2248            &config_path,
2249            "
2250version: 2
2251extends:
2252  - ./caproni.hostnames.yaml
2253cluster:
2254  driver: colima
2255  network:
2256    hostname: test.local
2257    additional_hostnames:
2258      - base.local
2259  colima:
2260    memory: 4GiB
2261    cores: 2
2262repositories:
2263  app:
2264    repository: https://example.com/app.git
2265    directory: ./app/
2266",
2267        )
2268        .unwrap();
2269
2270        let cfg = load(config_path.to_string_lossy().into_owned()).expect("load should succeed");
2271        let network = cfg.cluster.network.as_ref().unwrap();
2272        assert_eq!(
2273            network.additional_hostnames,
2274            vec!["fragment.local".to_string(), "base.local".to_string()]
2275        );
2276    }
2277
2278    // Port of TestLoadCaproniConfig_ExtendsPathTraversal_Fails.
2279    #[test]
2280    fn extends_path_traversal_fails() {
2281        let tmp_dir = tempfile::tempdir().unwrap();
2282        let config_path = tmp_dir.path().join("caproni.yaml");
2283        fs::write(
2284            &config_path,
2285            "
2286version: 2
2287extends:
2288  - ../caproni.evil.yaml
2289cluster:
2290  driver: colima
2291  network:
2292    hostname: test.local
2293  colima:
2294    memory: 4GiB
2295    cores: 2
2296repositories: {}
2297",
2298        )
2299        .unwrap();
2300
2301        let err = load(config_path.to_string_lossy().into_owned())
2302            .expect_err("path traversal should fail");
2303        assert!(matches!(err, ConfigError::FragmentOutsideDir));
2304    }
2305
2306    // Port of TestLoadCaproniConfig_ExtendsInvalidFragmentName_Fails.
2307    #[test]
2308    fn extends_invalid_fragment_name_fails() {
2309        let tmp_dir = tempfile::tempdir().unwrap();
2310        let config_path = tmp_dir.path().join("caproni.yaml");
2311        fs::write(
2312            &config_path,
2313            "
2314version: 2
2315extends:
2316  - ./my-arbitrary-config.yaml
2317cluster:
2318  driver: colima
2319  network:
2320    hostname: test.local
2321  colima:
2322    memory: 4GiB
2323    cores: 2
2324repositories: {}
2325",
2326        )
2327        .unwrap();
2328
2329        let err = load(config_path.to_string_lossy().into_owned())
2330            .expect_err("invalid fragment name should fail");
2331        assert!(matches!(err, ConfigError::InvalidFragmentName));
2332    }
2333
2334    // xianyun.<name>.yaml is a valid fragment name too, not just the
2335    // pre-rename caproni.<name>.yaml convention.
2336    #[test]
2337    fn extends_xianyun_prefixed_fragment_name_is_valid() {
2338        let tmp_dir = tempfile::tempdir().unwrap();
2339        write_fragment(tmp_dir.path(), "xianyun.infra.yaml", "\ndeployers: {}\n");
2340
2341        let config_path = tmp_dir.path().join("caproni.yaml");
2342        fs::write(
2343            &config_path,
2344            "
2345version: 2
2346extends:
2347  - ./xianyun.infra.yaml
2348cluster:
2349  driver: colima
2350  network:
2351    hostname: test.local
2352  colima:
2353    memory: 4GiB
2354    cores: 2
2355repositories: {}
2356",
2357        )
2358        .unwrap();
2359
2360        load(config_path.to_string_lossy().into_owned())
2361            .expect("xianyun.<name>.yaml fragment name must be accepted");
2362    }
2363
2364    // Port of TestLoadCaproniConfig_ExtendsDuplicateFragment_Fails.
2365    #[test]
2366    fn extends_duplicate_fragment_fails() {
2367        let tmp_dir = tempfile::tempdir().unwrap();
2368        write_fragment(
2369            tmp_dir.path(),
2370            "caproni.frag.yaml",
2371            "
2372repositories:
2373  extra:
2374    repository: https://example.com/extra.git
2375    directory: ./extra/
2376",
2377        );
2378
2379        let config_path = tmp_dir.path().join("caproni.yaml");
2380        fs::write(
2381            &config_path,
2382            "
2383version: 2
2384extends:
2385  - ./caproni.frag.yaml
2386  - ./caproni.frag.yaml
2387cluster:
2388  driver: colima
2389  network:
2390    hostname: test.local
2391  colima:
2392    memory: 4GiB
2393    cores: 2
2394repositories: {}
2395",
2396        )
2397        .unwrap();
2398
2399        let err = load(config_path.to_string_lossy().into_owned())
2400            .expect_err("duplicate fragment should fail");
2401        assert!(matches!(err, ConfigError::DuplicateExtends(_)));
2402    }
2403
2404    // Port of TestLoadCaproniConfig_ExtendsLocalPlainCaproniYAMLInSubdir.
2405    #[test]
2406    fn extends_local_plain_caproni_yaml_in_subdir() {
2407        let tmp_dir = tempfile::tempdir().unwrap();
2408        let sub_dir = tmp_dir.path().join("sub");
2409        fs::create_dir(&sub_dir).unwrap();
2410        write_fragment(
2411            &sub_dir,
2412            "caproni.yaml",
2413            "
2414repositories:
2415  local:
2416    repository: https://example.com/local.git
2417    directory: ./local/
2418",
2419        );
2420
2421        let config_path = tmp_dir.path().join("caproni.yaml");
2422        fs::write(
2423            &config_path,
2424            "
2425version: 2
2426extends:
2427  - ./sub/caproni.yaml
2428cluster:
2429  driver: colima
2430  network:
2431    hostname: test.local
2432  colima:
2433    memory: 4GiB
2434    cores: 2
2435repositories: {}
2436",
2437        )
2438        .unwrap();
2439
2440        let cfg = load(config_path.to_string_lossy().into_owned()).expect("load should succeed");
2441        assert!(cfg.repositories.unwrap().has(&"local".to_string()));
2442    }
2443
2444    // Port of TestLoadCaproniConfig_ExtendsLocalFragmentRelativePaths: a local
2445    // fragment's relative paths are left untouched, resolved at load time
2446    // against the *main* config directory, not the fragment's own subdirectory.
2447    #[test]
2448    fn extends_local_fragment_relative_paths_helm_values_file() {
2449        let tmp_dir = tempfile::tempdir().unwrap();
2450        fs::write(tmp_dir.path().join("values.yaml"), "replicaCount: 3\n").unwrap();
2451
2452        let sub_dir = tmp_dir.path().join("sub");
2453        fs::create_dir(&sub_dir).unwrap();
2454        write_fragment(
2455            &sub_dir,
2456            "caproni.gitlab.yaml",
2457            "
2458deployers:
2459  gitlab:
2460    type: helm
2461    helm:
2462      namespace: gitlab
2463      values_file: values.yaml
2464      release:
2465        name: gitlab
2466        chart: gitlab/gitlab
2467",
2468        );
2469
2470        let config_path = tmp_dir.path().join("caproni.yaml");
2471        fs::write(
2472            &config_path,
2473            "
2474version: 2
2475extends:
2476  - ./sub/caproni.gitlab.yaml
2477cluster:
2478  driver: colima
2479  network:
2480    hostname: test.local
2481  colima:
2482    memory: 4GiB
2483    cores: 2
2484repositories: {}
2485",
2486        )
2487        .unwrap();
2488
2489        let cfg = load(config_path.to_string_lossy().into_owned()).expect("load should succeed");
2490        let deployers = cfg.deployers.unwrap();
2491        let dep = deployers.get(&"gitlab".to_string()).unwrap();
2492        let got = &dep.helm.as_ref().unwrap().values_file;
2493        assert_eq!(got, "values.yaml");
2494        assert!(!std::path::Path::new(got).is_absolute());
2495    }
2496
2497    // Companion sub-case of the same Go table test (kustomize path).
2498    #[test]
2499    fn extends_local_fragment_relative_paths_kustomize_path() {
2500        let tmp_dir = tempfile::tempdir().unwrap();
2501        fs::create_dir(tmp_dir.path().join("manifests")).unwrap();
2502
2503        let sub_dir = tmp_dir.path().join("sub");
2504        fs::create_dir(&sub_dir).unwrap();
2505        write_fragment(
2506            &sub_dir,
2507            "caproni.gitlab.yaml",
2508            "
2509deployers:
2510  gitlab:
2511    type: kustomize
2512    kustomize:
2513      path: manifests
2514",
2515        );
2516
2517        let config_path = tmp_dir.path().join("caproni.yaml");
2518        fs::write(
2519            &config_path,
2520            "
2521version: 2
2522extends:
2523  - ./sub/caproni.gitlab.yaml
2524cluster:
2525  driver: colima
2526  network:
2527    hostname: test.local
2528  colima:
2529    memory: 4GiB
2530    cores: 2
2531repositories: {}
2532",
2533        )
2534        .unwrap();
2535
2536        let cfg = load(config_path.to_string_lossy().into_owned()).expect("load should succeed");
2537        let deployers = cfg.deployers.unwrap();
2538        let dep = deployers.get(&"gitlab".to_string()).unwrap();
2539        let got = &dep.kustomize.as_ref().unwrap().path;
2540        assert_eq!(got, "manifests");
2541        assert!(!std::path::Path::new(got).is_absolute());
2542    }
2543
2544    /// A test double for [`RemoteFetcher`] that serves fixed responses from
2545    /// an in-memory map keyed by full URL, standing in for the Go original's
2546    /// `httptest.Server`-backed `serveFragments` helper. Unlike an actual
2547    /// mock HTTP server, this needs no TLS story for the `https://`-only
2548    /// scheme check (`is_remote_source`) to exercise: the URLs handed to
2549    /// `FakeFetcher::new` and embedded in a test's `caproni.yaml` fixture
2550    /// are already `https://fake-remote.test/...`, and `fetch` just looks
2551    /// them up rather than dispatching a real request. This is the same
2552    /// seam `UreqFetcher` is injected through in production, so the
2553    /// `resolve_extends`/`fetch_deployer_files` logic under test never
2554    /// knows the difference. (This session used this simpler in-process
2555    /// double instead of the `wiremock`+`tokio` approach this module's doc
2556    /// comment previously sketched — no real network call is exercised by
2557    /// any of these tests either way, since `RemoteFetcher` is exactly the
2558    /// injection seam `UreqFetcher`'s own logic sits behind, and a fake
2559    /// implementation of that trait needs no async runtime or TLS.)
2560    #[derive(Default)]
2561    struct FakeFetcher {
2562        responses: std::collections::HashMap<String, Vec<u8>>,
2563        // Mirrors `UreqFetcher`'s own "fetched at most once per instance"
2564        // cache, so `extends_remote_fragment`'s caching assertion (a repeat
2565        // `load_caproni_config_with_fetcher` call sharing one fetcher issues
2566        // no second request) exercises real caching behavior rather than a
2567        // fake that always re-serves the same canned response.
2568        cache: Mutex<std::collections::HashMap<String, Vec<u8>>>,
2569        hits: std::sync::atomic::AtomicU32,
2570    }
2571
2572    impl FakeFetcher {
2573        fn new(pairs: &[(&str, &str)]) -> Self {
2574            Self {
2575                responses: pairs
2576                    .iter()
2577                    .map(|(k, v)| ((*k).to_string(), v.as_bytes().to_vec()))
2578                    .collect(),
2579                cache: Mutex::new(std::collections::HashMap::new()),
2580                hits: std::sync::atomic::AtomicU32::new(0),
2581            }
2582        }
2583
2584        fn hit_count(&self) -> u32 {
2585            self.hits.load(std::sync::atomic::Ordering::SeqCst)
2586        }
2587    }
2588
2589    impl RemoteFetcher for FakeFetcher {
2590        fn fetch(&self, url: &str) -> Result<Vec<u8>, ConfigError> {
2591            if let Some(cached) = self.cache.lock().unwrap().get(url) {
2592                return Ok(cached.clone());
2593            }
2594
2595            self.hits.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
2596
2597            let body = self
2598                .responses
2599                .get(url)
2600                .ok_or_else(|| ConfigError::RemoteFragmentFetch(format!("{url}: not found")))?;
2601
2602            if body.len() as u64 > MAX_FRAGMENT_BYTES {
2603                return Err(ConfigError::RemoteFragmentFetch(format!(
2604                    "{url}: response exceeds {MAX_FRAGMENT_BYTES} bytes"
2605                )));
2606            }
2607
2608            self.cache
2609                .lock()
2610                .unwrap()
2611                .insert(url.to_string(), body.clone());
2612
2613            Ok(body.clone())
2614        }
2615    }
2616
2617    /// Base URL fixtures embed for a fake remote fragment host. Port of the
2618    /// Go `httptest.Server`'s `server.URL`.
2619    const FAKE_SERVER: &str = "https://fake-remote.test";
2620
2621    fn load_with_fetcher(
2622        path: String,
2623        fetcher: &dyn RemoteFetcher,
2624    ) -> Result<super::super::latest::CaproniConfig, ConfigError> {
2625        let gc = GlobalConfig {
2626            config_file: path,
2627            ..Default::default()
2628        };
2629        super::super::caproni::load_caproni_config_with_fetcher(&gc, fetcher)
2630    }
2631
2632    /// Port of the Go `baseConfigWithExtends` helper.
2633    fn base_config_with_extends(extends_url: &str) -> String {
2634        format!(
2635            "\nversion: 2\nextends:\n  - {extends_url}\ncluster:\n  driver: colima\n  network:\n    hostname: test.local\n  colima:\n    memory: 4GiB\n    cores: 2\nrepositories: {{}}\n"
2636        )
2637    }
2638
2639    // Port of TestLoadCaproniConfig_ExtendsRemoteFragment (including its
2640    // "caching" subtest, folded into the same test body since Rust has no
2641    // subtest equivalent worth introducing for one assertion).
2642    #[test]
2643    fn extends_remote_fragment() {
2644        let frag_url = format!("{FAKE_SERVER}/caproni.extended.yaml");
2645        let fetcher = FakeFetcher::new(&[(
2646            frag_url.as_str(),
2647            "\nrepositories:\n  extended:\n    repository: https://example.com/extended.git\n    directory: ./extended/\n",
2648        )]);
2649
2650        let base = format!(
2651            "\nversion: 2\nextends:\n  - {frag_url}\ncluster:\n  driver: colima\n  network:\n    hostname: test.local\n  colima:\n    memory: 4GiB\n    cores: 2\nrepositories:\n  base:\n    repository: https://example.com/base.git\n    directory: ./base/\n"
2652        );
2653        let (_dir, config_path) = write_test_config(&base);
2654
2655        let cfg = load_with_fetcher(config_path.clone(), &fetcher).expect("load should succeed");
2656        let repos = cfg.repositories.as_ref().unwrap();
2657        assert_eq!(repos.len(), 2);
2658
2659        let base_repo = repos.get(&"base".to_string()).unwrap();
2660        assert_eq!(base_repo.repository, "https://example.com/base.git");
2661        assert_eq!(base_repo.directory, "./base/");
2662
2663        let extended_repo = repos.get(&"extended".to_string()).unwrap();
2664        assert_eq!(extended_repo.repository, "https://example.com/extended.git");
2665        assert_eq!(extended_repo.directory, "./extended/");
2666
2667        assert_eq!(fetcher.hit_count(), 1);
2668        let cfg2 = load_with_fetcher(config_path, &fetcher).expect("load should succeed");
2669        assert_eq!(
2670            fetcher.hit_count(),
2671            1,
2672            "expected request count to stay at 1"
2673        );
2674        assert_eq!(
2675            serde_json::to_value(&cfg).unwrap(),
2676            serde_json::to_value(&cfg2).unwrap(),
2677            "second loaded config should be identical"
2678        );
2679    }
2680
2681    // Port of TestLoadCaproniConfig_ExtendsRemotePlainCaproniYAML.
2682    #[test]
2683    fn extends_remote_plain_caproni_yaml() {
2684        let frag_url = format!("{FAKE_SERVER}/caproni.yaml");
2685        let fetcher = FakeFetcher::new(&[(
2686            frag_url.as_str(),
2687            "\nrepositories:\n  extended:\n    repository: https://example.com/extended.git\n    directory: ./extended/\n",
2688        )]);
2689
2690        let base = format!(
2691            "\nversion: 2\nextends:\n  - {frag_url}\ncluster:\n  driver: colima\n  network:\n    hostname: test.local\n  colima:\n    memory: 4GiB\n    cores: 2\nrepositories: {{}}\n"
2692        );
2693        let (_dir, config_path) = write_test_config(&base);
2694
2695        let cfg = load_with_fetcher(config_path, &fetcher).expect("load should succeed");
2696        assert!(cfg.repositories.unwrap().has(&"extended".to_string()));
2697    }
2698
2699    // Port of TestLoadCaproniConfig_ExtendsLocalAndRemote.
2700    #[test]
2701    fn extends_local_and_remote() {
2702        let frag_url = format!("{FAKE_SERVER}/caproni.remote.yaml");
2703        let fetcher = FakeFetcher::new(&[(
2704            frag_url.as_str(),
2705            "\nrepositories:\n  remote:\n    repository: https://example.com/remote.git\n    directory: ./remote/\n",
2706        )]);
2707
2708        let dir = tempfile::tempdir().unwrap();
2709        write_fragment(
2710            dir.path(),
2711            "caproni.frag.yaml",
2712            "\nrepositories:\n  local:\n    repository: https://example.com/local.git\n    directory: ./local/\n",
2713        );
2714
2715        let config_path = dir.path().join("caproni.yaml");
2716        fs::write(
2717            &config_path,
2718            format!(
2719                "\nversion: 2\nextends:\n  - ./caproni.frag.yaml\n  - {frag_url}\ncluster:\n  driver: colima\n  network:\n    hostname: test.local\n  colima:\n    memory: 4GiB\n    cores: 2\nrepositories: {{}}\n"
2720            ),
2721        )
2722        .unwrap();
2723
2724        let cfg = load_with_fetcher(config_path.to_string_lossy().into_owned(), &fetcher)
2725            .expect("load should succeed");
2726        let repos = cfg.repositories.unwrap();
2727        assert_eq!(repos.len(), 2);
2728        assert!(repos.has(&"local".to_string()));
2729        assert!(repos.has(&"remote".to_string()));
2730    }
2731
2732    // Port of TestLoadCaproniConfig_ExtendsRemoteMissing_Fails.
2733    #[test]
2734    fn extends_remote_missing_fails() {
2735        let fetcher = FakeFetcher::new(&[]);
2736
2737        let base = base_config_with_extends(&format!("{FAKE_SERVER}/caproni.missing.yaml"));
2738        let (_dir, config_path) = write_test_config(&base);
2739
2740        let err = load_with_fetcher(config_path, &fetcher).expect_err("load should fail");
2741        assert!(matches!(err, ConfigError::RemoteFragmentFetch(_)));
2742    }
2743
2744    // Port of TestLoadCaproniConfig_ExtendsRemoteTooLarge_Fails.
2745    #[test]
2746    fn extends_remote_too_large_fails() {
2747        let oversized = "a".repeat((MAX_FRAGMENT_BYTES + 1) as usize);
2748        let frag_url = format!("{FAKE_SERVER}/caproni.big.yaml");
2749        let fetcher = FakeFetcher::new(&[(frag_url.as_str(), oversized.as_str())]);
2750
2751        let base = base_config_with_extends(&frag_url);
2752        let (_dir, config_path) = write_test_config(&base);
2753
2754        let err = load_with_fetcher(config_path, &fetcher).expect_err("load should fail");
2755        assert!(matches!(err, ConfigError::RemoteFragmentFetch(_)));
2756    }
2757
2758    // Port of TestLoadCaproniConfig_ExtendsRemoteHTTP_Fails. Exercises the
2759    // real `load_caproni_config` (no injected fetcher): the `https://`-only
2760    // scheme check rejects a plain `http://` extends: URL before any fetch
2761    // is attempted, so no fake server is needed.
2762    #[test]
2763    fn extends_remote_http_fails() {
2764        let base = "
2765version: 2
2766extends:
2767  - http://example.com/caproni.frag.yaml
2768cluster:
2769  driver: colima
2770  network:
2771    hostname: test.local
2772  colima:
2773    memory: 4GiB
2774    cores: 2
2775repositories: {}
2776";
2777        let (_dir, config_path) = write_test_config(base);
2778
2779        let gc = GlobalConfig {
2780            config_file: config_path,
2781            ..Default::default()
2782        };
2783        let err = super::super::caproni::load_caproni_config(&gc).expect_err("load should fail");
2784        assert!(matches!(err, ConfigError::RemoteFragmentFetch(_)));
2785    }
2786
2787    // Port of TestLoadCaproniConfig_ExtendsRemoteInvalidName_Fails.
2788    #[test]
2789    fn extends_remote_invalid_name_fails() {
2790        let base = "
2791version: 2
2792extends:
2793  - https://example.com/some-path/gitlab-dev-stack-values.yaml
2794cluster:
2795  driver: colima
2796  network:
2797    hostname: test.local
2798  colima:
2799    memory: 4GiB
2800    cores: 2
2801repositories: {}
2802";
2803        let (_dir, config_path) = write_test_config(base);
2804
2805        let gc = GlobalConfig {
2806            config_file: config_path,
2807            ..Default::default()
2808        };
2809        let err = super::super::caproni::load_caproni_config(&gc).expect_err("load should fail");
2810        assert!(matches!(err, ConfigError::InvalidFragmentName));
2811    }
2812
2813    // Port of TestLoadCaproniConfig_ExtendsRemoteHasExtends_Fails.
2814    #[test]
2815    fn extends_remote_has_extends_fails() {
2816        let frag_url = format!("{FAKE_SERVER}/caproni.extended.yaml");
2817        let fetcher = FakeFetcher::new(&[(
2818            frag_url.as_str(),
2819            "\nextends:\n  - ./caproni.nested.yaml\nrepositories: {}\n",
2820        )]);
2821
2822        let base = base_config_with_extends(&frag_url);
2823        let (_dir, config_path) = write_test_config(&base);
2824
2825        let err = load_with_fetcher(config_path, &fetcher).expect_err("load should fail");
2826        assert!(matches!(err, ConfigError::FragmentHasExtends));
2827    }
2828
2829    // Port of TestValidateProjectFileSchema (table-driven).
2830    #[test]
2831    fn validate_project_file_schema_cases() {
2832        struct Case {
2833            name: &'static str,
2834            frag: Value,
2835            want_err: bool,
2836        }
2837
2838        let cases = vec![
2839            Case {
2840                name: "valid reloaders only",
2841                frag: json!({
2842                    "reloaders": {
2843                        "web": {
2844                            "type": "mirror",
2845                            "repository": "myrepo",
2846                            "mirror": {
2847                                "target": {
2848                                    "namespace": "ns",
2849                                    "kubernetes_type": "deployment",
2850                                    "name": "deploy",
2851                                },
2852                                "edit_mode_processes": {
2853                                    "web": {"command": "bin/server"},
2854                                },
2855                            },
2856                        },
2857                    },
2858                }),
2859                want_err: false,
2860            },
2861            Case {
2862                name: "valid lifecycle_hooks only",
2863                frag: json!({
2864                    "lifecycle_hooks": [
2865                        {"type": "edit_mode_start", "script": "./hook.sh"},
2866                    ],
2867                }),
2868                want_err: false,
2869            },
2870            Case {
2871                name: "disallowed top-level key",
2872                frag: json!({"cluster": {}}),
2873                want_err: true,
2874            },
2875            Case {
2876                name: "lifecycle_hook missing type",
2877                frag: json!({
2878                    "lifecycle_hooks": [{"script": "./hook.sh"}],
2879                }),
2880                want_err: true,
2881            },
2882            Case {
2883                name: "lifecycle_hook missing action",
2884                frag: json!({
2885                    "lifecycle_hooks": [{"type": "edit_mode_start"}],
2886                }),
2887                want_err: true,
2888            },
2889            Case {
2890                name: "lifecycle_hook invalid type value",
2891                frag: json!({
2892                    "lifecycle_hooks": [
2893                        {"type": "not_a_valid_type", "script": "./hook.sh"},
2894                    ],
2895                }),
2896                want_err: true,
2897            },
2898        ];
2899
2900        for case in cases {
2901            let result = validate_project_file_schema(&case.frag);
2902            if case.want_err {
2903                assert!(result.is_err(), "case {:?} should have failed", case.name);
2904                assert!(
2905                    matches!(result.unwrap_err(), ConfigError::SchemaValidation(_)),
2906                    "case {:?} should fail with SchemaValidation",
2907                    case.name
2908                );
2909            } else {
2910                assert!(
2911                    result.is_ok(),
2912                    "case {:?} should have passed: {:?}",
2913                    case.name,
2914                    result.unwrap_err()
2915                );
2916            }
2917        }
2918    }
2919
2920    // Port of TestValidateRepoDeployersFragment (table-driven).
2921    #[test]
2922    fn validate_repo_deployers_fragment_cases() {
2923        struct Case {
2924            name: &'static str,
2925            frag: Value,
2926            repo_name: &'static str,
2927            want_err: Option<fn(&ConfigError) -> bool>,
2928            err_substr: &'static str,
2929        }
2930
2931        let cases = vec![
2932            Case {
2933                name: "valid fragment",
2934                repo_name: "myrepo",
2935                frag: json!({
2936                    "reloaders": {"web": {"repository": "myrepo"}},
2937                }),
2938                want_err: None,
2939                err_substr: "",
2940            },
2941            Case {
2942                name: "empty fragment",
2943                repo_name: "myrepo",
2944                frag: json!({}),
2945                want_err: None,
2946                err_substr: "",
2947            },
2948            Case {
2949                name: "reloader references wrong repository",
2950                repo_name: "myrepo",
2951                frag: json!({
2952                    "reloaders": {"web": {"repository": "otherrepo"}},
2953                }),
2954                want_err: Some(|e| matches!(e, ConfigError::RepoFragmentWrongRepository { .. })),
2955                err_substr: "\"otherrepo\"",
2956            },
2957            Case {
2958                name: "reloader missing repository field",
2959                repo_name: "myrepo",
2960                frag: json!({
2961                    "reloaders": {"web": {"type": "mirror"}},
2962                }),
2963                want_err: Some(|e| matches!(e, ConfigError::RepoFragmentWrongRepository { .. })),
2964                err_substr: "\"\"",
2965            },
2966        ];
2967
2968        for case in cases {
2969            let result = validate_repo_deployers_fragment(&case.frag, case.repo_name);
2970
2971            match case.want_err {
2972                None => assert!(result.is_ok(), "case {:?} should have passed", case.name),
2973                Some(check) => {
2974                    let err = result.unwrap_err();
2975                    assert!(
2976                        check(&err),
2977                        "case {:?}: unexpected error {err:?}",
2978                        case.name
2979                    );
2980                    if !case.err_substr.is_empty() {
2981                        assert!(
2982                            err.to_string().contains(case.err_substr),
2983                            "case {:?}: error {err} should contain {:?}",
2984                            case.name,
2985                            case.err_substr
2986                        );
2987                    }
2988                }
2989            }
2990        }
2991    }
2992
2993    fn minimal_reloader_yaml(reloader_name: &str, repo_name: &str) -> String {
2994        format!(
2995            "
2996reloaders:
2997  {reloader_name}:
2998    type: mirror
2999    repository: {repo_name}
3000    mirror:
3001      target:
3002        namespace: ns
3003        kubernetes_type: deployment
3004        name: deploy
3005      edit_mode_processes:
3006        web:
3007          command: bin/server
3008"
3009        )
3010    }
3011
3012    fn write_deployers_fragment(dir: &std::path::Path, content: &str) {
3013        std::fs::write(dir.join("caproni-project.yaml"), content)
3014            .expect("writing caproni-project.yaml should succeed");
3015    }
3016
3017    // xianyun-project.yaml is the primary per-repository fragment
3018    // filename; caproni-project.yaml is supported as a compatibility
3019    // alias so existing Go-caproni projects keep working unmodified.
3020    #[test]
3021    fn apply_repo_deployers_fragments_prefers_xianyun_project_yaml_over_caproni_alias() {
3022        let repo_dir = tempfile::tempdir().unwrap();
3023        std::fs::write(
3024            repo_dir.path().join("xianyun-project.yaml"),
3025            minimal_reloader_yaml("primary-reloader", "myrepo"),
3026        )
3027        .unwrap();
3028        std::fs::write(
3029            repo_dir.path().join("caproni-project.yaml"),
3030            minimal_reloader_yaml("alias-reloader", "myrepo"),
3031        )
3032        .unwrap();
3033
3034        let base = json!({
3035            "repositories": {
3036                "myrepo": {
3037                    "repository": "https://example.com/myrepo.git",
3038                    "directory": repo_dir.path().to_string_lossy(),
3039                    "edit_mode": true,
3040                },
3041            },
3042        });
3043
3044        let result = apply_caproni_project(base, std::path::Path::new("/irrelevant")).unwrap();
3045        let reloaders = result.get("reloaders").and_then(Value::as_object).unwrap();
3046        assert!(
3047            reloaders.contains_key("primary-reloader"),
3048            "xianyun-project.yaml must win over caproni-project.yaml when both exist"
3049        );
3050        assert!(!reloaders.contains_key("alias-reloader"));
3051    }
3052
3053    #[test]
3054    fn apply_repo_deployers_fragments_falls_back_to_caproni_project_yaml_alias() {
3055        let repo_dir = tempfile::tempdir().unwrap();
3056        write_deployers_fragment(
3057            repo_dir.path(),
3058            &minimal_reloader_yaml("alias-only-reloader", "myrepo"),
3059        );
3060
3061        let base = json!({
3062            "repositories": {
3063                "myrepo": {
3064                    "repository": "https://example.com/myrepo.git",
3065                    "directory": repo_dir.path().to_string_lossy(),
3066                    "edit_mode": true,
3067                },
3068            },
3069        });
3070
3071        let result = apply_caproni_project(base, std::path::Path::new("/irrelevant")).unwrap();
3072        let reloaders = result.get("reloaders").and_then(Value::as_object).unwrap();
3073        assert!(
3074            reloaders.contains_key("alias-only-reloader"),
3075            "caproni-project.yaml must still be found when xianyun-project.yaml is absent"
3076        );
3077    }
3078
3079    // Port of TestApplyRepoDeployersFragments.
3080    #[test]
3081    fn apply_repo_deployers_fragments_loads_reloaders_from_edit_mode_repo() {
3082        let repo_dir = tempfile::tempdir().unwrap();
3083        write_deployers_fragment(
3084            repo_dir.path(),
3085            &minimal_reloader_yaml("my-reloader", "myrepo"),
3086        );
3087
3088        let base = json!({
3089            "repositories": {
3090                "myrepo": {
3091                    "repository": "https://example.com/myrepo.git",
3092                    "directory": repo_dir.path().to_string_lossy(),
3093                    "edit_mode": true,
3094                },
3095            },
3096        });
3097
3098        let result = apply_caproni_project(base, std::path::Path::new("/irrelevant")).unwrap();
3099        let reloaders = result.get("reloaders").and_then(Value::as_object).unwrap();
3100        assert!(reloaders.contains_key("my-reloader"));
3101    }
3102
3103    #[test]
3104    fn apply_repo_deployers_fragments_main_config_wins_over_repo_fragment() {
3105        let repo_dir = tempfile::tempdir().unwrap();
3106        write_deployers_fragment(
3107            repo_dir.path(),
3108            "
3109reloaders:
3110  shared:
3111    type: mirror
3112    repository: myrepo
3113    mirror:
3114      target:
3115        namespace: from-fragment
3116        kubernetes_type: deployment
3117        name: deploy
3118      edit_mode_processes:
3119        web:
3120          command: bin/server
3121",
3122        );
3123
3124        let base = json!({
3125            "repositories": {
3126                "myrepo": {
3127                    "repository": "https://example.com/myrepo.git",
3128                    "directory": repo_dir.path().to_string_lossy(),
3129                    "edit_mode": true,
3130                },
3131            },
3132            "reloaders": {
3133                "shared": {
3134                    "type": "mirror",
3135                    "repository": "myrepo",
3136                    "mirror": {
3137                        "target": {
3138                            "namespace": "from-base",
3139                            "kubernetes_type": "deployment",
3140                            "name": "deploy",
3141                        },
3142                        "edit_mode_processes": {
3143                            "web": {"command": "bin/server"},
3144                        },
3145                    },
3146                },
3147            },
3148        });
3149
3150        let result = apply_caproni_project(base, std::path::Path::new("/irrelevant")).unwrap();
3151        let namespace = &result["reloaders"]["shared"]["mirror"]["target"]["namespace"];
3152        assert_eq!(namespace, "from-base", "base config must win");
3153    }
3154
3155    #[test]
3156    fn apply_repo_deployers_fragments_skips_repos_without_edit_mode() {
3157        let repo_dir = tempfile::tempdir().unwrap();
3158        write_deployers_fragment(
3159            repo_dir.path(),
3160            &minimal_reloader_yaml("unwanted", "myrepo"),
3161        );
3162
3163        let base = json!({
3164            "repositories": {
3165                "myrepo": {
3166                    "repository": "https://example.com/myrepo.git",
3167                    "directory": repo_dir.path().to_string_lossy(),
3168                },
3169            },
3170        });
3171
3172        let result = apply_caproni_project(base, std::path::Path::new("/irrelevant")).unwrap();
3173        assert!(result.get("reloaders").is_none());
3174    }
3175
3176    #[test]
3177    fn apply_repo_deployers_fragments_skips_repos_with_missing_directory() {
3178        let base = json!({
3179            "repositories": {
3180                "myrepo": {
3181                    "repository": "https://example.com/myrepo.git",
3182                    "directory": "/nonexistent/path/that/does/not/exist",
3183                    "edit_mode": true,
3184                },
3185            },
3186        });
3187
3188        let result = apply_caproni_project(base, std::path::Path::new("/irrelevant")).unwrap();
3189        assert!(result.get("reloaders").is_none());
3190    }
3191
3192    #[test]
3193    fn apply_repo_deployers_fragments_noop_when_project_file_absent() {
3194        let repo_dir = tempfile::tempdir().unwrap();
3195
3196        let base = json!({
3197            "repositories": {
3198                "myrepo": {
3199                    "repository": "https://example.com/myrepo.git",
3200                    "directory": repo_dir.path().to_string_lossy(),
3201                    "edit_mode": true,
3202                },
3203            },
3204        });
3205
3206        let result =
3207            apply_caproni_project(base.clone(), std::path::Path::new("/irrelevant")).unwrap();
3208        assert_eq!(result, base);
3209    }
3210
3211    #[test]
3212    fn apply_repo_deployers_fragments_resolves_relative_directory_against_config_dir() {
3213        let config_dir = tempfile::tempdir().unwrap();
3214        let repo_dir = config_dir.path().join("myrepo");
3215        std::fs::create_dir_all(&repo_dir).unwrap();
3216        write_deployers_fragment(&repo_dir, &minimal_reloader_yaml("rel-reloader", "myrepo"));
3217
3218        let base = json!({
3219            "repositories": {
3220                "myrepo": {
3221                    "repository": "https://example.com/myrepo.git",
3222                    "directory": "./myrepo",
3223                    "edit_mode": true,
3224                },
3225            },
3226        });
3227
3228        let result = apply_caproni_project(base, config_dir.path()).unwrap();
3229        let reloaders = result.get("reloaders").and_then(Value::as_object).unwrap();
3230        assert!(reloaders.contains_key("rel-reloader"));
3231    }
3232
3233    #[test]
3234    fn apply_repo_deployers_fragments_disallowed_top_level_key_errors() {
3235        let repo_dir = tempfile::tempdir().unwrap();
3236        write_deployers_fragment(
3237            repo_dir.path(),
3238            "
3239deployers:
3240  something:
3241    type: namespace
3242",
3243        );
3244
3245        let base = json!({
3246            "repositories": {
3247                "myrepo": {
3248                    "repository": "https://example.com/myrepo.git",
3249                    "directory": repo_dir.path().to_string_lossy(),
3250                    "edit_mode": true,
3251                },
3252            },
3253        });
3254
3255        let err = apply_caproni_project(base, std::path::Path::new("/irrelevant")).unwrap_err();
3256        assert!(matches!(err, ConfigError::SchemaValidation(_)));
3257    }
3258
3259    #[test]
3260    fn apply_repo_deployers_fragments_wrong_repository_errors() {
3261        let repo_dir = tempfile::tempdir().unwrap();
3262        write_deployers_fragment(repo_dir.path(), &minimal_reloader_yaml("web", "otherrepo"));
3263
3264        let base = json!({
3265            "repositories": {
3266                "myrepo": {
3267                    "repository": "https://example.com/myrepo.git",
3268                    "directory": repo_dir.path().to_string_lossy(),
3269                    "edit_mode": true,
3270                },
3271            },
3272        });
3273
3274        let err = apply_caproni_project(base, std::path::Path::new("/irrelevant")).unwrap_err();
3275        assert!(matches!(
3276            err,
3277            ConfigError::RepoFragmentWrongRepository { .. }
3278        ));
3279    }
3280
3281    #[test]
3282    fn apply_repo_deployers_fragments_duplicate_reloader_across_repos_errors() {
3283        let dir_a = tempfile::tempdir().unwrap();
3284        let dir_b = tempfile::tempdir().unwrap();
3285        write_deployers_fragment(dir_a.path(), &minimal_reloader_yaml("web", "repo-a"));
3286        write_deployers_fragment(dir_b.path(), &minimal_reloader_yaml("web", "repo-b"));
3287
3288        let base = json!({
3289            "repositories": {
3290                "repo-a": {
3291                    "repository": "https://example.com/a.git",
3292                    "directory": dir_a.path().to_string_lossy(),
3293                    "edit_mode": true,
3294                },
3295                "repo-b": {
3296                    "repository": "https://example.com/b.git",
3297                    "directory": dir_b.path().to_string_lossy(),
3298                    "edit_mode": true,
3299                },
3300            },
3301        });
3302
3303        let err = apply_caproni_project(base, std::path::Path::new("/irrelevant")).unwrap_err();
3304        assert!(matches!(err, ConfigError::RepoFragmentDuplicateReloader(_)));
3305        assert!(err.to_string().contains("\"web\""));
3306    }
3307
3308    #[test]
3309    fn apply_repo_deployers_fragments_lifecycle_hooks_appended_to_repo_hooks() {
3310        let repo_dir = tempfile::tempdir().unwrap();
3311        write_deployers_fragment(
3312            repo_dir.path(),
3313            "
3314lifecycle_hooks:
3315  - type: edit_mode_start
3316    script: ./project-hook.sh
3317",
3318        );
3319
3320        let base = json!({
3321            "repositories": {
3322                "myrepo": {
3323                    "repository": "https://example.com/myrepo.git",
3324                    "directory": repo_dir.path().to_string_lossy(),
3325                    "edit_mode": true,
3326                    "lifecycle_hooks": [
3327                        {"type": "edit_mode_start", "script": "./base-hook.sh"},
3328                    ],
3329                },
3330            },
3331        });
3332
3333        let result = apply_caproni_project(base, std::path::Path::new("/irrelevant")).unwrap();
3334        let hooks = result["repositories"]["myrepo"]["lifecycle_hooks"]
3335            .as_array()
3336            .unwrap();
3337        assert_eq!(hooks.len(), 2);
3338        assert_eq!(
3339            hooks[0]["script"], "./base-hook.sh",
3340            "base hook must be first"
3341        );
3342        assert_eq!(
3343            hooks[1]["script"], "./project-hook.sh",
3344            "project hook must be appended"
3345        );
3346    }
3347
3348    #[test]
3349    fn apply_repo_deployers_fragments_lifecycle_hooks_added_when_repo_has_none() {
3350        let repo_dir = tempfile::tempdir().unwrap();
3351        write_deployers_fragment(
3352            repo_dir.path(),
3353            "
3354lifecycle_hooks:
3355  - type: edit_mode_start
3356    script: ./project-hook.sh
3357",
3358        );
3359
3360        let base = json!({
3361            "repositories": {
3362                "myrepo": {
3363                    "repository": "https://example.com/myrepo.git",
3364                    "directory": repo_dir.path().to_string_lossy(),
3365                    "edit_mode": true,
3366                },
3367            },
3368        });
3369
3370        let result = apply_caproni_project(base, std::path::Path::new("/irrelevant")).unwrap();
3371        let hooks = result["repositories"]["myrepo"]["lifecycle_hooks"]
3372            .as_array()
3373            .unwrap();
3374        assert_eq!(hooks.len(), 1);
3375        assert_eq!(hooks[0]["script"], "./project-hook.sh");
3376    }
3377
3378    // Port of TestLoadCaproniConfig_ExtendsDuplicateRemoteFragment_Fails.
3379    #[test]
3380    fn extends_duplicate_remote_fragment_fails() {
3381        let frag_url = format!("{FAKE_SERVER}/caproni.extended.yaml");
3382        let fetcher = FakeFetcher::new(&[(
3383            frag_url.as_str(),
3384            "\nrepositories:\n  extended:\n    repository: https://example.com/extended.git\n    directory: ./extended/\n",
3385        )]);
3386
3387        let base = format!(
3388            "\nversion: 2\nextends:\n  - {frag_url}\n  - {frag_url}\ncluster:\n  driver: colima\n  network:\n    hostname: test.local\nrepositories: {{}}\n"
3389        );
3390        let (_dir, config_path) = write_test_config(&base);
3391
3392        let err = load_with_fetcher(config_path, &fetcher).expect_err("load should fail");
3393        assert!(matches!(err, ConfigError::DuplicateExtends(_)));
3394    }
3395
3396    // Port of TestLoadCaproniConfig_ExtendsRemoteFragmentDownloadsValuesFile.
3397    #[test]
3398    fn extends_remote_fragment_downloads_values_file() {
3399        const VALUES_CONTENT: &str = "replicaCount: 3\n";
3400
3401        let frag_url = format!("{FAKE_SERVER}/download/caproni.frag.yaml");
3402        let values_url = format!("{FAKE_SERVER}/download/values.yaml");
3403        let fetcher = FakeFetcher::new(&[
3404            (
3405                frag_url.as_str(),
3406                "\ndeployers:\n  gitlab:\n    type: helm\n    helm:\n      namespace: gitlab\n      values_file: values.yaml\n      release:\n        name: gitlab\n        chart: gitlab/gitlab\n",
3407            ),
3408            (values_url.as_str(), VALUES_CONTENT),
3409        ]);
3410
3411        let base = base_config_with_extends(&frag_url);
3412        let (_dir, config_path) = write_test_config(&base);
3413
3414        let cfg = load_with_fetcher(config_path, &fetcher).expect("load should succeed");
3415        let deployers = cfg.deployers.unwrap();
3416        let dep = deployers.get(&"gitlab".to_string()).unwrap();
3417        let helm = dep.helm.as_ref().unwrap();
3418        assert_ne!(
3419            helm.values_file, "values.yaml",
3420            "values_file is rewritten to a local path"
3421        );
3422        assert!(std::path::Path::new(&helm.values_file).is_absolute());
3423
3424        let got = fs::read_to_string(&helm.values_file).unwrap();
3425        assert_eq!(got, VALUES_CONTENT);
3426    }
3427
3428    // Port of TestLoadCaproniConfig_ExtendsRemoteFragmentLocalOverride.
3429    #[test]
3430    fn extends_remote_fragment_local_override() {
3431        let frag_url = format!("{FAKE_SERVER}/local-override/caproni.frag.yaml");
3432        let values_url = format!("{FAKE_SERVER}/local-override/values.yaml");
3433        let fetcher = FakeFetcher::new(&[
3434            (
3435                frag_url.as_str(),
3436                "\ndeployers:\n  gitlab:\n    type: helm\n    helm:\n      namespace: gitlab\n      values_file: values.yaml\n      release:\n        name: gitlab\n        chart: gitlab/gitlab\n",
3437            ),
3438            (values_url.as_str(), "replicaCount: 3\n"),
3439        ]);
3440
3441        let dir = tempfile::tempdir().unwrap();
3442        let local_values = dir.path().join("local-values.yaml");
3443        fs::write(&local_values, "replicaCount: 5\n").unwrap();
3444
3445        let config_path = dir.path().join("caproni.yaml");
3446        fs::write(
3447            &config_path,
3448            format!(
3449                "\nversion: 2\nextends:\n  - {frag_url}\ndeployers:\n  gitlab:\n    type: helm\n    helm:\n      namespace: gitlab\n      values_file: {}\n      release:\n        name: gitlab\n        chart: gitlab/gitlab\ncluster:\n  driver: colima\n  network:\n    hostname: test.local\n  colima:\n    memory: 4GiB\n    cores: 2\nrepositories: {{}}\n",
3450                local_values.to_string_lossy()
3451            ),
3452        )
3453        .unwrap();
3454
3455        let cfg = load_with_fetcher(config_path.to_string_lossy().into_owned(), &fetcher)
3456            .expect("load should succeed");
3457        let deployers = cfg.deployers.unwrap();
3458        let dep = deployers.get(&"gitlab".to_string()).unwrap();
3459        let helm = dep.helm.as_ref().unwrap();
3460        assert_eq!(
3461            helm.values_file,
3462            local_values.to_string_lossy(),
3463            "local override should be preserved"
3464        );
3465
3466        let got = fs::read_to_string(&helm.values_file).unwrap();
3467        assert_eq!(got, "replicaCount: 5\n");
3468    }
3469
3470    // Port of TestLoadCaproniConfig_ExtendsRemoteFragmentKustomizeDir_Fails.
3471    #[test]
3472    fn extends_remote_fragment_kustomize_dir_fails() {
3473        let frag_url = format!("{FAKE_SERVER}/caproni.frag.yaml");
3474        let fetcher = FakeFetcher::new(&[(
3475            frag_url.as_str(),
3476            "\ndeployers:\n  gitlab:\n    type: kustomize\n    kustomize:\n      path: ./gitlab\n",
3477        )]);
3478
3479        let base = base_config_with_extends(&frag_url);
3480        let (_dir, config_path) = write_test_config(&base);
3481
3482        let err = load_with_fetcher(config_path, &fetcher).expect_err("load should fail");
3483        assert!(matches!(err, ConfigError::RemoteFragmentDirRef), "{err:?}");
3484    }
3485
3486    // Port of TestLoadCaproniConfig_ExtendsRemoteFragmentKustomizePathOverride
3487    // (table-driven).
3488    #[test]
3489    fn extends_remote_fragment_kustomize_path_override() {
3490        const FRAGMENT_PATH: &str = "./capronix";
3491        const LOCAL_OVERLAY: &str = "./local-overlay";
3492        const REMOTE_TARGET: &str = "https://gitlab.com/org/infra.git//overlays/dev?ref=v1.2.0";
3493
3494        fn override_for(path: &str) -> String {
3495            format!(
3496                "\ndeployers:\n  nix-builder:\n    type: kustomize\n    kustomize:\n      path: {path}\n"
3497            )
3498        }
3499
3500        struct Case {
3501            name: &'static str,
3502            base_override: String,
3503            fragment_override: String,
3504            local_override: String,
3505            want_path: &'static str,
3506            want_err: bool,
3507        }
3508
3509        let cases = vec![
3510            Case {
3511                name: "base override to local dir",
3512                base_override: override_for(LOCAL_OVERLAY),
3513                fragment_override: String::new(),
3514                local_override: String::new(),
3515                want_path: LOCAL_OVERLAY,
3516                want_err: false,
3517            },
3518            Case {
3519                name: "local fragment override to local dir",
3520                base_override: String::new(),
3521                fragment_override: override_for(LOCAL_OVERLAY),
3522                local_override: String::new(),
3523                want_path: LOCAL_OVERLAY,
3524                want_err: false,
3525            },
3526            Case {
3527                name: "caproni.local.yaml override to local dir",
3528                base_override: String::new(),
3529                fragment_override: String::new(),
3530                local_override: override_for(LOCAL_OVERLAY),
3531                want_path: LOCAL_OVERLAY,
3532                want_err: false,
3533            },
3534            Case {
3535                name: "no override keeps rejecting the fragment's local path",
3536                base_override: String::new(),
3537                fragment_override: String::new(),
3538                local_override: String::new(),
3539                want_path: "",
3540                want_err: true,
3541            },
3542            Case {
3543                name: "base override to remote target",
3544                base_override: override_for(REMOTE_TARGET),
3545                fragment_override: String::new(),
3546                local_override: String::new(),
3547                want_path: REMOTE_TARGET,
3548                want_err: false,
3549            },
3550        ];
3551
3552        for case in cases {
3553            let frag_url = format!("{FAKE_SERVER}/caproni.nix-builder.yaml");
3554            let fragment_body = override_for(FRAGMENT_PATH);
3555            let fetcher = FakeFetcher::new(&[(frag_url.as_str(), fragment_body.as_str())]);
3556
3557            let dir = tempfile::tempdir().unwrap();
3558            fs::create_dir(dir.path().join("local-overlay")).unwrap();
3559
3560            let mut extends = vec![frag_url.clone()];
3561
3562            if !case.fragment_override.is_empty() {
3563                let override_path = dir.path().join("caproni.override.yaml");
3564                fs::write(&override_path, &case.fragment_override).unwrap();
3565                extends.push("./caproni.override.yaml".to_string());
3566            }
3567
3568            let config_path = dir.path().join("caproni.yaml");
3569            let base = format!(
3570                "\nversion: 2\nextends:\n  - {}\ncluster:\n  driver: colima\n  network:\n    hostname: test.local\n  colima:\n    memory: 4GiB\n    cores: 2\nrepositories: {{}}\n{}",
3571                extends.join("\n  - "),
3572                case.base_override
3573            );
3574            fs::write(&config_path, &base).unwrap();
3575
3576            if !case.local_override.is_empty() {
3577                fs::write(
3578                    dir.path().join("caproni.local.yaml"),
3579                    format!("version: 2\n{}", case.local_override),
3580                )
3581                .unwrap();
3582            }
3583
3584            let result = load_with_fetcher(config_path.to_string_lossy().into_owned(), &fetcher);
3585
3586            if case.want_err {
3587                let err = result.expect_err(case.name);
3588                assert!(
3589                    matches!(err, ConfigError::RemoteFragmentDirRef),
3590                    "{}: {err:?}",
3591                    case.name
3592                );
3593                continue;
3594            }
3595
3596            let cfg = result.unwrap_or_else(|err| panic!("{}: {err:?}", case.name));
3597            let deployers = cfg.deployers.unwrap();
3598            let dep = deployers.get(&"nix-builder".to_string()).unwrap();
3599            let kustomize = dep.kustomize.as_ref().unwrap();
3600            assert_eq!(kustomize.path, case.want_path, "{}", case.name);
3601        }
3602    }
3603
3604    // Port of TestLoadCaproniConfig_ExtendsRemoteFragmentKustomizeBuiltin_Succeeds.
3605    #[test]
3606    fn extends_remote_fragment_kustomize_builtin_succeeds() {
3607        let frag_url = format!("{FAKE_SERVER}/caproni.frag.yaml");
3608        let fetcher = FakeFetcher::new(&[(
3609            frag_url.as_str(),
3610            "\ndeployers:\n  caproni-dev-ca:\n    type: kustomize\n    kustomize:\n      path: builtin:dev-ca\n",
3611        )]);
3612
3613        let base = base_config_with_extends(&frag_url);
3614        let (_dir, config_path) = write_test_config(&base);
3615
3616        let cfg = load_with_fetcher(config_path, &fetcher).expect("load should succeed");
3617        let deployers = cfg.deployers.unwrap();
3618        let dep = deployers.get(&"caproni-dev-ca".to_string()).unwrap();
3619        assert_eq!(
3620            dep.kustomize.as_ref().unwrap().path,
3621            "builtin:dev-ca",
3622            "a builtin: path must survive untouched"
3623        );
3624    }
3625
3626    // Port of TestLoadCaproniConfig_ExtendsRemoteFragmentKustomizeRemoteTarget_Succeeds.
3627    #[test]
3628    fn extends_remote_fragment_kustomize_remote_target_succeeds() {
3629        let frag_url = format!("{FAKE_SERVER}/caproni.frag.yaml");
3630        let fetcher = FakeFetcher::new(&[(
3631            frag_url.as_str(),
3632            "\ndeployers:\n  shared-overlay:\n    type: kustomize\n    kustomize:\n      path: https://gitlab.com/org/infra.git//overlays/dev?ref=v1.2.0\n",
3633        )]);
3634
3635        let base = base_config_with_extends(&frag_url);
3636        let (_dir, config_path) = write_test_config(&base);
3637
3638        let cfg = load_with_fetcher(config_path, &fetcher).expect("load should succeed");
3639        let deployers = cfg.deployers.unwrap();
3640        let dep = deployers.get(&"shared-overlay".to_string()).unwrap();
3641        assert_eq!(
3642            dep.kustomize.as_ref().unwrap().path,
3643            "https://gitlab.com/org/infra.git//overlays/dev?ref=v1.2.0",
3644            "a remote target must survive untouched"
3645        );
3646    }
3647
3648    // Port of TestLoadCaproniConfig_ExtendsRemoteFragmentMissingValuesFile_Fails.
3649    #[test]
3650    fn extends_remote_fragment_missing_values_file_fails() {
3651        let frag_url = format!("{FAKE_SERVER}/missing-values/caproni.frag.yaml");
3652        let fetcher = FakeFetcher::new(&[(
3653            frag_url.as_str(),
3654            "\ndeployers:\n  gitlab:\n    type: helm\n    helm:\n      namespace: gitlab\n      values_file: values.yaml\n      release:\n        name: gitlab\n        chart: gitlab/gitlab\n",
3655        )]);
3656
3657        let base = base_config_with_extends(&frag_url);
3658        let (_dir, config_path) = write_test_config(&base);
3659
3660        let err = load_with_fetcher(config_path, &fetcher).expect_err("load should fail");
3661        assert!(
3662            err.to_string().contains("values file not found"),
3663            "unexpected error: {err}"
3664        );
3665    }
3666
3667    // xianyun.local.yaml is the primary local-override filename;
3668    // caproni.local.yaml is supported as a compatibility alias so
3669    // existing Go-caproni projects keep working unmodified. When both
3670    // exist, xianyun.local.yaml wins. See PORT_NOTES.md's
3671    // naming-migration notes.
3672    #[test]
3673    fn load_local_override_prefers_xianyun_local_yaml_over_caproni_local_yaml_alias() {
3674        let dir = tempfile::tempdir().unwrap();
3675        let config_path = dir.path().join("xianyun.yaml");
3676        fs::write(dir.path().join("xianyun.local.yaml"), "primary: true\n").unwrap();
3677        fs::write(dir.path().join("caproni.local.yaml"), "primary: false\n").unwrap();
3678
3679        let result = load_local_override(&config_path).unwrap().unwrap();
3680        assert_eq!(result.get("primary"), Some(&json!(true)));
3681    }
3682
3683    #[test]
3684    fn load_local_override_falls_back_to_caproni_local_yaml_alias() {
3685        let dir = tempfile::tempdir().unwrap();
3686        let config_path = dir.path().join("xianyun.yaml");
3687        fs::write(dir.path().join("caproni.local.yaml"), "alias: true\n").unwrap();
3688
3689        let result = load_local_override(&config_path).unwrap().unwrap();
3690        assert_eq!(result.get("alias"), Some(&json!(true)));
3691    }
3692
3693    #[test]
3694    fn load_local_override_returns_none_when_neither_exists() {
3695        let dir = tempfile::tempdir().unwrap();
3696        let config_path = dir.path().join("xianyun.yaml");
3697
3698        assert!(load_local_override(&config_path).unwrap().is_none());
3699    }
3700}